a73x

a8b00e12

docs: add design handoff and M1 plan

a73x   2026-08-07 06:48

Commit message
docs: add design handoff and M1 plan

.gitignore
Old New
@@ -0,0 +1,2 @@
1 zig-out/
2 .zig-cache/
docs/handoff.md
Old New
@@ -0,0 +1,199 @@
1 # Handoff: Linux-only multiplexer prototype
2
3 **Status:** design handoff, no code written yet
4 **Audience:** whoever picks up implementation
5 **Scope:** Linux only. No macOS, no iOS, no web, no network.
6
7 ---
8
9 ## 0. What this is and isn't
10
11 We are building a terminal multiplexer where **the terminal engine runs on both ends** — authoritative in a daemon, replicated in the client — instead of the tmux model where a second emulator is nested inside your first one.
12
13 This prototype exists to answer two questions and nothing else:
14
15 1. Can libghostty serve as an authoritative, headless, serializable grid in a daemon?
16 2. Does detach/reattach as a *state sync* (rather than an escape-sequence replay) actually feel correct and fast?
17
18 If the answer to either is no, we want to know in weeks, not months. Everything downstream — offload, mesh networking, multiplayer, agent sessions, production controls — is deliberately out of scope and should stay out until these two are settled.
19
20 **Explicitly not in this prototype:** panes/splits, local echo/prediction, config or theming, plugins, keybinding layer, auth, TLS, TCP, checkpoint/restore, session migration, sharing.
21
22 ---
23
24 ## 1. Why incremental
25
26 The temptation is to build the whole daemon + protocol + client and then turn it on. Don't. The riskiest assumption is buried in step 1 (is libghostty's grid extractable?), and a one-shot build would surface that after you've already written a protocol and a client around it.
27
28 Each milestone below is **independently demoable and independently falsifying**. Every one should end with something you can run and look at. If a milestone can't be demoed, it's too big — split it.
29
30 Ordering principle: **prove the engine, then the loop, then the promise, then the scale, then the concurrency.** Each stage assumes the previous one held.
31
32 ---
33
34 ## 2. Target architecture (end state of this prototype)
35
36 ```
37 muxd (daemon, systemd user service)
38 ├─ session registry
39 ├─ per session:
40 │ ├─ PTY (forkpty, child = user's $SHELL)
41 │ ├─ libghostty grid ← source of truth
42 │ └─ scrollback ring buffer
43 └─ listener: $XDG_RUNTIME_DIR/muxd.sock (Unix domain socket)
44
45 mux (client)
46 ├─ libghostty replica grid
47 ├─ GPU rendering (reuse Ghostty's existing Linux/GTK path)
48 └─ socket client
49 ```
50
51 Unix socket only. Same user, same machine, no auth. Transport is swappable later; the point now is that the *data model* is right. If the protocol is well-formed over a socket, moving it to QUIC/TLS later is a transport change, not a redesign.
52
53 ---
54
55 ## 3. Milestones
56
57 ### M1 — Headless engine
58 **Goal:** prove libghostty runs server-side with no display and its grid can be read out.
59
60 Build `muxd` far enough to: spawn a PTY, run the user's shell in it, feed PTY output into a libghostty instance, and expose a debug command that dumps the current grid as plain text.
61
62 **Demo:** `muxd-debug dump` prints a screen that matches what the shell actually rendered. Run something non-trivial in it — `htop`, `vim`, `less` on a UTF-8 file with emoji and CJK.
63
64 **Falsifies:** the whole thesis. If libghostty can't be driven headlessly, or the grid can't be extracted cleanly, stop and reassess before writing anything else.
65
66 **Expected pain:** libghostty was built to render, not to serialize. Extracting a snapshot API is the single largest chunk of genuinely new work in this prototype, and it belongs here — first — precisely because it's the biggest unknown.
67
68 **Done when:** grid dump is byte-correct for wide chars, grapheme clusters, and SGR attributes.
69
70 ---
71
72 ### M2 — The loop
73 **Goal:** prove input and output flow end to end through a real client.
74
75 Add the socket listener, a minimal `Attach` / `Input` / `Snapshot` message set, and a `mux` client that renders the replica grid and forwards keystrokes.
76
77 Full snapshot on every update is fine here. It will be wasteful and that's acceptable — deltas are M4.
78
79 **Demo:** type in `mux`, see it echo, run `vim`, edit a file, `:wq`. It should feel indistinguishable from a normal terminal.
80
81 **Falsifies:** the two-sided-engine model. If the replica diverges from the authoritative grid under normal use, the data model is wrong.
82
83 **Done when:** a full interactive session (shell + a TUI app + a pager) works without visual artifacts.
84
85 ---
86
87 ### M3 — The promise
88 **Goal:** detach and reattach as state sync. **This is the milestone that matters most.**
89
90 Client can disconnect and reconnect. Daemon keeps parsing PTY output while nobody is attached. On reattach, client receives a snapshot and reconstructs natively — no replaying a firehose of escape sequences.
91
92 **Demo:** start a long-running command, kill the client mid-run, reconnect, land exactly where you left off with correct screen state and correct scrollback. Then do it while `vim` is open.
93
94 **Falsifies:** the core product promise. If reattach is slow, lossy, or wrong, nothing downstream is worth building.
95
96 **Done when:** reattach is visually instant and state is correct for both line-mode and full-screen-TUI sessions.
97
98 ---
99
100 ### M4 — Deltas
101 **Goal:** prove the protocol will survive a real network later.
102
103 Replace full snapshots with sequence-numbered deltas and damage regions. Client sends `have_seq` on attach; daemon replies with a delta if it can, a full snapshot if the client is too far behind. Add lazy scrollback fetch — snapshot carries the visible grid only, history is requested on scroll.
104
105 **Demo:** instrument bytes-on-wire. Compare M2's full-snapshot volume against M4's for the same session. The gap is the whole point.
106
107 **Falsifies:** network viability. Over a Unix socket everything feels fine; this milestone is what tells you whether the design survives cellular.
108
109 **Done when:** steady-state typing sends bytes proportional to what changed, not to screen size, and reattach-after-a-gap still resolves correctly.
110
111 ---
112
113 ### M5 — Two clients
114 **Goal:** prove the authoritative-daemon model under concurrency, and force the resize decision.
115
116 Two `mux` instances attached to one session simultaneously. Both stay in sync. Client-local view state (scroll position, selection) stays independent.
117
118 **Demo:** two terminals side by side on one session; type in either, both update. Scroll back in one; the other doesn't move.
119
120 **Surfaces the resize question, which you must now answer:** when attached clients have different window sizes, whose dimensions win? tmux's answer — smallest wins, ugly borders — is widely disliked. Options: smallest-wins, authoritative-client, per-client reflow. **Decide this explicitly and write it down**; it leaks into the grid model and is expensive to retrofit.
121
122 **Done when:** two clients converge reliably and divergent view state behaves as a feature rather than a bug.
123
124 ---
125
126 ## 4. Protocol sketch
127
128 Starting point, expected to change:
129
130 ```
131 client → daemon:
132 Attach { session_id, viewport_size, have_seq }
133 Input { bytes }
134 Resize { cols, rows }
135 FetchScrollback { range }
136 Detach {}
137
138 daemon → client:
139 Snapshot { grid, cursor, seq }
140 Delta { damage_regions, cursor, seq }
141 ScrollbackChunk { range, rows }
142 Bell {}
143 TitleChange { title }
144 ExitStatus { code }
145 ```
146
147 Wire format: msgpack or protobuf. **Do not invent one.**
148
149 Non-negotiable properties, because they're the ones that are expensive to add later:
150 - **Sequence numbers from M4 onward** — reattach must be able to request a delta
151 - **Damage regions, not full-grid sends** — decides network viability
152 - **Lazy scrollback** — history is fetched, never pushed wholesale
153
154 ---
155
156 ## 5. Known hard parts
157
158 | Area | Why it's hard | Where it lands |
159 |---|---|---|
160 | Grid serialization | libghostty renders; it doesn't ship state over a wire. Needs a new snapshot/delta API. | M1 |
161 | Scrollback reflow on resize | Genuinely hard. Ghostty already solved it locally — reusing that is the main reason we're not writing a grid from scratch. | M3/M5 |
162 | Resize with multiple clients | No good industry answer. Affects the grid model. | M5, decide explicitly |
163 | Daemon lifecycle | systemd user unit + socket activation. Sessions survive logout only with lingering enabled. Cheap now, annoying to retrofit. | M2 |
164 | Prediction / local echo | Deliberately deferred. Over a socket it buys nothing; over a network it's essential and conservative-by-default (predict in line mode, fall back to server-confirmed inside TUIs). | Post-prototype |
165
166 ---
167
168 ## 6. Deferred, with reasons
169
170 - **Panes/splits** — client-side layout once the daemon serves N sessions. Not a daemon concern. Feels core, isn't.
171 - **Local echo/prediction** — zero latency over a Unix socket; adding it now would be optimizing a problem we don't have.
172 - **Network transport, auth, TLS** — transport swap, not redesign. Prove the model first.
173 - **Offload / checkpoint-restore / mesh** — separate problem entirely (process location, not terminal rendering). Do not let it contaminate this prototype.
174 - **Sharing/multiplayer** — M5 proves the underlying mechanism; the product surface comes later.
175 - **Config, theming, plugins, keybindings** — distraction.
176
177 ---
178
179 ## 7. Decision log to maintain
180
181 Keep a running file. At minimum, record decisions on:
182
183 - Resize policy under multiple clients (M5)
184 - Snapshot vs delta threshold — how far behind before a client gets a full snapshot
185 - Scrollback retention limit per session, and eviction behaviour
186 - Daemon lifetime — session persistence across logout and reboot
187 - Wire format choice and versioning strategy
188
189 ---
190
191 ## 8. Kill criteria
192
193 Stop and reassess, rather than pushing through, if:
194
195 - **M1:** libghostty's grid cannot be extracted without invasive forking of the engine
196 - **M3:** reattach cannot be made both fast and correct for full-screen TUI sessions
197 - **M4:** delta traffic doesn't scale down meaningfully versus full snapshots
198
199 These are the three places the design could actually be wrong. Everything else is engineering effort, not risk.
docs/superpowers/plans/2026-08-07-m1-headless-engine.md
Old New
@@ -0,0 +1,1036 @@
1 # M1 — Headless Engine Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** Prove ghostty-vt runs headless in a daemon: `muxd run` hosts `$SHELL` on a PTY feeding an authoritative ghostty-vt grid, and `muxd dump` prints that grid byte-correct for wide chars, grapheme clusters, and SGR attributes.
6
7 **Architecture:** Single-threaded `poll(2)` loop in one `muxd` binary. PTY output feeds a heap-pinned `Engine` (ghostty-vt `Terminal` + `TerminalStream`); a throwaway line-command debug socket serves grid dumps via ghostty-vt's built-in `TerminalFormatter` (plain and VT formats). No forking of ghostty — we consume the upstream Zig package's `ghostty-vt` module, already pinned in the local Zig cache.
8
9 **Tech Stack:** Zig 0.17.0-dev (installed at `~/.local/bin/zig`), ghostty package `1.3.2-dev` pinned at commit `853183e9` (hash `ghostty-1.3.2-dev-5UdBC7VOBgVv0iA-qLRtBnau_zLIv7iGGLdnEiW6fUYU`, already in `~/.cache/zig/p/` — builds offline), libc (forkpty).
10
11 **Constraints from the user:**
12 - Do NOT copy code from `~/code/rad/waystty` — the user considers it non-performant. It may be consulted only as *documentation* of ghostty-vt API signatures. Concretely: our PTY uses blocking fds driven by `poll`, not waystty's nonblocking-fd + sleep-loop pattern, and our engine wrapper does not adopt waystty's `RenderState`-per-frame design.
13 - The pinned ghostty package source is browsable at `~/.cache/zig/p/ghostty-1.3.2-dev-5UdBC7VOBgVv0iA-qLRtBnau_zLIv7iGGLdnEiW6fUYU/` — when a signature in this plan doesn't compile, check `src/lib_vt.zig`, `src/terminal/Terminal.zig`, `src/terminal/stream_terminal.zig`, `src/terminal/formatter.zig` there. The plan was written against that exact source, but ghostty-vt's API is explicitly unstable.
14
15 **Verified API facts (from the pinned package source):**
16 - Module name: `dep.module("ghostty-vt")`; import as `@import("ghostty-vt")`.
17 - `vt.Terminal.init(alloc, .{ .cols, .rows, .max_scrollback }) !Terminal` (by value); `term.deinit(alloc)`; `term.resize(alloc, cols, rows) !void`; `term.plainString(alloc) ![]const u8` (visible screen, trailing whitespace trimmed).
18 - `vt.TerminalStream = Stream(stream_terminal.Handler)`; `TerminalStream.initAlloc(alloc, .{ .terminal = &term })`; `stream.nextSlice(bytes)`; `stream.deinit()`. Handler field is `stream.handler`; effects at `stream.handler.effects` (default `.readonly`, all callbacks null). `effects.write_pty: ?*const fn (*Handler, [:0]const u8) void` — required for DSR/DA responses to reach the child app.
19 - `vt.formatter.TerminalFormatter.init(&term, opts)` where `opts` coerces from `.plain` / `.vt`; `.format(writer: *std.Io.Writer)`. Default `extra = .styles`; `.all` "reconstructs the terminal state as closely as possible".
20
21 ---
22
23 ## File Structure
24
25 ```
26 mux/
27 ├── build.zig — muxd exe + unit-test step + e2e step
28 ├── build.zig.zon — pinned ghostty dependency
29 ├── .gitignore
30 ├── README.md — one paragraph + M1 demo instructions (Task 6)
31 ├── docs/
32 │ ├── handoff.md — the design handoff (already created)
33 │ ├── decisions.md — running decision log (Task 6)
34 │ └── superpowers/plans/2026-08-07-m1-headless-engine.md — this file
35 ├── src/
36 │ ├── main.zig — CLI (`run` / `dump`), poll loop, stdin forwarding
37 │ ├── engine.zig — Engine: ghostty-vt Terminal + TerminalStream + dumps
38 │ ├── pty.zig — Pty: forkpty/read/write/resize/exit detection
39 │ └── debug.zig — DebugServer: M1-only line-command socket
40 └── test/
41 └── e2e.sh — end-to-end: muxd run + muxd dump round trip
42 ```
43
44 Responsibilities: `engine.zig` never touches fds; `pty.zig` never touches the engine; `debug.zig` knows the engine only through `dumpPlain`/`dumpVt`; `main.zig` is the only place that wires them together. This keeps M2 (real protocol) a replacement of `debug.zig` + `main.zig` only.
45
46 ---
47
48 ### Task 1: Scaffold + headless smoke test
49
50 The M1 kill criterion in miniature: if this task's one test compiles and passes, ghostty-vt is drivable headless and the thesis survives.
51
52 **Files:**
53 - Create: `.gitignore`, `build.zig.zon`, `build.zig`, `src/engine.zig` (test only, minimal impl), `src/main.zig` (stub)
54
55 - [ ] **Step 1: Init repo**
56
57 ```bash
58 cd /home/xanderle/code/rad/mux
59 git init
60 printf 'zig-out/\n.zig-cache/\n' > .gitignore
61 git add .gitignore docs/
62 git commit -m "docs: add design handoff and M1 plan"
63 ```
64
65 - [ ] **Step 2: Write `build.zig.zon`**
66
67 ```zig
68 .{
69 .name = .mux,
70 .version = "0.0.1",
71 .fingerprint = 0x0, // placeholder — step 4 replaces it
72 .minimum_zig_version = "0.15.2",
73 .paths = .{
74 "build.zig",
75 "build.zig.zon",
76 "src",
77 },
78 .dependencies = .{
79 .ghostty = .{
80 .url = "git+https://github.com/ghostty-org/ghostty#853183e911b70ff7b61057f52fc7b47ea4934238",
81 .hash = "ghostty-1.3.2-dev-5UdBC7VOBgVv0iA-qLRtBnau_zLIv7iGGLdnEiW6fUYU",
82 .lazy = true,
83 },
84 },
85 }
86 ```
87
88 The hash matches `~/.cache/zig/p/`, so no network fetch happens.
89
90 - [ ] **Step 3: Write `build.zig`**
91
92 ```zig
93 const std = @import("std");
94
95 pub fn build(b: *std.Build) void {
96 const target = b.standardTargetOptions(.{});
97 const optimize = b.standardOptimizeOption(.{});
98
99 const ghostty_dep = b.lazyDependency("ghostty", .{
100 .target = target,
101 .optimize = optimize,
102 });
103
104 const engine_mod = b.createModule(.{
105 .root_source_file = b.path("src/engine.zig"),
106 .target = target,
107 .optimize = optimize,
108 });
109 if (ghostty_dep) |dep| {
110 engine_mod.addImport("ghostty-vt", dep.module("ghostty-vt"));
111 }
112
113 const pty_mod = b.createModule(.{
114 .root_source_file = b.path("src/pty.zig"),
115 .target = target,
116 .optimize = optimize,
117 .link_libc = true,
118 });
119
120 const debug_mod = b.createModule(.{
121 .root_source_file = b.path("src/debug.zig"),
122 .target = target,
123 .optimize = optimize,
124 });
125 debug_mod.addImport("engine", engine_mod);
126
127 const exe_mod = b.createModule(.{
128 .root_source_file = b.path("src/main.zig"),
129 .target = target,
130 .optimize = optimize,
131 .link_libc = true,
132 });
133 exe_mod.addImport("engine", engine_mod);
134 exe_mod.addImport("pty", pty_mod);
135 exe_mod.addImport("debug", debug_mod);
136
137 const exe = b.addExecutable(.{ .name = "muxd", .root_module = exe_mod });
138 b.installArtifact(exe);
139
140 const test_step = b.step("test", "Run unit tests");
141 for ([_]*std.Build.Module{ engine_mod, pty_mod, debug_mod }) |mod| {
142 const t = b.addTest(.{ .root_module = mod });
143 test_step.dependOn(&b.addRunArtifact(t).step);
144 }
145
146 const e2e = b.addSystemCommand(&.{"test/e2e.sh"});
147 e2e.addArtifactArg(exe);
148 const e2e_step = b.step("e2e", "Run end-to-end test");
149 e2e_step.dependOn(&e2e.step);
150 }
151 ```
152
153 Note: `src/pty.zig` and `src/debug.zig` don't exist until Tasks 3–4. For this task, create them as empty files (`touch src/pty.zig src/debug.zig`) so the build graph resolves; `main.zig` stub:
154
155 ```zig
156 pub fn main() !void {}
157 ```
158
159 - [ ] **Step 4: Write the failing smoke test in `src/engine.zig`**
160
161 ```zig
162 //! Authoritative headless terminal engine. Wraps ghostty-vt's Terminal
163 //! and TerminalStream behind the small surface muxd needs.
164 const std = @import("std");
165 const vt = @import("ghostty-vt");
166
167 test "ghostty-vt boots headless and text lands in the grid" {
168 const alloc = std.testing.allocator;
169 var term = try vt.Terminal.init(alloc, .{ .cols = 80, .rows = 24 });
170 defer term.deinit(alloc);
171
172 var stream: vt.TerminalStream = .initAlloc(alloc, .{ .terminal = &term });
173 defer stream.deinit();
174
175 stream.nextSlice("hello");
176
177 const s = try term.plainString(alloc);
178 defer alloc.free(s);
179 try std.testing.expectEqualStrings("hello", s);
180 }
181 ```
182
183 - [ ] **Step 5: Run and fix the fingerprint, then verify the test passes**
184
185 Run: `zig build test`
186 Expected: first invocation errors with `invalid fingerprint: 0x0; if this is a new package, use "0x..."` — copy the suggested value into `build.zig.zon` and re-run.
187 Expected: `zig build test` exits 0 (test passed silently).
188
189 If instead the ghostty package itself fails to compile under Zig 0.17-dev, STOP: this is the M1 kill-criterion path. Check what zig version `~/code/rad/waystty` pins before concluding anything (a version mismatch is an environment problem, not a thesis failure).
190
191 - [ ] **Step 6: Commit**
192
193 ```bash
194 git add build.zig build.zig.zon src/
195 git commit -m "feat: scaffold muxd; prove ghostty-vt drives headless"
196 ```
197
198 ---
199
200 ### Task 2: Engine wrapper with byte-correctness tests
201
202 **Files:**
203 - Modify: `src/engine.zig`
204
205 - [ ] **Step 1: Write the failing tests (append to `src/engine.zig`)**
206
207 ```zig
208 test "Engine: wide CJK chars dump byte-correct" {
209 const alloc = std.testing.allocator;
210 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
211 defer e.deinit();
212
213 e.feed("漢字 wide");
214 const s = try e.dumpPlain(alloc);
215 defer alloc.free(s);
216 try std.testing.expectEqualStrings("漢字 wide", s);
217 }
218
219 test "Engine: ZWJ emoji grapheme cluster dumps byte-correct" {
220 const alloc = std.testing.allocator;
221 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
222 defer e.deinit();
223
224 // Woman-astronaut: woman + ZWJ + rocket, one grapheme cluster.
225 e.feed("\u{1F469}\u{200D}\u{1F680}x");
226 const s = try e.dumpPlain(alloc);
227 defer alloc.free(s);
228 try std.testing.expectEqualStrings("\u{1F469}\u{200D}\u{1F680}x", s);
229 }
230
231 test "Engine: SGR attributes survive a vt dump round-trip" {
232 const alloc = std.testing.allocator;
233 var a = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
234 defer a.deinit();
235
236 a.feed("\x1b[1;31mbold red\x1b[0m plain \x1b[4;38;5;42munderline\x1b[0m");
237 const dump_a = try a.dumpVt(alloc);
238 defer alloc.free(dump_a);
239
240 // Feed A's styled dump into a fresh engine; it must reproduce the
241 // same grid, and re-dumping must be a fixed point.
242 var b = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
243 defer b.deinit();
244 b.feed(dump_a);
245
246 const plain_a = try a.dumpPlain(alloc);
247 defer alloc.free(plain_a);
248 const plain_b = try b.dumpPlain(alloc);
249 defer alloc.free(plain_b);
250 try std.testing.expectEqualStrings(plain_a, plain_b);
251
252 const dump_b = try b.dumpVt(alloc);
253 defer alloc.free(dump_b);
254 try std.testing.expectEqualStrings(dump_a, dump_b);
255 }
256
257 test "Engine: DSR cursor-position query response lands in ptyOutput" {
258 const alloc = std.testing.allocator;
259 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
260 defer e.deinit();
261
262 e.feed("\x1b[6n");
263 try std.testing.expectEqualStrings("\x1b[1;1R", e.ptyOutput());
264 e.clearPtyOutput();
265 try std.testing.expectEqual(@as(usize, 0), e.ptyOutput().len);
266 }
267
268 test "Engine: resize" {
269 const alloc = std.testing.allocator;
270 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
271 defer e.deinit();
272 try e.resize(120, 40);
273 }
274 ```
275
276 - [ ] **Step 2: Run tests to verify they fail**
277
278 Run: `zig build test`
279 Expected: compile error — `Engine` not defined.
280
281 - [ ] **Step 3: Implement `Engine` (above the tests in `src/engine.zig`)**
282
283 ```zig
284 pub const Engine = struct {
285 alloc: std.mem.Allocator,
286 term: vt.Terminal,
287 stream: vt.TerminalStream,
288 /// Response bytes the terminal wants written back to the PTY
289 /// (cursor position reports, device attributes, ...). Owner drains
290 /// via ptyOutput()/clearPtyOutput().
291 pty_out: std.ArrayList(u8),
292
293 pub const Options = struct {
294 cols: u16,
295 rows: u16,
296 max_scrollback: usize = 10_000,
297 };
298
299 /// Heap-allocates: stream.handler holds a pointer to `term`, so an
300 /// Engine must never move after init.
301 pub fn init(alloc: std.mem.Allocator, opts: Options) !*Engine {
302 const self = try alloc.create(Engine);
303 errdefer alloc.destroy(self);
304
305 self.* = .{
306 .alloc = alloc,
307 .term = try vt.Terminal.init(alloc, .{
308 .cols = @intCast(opts.cols),
309 .rows = @intCast(opts.rows),
310 .max_scrollback = opts.max_scrollback,
311 }),
312 .stream = undefined,
313 .pty_out = .empty,
314 };
315 errdefer self.term.deinit(alloc);
316
317 self.stream = .initAlloc(alloc, .{ .terminal = &self.term });
318 self.stream.handler.effects.write_pty = &onWritePty;
319 return self;
320 }
321
322 pub fn deinit(self: *Engine) void {
323 self.pty_out.deinit(self.alloc);
324 self.stream.deinit();
325 self.term.deinit(self.alloc);
326 self.alloc.destroy(self);
327 }
328
329 pub fn feed(self: *Engine, bytes: []const u8) void {
330 self.stream.nextSlice(bytes);
331 }
332
333 pub fn ptyOutput(self: *const Engine) []const u8 {
334 return self.pty_out.items;
335 }
336
337 pub fn clearPtyOutput(self: *Engine) void {
338 self.pty_out.clearRetainingCapacity();
339 }
340
341 /// Visible screen as plain UTF-8 text. Caller frees.
342 pub fn dumpPlain(self: *Engine, alloc: std.mem.Allocator) ![]const u8 {
343 return self.term.plainString(alloc);
344 }
345
346 /// Visible screen with SGR/style sequences preserved. Caller frees.
347 pub fn dumpVt(self: *Engine, alloc: std.mem.Allocator) ![]u8 {
348 var aw: std.Io.Writer.Allocating = .init(alloc);
349 defer aw.deinit();
350 const f = vt.formatter.TerminalFormatter.init(&self.term, .vt);
351 try f.format(&aw.writer);
352 return try aw.toOwnedSlice();
353 }
354
355 pub fn resize(self: *Engine, cols: u16, rows: u16) !void {
356 try self.term.resize(self.alloc, @intCast(cols), @intCast(rows));
357 }
358
359 fn onWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void {
360 const stream_ptr: *vt.TerminalStream = @fieldParentPtr("handler", handler);
361 const self: *Engine = @fieldParentPtr("stream", stream_ptr);
362 self.pty_out.appendSlice(self.alloc, data) catch {};
363 }
364 };
365 ```
366
367 API-drift notes for the implementer: if `TerminalFormatter.format` needs a mutable formatter, make `f` a `var`. If the fixed-point assertion in the SGR test fails on `extra`-emitted state (palette OSC 4 lines are expected and deterministic — they should be identical in both dumps), diagnose by printing both dumps with `std.testing.expectEqualStrings`'s diff output before weakening the test; only fall back to `plain_a == plain_b` plus substring checks for `[1m`/`[31m`-family sequences if the formatter output is genuinely non-idempotent, and record that in `docs/decisions.md`.
368
369 - [ ] **Step 4: Update the Task 1 smoke test to use Engine**
370
371 Replace the Task 1 test body with:
372
373 ```zig
374 test "ghostty-vt boots headless and text lands in the grid" {
375 const alloc = std.testing.allocator;
376 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
377 defer e.deinit();
378 e.feed("hello");
379 const s = try e.dumpPlain(alloc);
380 defer alloc.free(s);
381 try std.testing.expectEqualStrings("hello", s);
382 }
383 ```
384
385 - [ ] **Step 5: Run tests to verify they pass**
386
387 Run: `zig build test`
388 Expected: exit 0.
389
390 - [ ] **Step 6: Commit**
391
392 ```bash
393 git add src/engine.zig
394 git commit -m "feat: Engine wrapper with byte-correct dump tests (wide, ZWJ, SGR, DSR)"
395 ```
396
397 ---
398
399 ### Task 3: PTY module
400
401 Fresh implementation (not waystty's): blocking master fd, `poll`-driven by the caller; exit detection via `std.posix.waitpid` with `W.NOHANG`.
402
403 **Files:**
404 - Modify: `src/pty.zig` (currently empty)
405
406 - [ ] **Step 1: Write the failing tests**
407
408 ```zig
409 const std = @import("std");
410 const c = @cImport({
411 @cInclude("pty.h");
412 @cInclude("stdlib.h");
413 @cInclude("sys/ioctl.h");
414 });
415
416 test "Pty: spawn /bin/sh, echo round trip" {
417 var pty = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" });
418 defer pty.deinit();
419
420 _ = try pty.write("echo m1-pty-ok\n");
421
422 var out: std.ArrayList(u8) = .empty;
423 defer out.deinit(std.testing.allocator);
424 var buf: [4096]u8 = undefined;
425
426 // Poll-read up to 5s total; a loaded machine can be slow to exec sh.
427 var waited_ms: u64 = 0;
428 while (waited_ms < 5000) {
429 var fds = [_]std.posix.pollfd{
430 .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
431 };
432 const ready = try std.posix.poll(&fds, 100);
433 waited_ms += 100;
434 if (ready == 0) continue;
435 const n = std.posix.read(pty.master, &buf) catch break;
436 if (n == 0) break;
437 try out.appendSlice(std.testing.allocator, buf[0..n]);
438 if (std.mem.indexOf(u8, out.items, "m1-pty-ok") != null) break;
439 }
440 try std.testing.expect(std.mem.indexOf(u8, out.items, "m1-pty-ok") != null);
441 }
442
443 test "Pty: resize is visible via TIOCGWINSZ" {
444 var pty = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" });
445 defer pty.deinit();
446
447 try pty.resize(120, 40);
448
449 var ws: c.struct_winsize = undefined;
450 try std.testing.expectEqual(
451 @as(c_int, 0),
452 c.ioctl(pty.master, c.TIOCGWINSZ, &ws),
453 );
454 try std.testing.expectEqual(@as(c_ushort, 120), ws.ws_col);
455 try std.testing.expectEqual(@as(c_ushort, 40), ws.ws_row);
456 }
457
458 test "Pty: checkExited reports shell exit" {
459 var pty = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" });
460 defer pty.deinit();
461
462 try std.testing.expect(pty.checkExited() == null);
463 _ = try pty.write("exit 7\n");
464
465 var waited_ms: u64 = 0;
466 var code: ?u32 = null;
467 while (waited_ms < 5000) : (waited_ms += 50) {
468 code = pty.checkExited();
469 if (code != null) break;
470 std.Thread.sleep(50 * std.time.ns_per_ms);
471 }
472 try std.testing.expectEqual(@as(?u32, 7), code);
473 }
474 ```
475
476 - [ ] **Step 2: Run tests to verify they fail**
477
478 Run: `zig build test`
479 Expected: compile error — `Pty` not defined.
480
481 - [ ] **Step 3: Implement `Pty` (above the tests)**
482
483 ```zig
484 pub const Pty = struct {
485 master: std.posix.fd_t,
486 child: std.posix.pid_t,
487 exit_status: ?u32 = null,
488
489 pub const SpawnOptions = struct {
490 cols: u16,
491 rows: u16,
492 shell: [:0]const u8,
493 };
494
495 pub fn spawn(opts: SpawnOptions) !Pty {
496 var master: c_int = undefined;
497 var ws: c.struct_winsize = .{
498 .ws_row = opts.rows,
499 .ws_col = opts.cols,
500 .ws_xpixel = 0,
501 .ws_ypixel = 0,
502 };
503
504 const pid = c.forkpty(&master, null, null, &ws);
505 if (pid < 0) return error.ForkPtyFailed;
506
507 if (pid == 0) {
508 // Child. xterm-256color: ghostty-vt understands more, but this
509 // terminfo exists everywhere the shell will look.
510 _ = c.setenv("TERM", "xterm-256color", 1);
511 var argv = [_:null]?[*:0]const u8{ opts.shell.ptr, null };
512 std.posix.execveZ(opts.shell.ptr, &argv, std.c.environ) catch {};
513 std.process.exit(127);
514 }
515
516 return .{ .master = master, .child = pid };
517 }
518
519 pub fn read(self: *Pty, buf: []u8) !usize {
520 return std.posix.read(self.master, buf);
521 }
522
523 pub fn write(self: *Pty, data: []const u8) !usize {
524 return std.posix.write(self.master, data);
525 }
526
527 pub fn resize(self: *Pty, cols: u16, rows: u16) !void {
528 var ws: c.struct_winsize = .{
529 .ws_row = rows,
530 .ws_col = cols,
531 .ws_xpixel = 0,
532 .ws_ypixel = 0,
533 };
534 if (c.ioctl(self.master, c.TIOCSWINSZ, &ws) < 0) return error.IoctlFailed;
535 }
536
537 /// Non-blocking: exit code if the child has exited, else null.
538 pub fn checkExited(self: *Pty) ?u32 {
539 if (self.exit_status) |s| return s;
540 const res = std.posix.waitpid(self.child, std.posix.W.NOHANG);
541 if (res.pid != self.child) return null;
542 self.exit_status = if (std.posix.W.IFEXITED(res.status))
543 std.posix.W.EXITSTATUS(res.status)
544 else
545 128;
546 return self.exit_status;
547 }
548
549 pub fn deinit(self: *Pty) void {
550 std.posix.close(self.master);
551 if (self.exit_status == null) {
552 std.posix.kill(self.child, std.posix.SIG.TERM) catch {};
553 _ = std.posix.waitpid(self.child, 0);
554 }
555 }
556 };
557 ```
558
559 API-drift note: `std.posix.waitpid(pid, W.NOHANG)` returns a `WaitPidResult` with `.pid` and `.status`; on "still running" the returned pid is 0. If the shape differs on this std version, check `std.posix` source under `~/.local/bin/../lib/zig` (or `zig std`).
560
561 - [ ] **Step 4: Run tests to verify they pass**
562
563 Run: `zig build test`
564 Expected: exit 0.
565
566 - [ ] **Step 5: Commit**
567
568 ```bash
569 git add src/pty.zig
570 git commit -m "feat: blocking-fd Pty with spawn/resize/exit detection"
571 ```
572
573 ---
574
575 ### Task 4: Debug dump socket
576
577 M1-only, replaced wholesale in M2. Protocol: client sends one LF-terminated command (`dump plain` or `dump vt`), server replies with raw payload and closes (EOF-delimited).
578
579 **Files:**
580 - Modify: `src/debug.zig` (currently empty)
581
582 - [ ] **Step 1: Write the failing test**
583
584 ```zig
585 const std = @import("std");
586 const Engine = @import("engine").Engine;
587
588 test "DebugServer: dump plain round trip over unix socket" {
589 const alloc = std.testing.allocator;
590
591 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
592 defer e.deinit();
593 e.feed("debug-sock-ok");
594
595 var tmp = std.testing.tmpDir(.{});
596 defer tmp.cleanup();
597 var path_buf: [256]u8 = undefined;
598 const dir_path = try tmp.dir.realpath(".", &path_buf);
599 const sock_path = try std.fmt.allocPrint(alloc, "{s}/d.sock", .{dir_path});
600 defer alloc.free(sock_path);
601
602 var srv = try DebugServer.init(sock_path);
603 defer srv.deinit();
604
605 const Client = struct {
606 fn go(path: []const u8, out: *std.ArrayList(u8), a: std.mem.Allocator) !void {
607 const stream = try std.net.connectUnixSocket(path);
608 defer stream.close();
609 var idx: usize = 0;
610 const msg = "dump plain\n";
611 while (idx < msg.len) idx += try std.posix.write(stream.handle, msg[idx..]);
612 var buf: [4096]u8 = undefined;
613 while (true) {
614 const n = try std.posix.read(stream.handle, &buf);
615 if (n == 0) break;
616 try out.appendSlice(a, buf[0..n]);
617 }
618 }
619 };
620
621 var reply: std.ArrayList(u8) = .empty;
622 defer reply.deinit(alloc);
623 const t = try std.Thread.spawn(.{}, Client.go, .{ sock_path, &reply, alloc });
624
625 srv.serviceOne(alloc, e); // blocks in accept until the client connects
626 t.join();
627
628 try std.testing.expectEqualStrings("debug-sock-ok", reply.items);
629 }
630 ```
631
632 - [ ] **Step 2: Run tests to verify they fail**
633
634 Run: `zig build test`
635 Expected: compile error — `DebugServer` not defined.
636
637 - [ ] **Step 3: Implement `DebugServer` (above the test)**
638
639 ```zig
640 /// M1-only debug listener. One LF-terminated command per connection:
641 /// "dump plain" | "dump vt"
642 /// Reply is the raw payload, EOF-delimited. Replaced by the real
643 /// protocol in M2.
644 pub const DebugServer = struct {
645 server: std.net.Server,
646 path: []const u8,
647
648 pub fn init(path: []const u8) !DebugServer {
649 std.fs.cwd().deleteFile(path) catch {};
650 const addr = try std.net.Address.initUnix(path);
651 return .{ .server = try addr.listen(.{}), .path = path };
652 }
653
654 pub fn deinit(self: *DebugServer) void {
655 self.server.deinit();
656 std.fs.cwd().deleteFile(self.path) catch {};
657 }
658
659 /// Pollable listener fd for the daemon's event loop.
660 pub fn fd(self: *const DebugServer) std.posix.fd_t {
661 return self.server.stream.handle;
662 }
663
664 /// Accept one connection, service it synchronously, close it.
665 /// Dumps are small and local; blocking here is fine for a debug tool.
666 pub fn serviceOne(self: *DebugServer, alloc: std.mem.Allocator, eng: *Engine) void {
667 const conn = self.server.accept() catch return;
668 defer conn.stream.close();
669
670 var buf: [256]u8 = undefined;
671 const n = std.posix.read(conn.stream.handle, &buf) catch return;
672 const line = std.mem.trimRight(u8, buf[0..n], "\r\n");
673
674 const reply: []const u8 = if (std.mem.eql(u8, line, "dump plain"))
675 eng.dumpPlain(alloc) catch return
676 else if (std.mem.eql(u8, line, "dump vt"))
677 eng.dumpVt(alloc) catch return
678 else
679 "error: unknown command (want: dump plain | dump vt)";
680 const owned = !std.mem.startsWith(u8, reply, "error:");
681 defer if (owned) alloc.free(reply);
682
683 var idx: usize = 0;
684 while (idx < reply.len) {
685 idx += std.posix.write(conn.stream.handle, reply[idx..]) catch return;
686 }
687 }
688 };
689 ```
690
691 - [ ] **Step 4: Run tests to verify they pass**
692
693 Run: `zig build test`
694 Expected: exit 0.
695
696 - [ ] **Step 5: Commit**
697
698 ```bash
699 git add src/debug.zig
700 git commit -m "feat: M1 debug dump socket (line command, EOF-delimited reply)"
701 ```
702
703 ---
704
705 ### Task 5: `muxd` main — wire it together
706
707 **Files:**
708 - Modify: `src/main.zig` (replace stub)
709 - Create: `test/e2e.sh`
710
711 - [ ] **Step 1: Write the failing e2e test `test/e2e.sh`**
712
713 ```sh
714 #!/bin/sh
715 # End-to-end: run muxd headless with piped stdin, dump the grid from a
716 # second process, verify shell output landed in the ghostty-vt grid.
717 set -eu
718 MUXD="$1"
719 SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock"
720
721 cleanup() { kill "$DPID" 2>/dev/null || true; rm -f "$SOCK"; }
722 trap cleanup EXIT INT TERM
723
724 { printf 'printf "e2e-%%s\\n" works\n'; sleep 3; } | \
725 "$MUXD" run --sock "$SOCK" --shell /bin/sh &
726 DPID=$!
727
728 # Wait for the socket, then give the shell a moment to run the command.
729 i=0
730 while [ ! -S "$SOCK" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done
731 [ -S "$SOCK" ] || { echo "e2e FAIL: socket never appeared"; exit 1; }
732 sleep 1
733
734 OUT="$("$MUXD" dump --sock "$SOCK")"
735 case "$OUT" in
736 *e2e-works*) echo "e2e OK" ;;
737 *) echo "e2e FAIL: grid was:"; echo "$OUT"; exit 1 ;;
738 esac
739 ```
740
741 Then: `chmod +x test/e2e.sh`
742
743 - [ ] **Step 2: Run it to verify it fails**
744
745 Run: `zig build e2e`
746 Expected: failure — `muxd run` is still a stub (unknown args, no socket).
747
748 - [ ] **Step 3: Implement `src/main.zig`**
749
750 ```zig
751 const std = @import("std");
752 const Engine = @import("engine").Engine;
753 const Pty = @import("pty").Pty;
754 const debug = @import("debug");
755
756 const usage =
757 \\usage:
758 \\ muxd run [--sock PATH] [--shell PATH] run daemon in foreground;
759 \\ stdin is forwarded to the PTY
760 \\ muxd dump [--vt] [--sock PATH] print the current grid
761 \\
762 ;
763
764 pub fn main() !u8 {
765 var gpa: std.heap.DebugAllocator(.{}) = .init;
766 defer _ = gpa.deinit();
767 const alloc = gpa.allocator();
768
769 const args = try std.process.argsAlloc(alloc);
770 defer std.process.argsFree(alloc, args);
771
772 if (args.len < 2) {
773 std.debug.print("{s}", .{usage});
774 return 2;
775 }
776
777 var sock_arg: ?[]const u8 = null;
778 var shell_arg: ?[]const u8 = null;
779 var vt_mode = false;
780 var i: usize = 2;
781 while (i < args.len) : (i += 1) {
782 const a = args[i];
783 if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) {
784 i += 1;
785 sock_arg = args[i];
786 } else if (std.mem.eql(u8, a, "--shell") and i + 1 < args.len) {
787 i += 1;
788 shell_arg = args[i];
789 } else if (std.mem.eql(u8, a, "--vt")) {
790 vt_mode = true;
791 } else {
792 std.debug.print("unknown argument: {s}\n{s}", .{ a, usage });
793 return 2;
794 }
795 }
796
797 const sock_path = if (sock_arg) |s|
798 try alloc.dupe(u8, s)
799 else
800 try defaultSockPath(alloc);
801 defer alloc.free(sock_path);
802
803 if (std.mem.eql(u8, args[1], "run")) return run(alloc, sock_path, shell_arg);
804 if (std.mem.eql(u8, args[1], "dump")) return dump(alloc, sock_path, vt_mode);
805 std.debug.print("{s}", .{usage});
806 return 2;
807 }
808
809 fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 {
810 if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| {
811 return std.fmt.allocPrint(alloc, "{s}/muxd-debug.sock", .{dir});
812 }
813 return std.fmt.allocPrint(alloc, "/tmp/muxd-debug-{d}.sock", .{std.os.linux.getuid()});
814 }
815
816 fn run(alloc: std.mem.Allocator, sock_path: []const u8, shell_arg: ?[]const u8) !u8 {
817 const shell_z: [:0]const u8 = if (shell_arg) |s|
818 try alloc.dupeZ(u8, s)
819 else
820 try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh");
821 defer alloc.free(shell_z);
822
823 // Grid size: the controlling tty's size if we have one, else 80x24.
824 var cols: u16 = 80;
825 var rows: u16 = 24;
826 if (std.posix.isatty(std.posix.STDIN_FILENO)) {
827 var ws: std.posix.winsize = undefined;
828 if (std.os.linux.ioctl(
829 std.posix.STDIN_FILENO,
830 std.os.linux.T.IOCGWINSZ,
831 @intFromPtr(&ws),
832 ) == 0) {
833 cols = ws.col;
834 rows = ws.row;
835 }
836 }
837
838 const eng = try Engine.init(alloc, .{ .cols = cols, .rows = rows });
839 defer eng.deinit();
840
841 var pty = try Pty.spawn(.{ .cols = cols, .rows = rows, .shell = shell_z });
842 defer pty.deinit();
843
844 var srv = try debug.DebugServer.init(sock_path);
845 defer srv.deinit();
846
847 // Raw mode so keystrokes (arrows, ^C) pass through to the PTY.
848 const stdin_fd = std.posix.STDIN_FILENO;
849 var orig_termios: ?std.posix.termios = null;
850 if (std.posix.isatty(stdin_fd)) {
851 const orig = try std.posix.tcgetattr(stdin_fd);
852 orig_termios = orig;
853 var raw = orig;
854 raw.lflag.ICANON = false;
855 raw.lflag.ECHO = false;
856 raw.lflag.ISIG = false;
857 raw.iflag.IXON = false;
858 raw.iflag.ICRNL = false;
859 try std.posix.tcsetattr(stdin_fd, .FLUSH, raw);
860 }
861 defer if (orig_termios) |t| std.posix.tcsetattr(stdin_fd, .FLUSH, t) catch {};
862
863 var stdin_open = true;
864 var buf: [64 * 1024]u8 = undefined;
865 while (true) {
866 if (pty.checkExited()) |code| return @intCast(code & 0xff);
867
868 var fds = [_]std.posix.pollfd{
869 .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
870 .{ .fd = srv.fd(), .events = std.posix.POLL.IN, .revents = 0 },
871 .{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 },
872 };
873 // 100ms timeout so child exit is noticed even with no fd activity.
874 _ = std.posix.poll(&fds, 100) catch |err| switch (err) {
875 error.SignalInterrupt => continue,
876 else => return err,
877 };
878
879 if (fds[0].revents & std.posix.POLL.IN != 0) {
880 const n = std.posix.read(pty.master, &buf) catch 0;
881 if (n > 0) {
882 eng.feed(buf[0..n]);
883 const resp = eng.ptyOutput();
884 if (resp.len > 0) {
885 writeAll(pty.master, resp);
886 eng.clearPtyOutput();
887 }
888 }
889 }
890
891 if (fds[1].revents & std.posix.POLL.IN != 0) srv.serviceOne(alloc, eng);
892
893 if (stdin_open and fds[2].revents & std.posix.POLL.IN != 0) {
894 const n = std.posix.read(stdin_fd, &buf) catch 0;
895 if (n == 0) {
896 stdin_open = false; // piped stdin closed; keep running headless
897 } else {
898 writeAll(pty.master, buf[0..n]);
899 }
900 }
901 }
902 }
903
904 fn dump(alloc: std.mem.Allocator, sock_path: []const u8, vt_mode: bool) !u8 {
905 _ = alloc;
906 const stream = std.net.connectUnixSocket(sock_path) catch {
907 std.debug.print("muxd dump: cannot connect to {s} (is `muxd run` running?)\n", .{sock_path});
908 return 1;
909 };
910 defer stream.close();
911
912 writeAll(stream.handle, if (vt_mode) "dump vt\n" else "dump plain\n");
913
914 var buf: [4096]u8 = undefined;
915 while (true) {
916 const n = std.posix.read(stream.handle, &buf) catch break;
917 if (n == 0) break;
918 writeAll(std.posix.STDOUT_FILENO, buf[0..n]);
919 }
920 writeAll(std.posix.STDOUT_FILENO, "\n");
921 return 0;
922 }
923
924 fn writeAll(fd: std.posix.fd_t, data: []const u8) void {
925 var idx: usize = 0;
926 while (idx < data.len) {
927 idx += std.posix.write(fd, data[idx..]) catch return;
928 }
929 }
930 ```
931
932 API-drift notes: `std.posix.winsize` field names are `row`/`col`/`xpixel`/`ypixel` on current std (older: `ws_row`/`ws_col`). `poll` error set may not include `SignalInterrupt` (std retries EINTR internally on some versions) — if the compiler says the switch arm is unreachable, drop the switch. `main` returning `!u8` sets the process exit code.
933
934 - [ ] **Step 4: Run unit tests, then e2e**
935
936 Run: `zig build test`
937 Expected: exit 0.
938 Run: `zig build e2e`
939 Expected: prints `e2e OK`.
940
941 - [ ] **Step 5: Commit**
942
943 ```bash
944 git add src/main.zig test/e2e.sh
945 git commit -m "feat: muxd run/dump wired through poll loop; e2e passes"
946 ```
947
948 ---
949
950 ### Task 6: Demo, decision log, README
951
952 **Files:**
953 - Create: `docs/decisions.md`, `README.md`
954
955 - [ ] **Step 1: Manual demo (the M1 acceptance run)**
956
957 In terminal A:
958 ```bash
959 cd /home/xanderle/code/rad/mux && zig build && ./zig-out/bin/muxd run
960 ```
961 Terminal A now forwards keystrokes blind (the grid lives only in the daemon).
962
963 In terminal B, after typing each of the following in A, run `./zig-out/bin/muxd dump` and compare against reality:
964 1. Type `ls -la<Enter>` in A → dump shows the listing.
965 2. Type `htop<Enter>` in A → dump shows htop's frame (boxes/bars as text); `q` to quit.
966 3. Type `vim /tmp/m1.txt<Enter>`, `i`, `héllo 漢字 👩‍🚀`, `<Esc>:wq<Enter>` → dumps during editing show vim's UI including the tildes and statusline.
967 4. `printf 'á漢👩‍🚀\n%.0s' $(seq 40) > /tmp/utf8.txt; less /tmp/utf8.txt` → dump matches; also run `./zig-out/bin/muxd dump --vt` and `printf` the output in a real terminal — colors/styles must reproduce.
968
969 Record any mismatch as a bug before declaring M1 done. If a TUI hangs waiting for a terminal query response, the missing piece is an `effects` callback in `engine.zig` (likely `device_attributes` — wire it to return defaults `.{}`; see `stream_terminal.zig` in the pinned package for the exact signature).
970
971 - [ ] **Step 2: Write `docs/decisions.md`**
972
973 ```markdown
974 # Decision log
975
976 ## 2026-08-07 (M1)
977
978 - **Language: Zig.** The engine dependency (ghostty-vt) is a Zig module; a C
979 shim would add surface without adding capability.
980 - **Engine: upstream ghostty package, not a fork.** Pinned at commit
981 `853183e9` (1.3.2-dev), module `ghostty-vt`. The M1 kill criterion
982 ("cannot extract grid without invasive forking") is moot: upstream ships
983 a headless VT library with plain/VT/HTML formatters and a RenderState
984 dirty-tracking API (relevant for M4 deltas). API is documented unstable;
985 the pin is load-bearing.
986 - **No code reuse from waystty** (user decision: not performant). ghostty-vt
987 API knowledge only. muxd uses blocking fds + poll, single thread.
988 - **M1 debug protocol:** one LF-terminated command per connection on
989 `$XDG_RUNTIME_DIR/muxd-debug.sock`, EOF-delimited reply. Throwaway;
990 M2 replaces it and claims `muxd.sock` for the real protocol.
991 - **TERM=xterm-256color** in the child, not xterm-ghostty: terminfo
992 availability beats capability advertising for a prototype.
993 - **Scrollback: engine-native.** ghostty-vt's max_scrollback (10k lines)
994 is the ring buffer; no separate structure in muxd.
995
996 ## Open (owed by later milestones)
997
998 - Resize policy under multiple clients (M5)
999 - Snapshot-vs-delta threshold (M4)
1000 - Scrollback retention/eviction limits (M3/M4)
1001 - Daemon lifetime across logout/reboot (M2)
1002 - Wire format msgpack vs protobuf + versioning (M2/M4)
1003 ```
1004
1005 - [ ] **Step 3: Write `README.md`**
1006
1007 ```markdown
1008 # mux
1009
1010 Prototype terminal multiplexer: the terminal engine (ghostty-vt) runs
1011 authoritatively in a daemon and replicated in the client — state sync
1012 instead of escape-sequence replay. See `docs/handoff.md` for the design
1013 and `docs/decisions.md` for decisions made.
1014
1015 Status: **M1 — headless engine.**
1016
1017 zig build test && zig build e2e # verify
1018 ./zig-out/bin/muxd run # daemon, forwards stdin to the PTY
1019 ./zig-out/bin/muxd dump [--vt] # print the authoritative grid
1020 ```
1021
1022 - [ ] **Step 4: Commit**
1023
1024 ```bash
1025 git add docs/decisions.md README.md
1026 git commit -m "docs: M1 decision log, README, demo instructions"
1027 ```
1028
1029 ---
1030
1031 ## Self-Review
1032
1033 - **Spec coverage:** M1 spec = spawn PTY (Task 3) + run `$SHELL` (Task 3/5) + feed output into libghostty (Tasks 2/5) + debug dump command (Tasks 4/5) + byte-correct wide/grapheme/SGR (Task 2 tests) + demo with htop/vim/less UTF-8 (Task 6). Kill criterion is checked at Task 1 Step 5. Scrollback ring buffer from the architecture diagram is engine-native (decision recorded).
1034 - **Placeholders:** none; all steps carry complete code or exact commands.
1035 - **Type consistency:** `Engine.init/deinit/feed/ptyOutput/clearPtyOutput/dumpPlain/dumpVt/resize` used identically in Tasks 2, 4, 5. `Pty.master/spawn/read/write/resize/checkExited/deinit` consistent across Tasks 3, 5. `DebugServer.init/deinit/fd/serviceOne` consistent across Tasks 4, 5.
1036 - **Known risk, stated where it bites:** ghostty-vt's API is declared unstable; each code step carries an API-drift note pointing at the exact pinned source file to consult.