a73x

4c363aa6

feat(cmd): pure command state machine over OSC 133 mark events

a73x   2026-08-13 15:54

Commit message
feat(cmd): pure command state machine over OSC 133 mark events

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

build.zig
Old New
@@ -184,6 +184,17 @@ pub fn build(b: *std.Build) void {
184 delta_mod.addImport("engine", engine_mod); 184 delta_mod.addImport("engine", engine_mod);
185 delta_mod.addImport("protocol", protocol_mod); 185 delta_mod.addImport("protocol", protocol_mod);
186 186
187 // The session's command state machine: MarkEvents in, transitions out.
188 // Engine plus protocol and nothing else, same shape as delta_mod — pure,
189 // socket-free, and its own tests drive it with no daemon in sight.
190 const cmd_mod = b.createModule(.{
191 .root_source_file = b.path("src/cmd.zig"),
192 .target = target,
193 .optimize = optimize,
194 });
195 cmd_mod.addImport("engine", engine_mod);
196 cmd_mod.addImport("protocol", protocol_mod);
197
187 // The replay core: snapshot/delta application and the resume 198 // The replay core: snapshot/delta application and the resume
188 // coordinates, shared by the CLI client, the wasm core, and the 199 // coordinates, shared by the CLI client, the wasm core, and the
189 // server's test fixtures. Engine plus protocol and nothing else, and 200 // server's test fixtures. Engine plus protocol and nothing else, and
@@ -235,6 +246,7 @@ pub fn build(b: *std.Build) void {
235 server_mod.addImport("pty", pty_mod); 246 server_mod.addImport("pty", pty_mod);
236 server_mod.addImport("protocol", protocol_mod); 247 server_mod.addImport("protocol", protocol_mod);
237 server_mod.addImport("delta", delta_mod); 248 server_mod.addImport("delta", delta_mod);
249 server_mod.addImport("cmd", cmd_mod);
238 server_mod.addImport("replica", replica_mod); 250 server_mod.addImport("replica", replica_mod);
239 server_mod.addImport("sockpath", sockpath_mod); 251 server_mod.addImport("sockpath", sockpath_mod);
240 // Both: the listener it owns, and the vocabulary it names directly 252 // Both: the listener it owns, and the vocabulary it names directly
@@ -366,6 +378,7 @@ pub fn build(b: *std.Build) void {
366 }); 378 });
367 exe_mod.addImport("server", server_mod); 379 exe_mod.addImport("server", server_mod);
368 exe_mod.addImport("protocol", protocol_mod); 380 exe_mod.addImport("protocol", protocol_mod);
381 exe_mod.addImport("cmd", cmd_mod);
369 exe_mod.addImport("proxy", proxy_mod); 382 exe_mod.addImport("proxy", proxy_mod);
370 // The daemon entrypoint loads the key and constructs the listener, so it 383 // The daemon entrypoint loads the key and constructs the listener, so it
371 // needs the modules directly rather than through the server. 384 // needs the modules directly rather than through the server.
@@ -520,18 +533,18 @@ pub fn build(b: *std.Build) void {
520 b.installArtifact(webhub_exe); 533 b.installArtifact(webhub_exe);
521 534
522 const test_step = b.step("test", "Run unit tests"); 535 const test_step = b.step("test", "Run unit tests");
523 // delta_mod and sockpath_mod sit BEFORE server_mod, deliberately: their 536 // delta_mod, cmd_mod, and sockpath_mod sit BEFORE server_mod,
524 // tests are seconds-long and socket-free, while a regression in either 537 // deliberately: their tests are seconds-long and socket-free, while a
525 // can wedge a server test that waits on a client forever — and a wedged 538 // regression in any of them can wedge a server test that waits on a
526 // step prints nothing at all. Failing first is what makes the catch 539 // client forever — and a wedged step prints nothing at all. Failing
527 // legible. 540 // first is what makes the catch legible.
528 // 541 //
529 // mux_mod and exe_mod are executable roots, but they carry the argument 542 // mux_mod and exe_mod are executable roots, but they carry the argument
530 // parsers, and a test that is never built is not a test. exe_mod's 543 // parsers, and a test that is never built is not a test. exe_mod's
531 // absence here was a live hazard recorded in decisions.md — muxd's 544 // absence here was a live hazard recorded in decisions.md — muxd's
532 // entrypoint could grow tests that silently never ran, exactly as 545 // entrypoint could grow tests that silently never ran, exactly as
533 // mux_main.zig's five did before it was added. 546 // mux_main.zig's five did before it was added.
534 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, replica_mod, keymap_mod, webhub_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod, webhub_main_mod, wsclient_mod}) |mod| { 547 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, cmd_mod, replica_mod, keymap_mod, webhub_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod, webhub_main_mod, wsclient_mod}) |mod| {
535 const t = b.addTest(.{ .root_module = mod }); 548 const t = b.addTest(.{ .root_module = mod });
536 t.use_llvm = true; 549 t.use_llvm = true;
537 t.use_lld = true; 550 t.use_lld = true;
src/cmd.zig
Old New
@@ -0,0 +1,113 @@
1 //! The session's command state machine: MarkEvents in, transitions out.
2 //! Pure — no I/O, no clock, no seq. The server stamps seqs and decides who
3 //! hears about a transition; this module only decides what the marks mean.
4 //! Trust rule (spec): a D only counts if it closes a seen C; stray marks
5 //! reset to at_prompt rather than being believed.
6 const std = @import("std");
7 const proto = @import("protocol");
8 const Engine = @import("engine").Engine;
9
10 pub const Tracker = struct {
11 phase: proto.CmdPhase = .at_prompt,
12 /// Sticky: once any C has been seen, this session speaks marks and the
13 /// pgid fallback stops being consulted while a command is open.
14 marks_seen: bool = false,
15 start_row: u32 = 0,
16 end_row: u32 = 0,
17 exit_code: ?u8 = null,
18
19 pub const Transition = enum { running, returned, reset };
20
21 pub fn apply(self: *Tracker, ev: Engine.MarkEvent) ?Transition {
22 switch (ev.kind) {
23 .command_start => {
24 self.marks_seen = true;
25 self.phase = .running;
26 self.start_row = ev.row;
27 self.exit_code = null;
28 return .running;
29 },
30 .command_end => {
31 if (self.phase != .running) {
32 // A D with no open C: a nested program echoing marks it
33 // has no business emitting. Reset, believe nothing.
34 self.phase = .at_prompt;
35 return .reset;
36 }
37 self.phase = .returned;
38 self.end_row = ev.row;
39 self.exit_code = ev.exit_code;
40 return .returned;
41 },
42 .prompt_start => {
43 // 'A' after a return is the prompt redrawing: back to rest.
44 // 'A' mid-run (Ctrl-C redraw) also lands here — the shell
45 // is telling us the command is over even without a D.
46 if (self.phase == .running) {
47 self.phase = .returned;
48 self.end_row = ev.row;
49 self.exit_code = null; // interrupted: no honest code
50 return .returned;
51 }
52 self.phase = .at_prompt;
53 return null;
54 },
55 }
56 }
57
58 /// True while marks say a command is open — the window in which the
59 /// pgid fallback must NOT race the marks to a verdict.
60 pub fn marksOpen(self: *const Tracker) bool {
61 return self.marks_seen and self.phase == .running;
62 }
63 };
64
65 test "C then D is running then returned, with rows and code" {
66 var t = Tracker{};
67 try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 10, .exit_code = null }));
68 try std.testing.expectEqual(proto.CmdPhase.running, t.phase);
69 try std.testing.expectEqual(@as(?Tracker.Transition, .returned), t.apply(.{ .kind = .command_end, .row = 14, .exit_code = 1 }));
70 try std.testing.expectEqual(proto.CmdPhase.returned, t.phase);
71 try std.testing.expectEqual(@as(u32, 10), t.start_row);
72 try std.testing.expectEqual(@as(u32, 14), t.end_row);
73 try std.testing.expectEqual(@as(?u8, 1), t.exit_code);
74 }
75
76 test "a stray D resets and is not believed" {
77 var t = Tracker{};
78 try std.testing.expectEqual(@as(?Tracker.Transition, .reset), t.apply(.{ .kind = .command_end, .row = 3, .exit_code = 0 }));
79 try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
80 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
81 }
82
83 test "A closes an open command without a code (Ctrl-C at a prompt redraw)" {
84 var t = Tracker{};
85 _ = t.apply(.{ .kind = .command_start, .row = 5, .exit_code = null });
86 try std.testing.expectEqual(@as(?Tracker.Transition, .returned), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null }));
87 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
88 try std.testing.expectEqual(proto.CmdPhase.returned, t.phase);
89 // The next A settles back to rest with no transition.
90 try std.testing.expectEqual(@as(?Tracker.Transition, null), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null }));
91 try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
92 }
93
94 test "marksOpen guards the pgid race window" {
95 var t = Tracker{};
96 try std.testing.expect(!t.marksOpen());
97 _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
98 try std.testing.expect(t.marksOpen());
99 _ = t.apply(.{ .kind = .command_end, .row = 1, .exit_code = 0 });
100 try std.testing.expect(!t.marksOpen());
101 // Sticky across the next prompt: the session still speaks marks.
102 _ = t.apply(.{ .kind = .prompt_start, .row = 1, .exit_code = null });
103 try std.testing.expect(t.marks_seen);
104 }
105
106 test "back-to-back commands: second C reopens cleanly" {
107 var t = Tracker{};
108 _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
109 _ = t.apply(.{ .kind = .command_end, .row = 2, .exit_code = 0 });
110 try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 3, .exit_code = null }));
111 try std.testing.expectEqual(@as(u32, 3), t.start_row);
112 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
113 }