a73x

3b301dee

docs: retire the superpowers specs/plans and the original handoff — git history keeps them

a73x   2026-08-28 20:07

Commit message
docs: retire the superpowers specs/plans and the original handoff — git history keeps them

31,846 lines gone (tracked LOC 122,327 -> 90,481). Every shipped design is
recorded in docs/decisions.md; the six live references now point at git
history instead of a path that no longer exists.

CLAUDE.md
Old New
@@ -36,7 +36,6 @@ costs ~800k tokens; every token stays in context and is re-billed each turn.
36 answers most "what is this" questions for ~200 tokens. 36 answers most "what is this" questions for ~200 tokens.
37 - Pipe Bash output: `| tail -30`, `2>/dev/null`, `grep -c`. `make test` full 37 - Pipe Bash output: `| tail -30`, `2>/dev/null`, `grep -c`. `make test` full
38 output is thousands of tokens of re-billed noise. 38 output is thousands of tokens of re-billed noise.
39 - `docs/superpowers/plans/*` are historical (16 files, ~15k lines). Grep, don't read.
40 39
41 ## Layout 40 ## Layout
42 41
@@ -228,8 +227,8 @@ real pty), `wsclient` (browser stand-in), `rawmode`, `delaypipe`, `render`.
228 227
229 `docs/roadmap.md` (~650 ln) the ranked queue, but stale past 2026-08-16 — 228 `docs/roadmap.md` (~650 ln) the ranked queue, but stale past 2026-08-16 —
230 `git-collab issue list` is the live order · `docs/decisions.md` (7.4k ln, grep 229 `git-collab issue list` is the live order · `docs/decisions.md` (7.4k ln, grep
231 only) every decision + measurement · `docs/handoff.md` (~200 ln) the original 230 only) every decision + measurement · `README.md` user-facing usage. Design
232 design, historical · `README.md` user-facing usage. 231 history (specs, plans, the original handoff) lives in git: `git log -- docs/superpowers`.
233 232
234 ## Session hygiene 233 ## Session hygiene
235 234
README.md
Old New
@@ -576,8 +576,8 @@ can. Transport is deliberately dumb: the same frames ride a unix socket, an
576 ssh pipe (`muxd proxy` is a byte pump with zero protocol knowledge), or a 576 ssh pipe (`muxd proxy` is a byte pump with zero protocol knowledge), or a
577 QUIC stream, and the wire protocol has survived all three without changing. 577 QUIC stream, and the wire protocol has survived all three without changing.
578 578
579 Design: `docs/handoff.md`. Every decision and measurement: 579 Every decision and measurement: `docs/decisions.md`. What's next: `git-collab
580 `docs/decisions.md`. What's next: `docs/roadmap.md`. 580 issue list` (`docs/roadmap.md` is the older ranked queue).
581 581
582 ## Status 582 ## Status
583 583
docs/handoff.md
Old New
@@ -1,205 +0,0 @@
1 # Handoff: Linux-only multiplexer prototype
2
3 **Status:** HISTORICAL — the design handoff as written, before any code.
4 Kept unedited on purpose: what it got wrong is the record. M1–M5 below all
5 cleared; everything it declares out of scope (network, auth, TLS,
6 prediction, web) shipped anyway, and where it guessed (GPU/GTK rendering,
7 msgpack, `$XDG_RUNTIME_DIR/muxd.sock`, systemd socket activation) the built
8 thing differs. For what mux is now: `README.md`. For how each of those calls
9 was actually made and measured: `docs/decisions.md`.
10 **Audience:** whoever wants the original reasoning.
11 **Scope (as stated then):** Linux only. No macOS, no iOS, no web, no network.
12
13 ---
14
15 ## 0. What this is and isn't
16
17 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.
18
19 This prototype exists to answer two questions and nothing else:
20
21 1. Can libghostty serve as an authoritative, headless, serializable grid in a daemon?
22 2. Does detach/reattach as a *state sync* (rather than an escape-sequence replay) actually feel correct and fast?
23
24 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.
25
26 **Explicitly not in this prototype:** panes/splits, local echo/prediction, config or theming, plugins, keybinding layer, auth, TLS, TCP, checkpoint/restore, session migration, sharing.
27
28 ---
29
30 ## 1. Why incremental
31
32 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.
33
34 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.
35
36 Ordering principle: **prove the engine, then the loop, then the promise, then the scale, then the concurrency.** Each stage assumes the previous one held.
37
38 ---
39
40 ## 2. Target architecture (end state of this prototype)
41
42 ```
43 muxd (daemon, systemd user service)
44 ├─ session registry
45 ├─ per session:
46 │ ├─ PTY (forkpty, child = user's $SHELL)
47 │ ├─ libghostty grid ← source of truth
48 │ └─ scrollback ring buffer
49 └─ listener: $XDG_RUNTIME_DIR/muxd.sock (Unix domain socket)
50
51 mux (client)
52 ├─ libghostty replica grid
53 ├─ GPU rendering (reuse Ghostty's existing Linux/GTK path)
54 └─ socket client
55 ```
56
57 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.
58
59 ---
60
61 ## 3. Milestones
62
63 ### M1 — Headless engine
64 **Goal:** prove libghostty runs server-side with no display and its grid can be read out.
65
66 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.
67
68 **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.
69
70 **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.
71
72 **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.
73
74 **Done when:** grid dump is byte-correct for wide chars, grapheme clusters, and SGR attributes.
75
76 ---
77
78 ### M2 — The loop
79 **Goal:** prove input and output flow end to end through a real client.
80
81 Add the socket listener, a minimal `Attach` / `Input` / `Snapshot` message set, and a `mux` client that renders the replica grid and forwards keystrokes.
82
83 Full snapshot on every update is fine here. It will be wasteful and that's acceptable — deltas are M4.
84
85 **Demo:** type in `mux`, see it echo, run `vim`, edit a file, `:wq`. It should feel indistinguishable from a normal terminal.
86
87 **Falsifies:** the two-sided-engine model. If the replica diverges from the authoritative grid under normal use, the data model is wrong.
88
89 **Done when:** a full interactive session (shell + a TUI app + a pager) works without visual artifacts.
90
91 ---
92
93 ### M3 — The promise
94 **Goal:** detach and reattach as state sync. **This is the milestone that matters most.**
95
96 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.
97
98 **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.
99
100 **Falsifies:** the core product promise. If reattach is slow, lossy, or wrong, nothing downstream is worth building.
101
102 **Done when:** reattach is visually instant and state is correct for both line-mode and full-screen-TUI sessions.
103
104 ---
105
106 ### M4 — Deltas
107 **Goal:** prove the protocol will survive a real network later.
108
109 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.
110
111 **Demo:** instrument bytes-on-wire. Compare M2's full-snapshot volume against M4's for the same session. The gap is the whole point.
112
113 **Falsifies:** network viability. Over a Unix socket everything feels fine; this milestone is what tells you whether the design survives cellular.
114
115 **Done when:** steady-state typing sends bytes proportional to what changed, not to screen size, and reattach-after-a-gap still resolves correctly.
116
117 ---
118
119 ### M5 — Two clients
120 **Goal:** prove the authoritative-daemon model under concurrency, and force the resize decision.
121
122 Two `mux` instances attached to one session simultaneously. Both stay in sync. Client-local view state (scroll position, selection) stays independent.
123
124 **Demo:** two terminals side by side on one session; type in either, both update. Scroll back in one; the other doesn't move.
125
126 **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.
127
128 **Done when:** two clients converge reliably and divergent view state behaves as a feature rather than a bug.
129
130 ---
131
132 ## 4. Protocol sketch
133
134 Starting point, expected to change:
135
136 ```
137 client → daemon:
138 Attach { session_id, viewport_size, have_seq }
139 Input { bytes }
140 Resize { cols, rows }
141 FetchScrollback { range }
142 Detach {}
143
144 daemon → client:
145 Snapshot { grid, cursor, seq }
146 Delta { damage_regions, cursor, seq }
147 ScrollbackChunk { range, rows }
148 Bell {}
149 TitleChange { title }
150 ExitStatus { code }
151 ```
152
153 Wire format: msgpack or protobuf. **Do not invent one.**
154
155 Non-negotiable properties, because they're the ones that are expensive to add later:
156 - **Sequence numbers from M4 onward** — reattach must be able to request a delta
157 - **Damage regions, not full-grid sends** — decides network viability
158 - **Lazy scrollback** — history is fetched, never pushed wholesale
159
160 ---
161
162 ## 5. Known hard parts
163
164 | Area | Why it's hard | Where it lands |
165 |---|---|---|
166 | Grid serialization | libghostty renders; it doesn't ship state over a wire. Needs a new snapshot/delta API. | M1 |
167 | 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 |
168 | Resize with multiple clients | No good industry answer. Affects the grid model. | M5, decide explicitly |
169 | Daemon lifecycle | systemd user unit + socket activation. Sessions survive logout only with lingering enabled. Cheap now, annoying to retrofit. | M2 |
170 | 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 |
171
172 ---
173
174 ## 6. Deferred, with reasons
175
176 - **Panes/splits** — client-side layout once the daemon serves N sessions. Not a daemon concern. Feels core, isn't.
177 - **Local echo/prediction** — zero latency over a Unix socket; adding it now would be optimizing a problem we don't have.
178 - **Network transport, auth, TLS** — transport swap, not redesign. Prove the model first.
179 - **Offload / checkpoint-restore / mesh** — separate problem entirely (process location, not terminal rendering). Do not let it contaminate this prototype.
180 - **Sharing/multiplayer** — M5 proves the underlying mechanism; the product surface comes later.
181 - **Config, theming, plugins, keybindings** — distraction.
182
183 ---
184
185 ## 7. Decision log to maintain
186
187 Keep a running file. At minimum, record decisions on:
188
189 - Resize policy under multiple clients (M5)
190 - Snapshot vs delta threshold — how far behind before a client gets a full snapshot
191 - Scrollback retention limit per session, and eviction behaviour
192 - Daemon lifetime — session persistence across logout and reboot
193 - Wire format choice and versioning strategy
194
195 ---
196
197 ## 8. Kill criteria
198
199 Stop and reassess, rather than pushing through, if:
200
201 - **M1:** libghostty's grid cannot be extracted without invasive forking of the engine
202 - **M3:** reattach cannot be made both fast and correct for full-screen TUI sessions
203 - **M4:** delta traffic doesn't scale down meaningfully versus full snapshots
204
205 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
@@ -1,1036 +0,0 @@
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 - [x] **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 - [x] **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 - [x] **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 - [x] **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 - [x] **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 - [x] **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 - [x] **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 - [x] **Step 2: Run tests to verify they fail**
277
278 Run: `zig build test`
279 Expected: compile error — `Engine` not defined.
280
281 - [x] **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 - [x] **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 - [x] **Step 5: Run tests to verify they pass**
386
387 Run: `zig build test`
388 Expected: exit 0.
389
390 - [x] **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 - [x] **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 - [x] **Step 2: Run tests to verify they fail**
477
478 Run: `zig build test`
479 Expected: compile error — `Pty` not defined.
480
481 - [x] **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 - [x] **Step 4: Run tests to verify they pass**
562
563 Run: `zig build test`
564 Expected: exit 0.
565
566 - [x] **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 - [x] **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 - [x] **Step 2: Run tests to verify they fail**
633
634 Run: `zig build test`
635 Expected: compile error — `DebugServer` not defined.
636
637 - [x] **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 - [x] **Step 4: Run tests to verify they pass**
692
693 Run: `zig build test`
694 Expected: exit 0.
695
696 - [x] **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 - [x] **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 - [x] **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 - [x] **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 - [x] **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 - [x] **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 - [x] **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 - [x] **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 - [x] **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 - [x] **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.
docs/superpowers/plans/2026-08-07-m2-the-loop.md
Old New
@@ -1,1221 +0,0 @@
1 # M2 — The Loop 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 input and output flow end to end through a real client: `mux` attaches to `muxd` over `$XDG_RUNTIME_DIR/muxd.sock`, maintains a ghostty-vt **replica** grid rebuilt from full-state snapshots, renders it to the user's terminal, and forwards keystrokes — a full interactive session (shell + TUI + pager) with no visual artifacts.
6
7 **Architecture:** Length-prefixed frames (1-byte type + u32 LE length) over the Unix socket. Daemon (`muxd run`) becomes a pure daemon: single-threaded poll over {pty, listener, client}; after each engine update it sends a **Snapshot** = ghostty-vt `TerminalFormatter` dump with `extra = .all` (palette, modes, cursor CUP — verified: `ScreenFormatter.Extra.all` sets `.cursor = true`). The client holds a replica `Engine`, rebuilds it per snapshot via `fullReset()` + feed (state sync — the canonical serialization, never the raw PTY firehose), and repaints its tty under synchronized-output (CSI ?2026). Full snapshot per update is deliberately wasteful — deltas are M4.
8
9 **Tech Stack:** Zig 0.15.2 (Makefile-pinned), ghostty-vt (existing pin), two executables (`muxd`, `mux`) sharing modules.
10
11 **M2 falsification test:** replica grid must not diverge from the authoritative grid under normal use. Task 5's integration test attaches a scripted client, then byte-compares replica dump vs daemon dump.
12
13 **Verified API facts (beyond M1's):**
14 - `ScreenFormatter.Extra.all` emits cursor position (CUP), styles, hyperlinks, kitty keyboard state, charsets; `TerminalFormatter.Extra.all` adds palette, modes (incl. active screen), scrolling region, tabstops, pwd, keyboard modes.
15 - `stream` handles `.full_reset` (ESC `c`) → `Terminal.fullReset()`; also callable directly.
16 - `term.screens.active` is `*Screen`; `Screen.cursor.x/.y` are 0-based `size.CellCountInt`.
17
18 ---
19
20 ## File Structure
21
22 ```
23 src/
24 ├── protocol.zig — NEW: MsgType, frame read/write, fd helpers (no deps)
25 ├── engine.zig — MODIFY: add dumpState (extra=.all), cursorPos, reset
26 ├── pty.zig — unchanged
27 ├── server.zig — NEW: Server{engine,pty,listener,client}, pumpOnce loop,
28 │ frame handling, snapshot broadcast, sd_listen_fds
29 ├── client.zig — NEW: attach loop, replica rebuild, renderer, raw mode,
30 │ SIGWINCH, Ctrl-\ detach
31 ├── main.zig — REWRITE: muxd {run,dump} on the new protocol; daemon no
32 │ longer touches stdin/termios
33 ├── mux_main.zig — NEW: `mux` client binary entrypoint
34 └── debug.zig — DELETE (replaced by protocol debug_dump)
35 contrib/
36 ├── muxd.service — NEW: systemd user unit
37 └── muxd.socket — NEW: socket-activation unit
38 test/e2e.sh — REWRITE: muxd + mux end-to-end
39 ```
40
41 Module graph: `protocol` (no deps); `server` imports engine+pty+protocol; `client` imports engine+protocol; `muxd` = main.zig imports server+protocol; `mux` = mux_main.zig imports client. Default socket moves to `$XDG_RUNTIME_DIR/muxd.sock` (the real name — the M1 debug name retires with debug.zig).
42
43 Decisions this plan locks in (recorded in decisions.md, Task 7): wire format is a trivial type+length frame *for M2 only* — the handoff's "msgpack or protobuf, do not invent one" applies to structured payloads, which first appear with M4's deltas; M2 payloads are byte-blobs and pairs of u16s. Single client per daemon (M5 lifts this). Blocking frame I/O per connection (local socket, one client — buffered nonblocking I/O is owed by M4). Detach chord: `Ctrl-\` (0x1c) in the client. Renderer repaints with home+ED(2) bracketed by CSI ?2026 sync.
44
45 ---
46
47 ### Task 1: Protocol frames
48
49 **Files:**
50 - Create: `src/protocol.zig`
51 - Modify: `build.zig` (add protocol module + its test)
52
53 - [x] **Step 1: Write `src/protocol.zig` with failing tests**
54
55 ```zig
56 //! Wire protocol: length-prefixed frames over a Unix socket.
57 //! Frame = 1 byte MsgType, u32 LE payload length, payload bytes.
58 //! M2 payloads are raw bytes or fixed-width integers — a serialization
59 //! library (msgpack/protobuf) enters with M4's structured deltas.
60 const std = @import("std");
61
62 pub const MsgType = enum(u8) {
63 // client -> daemon
64 attach = 0x01, // payload: u16 LE cols, u16 LE rows
65 input = 0x02, // payload: raw bytes for the PTY
66 resize = 0x03, // payload: u16 LE cols, u16 LE rows
67 detach = 0x04, // payload: empty
68 debug_dump = 0x7f, // payload: 1 byte: 0 = plain, 1 = vt
69 // daemon -> client
70 snapshot = 0x81, // payload: full-state vt dump (TerminalFormatter .all)
71 exit_status = 0x82, // payload: 1 byte exit code
72 dump_reply = 0xff, // payload: requested dump bytes
73 _,
74 };
75
76 pub const max_payload = 16 * 1024 * 1024;
77
78 pub const Frame = struct {
79 type: MsgType,
80 payload: []u8,
81
82 pub fn deinit(self: Frame, alloc: std.mem.Allocator) void {
83 alloc.free(self.payload);
84 }
85 };
86
87 pub fn writeFrame(fd: std.posix.fd_t, t: MsgType, payload: []const u8) !void {
88 var hdr: [5]u8 = undefined;
89 hdr[0] = @intFromEnum(t);
90 std.mem.writeInt(u32, hdr[1..5], @intCast(payload.len), .little);
91 try writeAllFd(fd, &hdr);
92 try writeAllFd(fd, payload);
93 }
94
95 /// Blocking read of one frame. Returns null on clean EOF at a frame
96 /// boundary; errors on EOF mid-frame.
97 pub fn readFrame(alloc: std.mem.Allocator, fd: std.posix.fd_t) !?Frame {
98 var hdr: [5]u8 = undefined;
99 const first = try std.posix.read(fd, hdr[0..1]);
100 if (first == 0) return null;
101 try readExact(fd, hdr[1..5]);
102 const len = std.mem.readInt(u32, hdr[1..5], .little);
103 if (len > max_payload) return error.FrameTooLarge;
104 const payload = try alloc.alloc(u8, len);
105 errdefer alloc.free(payload);
106 try readExact(fd, payload);
107 return .{ .type = @enumFromInt(hdr[0]), .payload = payload };
108 }
109
110 pub fn writeAllFd(fd: std.posix.fd_t, data: []const u8) !void {
111 var idx: usize = 0;
112 while (idx < data.len) idx += try std.posix.write(fd, data[idx..]);
113 }
114
115 fn readExact(fd: std.posix.fd_t, buf: []u8) !void {
116 var idx: usize = 0;
117 while (idx < buf.len) {
118 const n = try std.posix.read(fd, buf[idx..]);
119 if (n == 0) return error.UnexpectedEof;
120 idx += n;
121 }
122 }
123
124 /// Encode a cols/rows pair (attach and resize payloads).
125 pub fn encodeSize(cols: u16, rows: u16) [4]u8 {
126 var buf: [4]u8 = undefined;
127 std.mem.writeInt(u16, buf[0..2], cols, .little);
128 std.mem.writeInt(u16, buf[2..4], rows, .little);
129 return buf;
130 }
131
132 pub const Size = struct { cols: u16, rows: u16 };
133
134 pub fn decodeSize(payload: []const u8) !Size {
135 if (payload.len != 4) return error.BadPayload;
136 return .{
137 .cols = std.mem.readInt(u16, payload[0..2], .little),
138 .rows = std.mem.readInt(u16, payload[2..4], .little),
139 };
140 }
141
142 test "frame round trip over a socketpair" {
143 const alloc = std.testing.allocator;
144 var fds: [2]std.posix.fd_t = undefined;
145 fds = try std.posix.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0);
146 defer std.posix.close(fds[0]);
147 defer std.posix.close(fds[1]);
148
149 try writeFrame(fds[0], .input, "keystrokes");
150 try writeFrame(fds[0], .attach, &encodeSize(120, 40));
151 std.posix.close(fds[0]);
152 // fds[0] closed below via defer would double-close; re-arm:
153 fds[0] = try std.posix.dup(fds[1]); // keep defers balanced
154
155 const f1 = (try readFrame(alloc, fds[1])).?;
156 defer f1.deinit(alloc);
157 try std.testing.expectEqual(MsgType.input, f1.type);
158 try std.testing.expectEqualStrings("keystrokes", f1.payload);
159
160 const f2 = (try readFrame(alloc, fds[1])).?;
161 defer f2.deinit(alloc);
162 try std.testing.expectEqual(MsgType.attach, f2.type);
163 const sz = try decodeSize(f2.payload);
164 try std.testing.expectEqual(@as(u16, 120), sz.cols);
165 try std.testing.expectEqual(@as(u16, 40), sz.rows);
166
167 try std.testing.expectEqual(@as(?Frame, null), try readFrame(alloc, fds[1]));
168 }
169
170 test "size encode/decode round trip" {
171 const sz = try decodeSize(&encodeSize(213, 58));
172 try std.testing.expectEqual(@as(u16, 213), sz.cols);
173 try std.testing.expectEqual(@as(u16, 58), sz.rows);
174 }
175 ```
176
177 Implementation note on the socketpair test: `std.posix.socketpair` returns `[2]fd_t`; if the double-close dance reads poorly, restructure with explicit `close` calls instead of defers — behavior over form.
178
179 - [x] **Step 2: Add module + test to `build.zig`**
180
181 Insert before `engine_mod`:
182
183 ```zig
184 const protocol_mod = b.createModule(.{
185 .root_source_file = b.path("src/protocol.zig"),
186 .target = target,
187 .optimize = optimize,
188 });
189 ```
190
191 Add `protocol_mod` to the test-step module list.
192
193 - [x] **Step 3: Run tests**
194
195 Run: `make test`
196 Expected: exit 0 (protocol tests pass; existing suites unaffected).
197
198 - [x] **Step 4: Commit**
199
200 ```bash
201 git add src/protocol.zig build.zig
202 git commit -m "feat: length-prefixed frame protocol"
203 ```
204
205 ---
206
207 ### Task 2: Engine snapshot state, cursor, reset
208
209 **Files:**
210 - Modify: `src/engine.zig`
211
212 - [x] **Step 1: Add failing tests (append to `src/engine.zig`)**
213
214 ```zig
215 test "Engine: full-state snapshot restores grid, style, and cursor in a fresh engine" {
216 const alloc = std.testing.allocator;
217 var a = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
218 defer a.deinit();
219
220 a.feed("line one\r\n\x1b[1;35mmagenta\x1b[0m\r\n");
221 a.feed("\x1b[2;5H"); // park cursor at row 2, col 5 (1-based)
222 const state = try a.dumpState(alloc);
223 defer alloc.free(state);
224
225 var b = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
226 defer b.deinit();
227 b.feed(state);
228
229 const plain_a = try a.dumpPlain(alloc);
230 defer alloc.free(plain_a);
231 const plain_b = try b.dumpPlain(alloc);
232 defer alloc.free(plain_b);
233 try std.testing.expectEqualStrings(plain_a, plain_b);
234
235 try std.testing.expectEqual(a.cursorPos().x, b.cursorPos().x);
236 try std.testing.expectEqual(a.cursorPos().y, b.cursorPos().y);
237 try std.testing.expectEqual(@as(u16, 4), b.cursorPos().x); // 0-based
238 try std.testing.expectEqual(@as(u16, 1), b.cursorPos().y);
239 }
240
241 test "Engine: reset clears grid and cursor for snapshot rebuild" {
242 const alloc = std.testing.allocator;
243 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
244 defer e.deinit();
245
246 e.feed("\x1b[7;9Hstale content\x1b[1;31m");
247 e.reset();
248
249 const s = try e.dumpPlain(alloc);
250 defer alloc.free(s);
251 try std.testing.expectEqualStrings("", s);
252 try std.testing.expectEqual(@as(u16, 0), e.cursorPos().x);
253 try std.testing.expectEqual(@as(u16, 0), e.cursorPos().y);
254 }
255
256 test "Engine: alt-screen state survives snapshot into fresh engine" {
257 const alloc = std.testing.allocator;
258 var a = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
259 defer a.deinit();
260
261 a.feed("primary text");
262 a.feed("\x1b[?1049h"); // enter alt screen (what vim/less do)
263 a.feed("alt screen text");
264 const state = try a.dumpState(alloc);
265 defer alloc.free(state);
266
267 var b = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
268 defer b.deinit();
269 b.feed(state);
270
271 const plain_b = try b.dumpPlain(alloc);
272 defer alloc.free(plain_b);
273 try std.testing.expect(std.mem.indexOf(u8, plain_b, "alt screen text") != null);
274
275 // Leaving the alt screen on the replica must reveal the primary content.
276 b.feed("\x1b[?1049l");
277 const primary_b = try b.dumpPlain(alloc);
278 defer alloc.free(primary_b);
279 try std.testing.expect(std.mem.indexOf(u8, primary_b, "primary text") != null);
280 }
281 ```
282
283 Note: the alt-screen test is the sharp edge — `TerminalFormatter` only emits the *active* screen's content plus mode state. If the third assertion (primary content after `?1049l`) fails, that is a real M2 limitation to record in decisions.md (primary screen restored blank after a TUI exits post-reattach); weaken only that assertion (drop it, keep the first), and keep going — M3 owes the fix.
284
285 - [x] **Step 2: Run to verify failure**
286
287 Run: `make test`
288 Expected: compile error — `dumpState`, `cursorPos`, `reset` not defined.
289
290 - [x] **Step 3: Implement (add to `Engine` in `src/engine.zig`)**
291
292 ```zig
293 /// Full terminal state (palette, modes, cursor, styles, active-screen
294 /// content) as a canonical VT byte sequence. Feeding this into a fresh
295 /// engine of the same size reconstructs the state: this is the M2
296 /// Snapshot payload.
297 pub fn dumpState(self: *Engine, alloc: std.mem.Allocator) ![]u8 {
298 var aw: std.Io.Writer.Allocating = .init(alloc);
299 defer aw.deinit();
300 var f = vt.formatter.TerminalFormatter.init(&self.term, .vt);
301 f.extra = .all;
302 try f.format(&aw.writer);
303 return try aw.toOwnedSlice();
304 }
305
306 pub const CursorPos = struct { x: u16, y: u16 };
307
308 /// 0-based cursor position on the active screen.
309 pub fn cursorPos(self: *const Engine) CursorPos {
310 const cur = self.term.screens.active.cursor;
311 return .{ .x = @intCast(cur.x), .y = @intCast(cur.y) };
312 }
313
314 /// Full reset (RIS): grid, modes, cursor, styles. Used by the client
315 /// before applying each snapshot.
316 pub fn reset(self: *Engine) void {
317 self.term.fullReset();
318 }
319 ```
320
321 - [x] **Step 4: Run tests**
322
323 Run: `make test`
324 Expected: exit 0. If the alt-screen test's last assertion fails, apply the note from Step 1.
325
326 - [x] **Step 5: Commit**
327
328 ```bash
329 git add src/engine.zig
330 git commit -m "feat: engine full-state snapshot, cursor accessor, reset"
331 ```
332
333 ---
334
335 ### Task 3: Server
336
337 **Files:**
338 - Create: `src/server.zig`
339 - Delete: `src/debug.zig`
340 - Modify: `build.zig`
341
342 - [x] **Step 1: Write `src/server.zig` (implementation + unit test)**
343
344 ```zig
345 //! muxd's daemon core: one session (engine + pty), one listener, at most
346 //! one attached client (M5 lifts this). Single-threaded; pumpOnce is one
347 //! poll iteration so tests can drive the loop.
348 const std = @import("std");
349 const Engine = @import("engine").Engine;
350 const Pty = @import("pty").Pty;
351 const proto = @import("protocol");
352
353 pub const Server = struct {
354 alloc: std.mem.Allocator,
355 eng: *Engine,
356 pty: Pty,
357 listener: std.net.Server,
358 sock_path: []const u8,
359 owns_sock_file: bool,
360 client: ?std.posix.fd_t = null,
361
362 pub const Options = struct {
363 sock_path: []const u8,
364 shell: [:0]const u8,
365 cols: u16 = 80,
366 rows: u16 = 24,
367 };
368
369 pub fn init(alloc: std.mem.Allocator, opts: Options) !Server {
370 const eng = try Engine.init(alloc, .{ .cols = opts.cols, .rows = opts.rows });
371 errdefer eng.deinit();
372
373 var pty = try Pty.spawn(.{ .cols = opts.cols, .rows = opts.rows, .shell = opts.shell });
374 errdefer pty.deinit();
375
376 // systemd socket activation: LISTEN_FDS=1 hands us the listener as fd 3.
377 if (listenFdFromSystemd()) |fd| {
378 return .{
379 .alloc = alloc,
380 .eng = eng,
381 .pty = pty,
382 .listener = .{
383 .listen_address = undefined,
384 .stream = .{ .handle = fd },
385 },
386 .sock_path = opts.sock_path,
387 .owns_sock_file = false,
388 };
389 }
390
391 std.fs.cwd().deleteFile(opts.sock_path) catch {};
392 const addr = try std.net.Address.initUnix(opts.sock_path);
393 return .{
394 .alloc = alloc,
395 .eng = eng,
396 .pty = pty,
397 .listener = try addr.listen(.{}),
398 .sock_path = opts.sock_path,
399 .owns_sock_file = true,
400 };
401 }
402
403 fn listenFdFromSystemd() ?std.posix.fd_t {
404 const pid_s = std.posix.getenv("LISTEN_PID") orelse return null;
405 const nfds_s = std.posix.getenv("LISTEN_FDS") orelse return null;
406 const pid = std.fmt.parseInt(std.posix.pid_t, pid_s, 10) catch return null;
407 const nfds = std.fmt.parseInt(u32, nfds_s, 10) catch return null;
408 if (pid != std.os.linux.getpid() or nfds < 1) return null;
409 return 3; // SD_LISTEN_FDS_START
410 }
411
412 pub fn deinit(self: *Server) void {
413 if (self.client) |fd| std.posix.close(fd);
414 self.listener.deinit();
415 if (self.owns_sock_file) std.fs.cwd().deleteFile(self.sock_path) catch {};
416 self.pty.deinit();
417 self.eng.deinit();
418 }
419
420 /// One poll iteration. Returns the shell's exit code once it exits,
421 /// null while the session lives.
422 pub fn pumpOnce(self: *Server, timeout_ms: i32) !?u8 {
423 if (self.pty.checkExited()) |code| {
424 if (self.client) |fd| {
425 proto.writeFrame(fd, .exit_status, &.{@intCast(code & 0xff)}) catch {};
426 }
427 return @intCast(code & 0xff);
428 }
429
430 var fds = [_]std.posix.pollfd{
431 .{ .fd = self.pty.master, .events = std.posix.POLL.IN, .revents = 0 },
432 .{ .fd = self.listener.stream.handle, .events = std.posix.POLL.IN, .revents = 0 },
433 .{ .fd = self.client orelse -1, .events = std.posix.POLL.IN, .revents = 0 },
434 };
435 _ = try std.posix.poll(&fds, timeout_ms);
436
437 if (fds[0].revents != 0) {
438 var buf: [64 * 1024]u8 = undefined;
439 const n = std.posix.read(self.pty.master, &buf) catch 0;
440 if (n > 0) {
441 self.eng.feed(buf[0..n]);
442 const resp = self.eng.ptyOutput();
443 if (resp.len > 0) {
444 proto.writeAllFd(self.pty.master, resp) catch {};
445 self.eng.clearPtyOutput();
446 }
447 self.sendSnapshot();
448 }
449 }
450
451 if (fds[1].revents & std.posix.POLL.IN != 0) self.acceptClient();
452
453 if (self.client != null and fds[2].revents != 0) self.serviceClient();
454
455 return null;
456 }
457
458 pub fn run(self: *Server) !u8 {
459 while (true) {
460 if (try self.pumpOnce(100)) |code| return code;
461 }
462 }
463
464 fn acceptClient(self: *Server) void {
465 const conn = self.listener.accept() catch return;
466 if (self.client != null) {
467 // M2: one client. Later attachers are turned away politely.
468 proto.writeFrame(conn.stream.handle, .exit_status, &.{1}) catch {};
469 conn.stream.close();
470 return;
471 }
472 self.client = conn.stream.handle;
473 }
474
475 fn dropClient(self: *Server) void {
476 if (self.client) |fd| std.posix.close(fd);
477 self.client = null;
478 }
479
480 fn serviceClient(self: *Server) void {
481 const fd = self.client.?;
482 const frame = proto.readFrame(self.alloc, fd) catch {
483 self.dropClient();
484 return;
485 } orelse {
486 self.dropClient();
487 return;
488 };
489 defer frame.deinit(self.alloc);
490
491 switch (frame.type) {
492 .attach => {
493 const sz = proto.decodeSize(frame.payload) catch return;
494 self.applySize(sz.cols, sz.rows);
495 self.sendSnapshot();
496 },
497 .input => proto.writeAllFd(self.pty.master, frame.payload) catch self.dropClient(),
498 .resize => {
499 const sz = proto.decodeSize(frame.payload) catch return;
500 self.applySize(sz.cols, sz.rows);
501 self.sendSnapshot();
502 },
503 .detach => self.dropClient(),
504 .debug_dump => {
505 const want_vt = frame.payload.len >= 1 and frame.payload[0] == 1;
506 const dump = if (want_vt)
507 self.eng.dumpVt(self.alloc) catch return
508 else
509 self.eng.dumpPlain(self.alloc) catch return;
510 defer self.alloc.free(dump);
511 proto.writeFrame(fd, .dump_reply, dump) catch self.dropClient();
512 },
513 else => {},
514 }
515 }
516
517 fn applySize(self: *Server, cols: u16, rows: u16) void {
518 self.eng.resize(cols, rows) catch return;
519 self.pty.resize(cols, rows) catch {};
520 }
521
522 fn sendSnapshot(self: *Server) void {
523 const fd = self.client orelse return;
524 const state = self.eng.dumpState(self.alloc) catch return;
525 defer self.alloc.free(state);
526 proto.writeFrame(fd, .snapshot, state) catch self.dropClient();
527 }
528 };
529 ```
530
531 - [x] **Step 2: Add the integration test (append to `src/server.zig`)**
532
533 This is the M2 falsification test: a scripted client attaches, types, rebuilds a replica from snapshots, and byte-compares replica vs daemon.
534
535 ```zig
536 fn serverThread(srv: *Server, stop: *std.atomic.Value(bool)) void {
537 while (!stop.load(.acquire)) {
538 const code = srv.pumpOnce(50) catch break;
539 if (code != null) break;
540 }
541 }
542
543 test "Server: replica rebuilt from snapshots matches the authoritative grid" {
544 const alloc = std.testing.allocator;
545
546 var tmp = std.testing.tmpDir(.{});
547 defer tmp.cleanup();
548 var path_buf: [256]u8 = undefined;
549 const dir_path = try tmp.dir.realpath(".", &path_buf);
550 const sock_path = try std.fmt.allocPrint(alloc, "{s}/m2.sock", .{dir_path});
551 defer alloc.free(sock_path);
552
553 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
554 defer srv.deinit();
555
556 var stop = std.atomic.Value(bool).init(false);
557 const th = try std.Thread.spawn(.{}, serverThread, .{ &srv, &stop });
558 defer th.join();
559 defer stop.store(true, .release);
560
561 const stream = try std.net.connectUnixSocket(sock_path);
562 defer stream.close();
563 const fd = stream.handle;
564
565 var replica = try Engine.init(alloc, .{ .cols = 100, .rows = 30 });
566 defer replica.deinit();
567
568 try proto.writeFrame(fd, .attach, &proto.encodeSize(100, 30));
569 try proto.writeFrame(fd, .input, "printf 'fidelity-%s\\n' ok\n");
570
571 // Consume snapshots until the replica shows the command output.
572 var deadline_ms: u64 = 10_000;
573 var converged = false;
574 while (deadline_ms > 0 and !converged) {
575 var pfd = [_]std.posix.pollfd{
576 .{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 },
577 };
578 const ready = try std.posix.poll(&pfd, 100);
579 deadline_ms -|= 100;
580 if (ready == 0) continue;
581 const frame = (try proto.readFrame(alloc, fd)) orelse break;
582 defer frame.deinit(alloc);
583 if (frame.type != .snapshot) continue;
584 replica.reset();
585 replica.feed(frame.payload);
586 const plain = try replica.dumpPlain(alloc);
587 defer alloc.free(plain);
588 if (std.mem.indexOf(u8, plain, "fidelity-ok") != null) converged = true;
589 }
590 try std.testing.expect(converged);
591
592 // Byte-compare replica vs authoritative daemon grid.
593 try proto.writeFrame(fd, .debug_dump, &.{1});
594 var daemon_vt: ?[]u8 = null;
595 defer if (daemon_vt) |d| alloc.free(d);
596 while (daemon_vt == null) {
597 const frame = (try proto.readFrame(alloc, fd)) orelse break;
598 if (frame.type == .dump_reply) {
599 daemon_vt = frame.payload; // ownership taken
600 } else {
601 // Late snapshots may arrive before the reply; apply them so the
602 // replica stays current with what the dump will show.
603 if (frame.type == .snapshot) {
604 replica.reset();
605 replica.feed(frame.payload);
606 }
607 frame.deinit(alloc);
608 }
609 }
610 const replica_vt = try replica.dumpVt(alloc);
611 defer alloc.free(replica_vt);
612 try std.testing.expectEqualStrings(daemon_vt.?, replica_vt);
613 }
614 ```
615
616 - [x] **Step 3: Wire into `build.zig`, remove debug module**
617
618 Replace the `debug_mod` block with:
619
620 ```zig
621 const server_mod = b.createModule(.{
622 .root_source_file = b.path("src/server.zig"),
623 .target = target,
624 .optimize = optimize,
625 .link_libc = true,
626 });
627 server_mod.addImport("engine", engine_mod);
628 server_mod.addImport("pty", pty_mod);
629 server_mod.addImport("protocol", protocol_mod);
630 ```
631
632 In `exe_mod` imports: replace `debug` with `server` and add `protocol`. Update the test-step module list to `{ protocol_mod, engine_mod, pty_mod, server_mod }`. Then `git rm src/debug.zig`.
633
634 `main.zig` still references `debug` at this point — Task 4 rewrites it; to keep this task green, apply Task 4's `main.zig` in the same commit if the build breaks, or temporarily stub `main.zig` to `pub fn main() !void {}` (restored in Task 4). Prefer the stub: smaller diff per commit.
635
636 - [x] **Step 4: Run tests**
637
638 Run: `make test`
639 Expected: exit 0. The integration test takes a few seconds (real shell under a pty). A timing flake here is a bug: the loop retries until the *replica* converges, and the final compare uses the daemon's own reply ordering — investigate rather than extending timeouts blindly.
640
641 - [x] **Step 5: Commit**
642
643 ```bash
644 git add -A
645 git commit -m "feat: daemon server core with snapshot broadcast; replica fidelity test"
646 ```
647
648 ---
649
650 ### Task 4: muxd main rewrite
651
652 **Files:**
653 - Rewrite: `src/main.zig`
654
655 - [x] **Step 1: Rewrite `src/main.zig`**
656
657 ```zig
658 //! muxd — daemon entrypoint. `run` hosts the session; `dump` prints the
659 //! authoritative grid over the protocol (debug aid, also used by e2e).
660 const std = @import("std");
661 const Server = @import("server").Server;
662 const proto = @import("protocol");
663
664 const usage =
665 \\usage:
666 \\ muxd run [--sock PATH] [--shell PATH] [--cols N] [--rows N]
667 \\ muxd dump [--vt] [--sock PATH]
668 \\
669 ;
670
671 pub fn main() !u8 {
672 var gpa: std.heap.DebugAllocator(.{}) = .init;
673 defer _ = gpa.deinit();
674 const alloc = gpa.allocator();
675
676 const args = try std.process.argsAlloc(alloc);
677 defer std.process.argsFree(alloc, args);
678
679 if (args.len < 2) {
680 std.debug.print("{s}", .{usage});
681 return 2;
682 }
683
684 var sock_arg: ?[]const u8 = null;
685 var shell_arg: ?[]const u8 = null;
686 var vt_mode = false;
687 var cols: u16 = 80;
688 var rows: u16 = 24;
689 var i: usize = 2;
690 while (i < args.len) : (i += 1) {
691 const a = args[i];
692 if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) {
693 i += 1;
694 sock_arg = args[i];
695 } else if (std.mem.eql(u8, a, "--shell") and i + 1 < args.len) {
696 i += 1;
697 shell_arg = args[i];
698 } else if (std.mem.eql(u8, a, "--cols") and i + 1 < args.len) {
699 i += 1;
700 cols = try std.fmt.parseInt(u16, args[i], 10);
701 } else if (std.mem.eql(u8, a, "--rows") and i + 1 < args.len) {
702 i += 1;
703 rows = try std.fmt.parseInt(u16, args[i], 10);
704 } else if (std.mem.eql(u8, a, "--vt")) {
705 vt_mode = true;
706 } else {
707 std.debug.print("unknown argument: {s}\n{s}", .{ a, usage });
708 return 2;
709 }
710 }
711
712 const sock_path = if (sock_arg) |s|
713 try alloc.dupe(u8, s)
714 else
715 try defaultSockPath(alloc);
716 defer alloc.free(sock_path);
717
718 if (std.mem.eql(u8, args[1], "run")) {
719 const shell_z: [:0]const u8 = if (shell_arg) |s|
720 try alloc.dupeZ(u8, s)
721 else
722 try alloc.dupeZ(u8, std.posix.getenv("SHELL") orelse "/bin/sh");
723 defer alloc.free(shell_z);
724
725 var srv = try Server.init(alloc, .{
726 .sock_path = sock_path,
727 .shell = shell_z,
728 .cols = cols,
729 .rows = rows,
730 });
731 defer srv.deinit();
732 return try srv.run();
733 }
734 if (std.mem.eql(u8, args[1], "dump")) return dump(alloc, sock_path, vt_mode);
735 std.debug.print("{s}", .{usage});
736 return 2;
737 }
738
739 pub fn defaultSockPath(alloc: std.mem.Allocator) ![]const u8 {
740 if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir| {
741 return std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir});
742 }
743 return std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()});
744 }
745
746 fn dump(alloc: std.mem.Allocator, sock_path: []const u8, vt_mode: bool) !u8 {
747 const stream = std.net.connectUnixSocket(sock_path) catch {
748 std.debug.print("muxd dump: cannot connect to {s} (is `muxd run` running?)\n", .{sock_path});
749 return 1;
750 };
751 defer stream.close();
752
753 try proto.writeFrame(stream.handle, .debug_dump, &.{if (vt_mode) @as(u8, 1) else 0});
754 while (try proto.readFrame(alloc, stream.handle)) |frame| {
755 defer frame.deinit(alloc);
756 if (frame.type != .dump_reply) continue; // skip snapshots meant for clients
757 try proto.writeAllFd(std.posix.STDOUT_FILENO, frame.payload);
758 try proto.writeAllFd(std.posix.STDOUT_FILENO, "\n");
759 return 0;
760 }
761 return 1;
762 }
763 ```
764
765 Note: `muxd dump` connects as a client; if a real client is attached, the daemon refuses (single-client M2) — the refusal frame is `exit_status`, which `dump` skips, then EOF ends the loop with exit 1. Acceptable for a debug aid; e2e dumps while *no* interactive client is attached, or tolerates the refusal.
766
767 Wait — that breaks the e2e flow (dump while client attached). Fix in `acceptClient` (Task 3 already shipped it): the refusal only bites when the *second* attacher sends frames the daemon would act on. Simplest correct M2 behavior that keeps `dump` working alongside a live client: allow extra connections but only ever *serve* one attached interactive client. Amend `acceptClient`/frame handling as follows — an unattached connection may send `debug_dump` and get a reply; `attach` on a busy daemon gets `exit_status{1}`. Implementation: keep a small list of "observer" fds instead of refusing.
768
769 Amended `Server` pieces (use these, not the refusal version, when executing Task 3):
770
771 ```zig
772 // in Server struct fields, replace `client: ?fd_t` with:
773 client: ?std.posix.fd_t = null, // the attached interactive client
774 observers: [4]?std.posix.fd_t = .{ null, null, null, null }, // dump-only conns
775
776 fn acceptClient(self: *Server) void {
777 const conn = self.listener.accept() catch return;
778 for (&self.observers) |*slot| {
779 if (slot.* == null) {
780 slot.* = conn.stream.handle;
781 return;
782 }
783 }
784 conn.stream.close(); // out of slots
785 }
786 ```
787
788 Observers are polled too; an observer that sends `attach` is promoted to `self.client` if the slot is free (else refused with `exit_status{1}` and closed). `debug_dump` works from any connection. The poll fd array becomes: pty, listener, client (or -1), observers[0..4] (or -1). On any observer error/EOF, clear the slot. This is ~30 lines of bookkeeping; keep it inside `serviceClient`-style helpers (`serviceFd(fd) enum { keep, drop }`).
789
790 - [x] **Step 2: Run tests + build**
791
792 Run: `make test && make build`
793 Expected: exit 0, both binaries build (mux arrives in Task 5; only muxd exists yet).
794
795 - [x] **Step 3: Commit**
796
797 ```bash
798 git add src/main.zig src/server.zig
799 git commit -m "feat: muxd speaks the real protocol; observer connections for dump"
800 ```
801
802 ---
803
804 ### Task 5: The mux client
805
806 **Files:**
807 - Create: `src/client.zig`, `src/mux_main.zig`
808 - Modify: `build.zig`
809
810 - [x] **Step 1: Write `src/client.zig`**
811
812 ```zig
813 //! mux client: connects, attaches, maintains a replica engine rebuilt
814 //! from snapshots, repaints the local terminal, forwards keystrokes.
815 //! Detach chord: Ctrl-\ (0x1c). No keybinding layer in this prototype.
816 const std = @import("std");
817 const Engine = @import("engine").Engine;
818 const proto = @import("protocol");
819
820 var winch_flag = std.atomic.Value(bool).init(false);
821
822 fn onWinch(_: c_int) callconv(.c) void {
823 winch_flag.store(true, .release);
824 }
825
826 pub fn attach(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
827 const stream = std.net.connectUnixSocket(sock_path) catch {
828 std.debug.print("mux: cannot connect to {s} (is muxd running?)\n", .{sock_path});
829 return 1;
830 };
831 defer stream.close();
832 const sock = stream.handle;
833
834 const stdin_fd = std.posix.STDIN_FILENO;
835 const stdout_fd = std.posix.STDOUT_FILENO;
836 const is_tty = std.posix.isatty(stdin_fd);
837
838 var size = ttySize(stdout_fd) orelse proto.Size{ .cols = 80, .rows = 24 };
839
840 var replica = try Engine.init(alloc, .{ .cols = size.cols, .rows = size.rows });
841 defer replica.deinit();
842
843 // Raw mode + alternate screen when we own a terminal.
844 var orig_termios: ?std.posix.termios = null;
845 if (is_tty) {
846 const orig = try std.posix.tcgetattr(stdin_fd);
847 orig_termios = orig;
848 var raw = orig;
849 raw.lflag.ICANON = false;
850 raw.lflag.ECHO = false;
851 raw.lflag.ISIG = false;
852 raw.iflag.IXON = false;
853 raw.iflag.ICRNL = false;
854 try std.posix.tcsetattr(stdin_fd, .FLUSH, raw);
855 try proto.writeAllFd(stdout_fd, "\x1b[?1049h\x1b[?25l");
856
857 var sa: std.posix.Sigaction = .{
858 .handler = .{ .handler = onWinch },
859 .mask = std.posix.sigemptyset(),
860 .flags = 0,
861 };
862 std.posix.sigaction(std.posix.SIG.WINCH, &sa, null);
863 }
864 defer if (orig_termios) |t| {
865 proto.writeAllFd(stdout_fd, "\x1b[?25h\x1b[?1049l") catch {};
866 std.posix.tcsetattr(stdin_fd, .FLUSH, t) catch {};
867 };
868
869 try proto.writeFrame(sock, .attach, &proto.encodeSize(size.cols, size.rows));
870
871 var stdin_open = true;
872 var buf: [16 * 1024]u8 = undefined;
873 while (true) {
874 if (winch_flag.swap(false, .acq_rel)) {
875 if (ttySize(stdout_fd)) |new_size| {
876 if (new_size.cols != size.cols or new_size.rows != size.rows) {
877 size = new_size;
878 try replica.resize(size.cols, size.rows);
879 try proto.writeFrame(sock, .resize, &proto.encodeSize(size.cols, size.rows));
880 }
881 }
882 }
883
884 var fds = [_]std.posix.pollfd{
885 .{ .fd = sock, .events = std.posix.POLL.IN, .revents = 0 },
886 .{ .fd = if (stdin_open) stdin_fd else -1, .events = std.posix.POLL.IN, .revents = 0 },
887 };
888 _ = try std.posix.poll(&fds, 100);
889
890 if (fds[0].revents != 0) {
891 const frame = (try proto.readFrame(alloc, sock)) orelse return 1;
892 defer frame.deinit(alloc);
893 switch (frame.type) {
894 .snapshot => {
895 replica.reset();
896 replica.feed(frame.payload);
897 try render(alloc, &replica, stdout_fd);
898 },
899 .exit_status => {
900 return if (frame.payload.len >= 1) frame.payload[0] else 0;
901 },
902 else => {},
903 }
904 }
905
906 if (stdin_open and fds[1].revents != 0) {
907 const n = std.posix.read(stdin_fd, &buf) catch 0;
908 if (n == 0) {
909 stdin_open = false;
910 } else {
911 if (std.mem.indexOfScalar(u8, buf[0..n], 0x1c) != null) {
912 // Ctrl-\: detach and leave the session running.
913 proto.writeFrame(sock, .detach, "") catch {};
914 return 0;
915 }
916 try proto.writeFrame(sock, .input, buf[0..n]);
917 }
918 }
919 }
920 }
921
922 fn ttySize(fd: std.posix.fd_t) ?proto.Size {
923 if (!std.posix.isatty(fd)) return null;
924 var ws: std.posix.winsize = undefined;
925 if (std.os.linux.ioctl(fd, std.os.linux.T.IOCGWINSZ, @intFromPtr(&ws)) != 0) return null;
926 return .{ .cols = ws.col, .rows = ws.row };
927 }
928
929 /// Repaint the whole replica: styled dump + cursor, bracketed by
930 /// synchronized output so capable terminals apply it atomically.
931 fn render(alloc: std.mem.Allocator, replica: *Engine, out_fd: std.posix.fd_t) !void {
932 var paint: std.ArrayList(u8) = .empty;
933 defer paint.deinit(alloc);
934
935 try paint.appendSlice(alloc, "\x1b[?2026h\x1b[?25l\x1b[H\x1b[2J");
936 const styled = try replica.dumpVt(alloc);
937 defer alloc.free(styled);
938 try paint.appendSlice(alloc, styled);
939
940 const cur = replica.cursorPos();
941 var cup_buf: [32]u8 = undefined;
942 const cup = try std.fmt.bufPrint(&cup_buf, "\x1b[{d};{d}H", .{ cur.y + 1, cur.x + 1 });
943 try paint.appendSlice(alloc, cup);
944
945 try paint.appendSlice(alloc, "\x1b[?25h\x1b[?2026l");
946 try proto.writeAllFd(out_fd, paint.items);
947 }
948
949 test "render paints replica content with cursor restore" {
950 const alloc = std.testing.allocator;
951 var replica = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
952 defer replica.deinit();
953 replica.feed("painted\x1b[3;7H");
954
955 const pipe = try std.posix.pipe();
956 defer std.posix.close(pipe[0]);
957 var out: [4096]u8 = undefined;
958 try render(alloc, &replica, pipe[1]);
959 std.posix.close(pipe[1]);
960 const n = try std.posix.read(pipe[0], &out);
961
962 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "painted") != null);
963 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[3;7H") != null); // cursor
964 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[?2026h") != null); // sync
965 }
966 ```
967
968 Sigaction API drift note: on this std version `sigaction` returns `void` and `handler` is `.{ .handler = f }` with `callconv(.c)`; if the compiler disagrees, check `std.posix.Sigaction` in the 0.15.2 lib source (`~/Downloads/zig-x86_64-linux-0.15.2/lib/std/posix.zig`).
969
970 - [x] **Step 2: Write `src/mux_main.zig`**
971
972 ```zig
973 //! mux — client binary. `mux [--sock PATH]` attaches to the running muxd.
974 const std = @import("std");
975 const client = @import("client");
976 const proto = @import("protocol");
977
978 const usage = "usage: mux [--sock PATH]\n";
979
980 pub fn main() !u8 {
981 var gpa: std.heap.DebugAllocator(.{}) = .init;
982 defer _ = gpa.deinit();
983 const alloc = gpa.allocator();
984
985 const args = try std.process.argsAlloc(alloc);
986 defer std.process.argsFree(alloc, args);
987
988 var sock_arg: ?[]const u8 = null;
989 var i: usize = 1;
990 while (i < args.len) : (i += 1) {
991 if (std.mem.eql(u8, args[i], "--sock") and i + 1 < args.len) {
992 i += 1;
993 sock_arg = args[i];
994 } else {
995 std.debug.print("{s}", .{usage});
996 return 2;
997 }
998 }
999
1000 const sock_path = if (sock_arg) |s|
1001 try alloc.dupe(u8, s)
1002 else if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir|
1003 try std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir})
1004 else
1005 try std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()});
1006 defer alloc.free(sock_path);
1007
1008 return client.attach(alloc, sock_path);
1009 }
1010 ```
1011
1012 - [x] **Step 3: Wire into `build.zig`**
1013
1014 After `server_mod`:
1015
1016 ```zig
1017 const client_mod = b.createModule(.{
1018 .root_source_file = b.path("src/client.zig"),
1019 .target = target,
1020 .optimize = optimize,
1021 .link_libc = true,
1022 });
1023 client_mod.addImport("engine", engine_mod);
1024 client_mod.addImport("protocol", protocol_mod);
1025
1026 const mux_mod = b.createModule(.{
1027 .root_source_file = b.path("src/mux_main.zig"),
1028 .target = target,
1029 .optimize = optimize,
1030 .link_libc = true,
1031 });
1032 mux_mod.addImport("client", client_mod);
1033 mux_mod.addImport("protocol", protocol_mod);
1034
1035 const mux_exe = b.addExecutable(.{ .name = "mux", .root_module = mux_mod });
1036 mux_exe.use_llvm = true;
1037 mux_exe.use_lld = true;
1038 b.installArtifact(mux_exe);
1039 ```
1040
1041 Add `client_mod` to the test-step list. Pass `mux_exe` to the e2e runner as a second artifact arg (`e2e.addArtifactArg(mux_exe);`).
1042
1043 - [x] **Step 4: Run tests + build**
1044
1045 Run: `make test && make build`
1046 Expected: exit 0; `zig-out/bin/mux` and `zig-out/bin/muxd` both exist.
1047
1048 - [x] **Step 5: Commit**
1049
1050 ```bash
1051 git add src/client.zig src/mux_main.zig build.zig
1052 git commit -m "feat: mux client with replica grid, renderer, resize, Ctrl-\\ detach"
1053 ```
1054
1055 ---
1056
1057 ### Task 6: End-to-end test
1058
1059 **Files:**
1060 - Rewrite: `test/e2e.sh`
1061
1062 - [x] **Step 1: Rewrite `test/e2e.sh`**
1063
1064 ```sh
1065 #!/bin/sh
1066 # End-to-end: muxd daemon + mux client. Input flows client -> daemon -> pty;
1067 # output flows pty -> engine -> snapshot -> client replica -> client stdout.
1068 set -eu
1069 MUXD="$1"
1070 MUX="$2"
1071 SOCK="${TMPDIR:-/tmp}/muxd-e2e-$$.sock"
1072 OUT="${TMPDIR:-/tmp}/mux-e2e-out-$$"
1073
1074 cleanup() { kill "$DPID" 2>/dev/null || true; rm -f "$SOCK" "$OUT"; }
1075 trap cleanup EXIT INT TERM
1076
1077 "$MUXD" run --sock "$SOCK" --shell /bin/sh &
1078 DPID=$!
1079
1080 i=0
1081 while [ ! -S "$SOCK" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done
1082 [ -S "$SOCK" ] || { echo "e2e FAIL: socket never appeared"; exit 1; }
1083
1084 # Client with piped stdio: types a command, waits, detaches with Ctrl-\ (034).
1085 { printf 'printf "e2e-%%s\\n" works\n'; sleep 2; printf '\034'; } | \
1086 "$MUX" --sock "$SOCK" > "$OUT"
1087
1088 # 1. The client's rendered output must contain the command's result.
1089 grep -q "e2e-works" "$OUT" || {
1090 echo "e2e FAIL: client render missing output; got:"; cat "$OUT"; exit 1;
1091 }
1092
1093 # 2. The daemon kept the session; its grid must match.
1094 "$MUXD" dump --sock "$SOCK" | grep -q "e2e-works" || {
1095 echo "e2e FAIL: daemon grid missing output"; exit 1;
1096 }
1097
1098 # 3. Detach left the daemon running (kill it ourselves to be sure it was up).
1099 kill -0 "$DPID" || { echo "e2e FAIL: daemon died on detach"; exit 1; }
1100
1101 echo "e2e OK"
1102 ```
1103
1104 - [x] **Step 2: Run it**
1105
1106 Run: `make e2e`
1107 Expected: `e2e OK`.
1108
1109 - [x] **Step 3: Commit**
1110
1111 ```bash
1112 git add test/e2e.sh
1113 git commit -m "test: e2e through the real client/daemon loop"
1114 ```
1115
1116 ---
1117
1118 ### Task 7: systemd units, docs, manual demo
1119
1120 **Files:**
1121 - Create: `contrib/muxd.service`, `contrib/muxd.socket`
1122 - Modify: `docs/decisions.md`, `README.md`
1123
1124 - [x] **Step 1: Write the units**
1125
1126 `contrib/muxd.socket`:
1127
1128 ```ini
1129 [Unit]
1130 Description=mux daemon socket
1131
1132 [Socket]
1133 ListenStream=%t/muxd.sock
1134
1135 [Install]
1136 WantedBy=sockets.target
1137 ```
1138
1139 `contrib/muxd.service`:
1140
1141 ```ini
1142 [Unit]
1143 Description=mux daemon (prototype)
1144 Requires=muxd.socket
1145
1146 [Service]
1147 # Adjust the path to your checkout; the prototype is not installed system-wide.
1148 ExecStart=%h/code/rad/mux/zig-out/bin/muxd run
1149 Restart=no
1150 ```
1151
1152 - [x] **Step 2: Manual demo (M2 acceptance)**
1153
1154 In terminal A: `make build && ./zig-out/bin/muxd run`
1155 In terminal B: `./zig-out/bin/mux` — a live shell appears. Then:
1156 1. Type and edit a command line — echo must feel instant, no artifacts.
1157 2. `nvim /tmp/m2.txt` — full-screen UI, `i`, type UTF-8, `Esc :wq`.
1158 3. `less /tmp/utf8.txt` — page with space/b, `q`.
1159 4. Resize terminal B's window — the session reflows to the new size.
1160 5. `Ctrl-\` — client exits, daemon keeps running; re-run `./zig-out/bin/mux` — session resumes where it was.
1161 6. `exit` in the shell — client exits with the shell's status; daemon exits.
1162
1163 Record any artifact (flicker beyond taste, wrong cells, stuck cursor) as a bug before declaring M2 done. Systemd socket-activation check (optional, needs lingering not required for the demo): `systemctl --user enable --now` the units from `contrib/` after copying to `~/.config/systemd/user/`, then `mux` with no daemon pre-started.
1164
1165 - [x] **Step 3: Update `docs/decisions.md`** — append under a new `## 2026-08-07 (M2)` heading:
1166
1167 ```markdown
1168 ## 2026-08-07 (M2)
1169
1170 - **Snapshot = canonical VT state serialization.** `TerminalFormatter` with
1171 `extra = .all` (palette, modes incl. active screen, cursor, styles). The
1172 client rebuilds its replica with fullReset + feed. This is state sync via
1173 the engine's own canonical form — not a replay of session history.
1174 - **Wire format: 1-byte type + u32 LE length frames, M2 only.** The
1175 handoff's "msgpack or protobuf, do not invent one" is owed at M4, where
1176 structured payloads (damage regions, cell runs) first appear; every M2
1177 payload is a byte blob or two u16s. Recorded so M4 doesn't inherit this
1178 by inertia.
1179 - **Single interactive client + dump-only observers.** Second `attach` is
1180 refused with exit_status{1}. M5 replaces this with real multi-client.
1181 - **Blocking frame I/O.** One local client on a Unix socket; a stuck client
1182 can stall the daemon. Buffered nonblocking I/O is owed by M4 (network).
1183 - **Detach chord: Ctrl-\ (0x1c).** No keybinding layer in the prototype;
1184 one hardcoded byte, documented in README.
1185 - **Render: home + ED(2) full repaint under CSI ?2026 sync.** Wasteful by
1186 design (deltas are M4); sync-output makes it artifact-free on modern
1187 terminals.
1188 - **Alt-screen snapshot limitation:** [fill in from Task 2's test result —
1189 either "none observed" or the exact behavior when a replica leaves the
1190 alt screen after rebuild].
1191 ```
1192
1193 - [x] **Step 4: Update `README.md`** — replace the Status/usage section:
1194
1195 ```markdown
1196 Status: **M2 — the loop.**
1197
1198 make test && make e2e # verify
1199 make build
1200 ./zig-out/bin/muxd run & # daemon
1201 ./zig-out/bin/mux # attach a client (Ctrl-\ detaches)
1202 ./zig-out/bin/muxd dump [--vt] # debug: print the authoritative grid
1203
1204 systemd user units (socket activation) live in `contrib/`.
1205 ```
1206
1207 - [x] **Step 5: Commit**
1208
1209 ```bash
1210 git add contrib/ docs/decisions.md README.md
1211 git commit -m "feat: systemd user units; docs: M2 decisions and usage"
1212 ```
1213
1214 ---
1215
1216 ## Self-Review
1217
1218 - **Spec coverage:** socket listener ✓ (Task 3), Attach/Input/Snapshot set ✓ (Tasks 1/3, plus Resize/Detach/ExitStatus from the handoff protocol sketch — Bell/TitleChange deferred, they're cosmetic and the engine already captures titles), client renders replica + forwards keystrokes ✓ (Task 5), full-snapshot-per-update ✓ (Task 3 `sendSnapshot`), demo = typing/vim/:wq indistinguishable ✓ (Task 7), falsification = replica divergence ✓ (Task 3 integration test byte-compare), daemon lifecycle/systemd from "known hard parts" ✓ (Task 7 + `listenFdFromSystemd`).
1219 - **Placeholder scan:** one deliberate fill-in slot in Task 7's decisions entry, fed by Task 2's test outcome — that's a measurement, not a placeholder. Task 3 Step 1's single-client `acceptClient` is superseded by Task 4 Step 1's observer amendment; executor should implement the observer version directly.
1220 - **Type consistency:** `proto.Size`/`encodeSize`/`decodeSize` used in server, client, main ✓; `Engine.dumpState/cursorPos/reset` defined Task 2, used Tasks 3/5 ✓; `Server.pumpOnce/run` defined Task 3, used Task 4 ✓; `client.attach` defined Task 5, used in `mux_main.zig` ✓.
1221 - **Known risks, stated where they bite:** sigaction API drift (Task 5 note); alt-screen snapshot semantics (Task 2 note + decisions entry); dump-while-attached (Task 4 note, solved by observers).
docs/superpowers/plans/2026-08-07-m3-the-promise.md
Old New
@@ -1,735 +0,0 @@
1 # M3 — The Promise 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:** Reattach as state sync, formalized: correct scrollback on demand (lazy, never pushed), correct primary-screen restoration when reattaching during a TUI, and a daemon/client pair that survives clients killed mid-run — reattach visually instant and correct for line-mode and full-screen sessions.
6
7 **Architecture:** Three independent gaps close: (1) **scrollback** — snapshots gain a `history_rows` count; a new `FetchScrollback`/`ScrollbackChunk` message pair serves styled row ranges in *screen space* (row 0 = oldest history line); the client adds a page-at-a-time scroll mode (Shift+PageUp/PageDown) that remote-pages through history and suppresses live repaints while scrolled. (2) **dual-screen snapshots** — when the alt screen is active, `dumpState` first emits the primary screen's content, then the existing full-state dump (whose mode section switches the replica to the alt screen), so leaving the alt screen after reattach reveals real shell history. (3) **kill-safety** — both processes ignore SIGPIPE (today a `kill -9`'d client makes the daemon's next snapshot write deliver SIGPIPE and kill it), and the daemon's write-failure path drops the client and keeps running.
8
9 **Tech Stack:** unchanged (Zig 0.15.2 pinned, ghostty-vt pin, existing module graph). No new files — each gap lands in the module that owns it.
10
11 **M3 falsification (from the handoff):** if reattach cannot be made both fast and correct for full-screen TUI sessions, stop. The alt-screen test (Task 3) and the kill-reattach e2e (Task 6) are the checks.
12
13 **Verified API facts (beyond M1/M2's):**
14 - `ScreenSet.get(key: Key) ?*Screen` — reach the *inactive* screen; `active_key: Key` says which is live.
15 - `PageList.pointFromPin(tag: point.Tag, p: Pin) ?point.Point` — `.screen` space counts from the top of history, so the viewport-top pin's `.screen.y` == number of history rows.
16 - `point.Point` spaces: `.active`, `.viewport`, `.screen`, `.history`. Selections may be built from `pages.pin(.{ .screen = .{ .x, .y } })`.
17 - Alt screens have no scrollback in ghostty; history exists on the primary screen only.
18
19 ---
20
21 ## File Structure
22
23 ```
24 src/
25 ├── protocol.zig — MODIFY: fetch_scrollback/scrollback_chunk types, u32 helpers,
26 │ snapshot payload = u32 history_rows ++ state bytes
27 ├── engine.zig — MODIFY: historyRows(), dumpScrollback(start,count),
28 │ dual-screen dumpState
29 ├── server.zig — MODIFY: SIGPIPE ignore, snapshot prefix, fetch handler,
30 │ dead-client survival test
31 ├── client.zig — MODIFY: SIGPIPE ignore, snapshot prefix parse, scroll mode
32 └── (pty.zig, main.zig, mux_main.zig unchanged)
33 test/e2e.sh — MODIFY: kill -9 mid-run reattach scenario
34 ```
35
36 Decisions locked in (recorded in decisions.md, Task 6): scrollback addressing is **screen-space row index** (0 = oldest retained history row) — positions may drift as history is evicted at max_scrollback; acceptable, scroll positions are ephemeral. Scroll keys are **Shift+PageUp/PageDown** (`\x1b[5;2~` / `\x1b[6;2~`) — the sequences terminals conventionally reserve for their own scrollback, matched against whole stdin reads (key sequences arrive unfragmented in practice; a split sequence falls through to the PTY harmlessly). Any other key exits scroll mode back to live and is swallowed. While scrolled, snapshots are applied to the replica but not painted. Scrollback is only offered when the primary screen is active (`history_rows = 0` sent otherwise).
37
38 ---
39
40 ### Task 1: Protocol — scrollback messages and integer helpers
41
42 **Files:**
43 - Modify: `src/protocol.zig`
44
45 - [x] **Step 1: Add failing tests (append to `src/protocol.zig`)**
46
47 ```zig
48 test "u32 encode/decode round trip" {
49 var buf: [4]u8 = undefined;
50 putU32(&buf, 123456789);
51 try std.testing.expectEqual(@as(u32, 123456789), getU32(buf[0..4]));
52 }
53
54 test "scrollback request encode/decode round trip" {
55 const req = try decodeScrollbackReq(&encodeScrollbackReq(70000, 24));
56 try std.testing.expectEqual(@as(u32, 70000), req.start);
57 try std.testing.expectEqual(@as(u16, 24), req.count);
58 }
59 ```
60
61 - [x] **Step 2: Run to verify failure**
62
63 Run: `make test`
64 Expected: compile error — `putU32` etc. undefined.
65
66 - [x] **Step 3: Implement (add to `src/protocol.zig`), and update the MsgType docs**
67
68 Replace the `MsgType` payload comments for `snapshot` and add the two new types:
69
70 ```zig
71 detach = 0x04, // payload: empty
72 fetch_scrollback = 0x05, // payload: u32 LE start screen-row, u16 LE row count
73 debug_dump = 0x7f, // payload: 1 byte: 0 = plain, 1 = vt
74 // daemon -> client
75 snapshot = 0x81, // payload: u32 LE history_rows ++ full-state vt dump
76 exit_status = 0x82, // payload: 1 byte exit code
77 taken_over = 0x84, // payload: empty; a newer client attached, you're out
78 scrollback_chunk = 0x85, // payload: u32 LE start, u16 LE count ++ vt rows
79 dump_reply = 0xff, // payload: requested dump bytes
80 ```
81
82 Then the helpers:
83
84 ```zig
85 pub fn putU32(buf: *[4]u8, v: u32) void {
86 std.mem.writeInt(u32, buf, v, .little);
87 }
88
89 pub fn getU32(buf: *const [4]u8) u32 {
90 return std.mem.readInt(u32, buf, .little);
91 }
92
93 pub const ScrollbackReq = struct { start: u32, count: u16 };
94
95 pub fn encodeScrollbackReq(start: u32, count: u16) [6]u8 {
96 var buf: [6]u8 = undefined;
97 std.mem.writeInt(u32, buf[0..4], start, .little);
98 std.mem.writeInt(u16, buf[4..6], count, .little);
99 return buf;
100 }
101
102 pub fn decodeScrollbackReq(payload: []const u8) !ScrollbackReq {
103 if (payload.len != 6) return error.BadPayload;
104 return .{
105 .start = std.mem.readInt(u32, payload[0..4], .little),
106 .count = std.mem.readInt(u16, payload[4..6], .little),
107 };
108 }
109 ```
110
111 - [x] **Step 4: Run tests** — `make test`, expected exit 0.
112
113 - [x] **Step 5: Commit**
114
115 ```bash
116 git add src/protocol.zig
117 git commit -m "feat: scrollback protocol messages and integer helpers"
118 ```
119
120 ---
121
122 ### Task 2: Engine — history introspection and scrollback dumps
123
124 **Files:**
125 - Modify: `src/engine.zig`
126
127 - [x] **Step 1: Add failing tests (append to `src/engine.zig`)**
128
129 ```zig
130 test "Engine: historyRows counts scrolled-off lines, zero on alt screen" {
131 const alloc = std.testing.allocator;
132 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
133 defer e.deinit();
134
135 try std.testing.expectEqual(@as(u32, 0), e.historyRows());
136
137 var i: usize = 1;
138 while (i <= 100) : (i += 1) {
139 var line: [32]u8 = undefined;
140 e.feed(std.fmt.bufPrint(&line, "line-{d}\r\n", .{i}) catch unreachable);
141 }
142 // 100 lines + prompt row - 24 visible = 77 in history.
143 try std.testing.expectEqual(@as(u32, 77), e.historyRows());
144
145 e.feed("\x1b[?1049h"); // alt screen: no scrollback there
146 try std.testing.expectEqual(@as(u32, 0), e.historyRows());
147 e.feed("\x1b[?1049l");
148 try std.testing.expectEqual(@as(u32, 77), e.historyRows());
149 }
150
151 test "Engine: dumpScrollback serves styled history rows by screen-space range" {
152 const alloc = std.testing.allocator;
153 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
154 defer e.deinit();
155
156 var i: usize = 1;
157 while (i <= 100) : (i += 1) {
158 var line: [48]u8 = undefined;
159 e.feed(std.fmt.bufPrint(&line, "\x1b[3{d}mline-{d}\x1b[0m\r\n", .{ i % 8, i }) catch unreachable);
160 }
161
162 // Rows 0..23 in screen space are the oldest 24 rows: line-1..line-24.
163 const chunk = try e.dumpScrollback(alloc, 0, 24);
164 defer alloc.free(chunk);
165 try std.testing.expect(std.mem.indexOf(u8, chunk, "line-1\x1b") != null);
166 try std.testing.expect(std.mem.indexOf(u8, chunk, "line-24") != null);
167 try std.testing.expect(std.mem.indexOf(u8, chunk, "line-25") == null);
168 // Styled: SGR survives.
169 try std.testing.expect(std.mem.indexOf(u8, chunk, "\x1b[31m") != null);
170
171 // A range reaching past the end clamps instead of erroring.
172 const tail = try e.dumpScrollback(alloc, 77 + 20, 24);
173 defer alloc.free(tail);
174 try std.testing.expect(std.mem.indexOf(u8, tail, "line-100") != null);
175 }
176 ```
177
178 - [x] **Step 2: Run to verify failure** — `make test`, expected: compile error.
179
180 - [x] **Step 3: Implement (add to `Engine`)**
181
182 ```zig
183 /// Number of history (scrolled-off) rows above the viewport on the
184 /// active screen. Alt screens have no scrollback: returns 0.
185 pub fn historyRows(self: *const Engine) u32 {
186 const screen = self.term.screens.active;
187 const top = screen.pages.pin(.{ .viewport = .{ .x = 0, .y = 0 } }) orelse return 0;
188 const pt = screen.pages.pointFromPin(.screen, top) orelse return 0;
189 return @intCast(pt.screen.y);
190 }
191
192 /// Styled dump of screen-space rows [start, start+count) on the active
193 /// screen (row 0 = oldest retained history row). Ranges are clamped to
194 /// what exists. Begins with an SGR reset so chunks are self-contained.
195 pub fn dumpScrollback(self: *Engine, alloc: std.mem.Allocator, start: u32, count: u16) ![]u8 {
196 const screen = self.term.screens.active;
197 const total: u32 = self.historyRows() + self.term.rows;
198 const first = @min(start, total -| 1);
199 const last = @min(first + count -| 1, total -| 1);
200
201 const tl = screen.pages.pin(.{ .screen = .{ .x = 0, .y = first } }) orelse
202 return alloc.dupe(u8, "");
203 const br = screen.pages.pin(.{ .screen = .{
204 .x = @intCast(self.term.cols - 1),
205 .y = last,
206 } }) orelse return alloc.dupe(u8, "");
207
208 var aw: std.Io.Writer.Allocating = .init(alloc);
209 defer aw.deinit();
210 try aw.writer.writeAll("\x1b[0m");
211 var f = vt.formatter.TerminalFormatter.init(&self.term, .vt);
212 f.extra = .none;
213 f.content = .{ .selection = vt.Selection.init(tl, br, false) };
214 try f.format(&aw.writer);
215 return try aw.toOwnedSlice();
216 }
217 ```
218
219 Type note: `pt.screen.y` is a coordinate integer; `total -| 1` guards the empty-terminal case. `self.term.rows` is `size.CellCountInt` — widen with `@as(u32, self.term.rows)` if the compiler complains about the addition.
220
221 - [x] **Step 4: Run tests** — `make test`, expected exit 0. If the `historyRows` expectation of 77 is off by one (prompt-row accounting), print the actual value, verify it equals `lines_fed + 1 - 24` reasoning, and adjust the *comment and constant together*.
222
223 - [x] **Step 5: Commit**
224
225 ```bash
226 git add src/engine.zig
227 git commit -m "feat: engine history introspection and styled scrollback dumps"
228 ```
229
230 ---
231
232 ### Task 3: Engine — dual-screen snapshots
233
234 **Files:**
235 - Modify: `src/engine.zig` (dumpState + the alt-screen test)
236
237 - [x] **Step 1: Un-weaken the alt-screen test**
238
239 In `test "Engine: alt-screen state survives snapshot into fresh engine"`, replace the final block:
240
241 ```zig
242 // Leaving the alt screen on the replica reveals the primary content —
243 // dumpState carries both screens (M3).
244 b.feed("\x1b[?1049l");
245 const primary_b = try b.dumpPlain(alloc);
246 defer alloc.free(primary_b);
247 try std.testing.expect(std.mem.indexOf(u8, primary_b, "primary text") != null);
248 }
249 ```
250
251 - [x] **Step 2: Run to verify failure** — `make test`, expected: that assertion fails.
252
253 - [x] **Step 3: Implement dual-screen `dumpState`**
254
255 Replace `dumpState` with:
256
257 ```zig
258 /// Full terminal state as a canonical VT byte sequence. Feeding this
259 /// into a fresh engine of the same size reconstructs the state: this
260 /// is the Snapshot payload body.
261 ///
262 /// When the alt screen is active, the primary screen's visible content
263 /// is emitted first (the replica starts on the primary screen after
264 /// its reset), then the full-state dump — whose mode section switches
265 /// to the alt screen before the alt content lands. Leaving the alt
266 /// screen on the replica then reveals real primary content. The
267 /// primary's saved-cursor ends up at the end of its content rather
268 /// than the exact pre-TUI spot; acceptable for the prototype.
269 pub fn dumpState(self: *Engine, alloc: std.mem.Allocator) ![]u8 {
270 var aw: std.Io.Writer.Allocating = .init(alloc);
271 defer aw.deinit();
272
273 if (self.term.screens.active_key != .primary) primary: {
274 const primary = self.term.screens.get(.primary) orelse break :primary;
275 const rows: u32 = self.term.rows;
276 const tl = primary.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }) orelse break :primary;
277 const br = primary.pages.pin(.{ .active = .{
278 .x = @intCast(self.term.cols - 1),
279 .y = @intCast(rows - 1),
280 } }) orelse break :primary;
281 var pf = vt.formatter.ScreenFormatter.init(primary, .vt);
282 pf.extra = .none;
283 pf.content = .{ .selection = vt.Selection.init(tl, br, false) };
284 try pf.format(&aw.writer);
285 }
286
287 var f = vt.formatter.TerminalFormatter.init(&self.term, .vt);
288 f.extra = .all;
289 // Visible grid only, per the handoff's lazy-scrollback rule:
290 // snapshots must not grow with session history.
291 f.content = .{ .selection = self.viewportSelection() };
292 try f.format(&aw.writer);
293 // The formatter emits scrolling region (DECSTBM homes the cursor)
294 // and tabstops (HTS walks the cursor) *after* the screen section's
295 // CUP, so the dump's final cursor position is wrong. Re-assert it.
296 const cur = self.cursorPos();
297 try aw.writer.print("\x1b[{d};{d}H", .{ cur.y + 1, cur.x + 1 });
298 return try aw.toOwnedSlice();
299 }
300 ```
301
302 API note: `ScreenFormatter.init(screen: *Screen, opts)` and its `extra: ScreenFormatter.Extra` (use `.none`) / `content` fields were verified in the M2 cycle (`formatter.zig:424-534`). If `ScreenFormatter.Extra.none` isn't the right accessor spelling, check `formatter.zig` around line 460.
303
304 Ordering subtlety that makes this correct: the TerminalFormatter's *modes* section (which contains the switch-to-alt-screen DECSET) is emitted **before** its screen-content section, so the replica is still on the primary screen while the prepended primary content applies, and already on the alt screen when the alt content applies.
305
306 - [x] **Step 4: Run tests** — `make test`, expected exit 0, including the strengthened alt test and the M2 fidelity test (which round-trips dumpState through a live daemon).
307
308 - [x] **Step 5: Commit**
309
310 ```bash
311 git add src/engine.zig
312 git commit -m "feat: dual-screen snapshots — primary content survives alt-screen reattach"
313 ```
314
315 ---
316
317 ### Task 4: Server — kill-safety, snapshot prefix, scrollback serving
318
319 **Files:**
320 - Modify: `src/server.zig`
321
322 - [x] **Step 1: Add failing tests (append to `src/server.zig`)**
323
324 ```zig
325 test "Server: survives a client that dies without detaching; next attach works" {
326 const alloc = std.testing.allocator;
327
328 var tmp = std.testing.tmpDir(.{});
329 defer tmp.cleanup();
330 var path_buf: [256]u8 = undefined;
331 const dir_path = try tmp.dir.realpath(".", &path_buf);
332 const sock_path = try std.fmt.allocPrint(alloc, "{s}/kill.sock", .{dir_path});
333 defer alloc.free(sock_path);
334
335 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
336 defer srv.deinit();
337 installSignalHandlers(); // includes SIGPIPE ignore
338
339 var stop = std.atomic.Value(bool).init(false);
340 const th = try std.Thread.spawn(.{}, serverThread, .{ &srv, &stop });
341 defer th.join();
342 defer stop.store(true, .release);
343
344 // Client 1 attaches, provokes output, then vanishes without detach.
345 const a = try std.net.connectUnixSocket(sock_path);
346 try proto.writeFrame(a.handle, .attach, &proto.encodeSize(80, 24));
347 try proto.writeFrame(a.handle, .input, "echo pre-kill\n");
348 std.Thread.sleep(300 * std.time.ns_per_ms);
349 a.close(); // abrupt: no detach frame
350
351 // Force more output so the daemon writes into the dead socket.
352 std.Thread.sleep(300 * std.time.ns_per_ms);
353
354 // Daemon must still be serving: a fresh attach gets a snapshot.
355 const b = try std.net.connectUnixSocket(sock_path);
356 defer b.close();
357 try proto.writeFrame(b.handle, .attach, &proto.encodeSize(80, 24));
358 var got_snapshot = false;
359 var deadline_ms: u64 = 5000;
360 while (deadline_ms > 0 and !got_snapshot) {
361 var pfd = [_]std.posix.pollfd{
362 .{ .fd = b.handle, .events = std.posix.POLL.IN, .revents = 0 },
363 };
364 const ready = try std.posix.poll(&pfd, 100);
365 deadline_ms -|= 100;
366 if (ready == 0) continue;
367 const frame = (try proto.readFrame(alloc, b.handle)) orelse break;
368 defer frame.deinit(alloc);
369 if (frame.type == .snapshot) got_snapshot = true;
370 }
371 try std.testing.expect(got_snapshot);
372 }
373
374 test "Server: serves scrollback chunks on request" {
375 const alloc = std.testing.allocator;
376
377 var tmp = std.testing.tmpDir(.{});
378 defer tmp.cleanup();
379 var path_buf: [256]u8 = undefined;
380 const dir_path = try tmp.dir.realpath(".", &path_buf);
381 const sock_path = try std.fmt.allocPrint(alloc, "{s}/sb.sock", .{dir_path});
382 defer alloc.free(sock_path);
383
384 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
385 defer srv.deinit();
386
387 var stop = std.atomic.Value(bool).init(false);
388 const th = try std.Thread.spawn(.{}, serverThread, .{ &srv, &stop });
389 defer th.join();
390 defer stop.store(true, .release);
391
392 const c = try std.net.connectUnixSocket(sock_path);
393 defer c.close();
394 try proto.writeFrame(c.handle, .attach, &proto.encodeSize(80, 24));
395 try proto.writeFrame(c.handle, .input, "seq 1 100\n");
396
397 // Wait until a snapshot reports enough history, then fetch the oldest page.
398 var history: u32 = 0;
399 var deadline_ms: u64 = 10_000;
400 while (deadline_ms > 0 and history < 50) {
401 var pfd = [_]std.posix.pollfd{
402 .{ .fd = c.handle, .events = std.posix.POLL.IN, .revents = 0 },
403 };
404 const ready = try std.posix.poll(&pfd, 100);
405 deadline_ms -|= 100;
406 if (ready == 0) continue;
407 const frame = (try proto.readFrame(alloc, c.handle)) orelse break;
408 defer frame.deinit(alloc);
409 if (frame.type == .snapshot and frame.payload.len >= 4) {
410 history = proto.getU32(frame.payload[0..4]);
411 }
412 }
413 try std.testing.expect(history >= 50);
414
415 try proto.writeFrame(c.handle, .fetch_scrollback, &proto.encodeScrollbackReq(0, 24));
416 var chunk: ?[]u8 = null;
417 defer if (chunk) |ch| alloc.free(ch);
418 deadline_ms = 5000;
419 while (deadline_ms > 0 and chunk == null) {
420 var pfd = [_]std.posix.pollfd{
421 .{ .fd = c.handle, .events = std.posix.POLL.IN, .revents = 0 },
422 };
423 const ready = try std.posix.poll(&pfd, 100);
424 deadline_ms -|= 100;
425 if (ready == 0) continue;
426 const frame = (try proto.readFrame(alloc, c.handle)) orelse break;
427 if (frame.type == .scrollback_chunk) {
428 chunk = frame.payload;
429 } else frame.deinit(alloc);
430 }
431 try std.testing.expect(chunk != null);
432 try std.testing.expect(chunk.?.len > 6);
433 const req_echo = try proto.decodeScrollbackReq(chunk.?[0..6]);
434 try std.testing.expectEqual(@as(u32, 0), req_echo.start);
435 // The oldest page contains the first line the shell printed.
436 try std.testing.expect(std.mem.indexOf(u8, chunk.?[6..], "seq 1 100") != null);
437 }
438 ```
439
440 - [x] **Step 2: Run to verify failure** — `make test`. Expected: first test may PASS already on platforms where the write happens after poll reveals POLLHUP (drop-on-read-EOF path) — that's fine, it pins the behavior; the scrollback test must FAIL (no fetch handler).
441
442 - [x] **Step 3: Implement**
443
444 In `installSignalHandlers`, add SIGPIPE ignore:
445
446 ```zig
447 var ign: std.posix.Sigaction = .{
448 .handler = .{ .handler = std.posix.SIG.IGN },
449 .mask = std.posix.sigemptyset(),
450 .flags = 0,
451 };
452 std.posix.sigaction(std.posix.SIG.PIPE, &ign, null);
453 ```
454
455 In `sendSnapshot`, prefix the history count:
456
457 ```zig
458 fn sendSnapshot(self: *Server) void {
459 const fd = self.client orelse return;
460 const state = self.eng.dumpState(self.alloc) catch return;
461 defer self.alloc.free(state);
462 const payload = self.alloc.alloc(u8, 4 + state.len) catch return;
463 defer self.alloc.free(payload);
464 proto.putU32(payload[0..4], self.eng.historyRows());
465 @memcpy(payload[4..], state);
466 proto.writeFrame(fd, .snapshot, payload) catch self.dropClient();
467 }
468 ```
469
470 In `serviceClient`'s switch, add:
471
472 ```zig
473 .fetch_scrollback => {
474 const req = proto.decodeScrollbackReq(frame.payload) catch return;
475 const rows = self.eng.dumpScrollback(self.alloc, req.start, req.count) catch return;
476 defer self.alloc.free(rows);
477 const payload = self.alloc.alloc(u8, 6 + rows.len) catch return;
478 defer self.alloc.free(payload);
479 @memcpy(payload[0..6], &proto.encodeScrollbackReq(req.start, req.count));
480 @memcpy(payload[6..], rows);
481 proto.writeFrame(fd, .scrollback_chunk, payload) catch self.dropClient();
482 },
483 ```
484
485 - [x] **Step 4: Run tests** — `make test`, expected exit 0. The M2 fidelity test still passes because the replica-feed in that test must be updated to skip the 4-byte prefix — do that now: in `test "Server: replica rebuilt from snapshots matches the authoritative grid"`, change both `replica.feed(frame.payload);` occurrences to `replica.feed(frame.payload[4..]);`.
486
487 - [x] **Step 5: Commit**
488
489 ```bash
490 git add src/server.zig
491 git commit -m "feat: daemon serves scrollback; snapshot carries history count; kill-safe"
492 ```
493
494 ---
495
496 ### Task 5: Client — snapshot prefix, scroll mode, SIGPIPE
497
498 **Files:**
499 - Modify: `src/client.zig`
500
501 - [x] **Step 1: Implement (no isolated unit test — the moving parts are fd-loop glue; coverage comes from Task 4's protocol tests plus Task 6's e2e; the marker rendering gets a small test below)**
502
503 At the top of `attach`, after the socket connects, ignore SIGPIPE (a daemon that dies mid-write must surface as an error return, not kill us):
504
505 ```zig
506 var ign: std.posix.Sigaction = .{
507 .handler = .{ .handler = std.posix.SIG.IGN },
508 .mask = std.posix.sigemptyset(),
509 .flags = 0,
510 };
511 std.posix.sigaction(std.posix.SIG.PIPE, &ign, null);
512 ```
513
514 Add scroll-mode state next to `stdin_open`:
515
516 ```zig
517 var stdin_open = true;
518 // Scroll mode: 0 = live; N = viewing the page N screenfuls above live.
519 var scroll_pages: u32 = 0;
520 var history_rows: u32 = 0;
521 var latest_state: ?[]u8 = null; // last snapshot body (prefix stripped)
522 defer if (latest_state) |s| alloc.free(s);
523 ```
524
525 Replace the `.snapshot` arm:
526
527 ```zig
528 .snapshot => {
529 if (frame.payload.len < 4) continue;
530 history_rows = proto.getU32(frame.payload[0..4]);
531 if (latest_state) |s| alloc.free(s);
532 latest_state = try alloc.dupe(u8, frame.payload[4..]);
533 if (scroll_pages == 0) {
534 replica.reset();
535 replica.feed(latest_state.?);
536 try render(alloc, replica, stdout_fd);
537 }
538 },
539 ```
540
541 Add a `.scrollback_chunk` arm:
542
543 ```zig
544 .scrollback_chunk => {
545 if (scroll_pages == 0 or frame.payload.len < 6) continue;
546 try renderScrollback(alloc, frame.payload[6..], size, stdout_fd);
547 },
548 ```
549
550 Replace the stdin-forwarding block's inner else (the part after the Ctrl-\ check) with scroll-key handling:
551
552 ```zig
553 const scroll_up = "\x1b[5;2~"; // Shift+PageUp
554 const scroll_dn = "\x1b[6;2~"; // Shift+PageDown
555 if (std.mem.eql(u8, buf[0..n], scroll_up)) {
556 if (history_rows > 0) {
557 const max_pages: u32 = (history_rows + size.rows - 1) / size.rows;
558 if (scroll_pages < max_pages) scroll_pages += 1;
559 try requestScrollPage(sock, scroll_pages, history_rows, size);
560 }
561 } else if (std.mem.eql(u8, buf[0..n], scroll_dn)) {
562 if (scroll_pages > 0) scroll_pages -= 1;
563 if (scroll_pages == 0) {
564 if (latest_state) |s| {
565 replica.reset();
566 replica.feed(s);
567 try render(alloc, replica, stdout_fd);
568 }
569 } else {
570 try requestScrollPage(sock, scroll_pages, history_rows, size);
571 }
572 } else if (scroll_pages > 0) {
573 // Any other key exits scroll mode (swallowed, not forwarded).
574 scroll_pages = 0;
575 if (latest_state) |s| {
576 replica.reset();
577 replica.feed(s);
578 try render(alloc, replica, stdout_fd);
579 }
580 } else {
581 try proto.writeFrame(sock, .input, buf[0..n]);
582 }
583 ```
584
585 Add the helpers and the marker test at file scope:
586
587 ```zig
588 fn requestScrollPage(
589 sock: std.posix.fd_t,
590 pages_up: u32,
591 history_rows: u32,
592 size: proto.Size,
593 ) !void {
594 // Page N shows `size.rows` rows ending N*rows above the live viewport
595 // top (screen-space row index history_rows).
596 const rows: u32 = size.rows;
597 const start = history_rows -| (pages_up * rows);
598 try proto.writeFrame(sock, .fetch_scrollback, &proto.encodeScrollbackReq(start, size.rows));
599 }
600
601 /// Paint a fetched history page: clear, rows, and an inverse [scroll]
602 /// marker top-right so the user knows they're not live.
603 fn renderScrollback(
604 alloc: std.mem.Allocator,
605 rows_vt: []const u8,
606 size: proto.Size,
607 out_fd: std.posix.fd_t,
608 ) !void {
609 var paint: std.ArrayList(u8) = .empty;
610 defer paint.deinit(alloc);
611 try paint.appendSlice(alloc, "\x1b[?2026h\x1b[?25l\x1b[H\x1b[2J");
612 try paint.appendSlice(alloc, rows_vt);
613 var mark_buf: [64]u8 = undefined;
614 const mark = try std.fmt.bufPrint(&mark_buf, "\x1b[1;{d}H\x1b[7m[scroll]\x1b[0m", .{
615 size.cols -| 8,
616 });
617 try paint.appendSlice(alloc, mark);
618 try paint.appendSlice(alloc, "\x1b[?2026l");
619 try proto.writeAllFd(out_fd, paint.items);
620 }
621
622 test "renderScrollback paints rows with an inverse scroll marker" {
623 const alloc = std.testing.allocator;
624 const pipe = try std.posix.pipe();
625 defer std.posix.close(pipe[0]);
626 try renderScrollback(alloc, "old-row-1\r\nold-row-2", .{ .cols = 80, .rows = 24 }, pipe[1]);
627 std.posix.close(pipe[1]);
628
629 var out: [4096]u8 = undefined;
630 const n = try std.posix.read(pipe[0], &out);
631 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "old-row-1") != null);
632 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[7m[scroll]") != null);
633 }
634 ```
635
636 Note the `.snapshot` arm no longer calls `replica.reset()+feed` unconditionally — while scrolled, the latest state is stored and painted on scroll-exit, so the user never loses live output, it's just deferred.
637
638 - [x] **Step 2: Run tests + build** — `make test && make build`, expected exit 0.
639
640 - [x] **Step 3: Commit**
641
642 ```bash
643 git add src/client.zig
644 git commit -m "feat: client scroll mode with lazy history paging; SIGPIPE-safe"
645 ```
646
647 ---
648
649 ### Task 6: e2e kill-reattach, demo, docs
650
651 **Files:**
652 - Modify: `test/e2e.sh`, `docs/decisions.md`, `README.md`
653
654 - [x] **Step 1: Append the kill-reattach scenario to `test/e2e.sh`** (before the final `echo "e2e OK"`)
655
656 ```sh
657 # --- M3: kill a client mid-run; daemon survives; reattach lands correctly.
658 { printf 'seq 1 60\n'; sleep 2; } | "$MUX" --sock "$SOCK" > "$OUT.kill" &
659 CPID=$!
660 sleep 1
661 kill -9 "$CPID" 2>/dev/null || true
662 sleep 1
663 kill -0 "$DPID" || { echo "e2e FAIL: daemon died after client kill -9"; exit 1; }
664
665 { sleep 1; printf '\034'; } | "$MUX" --sock "$SOCK" > "$OUT.re"
666 grep -q "60" "$OUT.re" || {
667 echo "e2e FAIL: reattach after kill missing state"; cat "$OUT.re"; exit 1;
668 }
669 rm -f "$OUT.kill" "$OUT.re"
670 ```
671
672 Also add `"$OUT.kill" "$OUT.re"` to the `cleanup()` rm line.
673
674 - [x] **Step 2: Run** — `make e2e`, expected `e2e OK`.
675
676 - [x] **Step 3: Manual demo (M3 acceptance)** — scripted equivalents acceptable, real-terminal run preferred:
677
678 1. `muxd run &`, `mux`, run `seq 1 200`, **Shift+PageUp** — oldest lines appear with an inverse `[scroll]` marker; Shift+PageUp/PageDown page through; any key returns live.
679 2. Run `while true; do date; sleep 1; done`, `kill -9` the mux process from another terminal, wait a few seconds, re-run `mux`: the counter continued (timestamps advanced while detached) and the screen is current.
680 3. Open `nvim`, kill the client, reattach: nvim's screen is correct. Quit nvim (`:q`): the shell history is there, not a blank screen.
681 4. Scrollback after reattach: the history from before the detach pages correctly.
682
683 - [x] **Step 4: Update `docs/decisions.md`** — append:
684
685 ```markdown
686 ## 2026-08-07 (M3)
687
688 - **Scrollback addressing: screen-space row index** (0 = oldest retained
689 row). Positions drift as history evicts at max_scrollback; scroll
690 positions are ephemeral, so drift is acceptable. Fetch is pull-only:
691 snapshots carry a u32 history_rows count, never history content.
692 - **Scroll UX: Shift+PageUp/PageDown** (`\x1b[5;2~`/`\x1b[6;2~`), page at a
693 time, remote-paged with no client cache (a page fetch is ~2KB over a
694 local socket). Any other key snaps back to live and is swallowed. Live
695 snapshots keep applying while scrolled but paint only on return.
696 - **Dual-screen snapshots.** When the alt screen is active, dumpState
697 prepends the primary screen's visible content before the full-state dump
698 (whose mode section performs the alt switch). Primary saved-cursor lands
699 at end-of-content, not the exact pre-TUI position — accepted.
700 - **SIGPIPE ignored in both processes.** A kill -9'd client previously
701 killed the daemon via SIGPIPE on the next snapshot write — found by the
702 M3 kill-reattach scenario, now covered by a server test and e2e.
703 ```
704
705 - [x] **Step 5: Update `README.md`** — replace the status line and usage block:
706
707 ```markdown
708 Status: **M3 — the promise.**
709
710 make test && make e2e # verify
711 make build
712 ./zig-out/bin/muxd run & # daemon
713 ./zig-out/bin/mux # attach (Ctrl-\ detach, Shift+PgUp scroll)
714 ./zig-out/bin/muxd dump [--vt] # debug: print the authoritative grid
715
716 Detach, or kill the client outright — the session survives and `mux`
717 resumes it from a state snapshot, including scrollback (fetched lazily)
718 and TUI screens.
719 ```
720
721 - [x] **Step 6: Commit**
722
723 ```bash
724 git add test/e2e.sh docs/decisions.md README.md
725 git commit -m "test: kill-reattach e2e; docs: M3 decisions and usage"
726 ```
727
728 ---
729
730 ## Self-Review
731
732 - **Spec coverage:** daemon parses while detached ✓ (M2 behavior, e2e-pinned), reattach snapshot reconstruction ✓ (M2 + Task 4 prefix), correct scrollback on reattach ✓ (Tasks 1/2/4/5 — lazy fetch per the handoff's non-negotiable), kill-mid-run demo ✓ (Task 6 e2e + demo 2), vim-open reattach ✓ (Task 3 + demo 3), "visually instant" ✓ (single snapshot on attach — same path the M2 demo showed instant). `Bell`/`TitleChange` from the protocol sketch remain deferred (cosmetic; engine captures titles already).
733 - **Placeholders:** none. Task 2 Step 4 allows adjusting a test constant only together with its justifying comment — a measurement, not a placeholder.
734 - **Type consistency:** `proto.putU32/getU32/encodeScrollbackReq/decodeScrollbackReq/ScrollbackReq` (Task 1) used in Tasks 4/5 ✓; `Engine.historyRows/dumpScrollback` (Task 2) used in Task 4 ✓; snapshot payload layout (u32 ++ state) consistent between Task 4 sender and Task 5 parser and the Task 4 fidelity-test fix ✓; `requestScrollPage/renderScrollback` defined and used in Task 5 ✓.
735 - **Known risks stated where they bite:** `pt.screen.y` int widths (Task 2 note); `ScreenFormatter` accessor spelling (Task 3 note); split escape sequences fall through harmlessly (File Structure decisions paragraph).
docs/superpowers/plans/2026-08-07-m4-deltas.md
Old New
@@ -1,1076 +0,0 @@
1 # M4 — Deltas 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:** Replace full-snapshot-per-update with sequence-numbered, row-granular deltas plus bytes-on-wire instrumentation, so steady-state typing sends bytes proportional to what changed — and prove it with a measured ratio against the full-snapshot equivalent.
6
7 **Architecture:** The daemon keeps a `DeltaTracker`: per-viewport-row content hashes, a per-row `last_change_seq`, a global `seq`, and a `reset_seq` marking the last discontinuity (attach/resize/screen-switch). After each engine update it re-dumps each viewport row (styled, self-contained), hashes it, and emits a **Delta** frame containing only changed rows + cursor + history count. `Attach` gains `have_seq`: a reattaching connection whose `have_seq` postdates `reset_seq` receives a delta of rows changed since then; anyone older gets a full **Snapshot** (which now carries `seq`). The client applies deltas by composing per-row `CUP + EL + row-bytes` strings (shared helper `composeDelta` in protocol.zig) — fed to the replica *and* painted directly, so both wire and paint cost scale with damage. Stats counters (deltas/snapshots sent, bytes each, and the counterfactual "what full snapshots would have cost") are served over a new `stats` message and printed by `muxd stats`.
8
9 **Tech Stack:** unchanged — Zig 0.15.2 (pinned via Makefile: always build/test with `make test`, `make build`, `make e2e`, never bare `zig`), ghostty-vt pinned package. Row diffing is content-hash based (`std.hash.Wyhash`) rather than ghostty's RenderState dirty-tracking — deterministic, no new API surface; recorded as a decision.
10
11 ---
12
13 ## Context for executors with zero prior exposure
14
15 - Repo: `/home/xanderle/code/rad/mux`. Read `docs/handoff.md` §M4 and `docs/decisions.md` first. `README.md` explains the binaries.
16 - The system `zig` is 0.17-dev and CANNOT build this project. The Makefile pins `~/Downloads/zig-x86_64-linux-0.15.2/zig`. Use `make test` / `make build` / `make e2e` exclusively.
17 - Never copy code from `~/code/rad/waystty` (user decision — recorded in decisions.md).
18 - The ghostty-vt package source (for API reference when something doesn't compile) is at `~/.cache/zig/p/ghostty-1.3.2-dev-5UdBC7VOBgVv0iA-qLRtBnau_zLIv7iGGLdnEiW6fUYU/src/` — notably `terminal/formatter.zig`, `terminal/Terminal.zig`, `terminal/ScreenSet.zig`. Its API is unstable; the plan was written against this exact pin.
19 - Existing module layout: `src/protocol.zig` (framing, no deps), `src/engine.zig` (ghostty-vt wrapper; `Engine.init` returns `*Engine`), `src/pty.zig`, `src/server.zig` (Server + tests incl. a replica-fidelity integration test), `src/client.zig`, `src/main.zig` (muxd), `src/mux_main.zig` (mux). Tests live inside each module file; `build.zig` runs them per-module.
20 - Commit after each task with the message given in the task. Work directly on `main`.
21
22 **Current wire format (M2/M3, all little-endian):** frames are `u8 MsgType ++ u32 payload_len ++ payload`. `attach`=0x01 payload `u16 cols, u16 rows`; `resize`=0x03 same; `input`=0x02 raw bytes; `detach`=0x04 empty; `fetch_scrollback`=0x05 `u32 start, u16 count`; `debug_dump`=0x7f 1 byte; `snapshot`=0x81 `u32 history_rows ++ state-vt-bytes`; `exit_status`=0x82; `taken_over`=0x84; `scrollback_chunk`=0x85; `dump_reply`=0xff.
23
24 **M4 wire changes (this plan):** `attach` payload becomes `u16 cols, u16 rows, u64 have_seq` (12 bytes; 0 = "I have nothing"). `snapshot` payload becomes `u64 seq ++ u32 history_rows ++ state bytes`. New: `stats_req`=0x06 (empty), `delta`=0x87, `stats_reply`=0x86 (text). Delta payload layout:
25
26 ```
27 u64 seq
28 u32 history_rows
29 u16 cursor_x, u16 cursor_y (0-based)
30 u16 row_count
31 row_count × { u16 row_index, u32 byte_len, byte_len bytes }
32 ```
33
34 **Wire-format decision (record in decisions.md, Task 5):** the handoff says "msgpack or protobuf, do not invent one" for M4. Deliberate deviation: delta payloads are row-keyed byte blobs plus six integers — a serialization library adds a dependency (none is proven on Zig 0.15.2) without removing any complexity at this granularity. The moment payloads become genuinely structured (cell runs, multi-rect damage, capability negotiation), that judgment flips and msgpack becomes due. This is a considered decision, not drift — write it down.
35
36 ---
37
38 ## File Structure
39
40 ```
41 src/protocol.zig — MODIFY: new MsgTypes, attach v2 encode/decode, delta
42 header/row encode + iterate + composeDelta, u64 helpers
43 src/engine.zig — MODIFY: dumpVtRow(alloc, y), onAltScreen()
44 src/server.zig — MODIFY: DeltaTracker, sendUpdate() replacing
45 per-update sendSnapshot, stats, have_seq attach path
46 src/client.zig — MODIFY: attach v2 (have_seq=0), delta apply/paint
47 src/main.zig — MODIFY: `muxd stats` subcommand
48 test/bench.sh — CREATE: typing-workload byte ratio measurement
49 test/e2e.sh — MODIFY: assert deltas flow and ratio bound
50 ```
51
52 ---
53
54 ### Task 1: Protocol v2 — attach/have_seq, delta encoding, composeDelta
55
56 **Files:**
57 - Modify: `src/protocol.zig`
58
59 - [x] **Step 1: Add failing tests (append to `src/protocol.zig`)**
60
61 ```zig
62 test "attach v2 encode/decode round trip" {
63 const a = try decodeAttach(&encodeAttach(120, 40, 987654321));
64 try std.testing.expectEqual(@as(u16, 120), a.cols);
65 try std.testing.expectEqual(@as(u16, 40), a.rows);
66 try std.testing.expectEqual(@as(u64, 987654321), a.have_seq);
67 }
68
69 test "delta build/iterate round trip" {
70 const alloc = std.testing.allocator;
71 var payload: std.ArrayList(u8) = .empty;
72 defer payload.deinit(alloc);
73
74 try appendDeltaHeader(&payload, alloc, .{
75 .seq = 42,
76 .history_rows = 7,
77 .cursor_x = 3,
78 .cursor_y = 5,
79 .row_count = 2,
80 });
81 try appendDeltaRow(&payload, alloc, 5, "\x1b[0mhello");
82 try appendDeltaRow(&payload, alloc, 23, "\x1b[0mworld");
83
84 const hdr = try readDeltaHeader(payload.items);
85 try std.testing.expectEqual(@as(u64, 42), hdr.seq);
86 try std.testing.expectEqual(@as(u32, 7), hdr.history_rows);
87 try std.testing.expectEqual(@as(u16, 2), hdr.row_count);
88
89 var it = deltaRowIterator(payload.items);
90 const r1 = (try it.next()).?;
91 try std.testing.expectEqual(@as(u16, 5), r1.row);
92 try std.testing.expectEqualStrings("\x1b[0mhello", r1.bytes);
93 const r2 = (try it.next()).?;
94 try std.testing.expectEqual(@as(u16, 23), r2.row);
95 try std.testing.expectEqualStrings("\x1b[0mworld", r2.bytes);
96 try std.testing.expectEqual(@as(?DeltaRow, null), try it.next());
97 }
98
99 test "composeDelta produces CUP+EL row paints and final cursor restore" {
100 const alloc = std.testing.allocator;
101 var payload: std.ArrayList(u8) = .empty;
102 defer payload.deinit(alloc);
103 try appendDeltaHeader(&payload, alloc, .{
104 .seq = 1,
105 .history_rows = 0,
106 .cursor_x = 4,
107 .cursor_y = 2,
108 .row_count = 1,
109 });
110 try appendDeltaRow(&payload, alloc, 9, "\x1b[0mrow-ten");
111
112 const composed = try composeDelta(alloc, payload.items);
113 defer alloc.free(composed.bytes);
114 // Row 9 (0-based) paints at line 10; EL(2) clears the old content.
115 try std.testing.expect(std.mem.indexOf(u8, composed.bytes, "\x1b[10;1H\x1b[2K\x1b[0mrow-ten") != null);
116 // Ends with the cursor restore (1-based 3;5).
117 try std.testing.expect(std.mem.endsWith(u8, composed.bytes, "\x1b[3;5H"));
118 try std.testing.expectEqual(@as(u64, 1), composed.header.seq);
119 }
120 ```
121
122 - [x] **Step 2: Run to verify failure** — `make test`; expected: compile errors for the new symbols.
123
124 - [x] **Step 3: Implement (add to `src/protocol.zig`; also add the new MsgType members)**
125
126 In `MsgType`, add after `fetch_scrollback`:
127
128 ```zig
129 stats_req = 0x06, // payload: empty
130 ```
131
132 and after `scrollback_chunk`:
133
134 ```zig
135 stats_reply = 0x86, // payload: human-readable stats text
136 delta = 0x87, // payload: see DeltaHeader + rows
137 ```
138
139 Update the `attach` comment to `// payload: u16 LE cols, u16 LE rows, u64 LE have_seq` and the `snapshot` comment to `// payload: u64 LE seq ++ u32 LE history_rows ++ full-state vt dump`.
140
141 Then:
142
143 ```zig
144 pub const AttachReq = struct { cols: u16, rows: u16, have_seq: u64 };
145
146 pub fn encodeAttach(cols: u16, rows: u16, have_seq: u64) [12]u8 {
147 var buf: [12]u8 = undefined;
148 std.mem.writeInt(u16, buf[0..2], cols, .little);
149 std.mem.writeInt(u16, buf[2..4], rows, .little);
150 std.mem.writeInt(u64, buf[4..12], have_seq, .little);
151 return buf;
152 }
153
154 pub fn decodeAttach(payload: []const u8) !AttachReq {
155 if (payload.len != 12) return error.BadPayload;
156 return .{
157 .cols = std.mem.readInt(u16, payload[0..2], .little),
158 .rows = std.mem.readInt(u16, payload[2..4], .little),
159 .have_seq = std.mem.readInt(u64, payload[4..12], .little),
160 };
161 }
162
163 pub const DeltaHeader = struct {
164 seq: u64,
165 history_rows: u32,
166 cursor_x: u16,
167 cursor_y: u16,
168 row_count: u16,
169 };
170
171 pub const delta_header_len = 18;
172
173 pub fn appendDeltaHeader(
174 list: *std.ArrayList(u8),
175 alloc: std.mem.Allocator,
176 hdr: DeltaHeader,
177 ) !void {
178 var buf: [delta_header_len]u8 = undefined;
179 std.mem.writeInt(u64, buf[0..8], hdr.seq, .little);
180 std.mem.writeInt(u32, buf[8..12], hdr.history_rows, .little);
181 std.mem.writeInt(u16, buf[12..14], hdr.cursor_x, .little);
182 std.mem.writeInt(u16, buf[14..16], hdr.cursor_y, .little);
183 std.mem.writeInt(u16, buf[16..18], hdr.row_count, .little);
184 try list.appendSlice(alloc, &buf);
185 }
186
187 pub fn readDeltaHeader(payload: []const u8) !DeltaHeader {
188 if (payload.len < delta_header_len) return error.BadPayload;
189 return .{
190 .seq = std.mem.readInt(u64, payload[0..8], .little),
191 .history_rows = std.mem.readInt(u32, payload[8..12], .little),
192 .cursor_x = std.mem.readInt(u16, payload[12..14], .little),
193 .cursor_y = std.mem.readInt(u16, payload[14..16], .little),
194 .row_count = std.mem.readInt(u16, payload[16..18], .little),
195 };
196 }
197
198 pub fn appendDeltaRow(
199 list: *std.ArrayList(u8),
200 alloc: std.mem.Allocator,
201 row_index: u16,
202 bytes: []const u8,
203 ) !void {
204 var buf: [6]u8 = undefined;
205 std.mem.writeInt(u16, buf[0..2], row_index, .little);
206 std.mem.writeInt(u32, buf[2..6], @intCast(bytes.len), .little);
207 try list.appendSlice(alloc, &buf);
208 try list.appendSlice(alloc, bytes);
209 }
210
211 pub const DeltaRow = struct { row: u16, bytes: []const u8 };
212
213 pub const DeltaRowIterator = struct {
214 rest: []const u8,
215
216 pub fn next(self: *DeltaRowIterator) !?DeltaRow {
217 if (self.rest.len == 0) return null;
218 if (self.rest.len < 6) return error.BadPayload;
219 const row = std.mem.readInt(u16, self.rest[0..2], .little);
220 const len = std.mem.readInt(u32, self.rest[2..6], .little);
221 if (self.rest.len < 6 + len) return error.BadPayload;
222 const bytes = self.rest[6 .. 6 + len];
223 self.rest = self.rest[6 + len ..];
224 return .{ .row = row, .bytes = bytes };
225 }
226 };
227
228 pub fn deltaRowIterator(payload: []const u8) DeltaRowIterator {
229 return .{ .rest = payload[delta_header_len..] };
230 }
231
232 pub const ComposedDelta = struct { header: DeltaHeader, bytes: []u8 };
233
234 /// Turn a delta payload into the VT byte string that applies it: for each
235 /// row, CUP to the row start + EL(2) + the row's styled content; finally a
236 /// CUP to the delta's cursor. Feed the result to a replica engine and/or
237 /// paint it (inside sync-output brackets) to a terminal.
238 pub fn composeDelta(alloc: std.mem.Allocator, payload: []const u8) !ComposedDelta {
239 const hdr = try readDeltaHeader(payload);
240 var out: std.ArrayList(u8) = .empty;
241 errdefer out.deinit(alloc);
242
243 var it = deltaRowIterator(payload);
244 while (try it.next()) |row| {
245 var buf: [16]u8 = undefined;
246 const cup = try std.fmt.bufPrint(&buf, "\x1b[{d};1H\x1b[2K", .{@as(u32, row.row) + 1});
247 try out.appendSlice(alloc, cup);
248 try out.appendSlice(alloc, row.bytes);
249 }
250 var cbuf: [16]u8 = undefined;
251 const cur = try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{
252 @as(u32, hdr.cursor_y) + 1,
253 @as(u32, hdr.cursor_x) + 1,
254 });
255 try out.appendSlice(alloc, cur);
256 return .{ .header = hdr, .bytes = try out.toOwnedSlice(alloc) };
257 }
258 ```
259
260 Size note: `bufPrint` targets — `\x1b[NNN;1H\x1b[2K` needs up to 12 bytes for 3-digit rows; 16 is safe for u16 rows up to 5 digits? No: `\x1b[65535;1H\x1b[2K` is 15 bytes — fine; the cursor one `\x1b[65535;65535H` is 14 bytes — fine.
261
262 - [x] **Step 4: Run tests** — `make test`, expected exit 0. Existing callers of `encodeSize` for attach (client.zig, server tests) still compile because `encodeSize` is untouched — they break in Tasks 3/4 when the daemon starts decoding attach as 12 bytes; that's expected sequencing, and `make test` at THIS task must still pass because nothing decodes attach differently yet.
263
264 - [x] **Step 5: Commit**
265
266 ```bash
267 git add src/protocol.zig
268 git commit -m "feat: delta wire encoding, attach have_seq, composeDelta"
269 ```
270
271 ---
272
273 ### Task 2: Engine — per-row styled dumps and screen-key accessor
274
275 NOTE: Steps below show the original spec; the landed implementation differs per review — formatSelection/viewportRows helpers, dumpVtRow name, golden tests. See commits 77e4214 + f7a521d.
276
277 **Files:**
278 - Modify: `src/engine.zig`
279
280 - [x] **Step 1: Add failing tests (append to `src/engine.zig`)**
281
282 ```zig
283 test "Engine: dumpVtRow dumps one styled viewport row, self-contained" {
284 const alloc = std.testing.allocator;
285 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
286 defer e.deinit();
287
288 e.feed("plain row\r\n\x1b[1;31mred row\x1b[0m\r\nthird");
289
290 const r0 = try e.dumpVtRow(alloc, 0);
291 defer alloc.free(r0);
292 try std.testing.expect(std.mem.indexOf(u8, r0, "plain row") != null);
293 try std.testing.expect(std.mem.indexOf(u8, r0, "red row") == null);
294 try std.testing.expect(std.mem.startsWith(u8, r0, "\x1b[0m"));
295
296 const r1 = try e.dumpVtRow(alloc, 1);
297 defer alloc.free(r1);
298 try std.testing.expect(std.mem.indexOf(u8, r1, "red row") != null);
299 // Styled: bold survives in some SGR form.
300 try std.testing.expect(std.mem.indexOf(u8, r1, "\x1b[1m") != null or
301 std.mem.indexOf(u8, r1, ";1m") != null);
302
303 // A row past the content is empty (just the reset prefix).
304 const r9 = try e.dumpVtRow(alloc, 9);
305 defer alloc.free(r9);
306 try std.testing.expectEqualStrings("\x1b[0m", r9);
307 }
308
309 test "Engine: onAltScreen reflects 1049 switches" {
310 const alloc = std.testing.allocator;
311 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
312 defer e.deinit();
313 try std.testing.expect(!e.onAltScreen());
314 e.feed("\x1b[?1049h");
315 try std.testing.expect(e.onAltScreen());
316 e.feed("\x1b[?1049l");
317 try std.testing.expect(!e.onAltScreen());
318 }
319 ```
320
321 - [x] **Step 2: Run to verify failure** — `make test`; expected: compile errors.
322
323 - [x] **Step 3: Implement (add to `Engine`, near `dumpVt`)**
324
325 ```zig
326 /// One viewport row (0-based), styled, self-contained: starts with an
327 /// SGR reset, contains only that row's content, no trailing newline.
328 /// Delta payloads are built from these.
329 pub fn dumpVtRow(self: *Engine, alloc: std.mem.Allocator, y: u16) ![]u8 {
330 const screen = self.term.screens.active;
331 var aw: std.Io.Writer.Allocating = .init(alloc);
332 defer aw.deinit();
333 try aw.writer.writeAll("\x1b[0m");
334
335 const tl = screen.pages.pin(.{ .viewport = .{ .x = 0, .y = y } }) orelse
336 return try aw.toOwnedSlice();
337 const br = screen.pages.pin(.{ .viewport = .{
338 .x = @intCast(self.term.cols - 1),
339 .y = y,
340 } }) orelse return try aw.toOwnedSlice();
341
342 var f = vt.formatter.TerminalFormatter.init(&self.term, .vt);
343 f.extra = .none;
344 f.content = .{ .selection = vt.Selection.init(tl, br, false) };
345 try f.format(&aw.writer);
346 return try aw.toOwnedSlice();
347 }
348
349 /// True when the alternate screen is active (TUIs).
350 pub fn onAltScreen(self: *const Engine) bool {
351 return self.term.screens.active_key != .primary;
352 }
353 ```
354
355 - [x] **Step 4: Run tests** — `make test`, expected exit 0. If the bold-SGR assertion fails, print the actual bytes (temporary `std.debug.print`) — the formatter may emit a combined form; adjust the assertion to the observed canonical form and note it in the test comment (precedent: the formatter canonicalizes `31` to `38;5;1`, see decisions.md).
356
357 - [x] **Step 5: Commit**
358
359 ```bash
360 git add src/engine.zig
361 git commit -m "feat: engine per-row styled dumps and alt-screen accessor"
362 ```
363
364 ---
365
366 ### Task 3: Server — DeltaTracker, stats, have_seq attach
367
368 **Files:**
369 - Modify: `src/server.zig`
370
371 This is the core task. The daemon currently calls `sendSnapshot()` after every engine update (see `pumpOnce`, the `self.sendSnapshot()` call in the PTY branch) and on attach/resize (in `serviceClient`/`serviceObserver`). After this task: per-update it calls `sendUpdate()` (tracker diff → delta or discontinuity snapshot); attach/resize call `sendResync(have_seq)`.
372
373 - [x] **Step 1: Add failing tests (append to `src/server.zig`)**
374
375 ```zig
376 test "Server: typing produces deltas, not snapshots; stats track both" {
377 const alloc = std.testing.allocator;
378
379 var tmp = std.testing.tmpDir(.{});
380 defer tmp.cleanup();
381 var path_buf: [256]u8 = undefined;
382 const dir_path = try tmp.dir.realpath(".", &path_buf);
383 const sock_path = try std.fmt.allocPrint(alloc, "{s}/delta.sock", .{dir_path});
384 defer alloc.free(sock_path);
385
386 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
387 defer srv.deinit();
388
389 var stop = std.atomic.Value(bool).init(false);
390 const th = try std.Thread.spawn(.{}, serverThread, .{ &srv, &stop });
391 defer th.join();
392 defer stop.store(true, .release);
393
394 const c = try std.net.connectUnixSocket(sock_path);
395 defer c.close();
396 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0));
397
398 // First frame after attach must be a full snapshot carrying a seq.
399 var attach_seq: u64 = 0;
400 var deadline_ms: u64 = 5000;
401 while (deadline_ms > 0 and attach_seq == 0) {
402 var pfd = [_]std.posix.pollfd{
403 .{ .fd = c.handle, .events = std.posix.POLL.IN, .revents = 0 },
404 };
405 const ready = try std.posix.poll(&pfd, 100);
406 deadline_ms -|= 100;
407 if (ready == 0) continue;
408 const frame = (try proto.readFrame(alloc, c.handle)) orelse break;
409 defer frame.deinit(alloc);
410 if (frame.type == .snapshot and frame.payload.len >= 12) {
411 attach_seq = std.mem.readInt(u64, frame.payload[0..8], .little);
412 }
413 }
414 try std.testing.expect(attach_seq > 0);
415
416 // Type a character; the update must arrive as a delta with few rows.
417 try proto.writeFrame(c.handle, .input, "x");
418 var got_delta: ?proto.DeltaHeader = null;
419 deadline_ms = 5000;
420 while (deadline_ms > 0 and got_delta == null) {
421 var pfd = [_]std.posix.pollfd{
422 .{ .fd = c.handle, .events = std.posix.POLL.IN, .revents = 0 },
423 };
424 const ready = try std.posix.poll(&pfd, 100);
425 deadline_ms -|= 100;
426 if (ready == 0) continue;
427 const frame = (try proto.readFrame(alloc, c.handle)) orelse break;
428 defer frame.deinit(alloc);
429 if (frame.type == .delta) {
430 got_delta = try proto.readDeltaHeader(frame.payload);
431 } else if (frame.type == .snapshot) {
432 // A snapshot here means the tracker treated typing as a
433 // discontinuity — that's the bug this test exists to catch.
434 try std.testing.expect(false);
435 }
436 }
437 try std.testing.expect(got_delta != null);
438 try std.testing.expect(got_delta.?.seq > attach_seq);
439 try std.testing.expect(got_delta.?.row_count <= 3); // echo touches 1-2 rows
440
441 // Stats must show at least one snapshot and one delta.
442 try proto.writeFrame(c.handle, .stats_req, "");
443 var stats_text: ?[]u8 = null;
444 defer if (stats_text) |s| alloc.free(s);
445 deadline_ms = 5000;
446 while (deadline_ms > 0 and stats_text == null) {
447 var pfd = [_]std.posix.pollfd{
448 .{ .fd = c.handle, .events = std.posix.POLL.IN, .revents = 0 },
449 };
450 const ready = try std.posix.poll(&pfd, 100);
451 deadline_ms -|= 100;
452 if (ready == 0) continue;
453 const frame = (try proto.readFrame(alloc, c.handle)) orelse break;
454 if (frame.type == .stats_reply) {
455 stats_text = frame.payload;
456 } else frame.deinit(alloc);
457 }
458 try std.testing.expect(stats_text != null);
459 try std.testing.expect(std.mem.indexOf(u8, stats_text.?, "deltas=") != null);
460 try std.testing.expect(std.mem.indexOf(u8, stats_text.?, "snapshots=") != null);
461 }
462
463 test "Server: reattach with a recent have_seq gets a delta, stale gets snapshot" {
464 const alloc = std.testing.allocator;
465
466 var tmp = std.testing.tmpDir(.{});
467 defer tmp.cleanup();
468 var path_buf: [256]u8 = undefined;
469 const dir_path = try tmp.dir.realpath(".", &path_buf);
470 const sock_path = try std.fmt.allocPrint(alloc, "{s}/haveseq.sock", .{dir_path});
471 defer alloc.free(sock_path);
472
473 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
474 defer srv.deinit();
475
476 var stop = std.atomic.Value(bool).init(false);
477 const th = try std.Thread.spawn(.{}, serverThread, .{ &srv, &stop });
478 defer th.join();
479 defer stop.store(true, .release);
480
481 // Session 1: attach, learn the seq after some output, detach cleanly.
482 var last_seq: u64 = 0;
483 {
484 const c = try std.net.connectUnixSocket(sock_path);
485 defer c.close();
486 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0));
487 try proto.writeFrame(c.handle, .input, "echo before-detach\n");
488 var deadline_ms: u64 = 5000;
489 var settled_ms: u64 = 0;
490 while (deadline_ms > 0 and settled_ms < 500) {
491 var pfd = [_]std.posix.pollfd{
492 .{ .fd = c.handle, .events = std.posix.POLL.IN, .revents = 0 },
493 };
494 const ready = try std.posix.poll(&pfd, 100);
495 deadline_ms -|= 100;
496 if (ready == 0) {
497 if (last_seq != 0) settled_ms += 100;
498 continue;
499 }
500 settled_ms = 0;
501 const frame = (try proto.readFrame(alloc, c.handle)) orelse break;
502 defer frame.deinit(alloc);
503 switch (frame.type) {
504 .snapshot => last_seq = std.mem.readInt(u64, frame.payload[0..8], .little),
505 .delta => last_seq = (try proto.readDeltaHeader(frame.payload)).seq,
506 else => {},
507 }
508 }
509 try std.testing.expect(last_seq > 0);
510 try proto.writeFrame(c.handle, .detach, "");
511 }
512
513 // While detached the daemon keeps tracking; nothing changes here, so a
514 // reattach with have_seq == last_seq must get a DELTA (possibly empty),
515 // not a snapshot.
516 {
517 const c = try std.net.connectUnixSocket(sock_path);
518 defer c.close();
519 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, last_seq));
520 var first: ?proto.MsgType = null;
521 var deadline_ms: u64 = 5000;
522 while (deadline_ms > 0 and first == null) {
523 var pfd = [_]std.posix.pollfd{
524 .{ .fd = c.handle, .events = std.posix.POLL.IN, .revents = 0 },
525 };
526 const ready = try std.posix.poll(&pfd, 100);
527 deadline_ms -|= 100;
528 if (ready == 0) continue;
529 const frame = (try proto.readFrame(alloc, c.handle)) orelse break;
530 defer frame.deinit(alloc);
531 if (frame.type == .delta or frame.type == .snapshot) first = frame.type;
532 }
533 try std.testing.expectEqual(@as(?proto.MsgType, .delta), first);
534 try proto.writeFrame(c.handle, .detach, "");
535 }
536
537 // A have_seq of 1 (predates the reattach discontinuity) gets a snapshot.
538 {
539 const c = try std.net.connectUnixSocket(sock_path);
540 defer c.close();
541 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 1));
542 var first: ?proto.MsgType = null;
543 var deadline_ms: u64 = 5000;
544 while (deadline_ms > 0 and first == null) {
545 var pfd = [_]std.posix.pollfd{
546 .{ .fd = c.handle, .events = std.posix.POLL.IN, .revents = 0 },
547 };
548 const ready = try std.posix.poll(&pfd, 100);
549 deadline_ms -|= 100;
550 if (ready == 0) continue;
551 const frame = (try proto.readFrame(alloc, c.handle)) orelse break;
552 defer frame.deinit(alloc);
553 if (frame.type == .delta or frame.type == .snapshot) first = frame.type;
554 }
555 try std.testing.expectEqual(@as(?proto.MsgType, .snapshot), first);
556 }
557 }
558 ```
559
560 Wait — the second test asserts reattach-with-current-seq gets a delta, which requires that plain reattach NOT be a discontinuity when the size is unchanged and `have_seq >= reset_seq`. That is the intended design (see Step 3): `sendResync` only forces a snapshot when the size actually changed, the screen key changed, or `have_seq < reset_seq`.
561
562 Also update the existing fidelity test (`test "Server: replica rebuilt from snapshots matches the authoritative grid"`): the replica must now apply BOTH frame kinds. Replace each snapshot-handling block:
563
564 ```zig
565 if (frame.type == .snapshot) {
566 replica.reset();
567 replica.feed(frame.payload[12..]); // skip u64 seq + u32 history
568 } else if (frame.type == .delta) {
569 const composed = try proto.composeDelta(alloc, frame.payload);
570 defer alloc.free(composed.bytes);
571 replica.feed(composed.bytes);
572 } else continue;
573 ```
574
575 (Adapt to each loop's structure; the second loop's non-dump frames must apply the same way.) Also change its attach to `proto.encodeAttach(100, 30, 0)`, and the takeover/kill/scrollback tests' attaches to `proto.encodeAttach(80, 24, 0)`.
576
577 - [x] **Step 2: Run to verify failure** — `make test`; expected: compile errors (`encodeAttach` used with old decode on the server; new tests reference `sendResync` behavior that doesn't exist).
578
579 - [x] **Step 3: Implement**
580
581 Add near the top of `server.zig` (after `max_observers`):
582
583 ```zig
584 const Wyhash = std.hash.Wyhash;
585
586 const Stats = struct {
587 snapshots: u64 = 0,
588 snapshot_bytes: u64 = 0,
589 deltas: u64 = 0,
590 delta_bytes: u64 = 0,
591 /// What the same updates would have cost as full snapshots (M2 model):
592 /// measured, not estimated — dumpState length at each delta send.
593 snapshot_equiv_bytes: u64 = 0,
594 };
595
596 const DeltaTracker = struct {
597 seq: u64 = 0,
598 /// Seq at the last discontinuity (init/resize/screen switch). Clients
599 /// with have_seq older than this cannot be served a delta.
600 reset_seq: u64 = 0,
601 cols: u16 = 0,
602 rows: u16 = 0,
603 on_alt: bool = false,
604 cursor: Engine.CursorPos = .{ .x = 0, .y = 0 },
605 history_rows: u32 = 0,
606 row_hashes: []u64 = &.{},
607 row_seqs: []u64 = &.{},
608
609 fn deinit(self: *DeltaTracker, alloc: std.mem.Allocator) void {
610 alloc.free(self.row_hashes);
611 alloc.free(self.row_seqs);
612 }
613
614 fn rebuild(self: *DeltaTracker, alloc: std.mem.Allocator, eng: *Engine, rows: u16, cols: u16) !void {
615 if (self.row_hashes.len != rows) {
616 alloc.free(self.row_hashes);
617 alloc.free(self.row_seqs);
618 self.row_hashes = try alloc.alloc(u64, rows);
619 self.row_seqs = try alloc.alloc(u64, rows);
620 }
621 self.cols = cols;
622 self.rows = rows;
623 self.on_alt = eng.onAltScreen();
624 self.seq += 1;
625 self.reset_seq = self.seq;
626 self.cursor = eng.cursorPos();
627 self.history_rows = eng.historyRows();
628 for (0..rows) |y| {
629 const bytes = try eng.dumpVtRow(alloc, @intCast(y));
630 defer alloc.free(bytes);
631 self.row_hashes[y] = Wyhash.hash(0, bytes);
632 self.row_seqs[y] = self.seq;
633 }
634 }
635
636 const Update = union(enum) {
637 none,
638 discontinuity,
639 delta: []u8, // delta payload, caller frees
640 };
641
642 /// Diff current engine state against the tracked state. Advances seq
643 /// and tracked rows when anything changed.
644 fn update(self: *DeltaTracker, alloc: std.mem.Allocator, eng: *Engine) !Update {
645 if (self.rows == 0) return .discontinuity;
646 if (eng.onAltScreen() != self.on_alt) return .discontinuity;
647
648 var changed: std.ArrayList(u16) = .empty;
649 defer changed.deinit(alloc);
650 var new_hashes = try alloc.alloc(u64, self.rows);
651 defer alloc.free(new_hashes);
652
653 for (0..self.rows) |y| {
654 const bytes = try eng.dumpVtRow(alloc, @intCast(y));
655 defer alloc.free(bytes);
656 new_hashes[y] = Wyhash.hash(0, bytes);
657 if (new_hashes[y] != self.row_hashes[y]) try changed.append(alloc, @intCast(y));
658 }
659
660 const cur = eng.cursorPos();
661 const hist = eng.historyRows();
662 const cursor_moved = cur.x != self.cursor.x or cur.y != self.cursor.y;
663 if (changed.items.len == 0 and !cursor_moved and hist == self.history_rows)
664 return .none;
665
666 self.seq += 1;
667 self.cursor = cur;
668 self.history_rows = hist;
669 @memcpy(self.row_hashes, new_hashes);
670 for (changed.items) |y| self.row_seqs[y] = self.seq;
671
672 return .{ .delta = try self.buildDeltaSince(alloc, eng, self.seq - 1) };
673 }
674
675 /// Build a delta payload of all rows changed after `since`.
676 fn buildDeltaSince(self: *DeltaTracker, alloc: std.mem.Allocator, eng: *Engine, since: u64) ![]u8 {
677 var rows_changed: u16 = 0;
678 for (self.row_seqs) |s| {
679 if (s > since) rows_changed += 1;
680 }
681 var payload: std.ArrayList(u8) = .empty;
682 errdefer payload.deinit(alloc);
683 try proto.appendDeltaHeader(&payload, alloc, .{
684 .seq = self.seq,
685 .history_rows = self.history_rows,
686 .cursor_x = self.cursor.x,
687 .cursor_y = self.cursor.y,
688 .row_count = rows_changed,
689 });
690 for (self.row_seqs, 0..) |s, y| {
691 if (s <= since) continue;
692 const bytes = try eng.dumpVtRow(alloc, @intCast(y));
693 defer alloc.free(bytes);
694 try proto.appendDeltaRow(&payload, alloc, @intCast(y), bytes);
695 }
696 return payload.toOwnedSlice(alloc);
697 }
698 };
699 ```
700
701 Server struct gains fields:
702
703 ```zig
704 tracker: DeltaTracker = .{},
705 stats: Stats = .{},
706 ```
707
708 `deinit` additionally calls `self.tracker.deinit(self.alloc);`.
709
710 In `pumpOnce`, replace the PTY branch's `self.sendSnapshot();` with `self.sendUpdate();`.
711
712 Replace `sendSnapshot` and add the new send paths + stats/attach handling:
713
714 ```zig
715 /// Per-update path: diff and send a delta; discontinuities resync.
716 fn sendUpdate(self: *Server) void {
717 const upd = self.tracker.update(self.alloc, self.eng) catch return;
718 switch (upd) {
719 .none => {},
720 .discontinuity => self.resyncSnapshot(),
721 .delta => |payload| {
722 defer self.alloc.free(payload);
723 if (self.client) |fd| {
724 proto.writeFrame(fd, .delta, payload) catch {
725 self.dropClient();
726 return;
727 };
728 self.stats.deltas += 1;
729 self.stats.delta_bytes += payload.len;
730 // Counterfactual: what M2 would have sent.
731 if (self.eng.dumpState(self.alloc)) |state| {
732 self.stats.snapshot_equiv_bytes += 12 + state.len;
733 self.alloc.free(state);
734 } else |_| {}
735 }
736 },
737 }
738 }
739
740 /// Rebuild tracking and send a full snapshot (attach, resize, screen
741 /// switch, or a client too far behind).
742 fn resyncSnapshot(self: *Server) void {
743 self.tracker.rebuild(self.alloc, self.eng, self.rowsNow(), self.colsNow()) catch return;
744 const fd = self.client orelse return;
745 const state = self.eng.dumpState(self.alloc) catch return;
746 defer self.alloc.free(state);
747 const payload = self.alloc.alloc(u8, 12 + state.len) catch return;
748 defer self.alloc.free(payload);
749 std.mem.writeInt(u64, payload[0..8], self.tracker.seq, .little);
750 proto.putU32(payload[8..12], self.eng.historyRows());
751 @memcpy(payload[12..], state);
752 proto.writeFrame(fd, .snapshot, payload) catch {
753 self.dropClient();
754 return;
755 };
756 self.stats.snapshots += 1;
757 self.stats.snapshot_bytes += payload.len;
758 self.stats.snapshot_equiv_bytes += payload.len;
759 }
760
761 /// Attach/reattach: delta if the client's have_seq is serviceable and
762 /// nothing discontinuous happened; else full snapshot.
763 fn sendResync(self: *Server, have_seq: u64, size_changed: bool) void {
764 if (!size_changed and have_seq != 0 and
765 have_seq >= self.tracker.reset_seq and have_seq <= self.tracker.seq and
766 self.tracker.rows != 0)
767 {
768 const fd = self.client orelse return;
769 const payload = self.tracker.buildDeltaSince(self.alloc, self.eng, have_seq) catch return;
770 defer self.alloc.free(payload);
771 proto.writeFrame(fd, .delta, payload) catch {
772 self.dropClient();
773 return;
774 };
775 self.stats.deltas += 1;
776 self.stats.delta_bytes += payload.len;
777 return;
778 }
779 self.resyncSnapshot();
780 }
781
782 fn rowsNow(self: *Server) u16 {
783 return @intCast(self.eng.term.rows);
784 }
785
786 fn colsNow(self: *Server) u16 {
787 return @intCast(self.eng.term.cols);
788 }
789
790 fn replyStats(self: *Server, fd: std.posix.fd_t) !void {
791 var buf: [256]u8 = undefined;
792 const text = try std.fmt.bufPrint(
793 &buf,
794 "seq={d} snapshots={d} snapshot_bytes={d} deltas={d} delta_bytes={d} snapshot_equiv_bytes={d}",
795 .{
796 self.tracker.seq, self.stats.snapshots,
797 self.stats.snapshot_bytes, self.stats.deltas,
798 self.stats.delta_bytes, self.stats.snapshot_equiv_bytes,
799 },
800 );
801 try proto.writeFrame(fd, .stats_reply, text);
802 }
803 ```
804
805 `Engine.term` is accessible (the Engine struct exposes its fields); if `rowsNow` type-errors, `self.eng.term.rows` is `size.CellCountInt` — `@intCast` handles it.
806
807 Frame-handling changes:
808 - `serviceClient` `.attach, .resize` arm: split them. `.resize` keeps `decodeSize`, then `self.applySize(...)` and `self.sendUpdate()` won't notice a size change — so resize must resync: call `self.resyncSnapshot()` after `applySize`. `.attach` from an already-attached client: decode with `proto.decodeAttach`, `applySize`, then `self.resyncSnapshot()`.
809 - `serviceClient` and `serviceObserver` gain a `.stats_req` arm: `self.replyStats(fd) catch self.dropClient();` (observer variant: `catch self.dropObserver(i)`).
810 - `serviceObserver` `.attach` arm: decode with `proto.decodeAttach(frame.payload) catch { self.dropObserver(i); return; };` — after promotion, compute `const size_changed = (sz.cols != self.colsNow() or sz.rows != self.rowsNow());`, then `self.applySize(sz.cols, sz.rows);` and `self.sendResync(sz.have_seq, size_changed);` instead of `sendSnapshot`.
811 - Also in `pumpOnce` — while DETACHED the tracker must keep advancing so reattach-by-delta works: in the PTY branch, `sendUpdate` already runs unconditionally (it no-ops the send when `self.client == null` but still advances the tracker — note the `.delta` arm frees the payload and skips sending; the tracker was advanced inside `update`). Confirm this reading of the code: `tracker.update` advances state regardless of clients; only the socket write is conditional. That is the intended behavior.
812
813 - [x] **Step 4: Run tests** — `make test`. All server tests (old and new) must pass. The delta test's `row_count <= 3` may be violated if the shell redraws its whole prompt line region — if observed, print the actual count; up to 5 is acceptable with a comment; more means the differ is broken (investigate, don't relax).
814
815 - [x] **Step 5: Commit**
816
817 ```bash
818 git add src/server.zig
819 git commit -m "feat: row-granular deltas with seq tracking, have_seq resync, wire stats"
820 ```
821
822 ---
823
824 ### Task 4: Client + `muxd stats`
825
826 **Files:**
827 - Modify: `src/client.zig`, `src/main.zig`
828
829 - [x] **Step 1: Client changes (`src/client.zig`)**
830
831 Replace the attach send:
832
833 ```zig
834 try proto.writeFrame(sock, .attach, &proto.encodeAttach(size.cols, size.rows, 0));
835 ```
836
837 (The interactive client is always a fresh process; `have_seq=0`. The have_seq path is exercised by server tests and future network clients.)
838
839 In the frame switch, update `.snapshot` for the new 12-byte prefix:
840
841 ```zig
842 .snapshot => {
843 if (frame.payload.len < 12) continue;
844 history_rows = proto.getU32(frame.payload[8..12]);
845 if (latest_state) |s| alloc.free(s);
846 latest_state = try alloc.dupe(u8, frame.payload[12..]);
847 if (scroll_pages == 0) {
848 replica.reset();
849 replica.feed(latest_state.?);
850 try render(alloc, replica, stdout_fd);
851 }
852 },
853 ```
854
855 Add a `.delta` arm:
856
857 ```zig
858 .delta => {
859 const composed = proto.composeDelta(alloc, frame.payload) catch continue;
860 defer alloc.free(composed.bytes);
861 history_rows = composed.header.history_rows;
862 replica.feed(composed.bytes);
863 if (scroll_pages == 0) {
864 try paintDelta(alloc, composed.bytes, stdout_fd);
865 } else {
866 // Live changed while scrolled: repaint on scroll exit
867 // uses the replica, which is already up to date.
868 }
869 },
870 ```
871
872 Note: with deltas, `latest_state` becomes stale the moment a delta arrives; scroll-exit repaints must use the replica, not `latest_state`. Update BOTH scroll-exit paths (the Shift+PageDown-to-zero branch and the any-other-key branch) from re-feeding `latest_state` to just:
873
874 ```zig
875 try render(alloc, replica, stdout_fd);
876 ```
877
878 (delete the `replica.reset(); replica.feed(s);` lines there — the replica is continuously maintained now). Keep the `.snapshot` arm's reset+feed: snapshots are authoritative resyncs.
879
880 Add the paint helper near `render`:
881
882 ```zig
883 /// Paint an already-composed delta byte string atomically.
884 fn paintDelta(alloc: std.mem.Allocator, composed: []const u8, out_fd: std.posix.fd_t) !void {
885 var paint: std.ArrayList(u8) = .empty;
886 defer paint.deinit(alloc);
887 try paint.appendSlice(alloc, "\x1b[?2026h\x1b[?25l");
888 try paint.appendSlice(alloc, composed);
889 try paint.appendSlice(alloc, "\x1b[?25h\x1b[?2026l");
890 try proto.writeAllFd(out_fd, paint.items);
891 }
892
893 test "paintDelta wraps composed bytes in sync-output brackets" {
894 const alloc = std.testing.allocator;
895 const pipe = try std.posix.pipe();
896 defer std.posix.close(pipe[0]);
897 try paintDelta(alloc, "\x1b[3;1H\x1b[2Krow", pipe[1]);
898 std.posix.close(pipe[1]);
899 var out: [512]u8 = undefined;
900 const n = try std.posix.read(pipe[0], &out);
901 try std.testing.expect(std.mem.startsWith(u8, out[0..n], "\x1b[?2026h\x1b[?25l\x1b[3;1H"));
902 try std.testing.expect(std.mem.endsWith(u8, out[0..n], "\x1b[?25h\x1b[?2026l"));
903 }
904 ```
905
906 - [x] **Step 2: `muxd stats` subcommand (`src/main.zig`)**
907
908 In `main`, add after the `dump` dispatch:
909
910 ```zig
911 if (std.mem.eql(u8, args[1], "stats")) return stats(alloc, sock_path);
912 ```
913
914 Update the usage string's daemon block to include ` muxd stats [--sock PATH]`. Add:
915
916 ```zig
917 fn stats(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
918 const stream = std.net.connectUnixSocket(sock_path) catch {
919 std.debug.print("muxd stats: cannot connect to {s}\n", .{sock_path});
920 return 1;
921 };
922 defer stream.close();
923 try proto.writeFrame(stream.handle, .stats_req, "");
924 while (try proto.readFrame(alloc, stream.handle)) |frame| {
925 defer frame.deinit(alloc);
926 if (frame.type != .stats_reply) continue;
927 try proto.writeAllFd(std.posix.STDOUT_FILENO, frame.payload);
928 try proto.writeAllFd(std.posix.STDOUT_FILENO, "\n");
929 return 0;
930 }
931 return 1;
932 }
933 ```
934
935 - [x] **Step 3: Run everything** — `make test && make build && make e2e`, all green. e2e exercises attach/detach/kill through the new frames end to end.
936
937 - [x] **Step 4: Commit**
938
939 ```bash
940 git add src/client.zig src/main.zig
941 git commit -m "feat: client applies row deltas with damage-only paints; muxd stats"
942 ```
943
944 ---
945
946 ### Task 5: Bench, e2e ratio gate, docs
947
948 **Files:**
949 - Create: `test/bench.sh`
950 - Modify: `test/e2e.sh`, `build.zig`, `docs/decisions.md`, `README.md`
951
952 - [x] **Step 1: Write `test/bench.sh`**
953
954 ```sh
955 #!/bin/sh
956 # M4 bytes-on-wire measurement: a typing-heavy workload, then compare
957 # delta bytes actually sent against the measured full-snapshot equivalent.
958 set -eu
959 MUXD="$1"
960 MUX="$2"
961 SOCK="${TMPDIR:-/tmp}/muxd-bench-$$.sock"
962
963 cleanup() { kill "$DPID" 2>/dev/null || true; rm -f "$SOCK"; }
964 trap cleanup EXIT INT TERM
965
966 "$MUXD" run --sock "$SOCK" --shell /bin/sh &
967 DPID=$!
968 i=0
969 while [ ! -S "$SOCK" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done
970
971 # Steady-state typing: 120 single characters with small gaps (no newlines,
972 # so no scroll; this is the workload the handoff's done-criterion names).
973 {
974 sleep 0.5
975 j=0
976 while [ "$j" -lt 120 ]; do printf 'x'; sleep 0.05; j=$((j+1)); done
977 sleep 0.5
978 printf '\034'
979 } | "$MUX" --sock "$SOCK" > /dev/null
980
981 STATS="$("$MUXD" stats --sock "$SOCK")"
982 echo "$STATS"
983
984 DELTA=$(echo "$STATS" | sed -n 's/.*[^_]delta_bytes=\([0-9]*\).*/\1/p')
985 EQUIV=$(echo "$STATS" | sed -n 's/.*snapshot_equiv_bytes=\([0-9]*\).*/\1/p')
986 [ -n "$DELTA" ] && [ -n "$EQUIV" ] && [ "$EQUIV" -gt 0 ] || {
987 echo "bench FAIL: could not parse stats"; exit 1;
988 }
989 RATIO=$(( DELTA * 100 / EQUIV ))
990 echo "delta bytes: $DELTA snapshot-equivalent: $EQUIV ratio: ${RATIO}%"
991 if [ "$RATIO" -ge 50 ]; then
992 echo "bench FAIL: deltas are not meaningfully smaller (M4 kill criterion)"
993 exit 1
994 fi
995 echo "bench OK"
996 ```
997
998 `chmod +x test/bench.sh`. Wire into `build.zig` next to the e2e step:
999
1000 ```zig
1001 const bench = b.addSystemCommand(&.{"test/bench.sh"});
1002 bench.addArtifactArg(exe);
1003 bench.addArtifactArg(mux_exe);
1004 const bench_step = b.step("bench", "Measure delta vs snapshot bytes");
1005 bench_step.dependOn(&bench.step);
1006 ```
1007
1008 And add a `bench:` target to the `Makefile`:
1009
1010 ```make
1011 bench:
1012 $(ZIG) build bench
1013 ```
1014
1015 (add `bench` to the `.PHONY` line too).
1016
1017 - [x] **Step 2: Run it** — `make bench`. Expected: prints the stats line, the ratio, and `bench OK` with a ratio well under 50% (single-char echoes touch 1–2 rows of 24; expect single-digit percent). **This number is the M4 kill-criterion verdict — record the observed ratio in decisions.md in Step 4.**
1018
1019 - [x] **Step 3: e2e still green** — `make e2e`. The existing scenarios must pass unmodified (they assert content, not frame types).
1020
1021 - [x] **Step 4: Update `docs/decisions.md`** — append (fill the measured ratio in):
1022
1023 ```markdown
1024 ## 2026-08-07 (M4)
1025
1026 - **Deltas are row-granular.** Damage = set of viewport rows whose styled
1027 dump hash (Wyhash) changed, sent as self-contained row repaints
1028 (CUP+EL+content composed by protocol.composeDelta on the client). Chosen
1029 over cell-level damage rects: rows are the natural unit of the canonical
1030 formatter, and the measured win already clears the kill criterion.
1031 - **Wire format: still hand-rolled, deliberately.** The handoff says
1032 "msgpack or protobuf, do not invent one"; M4's payloads are row-keyed
1033 byte blobs plus six fixed-width integers, and no serialization library
1034 is proven on Zig 0.15.2. The judgment flips when payloads become truly
1035 structured (cell runs, multi-rect damage, negotiation) — msgpack is due
1036 then, and this entry is the tripwire.
1037 - **Snapshot-vs-delta threshold:** a client is served a delta iff its
1038 have_seq is >= the tracker's reset_seq (last attach-resize/screen-switch/
1039 init discontinuity) and the viewport size matches; otherwise snapshot.
1040 The tracker keeps per-row last-change seqs, advancing even while
1041 detached (row hashing at most 10 Hz), so reattach-after-a-gap resolves
1042 by delta when nothing discontinuous happened.
1043 - **Diffing is content-hash based,** not ghostty RenderState dirty
1044 tracking: deterministic, no new engine API surface, O(rows) styled row
1045 dumps per coalesced update. RenderState remains the optimization path if
1046 hashing ever shows up in a profile.
1047 - **Measured (bench.sh, 120-char typing workload):** delta bytes were
1048 [RATIO]% of the snapshot-equivalent bytes. M4 kill criterion cleared.
1049 - **Nonblocking buffered I/O: still owed,** now explicitly post-prototype
1050 (before any real network transport), since deltas shrink writes by an
1051 order of magnitude and the remaining stall window is a local-socket
1052 concern only.
1053 ```
1054
1055 - [x] **Step 5: Update `README.md`** — status line to `Status: **M4 — deltas.**`, and add to the command block:
1056
1057 ```markdown
1058 ./zig-out/bin/muxd stats # wire stats: deltas vs snapshot bytes
1059 make bench # typing-workload byte-ratio measurement
1060 ```
1061
1062 - [x] **Step 6: Commit**
1063
1064 ```bash
1065 git add test/bench.sh test/e2e.sh build.zig Makefile docs/decisions.md README.md
1066 git commit -m "feat: bytes-on-wire bench gates the M4 kill criterion; docs"
1067 ```
1068
1069 ---
1070
1071 ## Self-Review
1072
1073 - **Spec coverage (handoff §M4):** sequence-numbered deltas ✓ (Tasks 1/3), damage regions ✓ (row-granular, decision recorded), `have_seq` on attach with delta-or-snapshot reply ✓ (Tasks 1/3), lazy scrollback ✓ (already landed in M3, unchanged), bytes-on-wire instrumentation + comparison ✓ (Tasks 3/5), "reattach-after-a-gap still resolves correctly" ✓ (Task 3 second test + untouched e2e kill-reattach), kill criterion measured ✓ (Task 5 bench gate).
1074 - **Placeholders:** `[RATIO]` in Task 5's decisions text is the measured result the step exists to produce; everything else is complete code.
1075 - **Type consistency:** `proto.encodeAttach/decodeAttach/AttachReq`, `DeltaHeader/appendDeltaHeader/readDeltaHeader/appendDeltaRow/deltaRowIterator/DeltaRow/composeDelta/ComposedDelta` (Task 1) match uses in Tasks 3/4 and the fidelity-test rewrite ✓. `Engine.dumpVtRow/onAltScreen` (Task 2) match Task 3 ✓. Snapshot payload `u64 seq ++ u32 history ++ state` consistent across Task 3 sender, Task 3 test reader (`payload[0..8]`, skip 12), Task 4 client (`payload[8..12]`, `[12..]`) ✓. `sendUpdate/resyncSnapshot/sendResync/replyStats` defined and referenced only within Task 3 ✓.
1076 - **Sequencing:** Task 1 leaves the build green (nothing decodes the new attach yet); Task 3 flips the server and fixes all in-repo attach call sites in the same commit; Task 4 flips the client. e2e is only expected green again after Task 4 — Task 3's commit gate is `make test` only, stated in its Step 4.
docs/superpowers/plans/2026-08-07-m5-two-clients.md
Old New
@@ -1,443 +0,0 @@
1 # M5 — Two Clients 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:** Two (up to eight) `mux` instances attached to one session simultaneously, all converging on the authoritative grid, with client-local view state (scroll position) independent — and the resize question answered explicitly: **latest wins**.
6
7 **Architecture:** The daemon replaces its single client slot + takeover policy with a client list; every delta/snapshot broadcast goes to all attached clients (per-client failure drops that client only). **Resize policy — decided: latest-wins** (the grid tracks the most recent attach or resize event from any client, like modern tmux `window-size latest`; chosen over smallest-wins, which punishes the larger screen, and per-client reflow, which would need per-client engines/trackers and contradicts the single-authoritative-grid architecture). Because clients may now have a tty smaller or larger than the grid, the snapshot prefix grows to carry the grid size (16 bytes: seq u64, history u32, cols u16, rows u16), the client keeps its replica at the **daemon's** size, and rendering becomes per-row painting with autowrap disabled (DECAWM off) so oversized content clips at the right edge instead of wrapping; rows beyond the local tty are skipped and the cursor is clamped. Size changes always travel as snapshots (a resize is a tracker discontinuity), so deltas never need size fields.
8
9 **Tech Stack:** unchanged — Zig 0.15.2 (Makefile-pinned: ONLY `make test` / `make build` / `make e2e` / `make bench`), ghostty-vt pin.
10
11 ---
12
13 ## Context for executors with zero prior exposure
14
15 - Repo `/home/xanderle/code/rad/mux`, branch `main`, commit directly. Read `docs/handoff.md` §M5 and `docs/decisions.md` first.
16 - System `zig` is an incompatible 0.17-dev. The Makefile pins `~/Downloads/zig-x86_64-linux-0.15.2/zig`. Never run bare `zig`.
17 - Modules: `src/protocol.zig` (framing + delta encode/apply; `composeDelta` validates row_count; `deltaRowIterator` walks row records), `src/engine.zig` (`Engine` wraps ghostty-vt; `dumpVtRow(alloc,y)` = one styled viewport row asserting y < term.rows; `dumpState`, `historyRows`, `cursorPos`, `resize`), `src/server.zig` (Server + DeltaTracker + integration tests driving a real daemon thread + /bin/sh over unix sockets), `src/client.zig` (attach loop: raw mode, alt screen, replica Engine, snapshot/delta/scrollback arms, Shift+PageUp scroll mode, Ctrl-\ detach), `src/main.zig` (muxd run/dump/stats), `src/mux_main.zig`, `test/e2e.sh`, `test/bench.sh`.
18 - Current wire relevant to M5: attach = `u16 cols, u16 rows, u64 have_seq`; snapshot payload = `u64 seq ++ u32 history_rows ++ state` (12-byte prefix — THIS PLAN CHANGES IT TO 16); delta = 18-byte header + row records; `.taken_over` (0x84) is sent to a displaced client under the OLD takeover policy this plan retires.
19 - Zig 0.15 std: unmanaged ArrayList; `@splat(null)` initializes optional arrays.
20 - Existing test patterns to copy: poll-with-deadline frame loops and `serverThread` harness in src/server.zig tests; pipe-capture render tests in src/client.zig.
21
22 ---
23
24 ## File Structure
25
26 ```
27 src/protocol.zig — MODIFY: SnapshotPrefix (16B) + read/write helpers + tests
28 src/server.zig — MODIFY: clients[8] list, broadcast, latest-wins resize,
29 takeover retired, per-client service; multi-client tests
30 src/client.zig — MODIFY: daemon-size tracking, clipped row render,
31 row-filtered delta paint, DECAWM off, cursor clamp
32 test/e2e.sh — MODIFY: two-client scenario
33 docs/decisions.md — MODIFY: M5 section (the resize decision, mandated)
34 README.md — MODIFY: status
35 ```
36
37 ---
38
39 ### Task 1: Protocol — 16-byte snapshot prefix
40
41 **Files:** Modify `src/protocol.zig`, then mechanical prefix-width migration in `src/server.zig` and `src/client.zig` so the build stays green within the task.
42
43 - [x] **Step 1: Failing tests (append to src/protocol.zig)**
44
45 ```zig
46 test "snapshot prefix round trip and golden bytes" {
47 const p = SnapshotPrefix{ .seq = 258, .history_rows = 7, .cols = 120, .rows = 40 };
48 var buf: [snapshot_prefix_len]u8 = undefined;
49 writeSnapshotPrefix(&buf, p);
50 try std.testing.expectEqualSlices(u8, &[_]u8{
51 0x02, 0x01, 0, 0, 0, 0, 0, 0, // seq u64 LE
52 0x07, 0, 0, 0, // history_rows u32 LE
53 0x78, 0, // cols u16 LE
54 0x28, 0, // rows u16 LE
55 }, &buf);
56 const q = try readSnapshotPrefix(&buf);
57 try std.testing.expectEqual(p, q);
58 }
59
60 test "snapshot prefix rejects short payloads" {
61 try std.testing.expectError(error.BadPayload, readSnapshotPrefix(&[_]u8{0} ** 15));
62 }
63 ```
64
65 - [x] **Step 2: `make test` → compile failure.**
66
67 - [x] **Step 3: Implement in src/protocol.zig** (near the delta header helpers; update the `snapshot` MsgType comment to `// payload: SnapshotPrefix ++ full-state vt dump`):
68
69 ```zig
70 /// Fixed prefix of every snapshot payload. Carries the grid size because
71 /// under the latest-wins resize policy (M5) a client's tty may not match
72 /// the authoritative grid; the replica must follow the grid, not the tty.
73 pub const SnapshotPrefix = struct {
74 seq: u64,
75 history_rows: u32,
76 cols: u16,
77 rows: u16,
78 };
79
80 pub const snapshot_prefix_len = 16;
81
82 pub fn writeSnapshotPrefix(buf: *[snapshot_prefix_len]u8, p: SnapshotPrefix) void {
83 std.mem.writeInt(u64, buf[0..8], p.seq, .little);
84 std.mem.writeInt(u32, buf[8..12], p.history_rows, .little);
85 std.mem.writeInt(u16, buf[12..14], p.cols, .little);
86 std.mem.writeInt(u16, buf[14..16], p.rows, .little);
87 }
88
89 pub fn readSnapshotPrefix(payload: []const u8) !SnapshotPrefix {
90 if (payload.len < snapshot_prefix_len) return error.BadPayload;
91 return .{
92 .seq = std.mem.readInt(u64, payload[0..8], .little),
93 .history_rows = std.mem.readInt(u32, payload[8..12], .little),
94 .cols = std.mem.readInt(u16, payload[12..14], .little),
95 .rows = std.mem.readInt(u16, payload[14..16], .little),
96 };
97 }
98 ```
99
100 - [x] **Step 4: Migrate both senders/readers in the same commit** (mechanical; behavior identical since cols/rows are simply now carried):
101 - src/server.zig `resyncSnapshot`: build the payload as `snapshot_prefix_len + state.len`; fill via `proto.writeSnapshotPrefix(payload[0..proto.snapshot_prefix_len], .{ .seq = self.tracker.seq, .history_rows = self.eng.historyRows(), .cols = self.colsNow(), .rows = self.rowsNow() });` then `@memcpy(payload[proto.snapshot_prefix_len..], state);`
102 - src/server.zig tests: every `payload[0..8]` seq read → `(try proto.readSnapshotPrefix(frame.payload)).seq`; every `payload[8..12]` history read → `.history_rows` of the same; every `payload[12..]` state slice (incl. `applyFrame`) → `payload[proto.snapshot_prefix_len..]`.
103 - src/client.zig `.snapshot` arm: guard `payload.len < proto.snapshot_prefix_len`, parse with `readSnapshotPrefix`, use `.history_rows`, feed `payload[proto.snapshot_prefix_len..]`. (Daemon-size USE arrives in Task 3 — here just parse and keep behavior.)
104
105 - [x] **Step 5: `make test && make e2e` green. Commit:** `git add -A && git commit -m "feat: snapshot prefix carries grid size (16-byte prefix)"`
106
107 ---
108
109 ### Task 2: Server — client list, broadcast, latest-wins
110
111 **Files:** Modify `src/server.zig` only. Gate: `make test` green; `make e2e` must ALSO stay green (single-client flows unchanged from outside).
112
113 - [x] **Step 1: Failing tests (append to src/server.zig; copy the poll-with-deadline + serverThread patterns from existing tests)**
114
115 Test A — "Server: two clients converge on one session":
116 ```zig
117 test "Server: two clients converge on one session" {
118 const alloc = std.testing.allocator;
119
120 var tmp = std.testing.tmpDir(.{});
121 defer tmp.cleanup();
122 var path_buf: [256]u8 = undefined;
123 const dir_path = try tmp.dir.realpath(".", &path_buf);
124 const sock_path = try std.fmt.allocPrint(alloc, "{s}/two.sock", .{dir_path});
125 defer alloc.free(sock_path);
126
127 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
128 defer srv.deinit();
129
130 var stop = std.atomic.Value(bool).init(false);
131 const th = try std.Thread.spawn(.{}, serverThread, .{ &srv, &stop });
132 defer th.join();
133 defer stop.store(true, .release);
134
135 const a = try std.net.connectUnixSocket(sock_path);
136 defer a.close();
137 try proto.writeFrame(a.handle, .attach, &proto.encodeAttach(80, 24, 0));
138 const b = try std.net.connectUnixSocket(sock_path);
139 defer b.close();
140 try proto.writeFrame(b.handle, .attach, &proto.encodeAttach(80, 24, 0));
141
142 // Input through A must reach both replicas.
143 try proto.writeFrame(a.handle, .input, "echo both-see-this\n");
144
145 var replica_a = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
146 defer replica_a.deinit();
147 var replica_b = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
148 defer replica_b.deinit();
149
150 for ([_]struct { fd: std.posix.fd_t, rep: *Engine }{
151 .{ .fd = a.handle, .rep = replica_a },
152 .{ .fd = b.handle, .rep = replica_b },
153 }) |side| {
154 var deadline_ms: u64 = 10_000;
155 var seen = false;
156 while (deadline_ms > 0 and !seen) {
157 var pfd = [_]std.posix.pollfd{
158 .{ .fd = side.fd, .events = std.posix.POLL.IN, .revents = 0 },
159 };
160 const ready = try std.posix.poll(&pfd, 100);
161 deadline_ms -|= 100;
162 if (ready == 0) continue;
163 const frame = (try proto.readFrame(alloc, side.fd)) orelse break;
164 defer frame.deinit(alloc);
165 try applyFrame(alloc, side.rep, frame);
166 const plain = try side.rep.dumpPlain(alloc);
167 defer alloc.free(plain);
168 if (std.mem.indexOf(u8, plain, "both-see-this") != null) seen = true;
169 }
170 try std.testing.expect(seen);
171 }
172
173 // Byte-level convergence: both replicas match the daemon exactly.
174 try proto.writeFrame(a.handle, .debug_dump, &.{1});
175 var daemon_vt: ?[]u8 = null;
176 defer if (daemon_vt) |d| alloc.free(d);
177 var deadline_ms: u64 = 5000;
178 while (deadline_ms > 0 and daemon_vt == null) {
179 var pfd = [_]std.posix.pollfd{
180 .{ .fd = a.handle, .events = std.posix.POLL.IN, .revents = 0 },
181 };
182 const ready = try std.posix.poll(&pfd, 100);
183 deadline_ms -|= 100;
184 if (ready == 0) continue;
185 const frame = (try proto.readFrame(alloc, a.handle)) orelse break;
186 if (frame.type == .dump_reply) {
187 daemon_vt = frame.payload;
188 } else {
189 defer frame.deinit(alloc);
190 try applyFrame(alloc, replica_a, frame);
191 }
192 }
193 const va = try replica_a.dumpVt(alloc);
194 defer alloc.free(va);
195 const vb = try replica_b.dumpVt(alloc);
196 defer alloc.free(vb);
197 try std.testing.expectEqualStrings(daemon_vt.?, va);
198 try std.testing.expectEqualStrings(daemon_vt.?, vb);
199 }
200 ```
201 (If late frames for B race the final compare, drain B with a short bounded loop applying frames before dumping vb — same pattern.)
202
203 Test B — "Server: latest attacher's size wins; earlier client is resnapshotted at the new size": A attaches 80x24 and drains until its first snapshot; B attaches 100x30; A must then receive a snapshot whose `readSnapshotPrefix` reports cols 100 rows 30 (poll loop, 5s deadline). Also assert `srv.eng.term.cols == 100` afterward (direct read is fine — the server thread only mutates it through the same path being tested... read it AFTER stopping the thread: `stop.store(true, .release); th.join();` then assert, to avoid a data race).
204
205 Test C — "Server: scrollback fetch is per-client and independent": A and B attach 80x24; input `seq 1 100\n` via B; both drain until a frame reports history_rows >= 50 (delta headers or snapshot prefixes). A sends `fetch_scrollback` for rows 0..24 and must get a `scrollback_chunk` containing "seq 1 100"; B concurrently keeps receiving deltas (assert B's next state frame is NOT disturbed — just drain one frame successfully). This pins per-connection scrollback while another client streams.
206
207 - [x] **Step 2: `make test` → failures/compile errors.**
208
209 - [x] **Step 3: Implement multi-client in src/server.zig:**
210
211 ```zig
212 const max_clients = 8;
213 ```
214 Replace `client: ?std.posix.fd_t = null` with `clients: [max_clients]?std.posix.fd_t = @splat(null)`. Then, mechanically:
215 - `deinit`: close all client fds (loop like observers).
216 - `pumpOnce` poll array becomes `[2 + max_clients + max_observers]pollfd`: pty, listener, clients[0..8], observers[0..4]. Service each ready client slot via `serviceClient(i)` (now index-based, `fn serviceClient(self: *Server, i: usize)` with `const fd = self.clients[i].?;` and `dropClient(i)` closing+nulling that slot).
217 - `hasClients()` helper: any non-null client. `sendUpdate` gates payload build on `hasClients()`.
218 - `sendDelta` becomes `broadcastDelta(payload)`: loop clients, `writeFrame(fd, .delta, payload) catch { self.dropClient(i); continue; }`, `stats.deltas += 1; stats.delta_bytes += payload.len;` PER successful send; after the loop, accrue `snapshot_equiv_bytes` ONCE per broadcast event (comment: per-event counterfactual, per-send actuals).
219 - `resyncSnapshot` becomes: rebuild tracker (unconditionally, as today), build payload once with the 16-byte prefix, then broadcast to all clients with per-send `snapshots += 1; snapshot_bytes += payload.len;` and ONE `snapshot_equiv_bytes += payload.len` per event. Keep the "no clients → rebuild only" behavior (rebuild must still happen).
220 - `sendResync(i, have_seq, size_changed)` (attach path, per-client): serviceable → build `buildDeltaSince(have_seq)` and send to THAT client only (count per-send stats; on failure `resyncSnapshot()`); else `resyncSnapshot()` (which broadcasts — correct, because the unserviceable case implies a discontinuity that everyone must learn about only when size actually changed; when size is unchanged and the client is merely stale, a full broadcast is wasteful but harmless — add a comment accepting this for the prototype).
221 - `serviceObserver` `.attach`: TAKEOVER RETIRED. Find a free client slot; if none, `writeFrame(fd, .exit_status, &.{1}) catch {}` + drop observer (comment: session full). Otherwise promote (null the observer slot, set `clients[slot] = fd`), compute `size_changed` BEFORE `applySize`, `applySize(sz.cols, sz.rows)`, `sendResync(slot, sz.have_seq, size_changed)`. NOTE: when `size_changed`, `applySize` + the eventual `resyncSnapshot` broadcast IS the latest-wins policy — every other client gets the new-size snapshot.
222 - `.resize` from any client: `applySize` + `resyncSnapshot()` (broadcast) — latest-wins again.
223 - Child exit in `pumpOnce`: broadcast `exit_status` to all clients.
224 - `.taken_over` is no longer sent by anyone; leave the MsgType and the client handler in place (comment in protocol.zig: retired in M5, kept for wire-compat).
225 - `replyStats`/debug_dump/fetch_scrollback arms work per-fd already — ensure the client-arm versions use the indexed fd and drop the right slot on failure.
226
227 - [x] **Step 4: `make test` green AND `make e2e` green (single-client behavior must be externally unchanged). Commit:** `git commit -am "feat: multi-client broadcast with latest-wins resize; takeover retired"`
228
229 ---
230
231 ### Task 3: Client — replica follows the grid, clipped rendering
232
233 **Files:** Modify `src/client.zig` only. Gate: `make test && make e2e && make bench` all green.
234
235 - [x] **Step 1: Failing tests (append to src/client.zig)**
236
237 ```zig
238 test "renderClipped paints only rows that fit and clamps the cursor" {
239 const alloc = std.testing.allocator;
240 var replica = try Engine.init(alloc, .{ .cols = 100, .rows = 30 });
241 defer replica.deinit();
242 replica.feed("top row\r\n");
243 var i: usize = 0;
244 while (i < 28) : (i += 1) replica.feed("mid\r\n");
245 replica.feed("bottom row\x1b[30;100H"); // cursor parked at grid corner
246
247 const pipe = try std.posix.pipe();
248 defer std.posix.close(pipe[0]);
249 // Local tty is smaller than the 100x30 grid.
250 try renderClipped(alloc, replica, .{ .cols = 80, .rows = 24 }, pipe[1]);
251 std.posix.close(pipe[1]);
252
253 var out: std.ArrayList(u8) = .empty;
254 defer out.deinit(alloc);
255 var chunk: [4096]u8 = undefined;
256 while (true) {
257 const n = try std.posix.read(pipe[0], &chunk);
258 if (n == 0) break;
259 try out.appendSlice(alloc, chunk[0..n]);
260 }
261
262 try std.testing.expect(std.mem.indexOf(u8, out.items, "top row") != null);
263 // Row 29 (0-based) of the grid is beyond a 24-row tty: never painted.
264 try std.testing.expect(std.mem.indexOf(u8, out.items, "bottom row") == null);
265 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[25;") == null); // no CUP past the tty
266 // Cursor clamped into the tty (row 24, col 80), inside sync brackets.
267 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[24;80H") != null);
268 try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[?2026h") != null);
269 }
270
271 test "paintDeltaClipped skips rows beyond the tty and clamps the cursor" {
272 const alloc = std.testing.allocator;
273 var payload: std.ArrayList(u8) = .empty;
274 defer payload.deinit(alloc);
275 try proto.appendDeltaHeader(&payload, alloc, .{
276 .seq = 9,
277 .history_rows = 0,
278 .cursor_x = 99,
279 .cursor_y = 29,
280 .row_count = 2,
281 });
282 try proto.appendDeltaRow(&payload, alloc, 3, "\x1b[0mfits");
283 try proto.appendDeltaRow(&payload, alloc, 28, "\x1b[0mdoes-not-fit");
284
285 const pipe = try std.posix.pipe();
286 defer std.posix.close(pipe[0]);
287 try paintDeltaClipped(alloc, payload.items, .{ .cols = 80, .rows = 24 }, pipe[1]);
288 std.posix.close(pipe[1]);
289 var out: [4096]u8 = undefined;
290 const n = try std.posix.read(pipe[0], &out);
291
292 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[4;1H\x1b[2K\x1b[0mfits") != null);
293 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "does-not-fit") == null);
294 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[24;80H") != null); // clamped
295 }
296 ```
297
298 - [x] **Step 2: `make test` → compile failure.**
299
300 - [x] **Step 3: Implement in src/client.zig:**
301
302 New helpers (place near the old `render`/`paintDelta`, which they replace — delete the old ones and their tests EXCEPT keep `renderScrollback` untouched):
303
304 ```zig
305 fn clampCursor(cur: Engine.CursorPos, tty: proto.Size) Engine.CursorPos {
306 return .{
307 .x = @min(cur.x, tty.cols -| 1),
308 .y = @min(cur.y, tty.rows -| 1),
309 };
310 }
311
312 /// Full repaint of the replica, clipped to the local tty. The replica is
313 /// grid-sized (may exceed the tty under latest-wins); rows beyond the tty
314 /// are skipped and long rows clip at the right edge because autowrap is
315 /// off (DECAWM, set at attach).
316 fn renderClipped(alloc: std.mem.Allocator, replica: *Engine, tty: proto.Size, out_fd: std.posix.fd_t) !void {
317 var paint: std.ArrayList(u8) = .empty;
318 defer paint.deinit(alloc);
319 try paint.appendSlice(alloc, "\x1b[?2026h\x1b[?25l\x1b[H\x1b[2J");
320
321 const grid_rows: u16 = @intCast(replica.term.rows);
322 const limit = @min(grid_rows, tty.rows);
323 var y: u16 = 0;
324 while (y < limit) : (y += 1) {
325 var cup: [16]u8 = undefined;
326 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H", .{y + 1}));
327 const row = try replica.dumpVtRow(alloc, y);
328 defer alloc.free(row);
329 try paint.appendSlice(alloc, row);
330 }
331
332 const cur = clampCursor(replica.cursorPos(), tty);
333 var cbuf: [16]u8 = undefined;
334 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ cur.y + 1, cur.x + 1 }));
335 try paint.appendSlice(alloc, "\x1b[?25h\x1b[?2026l");
336 try proto.writeAllFd(out_fd, paint.items);
337 }
338
339 /// Paint a delta directly, skipping rows outside the local tty. The
340 /// replica is updated separately via composeDelta (full, unclipped).
341 fn paintDeltaClipped(alloc: std.mem.Allocator, payload: []const u8, tty: proto.Size, out_fd: std.posix.fd_t) !void {
342 const hdr = try proto.readDeltaHeader(payload);
343 var paint: std.ArrayList(u8) = .empty;
344 defer paint.deinit(alloc);
345 try paint.appendSlice(alloc, "\x1b[?2026h\x1b[?25l");
346
347 var it = proto.deltaRowIterator(payload);
348 while (try it.next()) |row| {
349 if (row.row >= tty.rows) continue;
350 var cup: [16]u8 = undefined;
351 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};1H\x1b[2K", .{@as(u32, row.row) + 1}));
352 try paint.appendSlice(alloc, row.bytes);
353 }
354
355 const cur = clampCursor(.{ .x = hdr.cursor_x, .y = hdr.cursor_y }, tty);
356 var cbuf: [16]u8 = undefined;
357 try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ cur.y + 1, cur.x + 1 }));
358 try paint.appendSlice(alloc, "\x1b[?25h\x1b[?2026l");
359 try proto.writeAllFd(out_fd, paint.items);
360 }
361 ```
362
363 Attach-loop changes:
364 - Terminal setup: after `\x1b[?1049h\x1b[?25l` also emit `\x1b[?7l` (autowrap off — clipping depends on it); teardown emits `\x1b[?7h` before `\x1b[?25h\x1b[?1049l`.
365 - The replica now follows the DAEMON's grid, not the tty. Keep `size` = local tty size (attach/resize/scroll rendering); add `var grid = size;`. In the `.snapshot` arm: parse the prefix; if `prefix.cols != grid.cols or prefix.rows != grid.rows`, `try replica.resize(prefix.cols, prefix.rows); grid = .{ .cols = prefix.cols, .rows = prefix.rows };` then reset+feed as today; render via `renderClipped(alloc, replica, size, stdout_fd)`.
366 - `.delta` arm: replica feed via composeDelta unchanged; paint via `paintDeltaClipped(alloc, frame.payload, size, stdout_fd)` when live. (composeDelta still also validates the payload — keep the resync-on-failure catch BEFORE painting.)
367 - WINCH: remove the local `replica.resize` (the replica follows the daemon; the resize frame goes out, the answering snapshot updates grid+replica). Update `size` locally for clipping.
368 - Scroll-exit paths and any other `render(...)` call sites become `renderClipped(alloc, replica, size, stdout_fd)`.
369 - Audit: `render` and `paintDelta` (and their tests) deleted; no other references remain.
370
371 - [x] **Step 4: `make test && make e2e && make bench` all green. Commit:** `git commit -am "feat: client renders the authoritative grid clipped to its tty"`
372
373 ---
374
375 ### Task 4: e2e two-client scenario, demo, decision record
376
377 **Files:** Modify `test/e2e.sh`, `docs/decisions.md`, `README.md`, plan checkboxes.
378
379 - [x] **Step 1: Append to test/e2e.sh** (before the final `echo "e2e OK"`; add `"$OUT.a" "$OUT.b"` to cleanup):
380
381 ```sh
382 # --- M5: two clients on one session, both converge; detach is independent.
383 { sleep 0.5; printf 'printf "m5-%%s\\n" both\n'; sleep 2.5; printf '\034'; } | \
384 "$MUX" --sock "$SOCK" > "$OUT.a" &
385 APID=$!
386 { sleep 3.5; printf '\034'; } | "$MUX" --sock "$SOCK" > "$OUT.b" &
387 BPID=$!
388 wait "$APID" "$BPID"
389
390 grep -q "m5-both" "$OUT.a" || { echo "e2e FAIL: client A missing shared output"; exit 1; }
391 grep -q "m5-both" "$OUT.b" || { echo "e2e FAIL: client B missing shared output"; exit 1; }
392 kill -0 "$DPID" || { echo "e2e FAIL: daemon died in two-client scenario"; exit 1; }
393 rm -f "$OUT.a" "$OUT.b"
394 ```
395
396 - [x] **Step 2: `make test && make e2e && make bench` all green.**
397
398 - [x] **Step 3: Scripted demo** (report transcript): daemon + two `mux` clients under `script` ptys of DIFFERENT sizes (e.g. 100x30 and 80x24) via fifos; type in one → text appears in both typescripts; grid follows the later attacher (verify `muxd dump` line width); Shift+PageUp in one client while typing in the other → the scrolled client's typescript shows the `[scroll]` marker page while the other keeps streaming (independence, the handoff's demo).
399
400 - [x] **Step 4: docs/decisions.md — append (THE MANDATED RECORD):**
401
402 ```markdown
403 ## 2026-08-07 (M5)
404
405 - **Resize policy: latest wins.** The authoritative grid follows the most
406 recent attach or resize event from any client (modern tmux
407 `window-size latest`). Rejected: smallest-wins (punishes the larger
408 screen for the smaller one's presence — the handoff calls the result
409 "widely disliked"); per-client reflow (needs a per-client engine or
410 reflow pass, contradicting the single-authoritative-grid architecture —
411 reconsider post-prototype only with a concrete need). Non-matching
412 clients render the grid clipped: autowrap off (DECAWM), rows beyond the
413 tty skipped, cursor clamped, no border art. Snapshots carry the grid
414 size (16-byte prefix); size changes always travel as snapshots, so
415 deltas stay size-free.
416 - **Multi-client: broadcast, up to 8.** Every attached client receives
417 every delta/snapshot; per-client write failure drops that client only.
418 Takeover is retired — attach joins; a full session refuses with
419 exit_status{1}. The taken_over frame stays in the protocol (unsent)
420 for wire-compat.
421 - **Client-local view state is per-connection by construction** — scroll
422 mode lives in the client, scrollback fetches are served per-fd — and
423 pinned by a server test (one client pages history while the other
424 streams deltas).
425 - **Stats under broadcast:** delta_bytes/snapshot_bytes count actual
426 per-client sends; snapshot_equiv_bytes accrues once per broadcast
427 event. The bench (single client) is unaffected.
428 ```
429
430 Also update the "## Open" section: remove "Resize policy under multiple clients (M5)" and "Daemon lifetime across logout/reboot (M2)" if still listed (the latter was answered by socket activation + lingering note in M2 — if it's still listed, resolve it with a pointer to contrib/).
431
432 - [x] **Step 5: README.md** — status `Status: **M5 — two clients.** All handoff milestones complete; all three kill criteria cleared.` and add a line under the usage block: `Multiple mux clients may attach to one session; the grid follows the most recent attacher/resize (latest wins).`
433
434 - [x] **Step 6: Flip all M5 plan checkboxes; commit:** `git commit -am "feat: two-client e2e; docs: resize decision recorded — M5 complete"`
435
436 ---
437
438 ## Self-Review
439
440 - **Spec coverage (handoff §M5):** two clients attached simultaneously ✓ (Task 2 + e2e), both stay in sync ✓ (Test A byte-convergence), client-local view state independent ✓ (Test C + demo), resize question decided explicitly and written down ✓ (latest-wins; Task 4 decisions entry), demo = type in either/both update + scroll one/other doesn't move ✓ (Task 4 demo). "Done when two clients converge reliably and divergent view state behaves as a feature" ✓ (Tests A/C are the reliability pins).
441 - **Placeholders:** none; all code complete.
442 - **Type consistency:** `SnapshotPrefix`/`snapshot_prefix_len`/`read/writeSnapshotPrefix` (Task 1) used in Tasks 2/3 ✓; `renderClipped`/`paintDeltaClipped`/`clampCursor` defined and used in Task 3 ✓; `clients`/`broadcastDelta`/`hasClients`/`dropClient(i)`/`sendResync(i,..)` internal to Task 2 ✓; `applyFrame` (existing test helper) reused in Test A ✓.
443 - **Sequencing:** Task 1 migrates all prefix readers in-commit (build stays green); Task 2 changes server only (client still single-attaches fine — it's just client[0]); Task 3 changes client only; e2e green required at every task boundary except none — all four tasks keep all gates green (no red-window this milestone).
docs/superpowers/plans/2026-08-07-m6-transport.md
Old New
@@ -1,241 +0,0 @@
1 # M6 — Transport 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:** Put the protocol on a real network and falsify the decision log's standing claim that "transport is a swap, not a redesign" — via an SSH-channel transport (`mux --via`), with the two pre-network debts paid first (buffered nonblocking client writes; session epoch), and the result *measured* on a real ~290ms WAN link.
6
7 **Architecture:** Transport stays a byte pipe. A new `muxd proxy` subcommand is a frame-agnostic bidirectional byte pump between its stdio and the local `muxd.sock`; the client gains `mux --via "CMD"`, which spawns CMD (typically `ssh host /path/muxd proxy ...`) and speaks the existing framed protocol over the child's stdin/stdout instead of a socket. The proxy knowing nothing about frames IS the thesis test — if that suffices, transport was a swap. Groundwork: (1) daemon writes to clients become queue-buffered and non-stalling (`MSG_DONTWAIT` + per-client pending buffer + POLLOUT flush + cap-drop) so one slow WAN client can't head-of-line-block the rest — reads stay blocking/POLLIN-gated (input frames are tiny; the observed hazard is write-side snapshots); (2) a random per-daemon-instance **epoch** rides in attach and snapshot so `have_seq` can never be honored across a daemon restart.
8
9 **M6 kill criterion (decide pass/fail by measurement, not vibes):** (a) structural — the remote path works with the proxy containing zero protocol knowledge; (b) measured on the real link — median keystroke-echo latency through mux ≤ raw-ssh-byte-echo baseline + 120ms (one daemon pump tick + shell), and reattach-after-kill over the WAN completes to first painted byte within ~2×RTT of the attach request. If the delta/have_seq design forces protocol changes beyond the planned epoch to survive the WAN, that is the criterion failing — report it, don't patch around it silently.
10
11 **Tech Stack:** unchanged (Zig 0.15.2 via Makefile ONLY; ghostty-vt pin). Remote deployment = `zig build -Dtarget=x86_64-linux-musl` (verified: produces static ELFs that run on the target box) + scp.
12
13 ---
14
15 ## Context for executors with zero prior exposure
16
17 - Repo `/home/xanderle/code/rad/mux`, branch `main`, commit directly. Read `docs/handoff.md`, `docs/decisions.md` (esp. the M4/M5 sections, the banked-cleanup list, and the "Prototype verdict"), `README.md`.
18 - System `zig` is an incompatible 0.17-dev. ONLY `make test` / `make build` / `make e2e` / `make bench`. For cross-compiling, the ONE permitted direct invocation is `~/Downloads/zig-x86_64-linux-0.15.2/zig build -Dtarget=x86_64-linux-musl`.
19 - Modules: `src/protocol.zig` (framing; attach=12B cols/rows/have_seq; SnapshotPrefix=16B seq/history/cols/rows; delta=18B header+rows; golden-byte + error-path test house style), `src/server.zig` (Server: clients[8]+observers[4], DeltaTracker, broadcast/unicast sends, pumpOnce poll loop, integration tests with `serverThread` + poll-with-deadline patterns), `src/client.zig` (attach loop over a socket fd; replica engine; clipped rendering), `src/main.zig` (muxd run/dump/stats), `src/mux_main.zig` (mux CLI), `test/e2e.sh`, `test/bench.sh`.
20 - **Test WAN box** (available now, may be ephemeral — never hardcode it in committed files; parameterize): `ssh -J ubuntu@gate.eitri.sh:2222 ubuntu@sandbox-9b70e9`, passwordless. Ubuntu 26.04 x86_64, 2 cpus, sudo+tc available. Use `-o ControlMaster=auto -o ControlPath=/tmp/mux-cm -o ControlPersist=300` (short ControlPath — long paths exceed sun_path). Measured ~290ms per exec round-trip with a warm master. Static musl muxd verified to run there.
21 - Established review-cycle rules: golden-byte tests for wire changes; error paths tested; mutation-check load-bearing assertions; report deviations with reasoning; never idle silently.
22
23 ---
24
25 ## File Structure
26
27 ```
28 src/protocol.zig — MODIFY: attach v3 (+have_epoch, 20B), SnapshotPrefix v2
29 (+epoch, 24B), appendFrame (frame bytes into a list)
30 src/server.zig — MODIFY: epoch; queued nonblocking client writes
31 (pending buffers, POLLOUT, cap-drop)
32 src/proxy.zig — CREATE: stdio<->unix-socket byte pump (no frame parsing)
33 src/client.zig — MODIFY: Conn {rfd,wfd} abstraction; --via child transport
34 src/mux_main.zig — MODIFY: --via flag
35 src/main.zig — MODIFY: `muxd proxy` subcommand
36 test/wan.sh — CREATE: deploy + measure harness (env-parameterized)
37 test/e2e.sh — MODIFY: local --via proxy scenario
38 docs/decisions.md — MODIFY: M6 section incl. the transport verdict + numbers
39 ```
40
41 ---
42
43 ### Task 1: Queued, non-stalling client writes
44
45 **Files:** `src/protocol.zig` (one helper), `src/server.zig`.
46
47 The daemon currently calls `proto.writeFrame(fd, ...)` — blocking, so one stalled client freezes the pump for everyone. Replace the client-send path with per-client outbound queues flushed opportunistically.
48
49 - [x] **Step 1: Failing tests.** In `src/protocol.zig`:
50
51 ```zig
52 test "appendFrame encodes the same bytes writeFrame sends" {
53 const alloc = std.testing.allocator;
54 var list: std.ArrayList(u8) = .empty;
55 defer list.deinit(alloc);
56 try appendFrame(&list, alloc, .input, "abc");
57 try std.testing.expectEqualSlices(u8, &[_]u8{ 0x02, 3, 0, 0, 0, 'a', 'b', 'c' }, list.items);
58 }
59 ```
60
61 In `src/server.zig`, two deterministic tests using **socketpair-free** primitives (no `std.posix.socketpair` on 0.15.2 — use `std.net` unix sockets in a tmpDir, or pipes where only writes matter; pipes support poll/POLLOUT and `fcntl` F_SETPIPE_SZ to shrink the buffer to 4096, which is the cleanest way to make a "slow reader" deterministic):
62
63 - "Server: a stalled client does not block delivery to others": install two fake client fds (pipe write-ends — the daemon only writes to clients in this path; follow the precedent of the existing stats test that installs pipe fds directly into `clients[]`). Shrink pipe A's buffer via `F.SETPIPE_SZ` to 4096 and do not read from it; drive `sendUpdate`-scale traffic (feed the engine a few KB, call the send path repeatedly, or call `broadcastSnapshot`-equivalent several times). Assert: pipe B's read side receives complete, parseable frames (read it and frame-parse); client A is still attached with a non-empty pending queue OR (after exceeding the cap) dropped; the calls never blocked (bound the test with a deadline — completing at all is the assertion, since a blocking write on the full 4KB pipe would hang the test).
64 - "Server: a client exceeding the pending cap is dropped": same setup, single stalled client, feed until `pending.items.len` would exceed the cap (set a small test cap — make the cap a `Server` field, default `8 * 1024 * 1024`, overridable in tests); assert the slot is nulled and the fd closed (write to the read end... simpler: assert slot nulled and `stats` unaffected thereafter).
65
66 - [x] **Step 2: `make test` → failures.**
67
68 - [x] **Step 3: Implement.**
69
70 `src/protocol.zig`:
71 ```zig
72 /// Append one frame's wire bytes (header + payload) to a list. The queued
73 /// counterpart of writeFrame: daemons buffer frames per client and flush
74 /// opportunistically instead of blocking on a slow peer.
75 pub fn appendFrame(
76 list: *std.ArrayList(u8),
77 alloc: std.mem.Allocator,
78 t: MsgType,
79 payload: []const u8,
80 ) !void {
81 var hdr: [5]u8 = undefined;
82 hdr[0] = @intFromEnum(t);
83 std.mem.writeInt(u32, hdr[1..5], @intCast(payload.len), .little);
84 try list.appendSlice(alloc, &hdr);
85 try list.appendSlice(alloc, payload);
86 }
87 ```
88
89 `src/server.zig`: replace `clients: [8]?fd` with slot structs:
90 ```zig
91 const ClientSlot = struct {
92 fd: std.posix.fd_t,
93 /// Frames queued but not yet accepted by the kernel. Bounded by
94 /// Server.pending_cap; a peer that stops reading gets dropped, never
95 /// waited on — one slow WAN client must not stall the session.
96 pending: std.ArrayList(u8) = .empty,
97 };
98 clients: [max_clients]?ClientSlot = @splat(null),
99 pending_cap: usize = 8 * 1024 * 1024,
100 ```
101 Send path: `queueFrame(i, t, payload)` — `proto.appendFrame` into `pending`, then `flushClient(i)`. `flushClient`: while pending non-empty, `std.posix.send(fd, pending.items[off..], std.posix.MSG.DONTWAIT | std.posix.MSG.NOSIGNAL)`; on `error.WouldBlock` stop (keep remainder — use a consumed-offset + `std.mem.copyForwards`/`replaceRange` discard of sent bytes, or track a start index and compact when fully flushed); on other errors `dropClient(i)`. After queueing, if `pending.items.len > self.pending_cap` → `dropClient(i)`. `pumpOnce`'s poll entries for clients request `POLL.IN | (POLL.OUT if pending non-empty)`; on POLLOUT revents → `flushClient(i)`. All existing send sites (`broadcastDelta`, `resyncSnapshot`'s loop, `snapshotTo`, `sendDeltaTo`, `replyStats` for clients, scrollback/dump replies to clients, exit_status broadcast) route through `queueFrame`. Observer replies MAY stay on blocking `writeFrame` (observers are local one-shot tools — note this in a comment) — but client-slot sends must all be queued. `dropClient` frees `pending`. `deinit` frees all pendings. Stats semantics unchanged: count bytes when QUEUED (comment: "sent" now means accepted into the queue; the cap bounds the lie).
102
103 Note: client fds stay otherwise blocking; `MSG_DONTWAIT` gives per-call nonblocking writes without touching the read path. Reads remain POLLIN-gated blocking `readFrame` — pre-existing, accepted (input frames are tiny); do not refactor reads.
104
105 - [x] **Step 4: `make test && make e2e && make bench` green (single-client behavior unchanged).**
106 - [x] **Step 5: Commit** `feat: queued non-stalling client writes with pending cap`.
107
108 ---
109
110 ### Task 2: Session epoch
111
112 **Files:** `src/protocol.zig`, `src/server.zig`, `src/client.zig`.
113
114 - [x] **Step 1: Failing tests.** protocol.zig: golden-byte + round-trip + error-path tests for **attach v3** — `encodeAttach(cols, rows, have_seq, have_epoch)` → 20 bytes (have_epoch u64 LE appended at [12..20]); `decodeAttach` requires exactly 20 — and **SnapshotPrefix v2** — `epoch: u64` appended at [16..24], `snapshot_prefix_len = 24`. Update ALL existing golden tests for the new layouts. server.zig test: "Server: an unknown epoch can never be served a delta" — attach, learn `(epoch, seq)` from the first snapshot prefix; detach; reattach with the REAL seq but `have_epoch = epoch ^ 1` → first state frame must be `.snapshot`; reattach with the real `(seq, epoch)` → `.delta` (this replaces/extends the existing have_seq reattach test — fold the epoch into it rather than duplicating the harness).
115
116 - [x] **Step 2: `make test` → compile failures.**
117
118 - [x] **Step 3: Implement.** Server: `epoch: u64` field; in `Server.init`: `var e: u64 = 0; while (e == 0) e = std.crypto.random.int(u64);` (0 is reserved = "none"). `buildSnapshotPayload` writes it. `sendResync`'s serviceable-delta guard additionally requires `have_epoch == self.epoch`. Client: sends `have_seq = 0, have_epoch = 0` (fresh process — unchanged semantics); parses and retains the epoch from snapshot prefixes in a local var (unused today; comment: reconnect logic will use it). Migrate every `encodeAttach` call site (server tests, client) to the 4-arg form.
119
120 - [x] **Step 4: gates green. Step 5: Commit** `feat: session epoch fences have_seq across daemon restarts`.
121
122 ---
123
124 ### Task 3: `muxd proxy` + `mux --via`
125
126 **Files:** create `src/proxy.zig`; modify `src/main.zig`, `src/client.zig`, `src/mux_main.zig`, `build.zig` (proxy module import for muxd), `test/e2e.sh`.
127
128 - [x] **Step 1: `src/proxy.zig`** — the whole point is what this file does NOT contain: no frame parsing, no protocol import.
129
130 ```zig
131 //! `muxd proxy`: a bidirectional byte pump between stdio and the local
132 //! daemon socket. Deliberately frame-agnostic — it contains no protocol
133 //! knowledge at all. That is the M6 transport thesis: if an opaque byte
134 //! pipe suffices to carry the protocol over SSH, transport is a swap,
135 //! not a redesign.
136 const std = @import("std");
137
138 pub fn run(sock_path: []const u8) !u8 {
139 const stream = std.net.connectUnixSocket(sock_path) catch {
140 std.debug.print("muxd proxy: cannot connect to {s}\n", .{sock_path});
141 return 1;
142 };
143 defer stream.close();
144 const sock = stream.handle;
145 const stdin_fd = std.posix.STDIN_FILENO;
146 const stdout_fd = std.posix.STDOUT_FILENO;
147
148 var buf: [64 * 1024]u8 = undefined;
149 while (true) {
150 var fds = [_]std.posix.pollfd{
151 .{ .fd = stdin_fd, .events = std.posix.POLL.IN, .revents = 0 },
152 .{ .fd = sock, .events = std.posix.POLL.IN, .revents = 0 },
153 };
154 _ = try std.posix.poll(&fds, -1);
155
156 if (fds[0].revents != 0) {
157 const n = std.posix.read(stdin_fd, &buf) catch return 0;
158 if (n == 0) return 0; // client hung up
159 writeAll(sock, buf[0..n]) catch return 0;
160 }
161 if (fds[1].revents != 0) {
162 const n = std.posix.read(sock, &buf) catch return 0;
163 if (n == 0) return 0; // daemon hung up
164 writeAll(stdout_fd, buf[0..n]) catch return 0;
165 }
166 }
167 }
168
169 fn writeAll(fd: std.posix.fd_t, data: []const u8) !void {
170 var i: usize = 0;
171 while (i < data.len) i += try std.posix.write(fd, data[i..]);
172 }
173
174 test "proxy pumps bytes both ways verbatim" {
175 // Pipe-in -> proxy -> unix socket server; server echoes reversed;
176 // assert client pipe-out sees the exact server bytes. Drive run()
177 // in a thread against a tmpDir socket with a trivial echo peer.
178 // (Executor: implement with the std.net listener + Thread patterns
179 // used in src/server.zig tests; keep run() testable by factoring the
180 // fd pair as parameters if stdio makes it awkward:
181 // pub fn pump(in_fd, out_fd, sock_path) !u8 with run() forwarding
182 // STDIN/STDOUT — test pump() directly with pipes.)
183 }
184 ```
185 Factor as `pump(in_fd, out_fd, sock_path)` + `run()` wrapper so the test drives real pipes. Wire `muxd proxy [--sock PATH]` into main.zig (default sock path logic shared with dump/stats).
186
187 - [x] **Step 2: `mux --via "CMD"`.** In `src/client.zig`, abstract the transport fds: `const Conn = struct { r: std.posix.fd_t, w: std.posix.fd_t };` — socket case `{sock, sock}`; via case: spawn `/bin/sh -c CMD` via `std.process.Child` with `.stdin_behavior = .Pipe, .stdout_behavior = .Pipe` (stderr inherit — ssh errors must reach the user), `conn = .{ .r = child.stdout.?.handle, .w = child.stdin.?.handle }`. Every `readFrame(alloc, sock)` → `readFrame(alloc, conn.r)`; every `writeFrame(sock, ...)` → `(conn.w, ...)`; the poll entry uses `conn.r`. On exit, kill+wait the child. `attach()` signature gains the transport: simplest is `pub fn attach(alloc, sock_path: ?[]const u8, via: ?[]const u8) !u8` — exactly one non-null, resolved in `mux_main.zig` (`--via` flag, mutually exclusive with `--sock`; usage string updated).
188
189 - [x] **Step 3: e2e scenario** (append before `echo "e2e OK"`; this is the local structural proof):
190
191 ```sh
192 # --- M6: same protocol over an arbitrary byte pipe (proxy transport).
193 { sleep 0.5; printf 'printf "m6-%%s\\n" via-pipe\n'; sleep 2; printf '\034'; } | \
194 "$MUX" --via "$MUXD proxy --sock $SOCK" > "$OUT.via"
195 grep -q "m6-via-pipe" "$OUT.via" || { echo "e2e FAIL: --via transport"; exit 1; }
196 rm -f "$OUT.via"
197 ```
198 (`$OUT.via` added to cleanup. `$MUXD` may contain a path with spaces — it won't in this build tree; note it.)
199
200 - [x] **Step 4: gates green (test, e2e, bench). Step 5: Commit** `feat: mux --via arbitrary-command transport; muxd proxy byte pump`.
201
202 ---
203
204 ### Task 4: WAN deployment + measurement harness
205
206 **Files:** create `test/wan.sh` (executable). NOT wired into build.zig — it needs a real remote box; it is run manually/by the controller.
207
208 - [x] **Step 1: Write `test/wan.sh`.** Parameterized by env: `MUX_WAN_SSH` (full ssh command string incl. jump/control flags, e.g. `ssh -o ControlMaster=auto -o ControlPath=/tmp/mux-cm -o ControlPersist=300 -J ubuntu@gate.eitri.sh:2222 ubuntu@sandbox-9b70e9`), `MUX_WAN_SCP` (matching scp, e.g. `scp -o ControlPath=/tmp/mux-cm`), `MUX_WAN_HOST` (scp target prefix, e.g. `ubuntu@sandbox-9b70e9`). Refuse with a usage message if unset. Steps the script performs:
209 1. `~/Downloads/zig-x86_64-linux-0.15.2/zig build -Dtarget=x86_64-linux-musl`; scp `zig-out/bin/muxd` to `$MUX_WAN_HOST:/tmp/muxd-wan`; rebuild native (`make build`) so the local `mux` is native.
210 2. **Baseline**: raw byte round-trip through the pipe — run `$MUX_WAN_SSH cat` as a coprocess, send a byte, time until it returns; 20 reps, report min/median. This is the number mux must approach.
211 3. Start the remote daemon: `$MUX_WAN_SSH 'rm -f /tmp/mux-wan.sock; nohup /tmp/muxd-wan run --sock /tmp/mux-wan.sock --shell /bin/bash >/tmp/muxd-wan.log 2>&1 &'`.
212 4. **Attach latency**: time from launching `mux --via "$MUX_WAN_SSH /tmp/muxd-wan proxy --sock /tmp/mux-wan.sock"` (piped stdio) to first byte on stdout. 3 reps.
213 5. **Keystroke echo**: with a held-open attached client (fifo stdin), send single chars, time each until it appears in the client's stdout stream; 20 reps; min/median/max. Timing loop in python3 (select on the stdout pipe, ns timestamps) — pure-sh byte timing is too crude; python3 exists locally.
214 6. **Reattach-after-kill**: `kill -9` the client mid-session; relaunch; time to first painted byte; verify a pre-kill marker string is present in the new client's first paint (state correctness over WAN).
215 7. **Optional netem stanza** (`MUX_WAN_NETEM=1`): `$MUX_WAN_SSH 'sudo tc qdisc add dev $(ip route | awk "/default/{print \$5; exit}") root netem delay 75ms loss 1%'` (≈150ms added RTT), re-run steps 5-6, then ALWAYS `sudo tc qdisc del ... root` in the cleanup trap.
216 8. Print a summary block (baseline, attach, echo, reattach, netem variants) formatted for pasting into decisions.md. Cleanup trap: kill remote daemon, remove remote socket, local fifos.
217
218 - [x] **Step 2: Run it against the box** (env values above are in this plan's Context section). Run twice; numbers should be stable to ~±20%. **If keystroke echo exceeds baseline + 120ms median, or reattach exceeds ~2×RTT to first byte: that is the M6 kill criterion failing — STOP and report with the raw numbers; do not tune the thresholds.**
219
220 - [x] **Step 3: Commit** `feat: WAN measurement harness` (script only — numbers go in Task 5's docs).
221
222 ---
223
224 ### Task 5: The verdict, docs, closure
225
226 **Files:** `docs/decisions.md`, `README.md`, plan checkboxes.
227
228 - [x] **Step 1: decisions.md M6 section**: the queued-writes design (cap semantics, "sent = queued" stats note, reads deliberately still blocking); the epoch (0 reserved, why attach+prefix and not deltas — mid-stream restart is impossible because the transport connection dies with the daemon); the `--via`/proxy design and **the transport verdict with evidence**: state plainly whether "transport is a swap, not a redesign" held — the proxy's zero protocol knowledge is the structural half, the measured numbers (paste the wan.sh summary: baseline, echo, attach, reattach, netem) are the empirical half, and the kill criterion pass/fail is the sentence. Record what transport did NOT need (no framing changes, no msgpack tripwire trip — payloads still unstructured). Update the banked list (strike "nonblocking/buffered writes"; the pre-network prerequisites are now: TLS/QUIC for non-SSH deployment, reconnect-with-epoch client logic, prediction). Note the WAN box is ephemeral and parameterized, not recorded.
229
230 - [x] **Step 2: README**: status `M6 — transport`; usage gains `mux --via "ssh host /path/muxd proxy --sock ..."` with one sentence (any command exposing the daemon socket over stdio works); note `test/wan.sh`.
231
232 - [x] **Step 3: All M6 checkboxes flipped; commit** `feat: SSH-channel transport measured over real WAN — M6 complete`.
233
234 ---
235
236 ## Self-Review
237
238 - **Coverage vs the milestone's own goal:** groundwork debts ✓ (Tasks 1-2 = the two decision-log prerequisites), transport spike ✓ (Task 3, structural thesis embodied in proxy.zig's emptiness), real-network measurement ✓ (Task 4 with baseline control), kill criterion defined with numbers and a stop rule ✓, verdict recorded ✓ (Task 5). Prediction explicitly NOT here — it's the next milestone, needing Task 4's numbers as its baseline.
239 - **Placeholders:** the proxy test body is directive-plus-pattern rather than full code (the factoring decision belongs to the implementer); everything else novel has full code or exact commands. wan.sh's step list is complete enough to write from; its numbers are measurements, produced not promised.
240 - **Type consistency:** `appendFrame` (T1) used by queueFrame (T1); `encodeAttach` 4-arg + `snapshot_prefix_len=24` (T2) migrate all call sites in-task; `Conn`/`attach(alloc, sock, via)` (T3) internal; `pump` referenced in T3's own test note ✓.
241 - **Sequencing:** every task keeps all gates green; Task 4 runs against the live box (controller supplies env); Task 5 needs Task 4's output.
docs/superpowers/plans/2026-08-07-m7-reconnect.md
Old New
@@ -1,229 +0,0 @@
1 # M7: Client Reconnect-with-Epoch 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:** A transport death is a non-event: the client keeps its replica, rebuilds the transport, re-attaches with `(have_seq, have_epoch)`, and resumes — served by a delta when the daemon still knows that seq, by a snapshot when it doesn't.
6
7 **Architecture:** All work is client-side. The daemon's `sendResync` (src/server.zig:968) already implements the whole resume policy: `have_epoch == epoch && have_seq ∈ [tracker.reset_seq, tracker.seq]` → delta-since, else snapshot — with daemon-restart and stale-seq tests in place. The client today parses `session_epoch` and throws it away (src/client.zig:145) and exits on any transport loss. M7: (1) factor the transport into a reopenable value, (2) track `last_seq`, (3) a reconnect loop with backoff replacing the "connection to muxd lost" exits, (4) e2e + WAN proof, (5) `mux HOST` sugar for the install-on-VMs use case.
8
9 **Driving use case (user):** muxd installed on several remote VMs; attach/detach/hop between them from a laptop; ssh drops, laptop sleeps, links flap — sessions must just resume.
10
11 **Tech Stack:** Zig 0.15.2 (pinned, `make test`/`make e2e`/`make bench` only), existing wire protocol (attach 20B already carries have_seq/have_epoch — zero protocol changes).
12
13 **Policy decisions (record in decisions.md in Task 5):**
14 - Input typed while disconnected is **dropped**, not queued. A reconnect that replays a burst of stale keystrokes into a shell is worse than losing them; the disconnected state is visible (indicator), and prediction (future milestone) is the principled fix.
15 - A reconnect that lands on a **different epoch** repaints from the fresh snapshot silently — new epoch means new session (daemon restarted); the fresh shell is self-evident. No modal "session lost" state.
16 - Attach refusal (`exit_status` before state) **during a reconnect** gets a bounded retry (~5s), because the daemon may not have reaped the dead predecessor slot yet (cap=2 ghost-slot race). Refusal on *first* attach stays an immediate loud exit.
17 - First-attach transport failure (ssh refused, bad host) stays an immediate loud exit — retry loops on a typo'd hostname help no one.
18
19 ---
20
21 ### Task 1: Transport as a reopenable value
22
23 **Files:**
24 - Modify: `src/client.zig` (attach(), new `Transport` struct)
25
26 The via-child plumbing lives inline in `attach()` (src/client.zig:27-51) and the socket path right below it; neither can be re-established mid-session. Extract both into one struct. **No behavior change in this task** — same messages, same exit codes, same child cleanup order (stdin close → kill; the comment at client.zig:40-49 explains why — keep it).
27
28 - [x] **Step 1: Write the struct and constructor**
29
30 ```zig
31 /// One live connection to a muxd, however reached. Reopenable: the
32 /// recipe (sock_path or via command) lives in Session so a dead
33 /// transport can be replaced without losing the replica.
34 const Transport = struct {
35 conn: Conn,
36 child: ?std.process.Child = null,
37
38 fn open(alloc: std.mem.Allocator, sock_path: ?[]const u8, via: ?[]const u8) !Transport {
39 if (via) |cmd| {
40 var child = std.process.Child.init(&.{ "/bin/sh", "-c", cmd }, alloc);
41 child.stdin_behavior = .Pipe;
42 child.stdout_behavior = .Pipe;
43 child.stderr_behavior = .Inherit; // keep: ssh's diagnostics are the only clue
44 try child.spawn();
45 return .{
46 .conn = .{ .r = child.stdout.?.handle, .w = child.stdin.?.handle },
47 .child = child,
48 };
49 }
50 const stream = try std.net.connectUnixSocket(sock_path.?);
51 return .{ .conn = .{ .r = stream.handle, .w = stream.handle } };
52 }
53
54 fn close(self: *Transport) void {
55 if (self.child) |*c| {
56 if (c.stdin) |*in| { in.close(); c.stdin = null; }
57 _ = c.kill() catch {};
58 } else {
59 std.posix.close(self.conn.r);
60 }
61 }
62 };
63 ```
64
65 - [x] **Step 2: Rewire `attach()` to use it, keeping today's error messages**
66
67 `attach()` opens the first Transport, prints the same "cannot start --via command"/"cannot connect to {s} (is muxd running?)" messages on failure, and passes `sock_path`, `via`, and the Transport down to `session()` (signature grows; the recipe is needed there next task).
68
69 - [x] **Step 3: `make test && make e2e` — both green, zero behavior change**
70
71 - [x] **Step 4: Commit** `refactor: transport is a reopenable value, recipe travels with the session`
72
73 ---
74
75 ### Task 2: Track what we have; reconnect instead of dying
76
77 **Files:**
78 - Modify: `src/client.zig` (session loop)
79
80 - [x] **Step 1: Track `last_seq`**
81
82 In the session loop state (near `session_epoch`, client.zig:145): `var last_seq: u64 = 0;`. Update from both authoritative sources: `prefix.seq` in the snapshot arm, `composed.header.seq` in the delta arm. Fix the now-stale comment at client.zig:142-144 ("Nothing reads it yet") — something reads it now.
83
84 - [x] **Step 2: Route every transport-loss exit into `reconnect()`**
85
86 The three exit sites that today set `exit_msg = "mux: connection to muxd lost"` on a *dead transport* (readFrame null at client.zig:180; the input-write catch at :292; the winch-resize catch at :157, plus the scroll-request catches) instead do: `if (!try reconnect(...)) { exit_msg = ...; return 1; }` and `continue` the main loop. The malformed-delta re-attach (client.zig:217-224) is **not** one of these — the transport is alive there, and its `have_seq=0` is deliberate (untrusted replica must force a snapshot); leave it, but extend its comment to say why it differs from reconnect().
87
88 - [x] **Step 3: Write `reconnect()`**
89
90 ```zig
91 /// The transport died but the session (daemon-side) very likely did not.
92 /// Keep the replica, rebuild the pipe, re-attach with what we hold; the
93 /// daemon answers with a delta when it can still interpret our seq
94 /// (sendResync), a snapshot when it cannot. Returns false when the user
95 /// gave up (Ctrl-\) or retries are exhausted.
96 fn reconnect(
97 alloc: std.mem.Allocator,
98 transport: *Transport,
99 sock_path: ?[]const u8,
100 via: ?[]const u8,
101 size: proto.Size,
102 last_seq: u64,
103 session_epoch: u64,
104 stdin_fd: std.posix.fd_t,
105 stdout_fd: std.posix.fd_t,
106 ) !bool {
107 paintBanner(stdout_fd, size, "[reconnecting]"); // inverse, top-right, renderScrollback-style
108 var backoff_ms: u64 = 200;
109 var waited_ms: u64 = 0;
110 while (true) {
111 transport.close();
112 // Drain stdin while we sleep: Ctrl-\ aborts, everything else is
113 // dropped by policy (see plan header).
114 if (drainStdinForQuit(stdin_fd, backoff_ms)) return false;
115 waited_ms += backoff_ms;
116 backoff_ms = @min(backoff_ms * 2, 2000);
117 transport.* = Transport.open(alloc, sock_path, via) catch continue;
118 proto.writeFrame(transport.conn.w, .attach,
119 &proto.encodeAttach(size.cols, size.rows, last_seq, session_epoch)) catch continue;
120 return true; // main loop resumes; the next frame is the resync
121 }
122 }
123 ```
124
125 Details the implementer owns: `paintBanner` (factor from `renderScrollback`'s marker paint, client.zig:394-398); `drainStdinForQuit` (poll stdin with the backoff as timeout; read; return true iff 0x1c seen — bytes are otherwise discarded); no unbounded retry cap on *transport* failures (the user has the abort key and the indicator; a laptop asleep for an hour should still resume on wake — that IS the use case), but see Step 4 for the refusal cap.
126
127 - [x] **Step 4: The refusal-during-reconnect grace**
128
129 After a reconnect-attach, if the next frame is `exit_status` before any state arrives (the session-full refusal shape, client.zig:241-243), and we were reconnecting (a flag alongside `got_state`), treat it as retryable for up to ~5s total (ghost-slot race: cap=2 and the daemon may not have reaped our dead predecessor). Past the grace: exit with the existing refusal message. A *real* exit_status (after state) still exits normally — the shell exiting while we reconnect must not loop.
130
131 - [x] **Step 5: `make test && make e2e && make bench` green**
132
133 - [x] **Step 6: Commit** `feat: transport death is a non-event — reconnect with have_seq, resume by delta`
134
135 ---
136
137 ### Task 3: e2e — tear the transport, resume by delta; restart the daemon, resume by snapshot
138
139 **Files:**
140 - Modify: `test/e2e.sh`
141
142 - [x] **Step 1: Scenario A — proxy killed, delta resume**
143
144 Append after the M6 --via scenario. Shape (implementer adapts to the file's conventions — set -eu, cleanup traps, `|| true` on the piped client):
145
146 ```sh
147 # --- M7: kill the transport mid-session; the client must resume by DELTA.
148 # The proxy is the transport; killing it is the ssh-drop stand-in. The
149 # snapshots counter must not move across the tear: a resume that costs a
150 # full snapshot on an idle session is the bug this milestone exists to kill.
151 { sleep 0.5; printf 'printf "m7-%%s\\n" before\n'; sleep 6; printf 'printf "m7-%%s\\n" after\n'; sleep 2.5; printf '\034'; } | \
152 XDG_RUNTIME_DIR=/nonexistent-mux-e2e "$MUX" --via "$MUXD proxy --sock $SOCK" > "$OUT.m7" || true &
153 M7PID=$!
154 sleep 2
155 SNAPS_BEFORE=$("$MUXD" stats --sock "$SOCK" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
156 pkill -f "muxd proxy --sock $SOCK" || pkill -f "proxy --sock $SOCK" # tear
157 wait "$M7PID"
158 grep -q "m7-before" "$OUT.m7" || { echo "e2e FAIL: m7 pre-tear output missing"; exit 1; }
159 grep -q "m7-after" "$OUT.m7" || { echo "e2e FAIL: m7 client did not resume after transport kill"; cat "$OUT.m7"; exit 1; }
160 SNAPS_AFTER=$("$MUXD" stats --sock "$SOCK" | sed -n 's/.*snapshots=\([0-9]*\).*/\1/p')
161 [ "$SNAPS_BEFORE" = "$SNAPS_AFTER" ] || {
162 echo "e2e FAIL: reconnect was served a snapshot ($SNAPS_BEFORE -> $SNAPS_AFTER), not a delta"; exit 1;
163 }
164 ```
165
166 Timing note for the implementer: the pkill must land while the client is mid-`sleep 6` (attached, idle). If flaky, drive the tear from a marker in `$OUT.m7` instead of sleep. The pkill pattern must not match the daemon itself — verify with a comment and a `kill -0 $DPID` assertion right after.
167
168 - [x] **Step 2: Scenario B — daemon killed and restarted, snapshot resume with the new epoch**
169
170 Kill -9 the daemon under an attached client, restart it on the same socket path, and assert the client resumes into the NEW session: a marker typed after restart echoes back, and the pre-restart marker is absent from the new daemon's dump. (This is the epoch fence doing its job end-to-end: same path, different epoch, have_seq rightly refused, snapshot served.) Note: daemon restart on the same path relies on the stale-socket recovery (`ECONNREFUSED` → unlink) — coordinate with the socket-steal fix if it hasn't landed first.
171
172 - [x] **Step 3: `make e2e` green, three runs (timing-sensitive scenarios must not flake)**
173
174 - [x] **Step 4: Commit** `test: e2e proves resume-by-delta across a transport tear and resume-by-snapshot across a daemon restart`
175
176 ---
177
178 ### Task 4: `mux HOST` — the VM hop
179
180 **Files:**
181 - Modify: `src/mux_main.zig`
182
183 The use case is "attach to any of my VMs": `mux vm1`, `mux ubuntu@sandbox-9b70e9`. Sugar for `--via "ssh <host> muxd proxy"` (muxd must be on the remote PATH; document in the usage string).
184
185 - [x] **Step 1: Extract arg parsing into a testable function**
186
187 `fn parseArgs(args: []const [:0]const u8) ParseResult` where ParseResult is a tagged union: `{ sock: ?[]const u8, via: ?[]const u8 } | usage_error | conflict`. A single positional arg (not starting with `-`) becomes `via = "ssh <arg> muxd proxy"` (allocPrint in main after parse, or return the host and let main format). Positional + --sock or --via = conflict, same as the existing pair.
188
189 - [x] **Step 2: Tests for parseArgs** — plain, `--sock P`, `--via C`, `host`, `user@host`, `host --sock P` (conflict), `--sock P --via C` (conflict), unknown flag (usage). Mutation check: break the positional branch, the host tests must fail.
190
191 - [x] **Step 3: Update usage string** to `usage: mux [HOST | --sock PATH | --via CMD]\n HOST attaches over "ssh HOST muxd proxy" (muxd must be on HOST's PATH)`
192
193 - [x] **Step 4: `make test && make e2e` green**
194
195 - [x] **Step 5: Commit** `feat: mux HOST attaches over ssh — the VM hop is one word`
196
197 ---
198
199 ### Task 5: WAN kill criterion + the record
200
201 **Files:**
202 - Modify: `test/wan.sh` (env-gated phase, same MUX_WAN_* convention)
203 - Modify: `docs/decisions.md` (M7 section)
204
205 - [x] **Step 1: wan.sh reconnect phase**
206
207 Attached over real ssh via, kill the local ssh child N=10 times in a loop; each iteration: assert resumption (marker echoes), record time-to-first-frame after respawn, assert daemon snapshots counter unchanged across all 10 (all delta-served). Print per-iteration and summary numbers labeled like the M6 phases. Gate: all 10 resumed hands-off AND delta-served. (Resume wall-clock will be dominated by ssh channel-open exactly as M6 measured for reattach — print it labeled, don't gate on it; the M6 ruling comment in wan.sh applies and should be referenced.)
208
209 - [x] **Step 2: Run it against the sandbox** (`MUX_WAN_SSH`/`MUX_WAN_SCP`/`MUX_WAN_HOST` as in mux-wan-box memory / wan.sh header comment), record the numbers.
210
211 - [x] **Step 3: decisions.md M7 section**
212
213 Record: kill criterion + measured result; the three policy decisions from the plan header (dropped input, silent epoch-crossing, refusal grace); `session_epoch` graduating from parsed-but-unused; `mux HOST` sugar; what stays banked (QUIC/TLS → M8 next per user, prediction after).
214
215 - [x] **Step 4: Commit** `feat(m7): reconnect held over the WAN — 10 tears, 10 delta resumes; the record`
216
217 ---
218
219 ## Kill criterion (M7)
220
221 Over the real WAN box: 10 consecutive transport kills against a live session, every one resumed with zero manual action, every one served by delta (daemon snapshots counter unchanged from first attach to last resume), replica converged after the last (client render matches `muxd dump`). If resume must fall back to snapshots on an idle session, or any tear needs a human, the milestone fails.
222
223 ## Explicitly out of scope
224
225 - QUIC/TLS transport (M8, next per user direction)
226 - Prediction/local echo (after M8)
227 - Queuing input typed while disconnected (recorded policy: dropped)
228 - Reconnect *across daemon identities* doing anything smarter than a fresh snapshot (new epoch = new session, by design)
229 - systemd socket-activation packaging for the VMs (listenFdFromSystemd exists; an install doc/unit file is deployment polish, bank it)
docs/superpowers/plans/2026-08-08-m8-quic.md
Old New
@@ -1,93 +0,0 @@
1 # M8: QUIC Transport 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:** Attack the connection-setup cost M6/M7 measured (ssh channel-open ≈ 2.3×RTT, the largest real term in every reconnect) with a direct, encrypted, PSK-authenticated QUIC transport — while the wire protocol stays byte-identical, testing the "transport is a swap" thesis a third time.
6
7 **Architecture:** A third transport arm. Server: `muxd run --quic HOST:PORT --key FILE` adds one UDP fd to the existing poll loop; each authenticated QUIC connection carries ONE bidirectional stream speaking the existing frame protocol, presenting to the daemon as an ordinary client slot. Client: `Transport.open` grows a quic variant (`mux quic://host:port --key FILE`); M7's reconnect loop is unchanged and becomes the second line of defense — QUIC connection migration absorbs pure IP-change roaming before a tear is even visible, and TLS session resumption (0-RTT where the stack supports it) makes the tears that do happen cheap.
8
9 **Driving decisions (vetoable, recorded in decisions.md at Task 5):**
10 - **PSK, not certificates.** The deploy model is "scp a static binary to a VM you own"; scp a key beside it. TLS 1.3 external-PSK gives mutual auth by possession, no CA/TOFU plumbing, and resumption/0-RTT nearly for free. Cert/TOFU model: banked.
11 - **ssh stays the default transport.** QUIC is opt-in per invocation. `mux HOST` sugar keeps meaning ssh.
12 - **Zero protocol changes.** Frames, attach/prefix layouts, seq/epoch semantics untouched. QUIC datagrams for input: banked.
13 - **Spike-gated.** No mature pure-Zig QUIC exists; a C stack must prove it builds under the PINNED Zig 0.15.2 + x86_64-linux-musl static cross-compile before Tasks 2–5 are planned in detail. If the spike fails its kill criterion, the milestone re-scopes (TCP+TLS-PSK fallback or defer) — that is a controller decision, not an implementer improvisation.
14
15 **Tech Stack:** Zig 0.15.2 (pinned; `make test`/`make e2e`/`make bench` only), vendored C QUIC stack chosen by the Task 1 spike — candidates in order: (a) ngtcp2 + wolfSSL (both C, musl-friendly, officially integrated pair, bring-your-own-event-loop), (b) picoquic + picotls (small, C, fd-drivable).
16
17 ---
18
19 ### Task 0: Immediate first reconnect attempt (banked M7 win, transport-agnostic)
20
21 **Files:**
22 - Modify: `src/client.zig` (`reconnect()`)
23 - Modify: `test/wan.sh` (expectation comment only)
24
25 M7 measured resume ≈ 255ms of which ~200ms is our own first backoff. A transport that died a millisecond ago is overwhelmingly likely to accept a new connection now (daemon restarts, link blips, proxy kills all reconnect instantly); a genuinely down link costs one wasted attempt. Standard shape: first attempt immediate, THEN 200ms→2s exponential backoff.
26
27 - [x] **Step 1: Restructure `reconnect()`** so the first `Transport.open` attempt happens before any sleep. The Ctrl-\ drain must still run during every wait (no abort regression), and the reviewer's single-ownership-per-iteration structure from f314b1b must survive — the immediate attempt is iteration zero with a zero-length wait, not a special-cased second code path. Comment records the M7 measurement that justified it and the flapping-link reasoning for why backoff still follows.
28 - [x] **Step 2: Keep the unit tests honest** — drainStdinForQuit tests are timing-parameterized and should not change; if any test asserted the pre-sleep, update it to assert the new shape (first attempt immediate) rather than deleting it.
29 - [x] **Step 3: Gates** (`make test`, `make e2e` — reconnect scenarios in e2e get faster, must stay green 3x — `make bench`).
30 - [x] **Step 4: Run the wan.sh reconnect phase once against the box**: expected med drops from ~255ms to ≈55ms (36ms channel + ~18ms protocol). Gate nothing new; record the number for the Task 5 record.
31 - [x] **Step 5: Commit** `perf: first reconnect attempt is immediate — the 200ms was our own`
32
33 ---
34
35 ### Task 1: SPIKE — a QUIC stack that our toolchain can actually ship (kill-gated)
36
37 **Files:**
38 - Create: `spike/quic/` (throwaway location; NOT wired into `make test`/`make build`; a README in the directory says it is a spike and what decided)
39 - Possibly create: `build.zig` additions guarded so the main build is untouched
40
41 The whole milestone stands on one question: can a C QUIC stack be vendored and built by `zig build` (addCSourceFiles preferred; a pinned-script-built static lib acceptable as fallback) for BOTH native and `x86_64-linux-musl` static, under Zig 0.15.2, with TLS 1.3 external-PSK and an event-loop-free (fd-drivable) API?
42
43 - [x] **Step 1: Candidate (a) — ngtcp2 + wolfSSL.** Vendor pinned source snapshots. Get a minimal QUIC echo pair (server + client binaries) building native, then musl-static. PSK mode, single bidi stream, blocking-ish fd loop is fine for the spike.
44 - [x] **Step 2: If (a) fails on toolchain grounds, candidate (b) — picoquic + picotls.** Same bar. Record precisely WHY (a) failed (the failure reasons are the spike's product as much as the success).
45 - [x] **Step 3: Prove the winner on the box.** scp the static echo pair; measure over the real WAN: (i) cold handshake to first echoed byte, (ii) resumed handshake (session ticket / 0-RTT if the stack exposes it) to first echoed byte. Compare against the same run's ssh viafloor. Box hygiene rules as always: TAG-unique paths and ports, UDP port freed afterward, nothing of the user's touched.
46 - [x] **Step 4: Integration assessment**, written down: how the stack wants fds/timers driven, what the daemon poll-loop integration looks like (one UDP fd + a timer fd?), PSK API shape, stream-data API shape, static binary size delta.
47 - [x] **Step 5: Commit** `spike: QUIC stack chosen — <name> builds static-musl under the pinned toolchain` (spike directory + findings; main build untouched, gates green by construction)
48
49 **Spike kill criterion:** a static-musl QUIC echo pair working over the WAN box, from sources vendored and built reproducibly by our pinned toolchain, within TWO candidate stacks' worth of honest effort. Neither builds → report BLOCKED with the failure evidence; the controller re-scopes the milestone. Do not reach for a third candidate or a prebuilt blob without a controller decision.
50
51 ---
52
53 ## CHECKPOINT — re-planned 2026-08-08 after the spike (e4343a5 + 40fb69d)
54
55 Tasks 2–5 below are now concrete, written against the corrected spike README (`spike/quic/README.md`), which is companion spec: its Integration Assessment section (fd model, flow control, expiry folding, Retry, PSK/stream APIs) and Gotchas section (UBSan/static-musl, AES-ECB, ENABLE_LIB_ONLY, keylog ban) bind Tasks 2–3. Environment change since the plan was written: the ssh-jump sandbox no longer exists (always flagged ephemeral); the validation target is the **LAN box** — `ubuntu@192.168.0.109`, inbound UDP verified, tc + sudo — with netem supplying WAN-realistic RTT over a real network path, labelled emulated-RTT-real-topology.
56
57 ### Task 2: daemon QUIC listener
58
59 Three commits, in this order, because the first must be provably inert:
60
61 **2a — the sink indirection (zero behavior change).** The daemon is fd-centric: `clients[i].fd` feeds the poll array, and `queueFrame`/`flushClient`/`drainPending` reach `std.posix.send` on it; `serviceClient` reads it. A QUIC client shares ONE UDP fd with every peer and is identified by connection ID, so "client i" needs a send/recv seam first: a small tagged union on ClientSlot (`.socket: fd_t` / `.quic: *QuicClient` later) with write-through helpers at the three or four `.fd` call sites, and an inbound path that lets bytes be *injected* into a slot's frame handling rather than only read from its fd. Socket clients must be byte-identical in behavior — existing gates are the proof, and the commit contains NO QUIC code. Commit: "refactor: client slots write through a sink — the fd was an assumption".
62
63 **2b — vendored deps in the main build.** Promote the spike's pinned fetch+build into `deps/quic/` (same pinned versions + sha256s, `WOLFSSL_KEYLOG_EXPORT=no` — the README's keylog ban is binding), producing static libs `build.zig` links for native and musl targets. `make build`/`make test` trigger the dep build when absent (network on first build; document). addCSourceFiles stays banked — record why in the commit (wolfSSL's configure-generated headers make script-built libs the honest v1). The lesson that survives from gotcha 3: library sources only, no examples. Commit: "build: QUIC deps vendored and pinned — the spike's script grows up".
64
65 **2c — the listener.** New `src/quic_server.zig` importing std + the C stack ONLY (proxy.zig discipline: zero frame-protocol knowledge — it moves opaque bytes between QUIC streams and slot sinks). Owns: the UDP fd; the conn table keyed by DCID; wolfSSL server ctx with external-PSK callback (mind the `const char **ciphersuite` out-param trap, README); **Retry/token address validation before conn creation** (amplification requirement); **flow-control extends on every consume** (`extend_max_stream_offset` + `extend_max_offset` — omission is a silent freeze in seconds, README's requirement #1); egress drain after every event; `get_expiry` folded to a MINIMUM across all conns feeding the existing `pumpOnce` timeout. Server wiring: `muxd run --quic HOST:PORT --key FILE` (both-or-neither; key = 32 raw or 64 hex bytes, refuse world/group-readable like ssh, refuse missing); an authenticated stream binds to a client slot exactly where `acceptConn` does. Keepalive + idle timeout flags with test-tunable values (`--quic-idle-ms`, default ~15000) — reconnect scenarios need fast, deterministic death detection. Tests: key-file loading (perms/format/absence, mutation-checked); an in-process loopback pair (two UDP sockets, real handshake, one frame round trip through a slot sink) proving PSK auth + wrong-key refusal + flow-control extends under a payload larger than the initial window (the freeze-shaped bug must have a test that would freeze). Commit: "feat: muxd speaks QUIC — one UDP fd, PSK, and the protocol never noticed".
66
67 > **Amendment 2026-08-08 (during Task 3):** 0-RTT is unavailable in this stack and is dropped from Task 3 scope, on evidence: wolfSSL's cmake build (the spike's deliberate choice for `zig cc`) exposes no early-data option — it exists only as an autotools flag, default off; the compiled libs export zero `*early_data*` symbols; forcing the define changes struct layout against the installed headers (silent ABI mismatch); and external-PSK auth has no session ticket to carry `max_early_data_size`, so 0-RTT would require adopting ticket-based resumption — a design change, not a flag. Cost assessment: ext-PSK already handshakes in 1-RTT, so kill-criterion legs 2 and 3 remain reachable without amendment; what is lost is margin, not the leg. Task 5 measures and records 1-RTT reconnect numbers. Banked: autotools-built wolfSSL with `--enable-earlydata` + ticket resumption atop the external PSK.
68
69 ### Task 3: client QUIC transport
70
71 `mux quic://host:port [--key FILE]` (parseArgs grows one arm, conflict-by-counting; `MUX_KEY_FILE` env fallback). Client mirror of 2a first: `session()` reads `transport.conn.r` via `readFrame` — for QUIC the UDP fd is the *pollable* thing but frame bytes come out of the stream layer, so Conn needs the same seam (poll on fd; read frames through a transport pump). Transport.open third variant; `close()` idempotence discipline per f314b1b (sentinel, whole-value overwrite on reopen). Resumption: session tickets cached in-process; reconnect attempts 0-RTT early data carrying the attach frame where the stack permits, falls back to 1-RTT on rejection — **PSK is already 1-RTT, so 0-RTT is the only remaining reconnect win (spike finding); measure both, claim only what lands.** Death detection: PTO/idle timeout tuned by the same flags so the reconnect loop (unchanged) sees a dead transport in ~1–3s in tests. Commit: "feat: mux quic://host — the transport swap, third pass".
72
73 ### Task 4: e2e over QUIC
74
75 Loopback scenarios mirroring the suite's structure and rules (counter assertions — markers are blind to resume kind; no pattern kills; daemons reused where scenarios allow): attach/echo/detach/reattach over `quic://127.0.0.1`; wrong-key refused loudly (audible exit, no retry loop — never-established gate covers it); **both resume kinds proven over QUIC**: daemon kill -9 + restart → snapshot resume under new epoch (stale-socket analog: stateless reset / fresh CIDs), and a live-daemon tear → delta resume with snapshots-counter-flat (tear mechanism: SIGSTOP the daemon past the tuned PTO so the client declares death and reconnects, SIGCONT — deterministic with short test timeouts; if that proves flaky, iptables-free alternatives first, root-dependent ones never in local e2e). Suite runtime watched; reuse daemons. Commit: "test: e2e over QUIC — same semantics, same counters, new transport".
76
77 ### Task 5: LAN-box criterion + the record
78
79 wan.sh gains a `quic` phase targeting the LAN box (env-driven like everything else; netem on the box's real interface for 16.5ms-class RTT, labelled emulated-RTT-real-topology): cold attach, echo, and the 10-tear reconnect with counters — tears via a brief `sudo nft`/iptables UDP drop on the box (a REAL network tear, which the ssh sandbox could never give us) — against `ssh`-via numbers to the same box in the same run. Kill-criterion legs measured here. decisions.md M8 section: spike findings incl. the PSK-is-already-1-RTT correction, the four driving decisions, the NAT finding and the sandbox's death (M7 WAN numbers now historical — say so), kill verdict with numbers, banked list carried forward + keylog ban as a standing build rule. README: QUIC quickstart (generate key, scp binary+key, `muxd run --quic`, `mux quic://…`). Deploy to the LAN box. Commit: "feat(m8): QUIC held on a real path — <verdict>; the record".
80
81 ---
82
83 ## Kill criterion (M8)
84
85 Over the real WAN box, single static-musl binaries both sides: (1) QUIC attach, echo, detach, reattach, and 10-tear reconnect all hold with every resume delta-served — M6/M7 semantics intact over the new transport; (2) median tear-to-usable over QUIC ≤ 2.5×RTT and strictly below the same run's ssh-via median (the channel-open floor is the thing being deleted); (3) cold QUIC attach ≤ ssh-via attach from the same run; (4) `git diff` on src/protocol.zig is empty across the whole milestone — the swap thesis, third pass. Any leg failing → the record says so and why.
86
87 ## Explicitly out of scope
88
89 - Certificates, TOFU, any CA story (banked; PSK is the model this milestone)
90 - NAT traversal / hole punching (VMs have reachable addresses; banked)
91 - QUIC datagrams for the input path, multiplexing multiple sessions per connection (banked)
92 - Replacing ssh as default; `mux HOST` semantics unchanged
93 - Prediction (next milestone candidate after M8)
docs/superpowers/plans/2026-08-08-m9-prediction.md
Old New
@@ -1,206 +0,0 @@
1 # M9 — Prediction (speculative local echo) Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** The client paints predicted echo for printable keystrokes immediately and reconciles against the daemon's authoritative deltas — echo latency stops being a function of RTT wherever prediction applies, and is *provably absent* wherever it must not apply.
6
7 **Architecture:** Prediction is an **overlay** on the client's replica, never fed into it. The replica keeps tracking exactly what the daemon said; a small queue of per-cell predictions is painted on top (underlined) and reconciled cell-by-cell as deltas land. Predictability is decided by a three-way policy fed by real evidence: the daemon ships the pty's termios mode bits (a new additive frame), and in raw mode a confidence state machine earns/loses the right to *display* by observing whether recent predictions confirmed. Scope per handoff §5: conservative — printable ASCII only, no Enter, no backspace (banked), instant fallback on anything unpredictable.
8
9 **Tech stack:** Zig 0.15.2 (pinned; `make build/test/e2e/bench` only), ghostty-vt replica already on both ends, existing seq/epoch delta machinery. New protocol frame `pty_mode = 0x88` (first protocol.zig change since M5 — allowed: leg 4's meaning is "no *transport* may need a protocol change"; this is a feature).
10
11 **Standing hazards (from decisions.md, read before Task 3):**
12 - Speculative buffers held across callbacks are the habitat of the UAF-that-never-crashes (M8 egress/teardown records). The overlay owns its memory outright; it never holds pointers into frame payloads or engine rows.
13 - No `pkill -f`/`pgrep -f` anywhere in tests (five sightings; select by comm+pid).
14 - Mutation checks mutate a VALUE, run `make build` before direct e2e (stale-binary trap), every refusal path under `timeout`.
15 - Counter assertions wherever behavior kind matters — markers are blind.
16
17 ---
18
19 ## Kill criterion (floor form — this section discharges the banked "RTT-multiple thresholds need rewriting" pre-req)
20
21 Thresholds are stated in absolute milliseconds with an explicit validity floor, never as RTT multiples. Each leg names the minimum RTT at which it is meaningful; below that RTT the leg is vacuously satisfied and MUST be reported as "not exercised", not "passed".
22
23 1. **Latency (meaningful at RTT ≥ 50ms).** On the LAN box over the real path with ≥150ms emulated RTT: median keystroke→painted-glyph for *predicted* input ≤ 30ms at the client (that is: paint cost plus overlay bookkeeping — independent of RTT by construction, and the number proves it). Control in the same run: *unpredicted* input (echo-off context) measures ≈ RTT, proving the harness measures the path and not the hardware.
24 2. **Convergence (meaningful at any RTT).** After every adversarial burst (typing across mode transitions, Escape storms, resize during pending predictions), the client grid converges byte-identical to `muxd dump`, and the client's `contradicted` counter is matched by zero surviving overlay cells — a contradicted prediction never outlives the next authoritative frame.
25 3. **Safety (meaningful at any RTT).** Zero predictions *displayed* in echo-off canonical contexts (`read -s` typing: displayed counter stays 0 while made counter may also be 0 — both asserted). In raw mode, demotion within one contradicted keystroke: after a normal-mode keystroke contradicts, displayed predictions stop until re-promotion (counter deltas asserted, not inferred from pixels).
26 4. **Fallback completeness (meaningful at any RTT).** Multi-byte input, cursor at last column, scroll mode, pending resize, unknown mode bits → prediction suppressed (suppressed counter increments; grid correctness unaffected).
27
28 ---
29
30 ## File structure
31
32 - Create: `src/predict.zig` — overlay + policy state machine, engine-free (testable without a daemon).
33 - Modify: `src/protocol.zig` — `pty_mode = 0x88` frame + golden-byte tests.
34 - Modify: `src/server.zig` — poll `tcgetattr` on the pty each `pumpOnce` (`server.zig:498`), broadcast `pty_mode` on change and on attach-ack path.
35 - Modify: `src/pty.zig` — expose the master fd termios read if not already public.
36 - Modify: `src/client.zig` — wire overlay into `session()` (frame dispatch at `client.zig:662-757`, stdin path, paint path `renderClipped`/`paintDeltaClipped`), counters, `MUX_PREDICT_STATS=1` dump-on-exit to stderr.
37 - Create: `test/rawmode.zig` (built as a test helper binary) — deterministic raw-mode program: phase 1 echoes like insert mode, phase 2 (after receiving `0x00`) swallows input like normal mode. Removes nvim from the automated loop; nvim stays as the manual demo.
38 - Create: `test/delaypipe.zig` (test helper) — stdio pump adding a fixed per-direction delay (`DELAY_MS` env), so e2e exercises prediction visibility without root/netem via `mux --via`.
39 - Modify: `test/e2e.sh` — prediction scenarios (below).
40 - Modify: `test/wan.sh` — `cmd_predict` phase for the LAN box measurement.
41 - Modify: `docs/decisions.md`, `docs/roadmap.md`, `README.md` — at close.
42
43 ---
44
45 ## Task 1: `pty_mode` frame — protocol + golden bytes
46
47 **Files:** Modify `src/protocol.zig`.
48
49 - [x] **Step 1: Failing test** — golden-byte round-trip in protocol.zig's test block:
50
51 ```zig
52 test "pty_mode frame round-trips and matches golden bytes" {
53 var buf: std.ArrayList(u8) = .empty;
54 defer buf.deinit(std.testing.allocator);
55 try appendFrame(std.testing.allocator, &buf, .pty_mode, &.{0b11});
56 // 0x88 type, 4-byte LE len=1, flags byte: bit0 icanon, bit1 echo
57 try std.testing.expectEqualSlices(u8, &.{ 0x88, 0x01, 0x00, 0x00, 0x00, 0b11 }, buf.items);
58 }
59 ```
60
61 - [x] **Step 2: Run** `make test` — expect FAIL (no `.pty_mode`).
62 - [x] **Step 3: Implement** — add to `MsgType`: `pty_mode = 0x88, // payload: 1 byte flags: bit0 icanon, bit1 echo` and a `pub const PtyModeFlags = packed struct(u8) { icanon: bool, echo: bool, _pad: u6 = 0 };` with encode/decode helpers + their own tests (decode refuses wrong payload length).
63 - [x] **Step 4:** `make test` green. **Step 5: Commit** `feat: pty_mode frame — the pty's echo state becomes wire truth`.
64
65 ## Task 2: daemon ships mode bits
66
67 **Files:** Modify `src/server.zig`, `src/pty.zig` (if the master fd isn't already reachable from Server).
68
69 - [x] **Step 1: Failing test** (server test binary, existing in-process harness style): drive a Server with a real pty child running `stty -echo; sleep 5`; pump until the termios change is observable; assert a `pty_mode` frame with `echo=false` arrived at a connected test client, exactly once (no re-send while unchanged). Also assert one `pty_mode` frame arrives immediately after attach (initial state).
70 - [x] **Step 2:** run, expect FAIL. **Step 3: Implement** — in `pumpOnce` after draining pty output: `tcgetattr` the pty master (reflects slave line discipline); compare `(icanon, echo)` to the last-sent pair stored on Server; on change or on new attach, `queueFrame` a `pty_mode` to every attached sink (the Sink seam from M8 means socket/QUIC need no distinction). `tcgetattr` per pump is one cheap syscall on an fd we own; no caching cleverness.
71 - [x] **Step 4:** `make test` green; run the M8 seam test to confirm framing untouched. **Step 5: Commit** `feat: daemon ships pty mode bits — predictability becomes knowledge, not guesswork`.
72
73 ## Task 3: `src/predict.zig` — overlay + policy core
74
75 **Files:** Create `src/predict.zig`; add to build.zig test list (**the recorded hazard: a module absent from the test list is silently never run — add it in this task, not later**).
76
77 Core types (complete, this is the contract later tasks use):
78
79 ```zig
80 pub const Cell = struct { row: u16, col: u16, ch: u8 }; // printable ASCII only in M9
81 pub const Counters = struct { made: u64 = 0, displayed: u64 = 0, confirmed: u64 = 0, contradicted: u64 = 0, suppressed: u64 = 0 };
82 pub const Context = enum { always, never, adaptive }; // icanon&echo, icanon&!echo, !icanon
83 pub const promote_after = 2; // consecutive confirms that earn display in adaptive
84
85 pub const Overlay = struct {
86 pending: std.ArrayList(Pred), // Pred = { cell: Cell, made_seq: u64 }
87 ctx: Context,
88 confident: bool, // adaptive only; .always is born confident, .never never predicts
89 streak: u8,
90 counters: Counters,
91 // API: setMode(flags), predictAt(cursor, ch) ?Cell (null = suppressed),
92 // reconcile(replica: *Engine, applied_seq: u64) — walks pending, compares
93 // replica cell content; confirm retires, contradict flushes ALL pending +
94 // demotes; flush() on snapshot/resync/scroll-mode-entry/resize.
95 // predictedCursor(base: CursorPos) CursorPos — base advanced past pending.
96 };
97 ```
98
99 Decisions pinned here so the implementer doesn't relitigate: contradiction flushes the *whole* queue (mosh's epoch bump — a wrong prediction poisons everything after it); the overlay stores copies, never slices of payloads or engine rows (the standing UAF hazard); `predictAt` refuses: non-printable, last column (wrap is app policy), `ctx == .never`, scroll mode, pending resize.
100
101 - [x] **Step 1: Failing tests** — table-driven over the state machine: `.always` displays from the first keystroke; `.never` makes nothing (made stays 0); `.adaptive` makes-but-hides until `promote_after` consecutive confirms, one contradiction flushes + demotes + re-earns; last-column suppression; reconcile against a real Engine fed a scripted delta (confirm) and a conflicting one (contradict). Mutation-check discipline: each assertion must fail if `promote_after`, the flush-all, or the copy-not-slice is broken — mutate a value to prove it.
102 - [x] **Step 2:** FAIL → **Step 3: implement** → **Step 4:** `make test` green. **Step 5: Commit** `feat: prediction overlay — speculation as a layer, never as state`.
103
104 ## Task 4: client wiring — predict, paint, reconcile, count
105
106 **Files:** Modify `src/client.zig` (`session()` stdin path; frame dispatch `.snapshot`/`.delta`/new `.pty_mode`; paint helpers).
107
108 - [x] **Step 1: Failing test** — client-side unit tests where they fit (predicted-cursor advance across a burst; overlay flush on snapshot), plus e2e stubs marked skip until Task 5's helpers exist.
109 - [x] **Step 3: Implement:**
110 - `.pty_mode` frame → `overlay.setMode` (dispatch block `client.zig:662-757`; the `else => {}` arm means old daemons simply never send it and the overlay stays `.never` — safe default: **no frame, no prediction**).
111 - stdin bytes: for each single printable byte in a predictable context, `overlay.predictAt(predictedCursor(...))`; paint accepted predictions immediately with SGR 4 (underline) at the cell, then restore cursor. Multi-byte/utf8 chunks: suppress (counter), send input unchanged.
112 - after `replica.feed`/delta apply: `overlay.reconcile(&replica, seq)`; repaint cells the reconcile touched (confirm = repaint from replica sans underline; contradict = full `renderClipped` — simplest correct rollback, and cheap because contradictions are rare by construction).
113 - snapshot / scroll-mode entry / resize / reconnect: `overlay.flush()`.
114 - `MUX_PREDICT_STATS=1` → on exit, one machine-greppable stderr line: `predict made=N displayed=N confirmed=N contradicted=N suppressed=N`.
115 - [x] **Step 4:** `make test && make e2e` green (existing suites — prediction adds no regressions before its own e2e lands). **Step 5: Commit** `feat: predicted echo — the keystroke paints before the RTT`.
116
117 ## Task 5: test helpers — `rawmode` + `delaypipe`
118
119 **Files:** Create `test/rawmode.zig`, `test/delaypipe.zig`; build.zig wires both as test-helper binaries (and into the test list).
120
121 - [x] rawmode: raw mode via termios; phase 1 echoes bytes back (insert-like); on byte `0x00` switches to phase 2: consumes bytes, prints nothing (normal-like); exits on `0x03`. Unit test drives it over a pipe pair.
122 - [x] delaypipe: two threads or poll loop pumping stdin→stdout with `DELAY_MS` (env) of buffering per chunk; unit test: a byte written enters no earlier than DELAY_MS and arrives intact. This makes `mux --via "delaypipe-wrapped muxd proxy"` a root-free 2×DELAY_MS RTT.
123 - [x] Commit `test: rawmode and delaypipe — deterministic mode transitions and root-free RTT`.
124
125 ## Task 6: e2e scenarios
126
127 **Files:** Modify `test/e2e.sh` (follow the M8 pattern: counters asserted, refusals under `timeout`, `make build` first).
128
129 - [x] **Line-mode:** attach via delaypipe (DELAY_MS=150); type marker; assert marker painted at client well under the delay (timestamp capture around the type), then `MUX_PREDICT_STATS` shows `confirmed>0 contradicted=0`; final grid == `muxd dump`.
130 - [x] **Password:** session runs `read -s`; type; assert `displayed=0` AND the typed bytes appear nowhere in the client's emitted terminal bytes (capture and grep — effect test, not just counter).
131 - [x] **Adaptive promote/demote:** session runs rawmode helper; type ≥ `promote_after`+2 chars (predictions confirm, displayed grows); send `0x00`, type more (first contradiction: contradicted=1, displayed stops growing); assert convergence to dump afterwards.
132 - [x] **Reconnect flush:** kill transport mid-pending-prediction (comm+pid, never pattern-kill); after resume, overlay empty, grid == dump.
133 - [x] Commit `test: e2e — prediction confirmed, suppressed, demoted, and flushed, by counter`.
134
135 ## Task 7: measurement — wan.sh phase + verdict data
136
137 **Files:** Modify `test/wan.sh` (`cmd_predict`), run against the LAN box (`ubuntu@192.168.0.109`, netem on the interface `ip route get` names — the dual-home trap is recorded).
138
139 - [x] netem 150ms on the box; measure per criterion leg 1 (predicted ≤ 30ms median, echo-off control ≈ RTT); ten adversarial bursts for leg 2 convergence; collect counters. Emit the same `#REP` machine-readable lines wan.sh already uses.
140 - [x] Commit `feat(m9): prediction measured on a real path — the record`.
141
142 ## Task 8: docs + roadmap close
143
144 - [x] decisions.md M9 section (verdict per leg, floor-form criterion recorded as the new standard, banked additions: backspace prediction, underline-only-when-late polish, per-context confidence memory); roadmap.md updated (M9 → done, next candidate per user); README status line. Commit `docs: M9 prediction — the record`.
145
146 ---
147
148 ## Self-review notes
149
150 - Old-daemon/new-client compat is *default-off prediction* (no `pty_mode` frame → `.never`), which is the safe direction; new-daemon/old-client is a frame the old client's `else => {}` drops. No version negotiation needed.
151 - The criterion's leg 1 control (echo-off ≈ RTT) is what keeps the measurement honest on fast hardware — it is the floor-form lesson applied, not decoration.
152 - rawmode helper, not nvim, in CI: nvim's redraw timing is nondeterministic and its presence on a box is not guaranteed; the demo section of the M9 record should still show real nvim by hand.
153
154 ---
155
156 ## As built — where the work differs from the steps above
157
158 Every box above is ticked because every step was genuinely done. Several were
159 done *differently* from how they were written, and the ticks would overclaim
160 without this list. The record is `decisions.md`, M9; this is the diff against
161 the plan.
162
163 - **Task 2, Step 1.** The child is a written-out script driven by the test
164 (`read` / `stty -echo` / `read` / `stty -icanon` / `read`), not
165 `stty -echo; sleep 5`. It also gained a third phase, which the plan did not
166 ask for and which turned out to be load-bearing: with echo already off, the
167 trigger keystroke is not echoed and `stty -icanon` prints nothing, so no
168 byte crosses the pty around that transition. Without it, folding the mode
169 poll into the "pty produced output" arm passed — the echo of the earlier
170 phase's own trigger was arriving at about the right moment.
171 - **Task 3, Step 1.** `predict.zig` stayed engine-free, so the real-Engine
172 reconcile test named here could not live in it. The debt was discharged at
173 the client wiring point (Task 4) instead, where a real Engine is fed real
174 VT bytes and the `prev_ch` read is pinned by a test that fails twice over
175 if it reads the wrong cell.
176 - **Task 3, added after the fact (3b/3c/3d).** Reconcile v2 — judge evidence,
177 not arrival order — replaced the mismatch-is-contradiction rule the plan
178 assumed. Under the original rule a burst typed faster than the round trip
179 refuted itself once per RTT, in every tier. Also: the churn policy (any
180 move in the mode bits flushes and un-earns display), the `abandoned`
181 counter and per-field unit documentation, `expire()` for the
182 quiet-application case the frame bound cannot reach, and the `painted` flag
183 so `displayed` means "reached the screen".
184 - **Task 4, Step 1.** No skipped e2e stubs were written; the real scenarios
185 landed in Task 6 instead. Client-side unit tests were written as planned.
186 - **Task 6, line-mode.** `DELAY_MS` is 300, not 150: the margin between "the
187 prediction is painted" and "the echo could have arrived" is what stops the
188 scenario being a race. `final grid == muxd dump` is asserted as the typed
189 text being present in the daemon's grid, not as a byte-identical
190 comparison — see the convergence narrowing in the M9 record.
191 - **Task 6, password.** The session is a written-out script that is canonical
192 with echo off from its first instruction, rather than `read -s` typed at a
193 prompt: typing the command at a bash prompt generates legitimate
194 predictions on the command line, so `made=0` could not be asserted for the
195 run. `made=0` is asserted as well as `displayed=0`.
196 - **Task 6, burst scenario.** Not in the original plan. Added because it is
197 what pins reconcile v2: regress the three-way judgment and it fails with
198 `confirmed=1` instead of 5.
199 - **Task 7.** The harness emits `#RESULT` lines, which is the convention
200 `wan.sh` actually uses; the plan's `#REP` does not exist. The "echo-off
201 control" is unmeasurable as written — with echo off nothing is painted, so
202 there is no arrival to time — and is built as input prediction *refuses*
203 (a multi-byte chunk), which measures the same claim and can fail.
204 - **Task 6.5, unplanned.** An ngtcp2 re-entrancy defect, found by following a
205 leaked test temp directory to an abort. Not prediction work, and the most
206 consequential thing the milestone produced.
docs/superpowers/plans/2026-08-09-m10-quic-ergonomics.md
Old New
@@ -1,1502 +0,0 @@
1 # M10 QUIC Ergonomics 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:** `muxd keygen` + default key path + default port 4433 + `muxd start` (detached daemonizer) + honest errors + `--version` + systemd remnant deletion, per `docs/superpowers/specs/2026-08-09-m10-quic-ergonomics-design.md`.
6
7 **Architecture:** Two new modules — `src/xdg.zig` (XDG-derived paths + key file creation, pure `*From` variants for tests) and `src/spawn.zig` (`ensureDaemon`: probe/spawn-detached/poll). `muxd` gains `keygen`, `start`, `--version`; `mux` gains `--version`, default key/port resolution; `client.zig` stops lying about `--via` failures; server.zig loses socket activation.
8
9 **Tech Stack:** Zig 0.15.2 (pinned: `~/Downloads/zig-x86_64-linux-0.15.2/zig` via Makefile — build ONLY with `make build` / `make test` / `make e2e`). Linux only. `make test` does NOT rebuild the binaries in `zig-out/bin` — run `make build` before any e2e or manual check.
10
11 **Project rules that bind every task:**
12 - TDD with **mutations written first**: before trusting a new test, break the code the way the task names and confirm the test fails, then restore. A test whose mutation doesn't fire doesn't count (M9: five first-run mutation survivals, all assertions-true-for-other-reasons).
13 - Kill spawned processes **by tracked PID only** — never `pkill`/`pgrep` name matching of any flavor.
14 - Any module gaining tests MUST be in build.zig's test loop (line ~233). A test not in the loop compiles and silently never runs (decisions.md hazard, cost mux_main.zig five invisible tests).
15 - Commit after every task. Do not batch.
16
17 ---
18
19 ## File map
20
21 | File | Role in this plan |
22 |---|---|
23 | `build.zig` | version constant + options module; register `xdg_mod`, `spawn_mod` (create + **add to test loop**) |
24 | `src/xdg.zig` | **create** — key path, log path, `writeNewKey`; pure `*From` variants |
25 | `src/spawn.zig` | **create** — `ensureDaemon` + `Progress` |
26 | `src/main.zig` | `keygen`/`start`/`--version` subcommands; port default in `splitHostPort`; key resolution in `run()` |
27 | `src/mux_main.zig` | `--version`; parse returns key-maybe-null; key resolution in `main()` |
28 | `src/client.zig` | port default in `parseQuicAddr`; honest pre-frame `--via` message |
29 | `src/server.zig` | delete `LISTEN_FDS` path (`listenFdFromSystemd`, `systemd_fd` branch, `owns_sock_file`) |
30 | `test/e2e.sh` | hermetic `XDG_CONFIG_HOME`; scenarios: version, keygen, start (incl. rerun + race), default-key QUIC attach, `--via` honesty |
31 | `contrib/` | **delete** |
32 | `README.md` | QUIC + ssh quick starts rewritten; linger sentence dropped; `KillUserProcesses` caveat |
33
34 ---
35
36 ### Task 1: `--version` on both binaries
37
38 **Files:**
39 - Modify: `build.zig` (top of `build()`, ~line 52, and module wiring ~187–215)
40 - Modify: `src/main.zig` (usage ~10, `Cmd` ~26, `parseArgs` ~61, dispatch ~196)
41 - Modify: `src/mux_main.zig` (usage ~8, `ParseResult` ~25, parse loop ~55, dispatch ~122)
42 - Modify: `test/e2e.sh` (new scenario near the top, after helper definitions)
43
44 - [x] **Step 1: Write the failing parse tests**
45
46 In `src/main.zig`, append to the test section:
47
48 ```zig
49 test "parseArgs: --version is a command, not a flag on one" {
50 const r = parse(&.{ "muxd", "--version" });
51 try std.testing.expect(r == .ok);
52 try std.testing.expect(r.ok.cmd == .version);
53 }
54 ```
55
56 In `src/mux_main.zig`, append:
57
58 ```zig
59 test "parseArgs: --version wins wherever it appears" {
60 try std.testing.expect(parse(&.{ "mux", "--version" }) == .version);
61 try std.testing.expect(parse(&.{ "mux", "--sock", "/x", "--version" }) == .version);
62 }
63 ```
64
65 - [x] **Step 2: Run to verify both fail**
66
67 Run: `make test`
68 Expected: compile errors (`.version` not a member of `Cmd` / `ParseResult`). A compile error in the test is the failing state here.
69
70 - [x] **Step 3: build.zig — version constant and options module**
71
72 At the top of `build()` in `build.zig` (immediately after `pub fn build(b: *std.Build) void {`):
73
74 ```zig
75 // Single source for both binaries' --version. Bumped at tag time.
76 const version = "0.0.1-3";
77 const version_opts = b.addOptions();
78 version_opts.addOption([]const u8, "version", version);
79 ```
80
81 After `exe_mod.addImport("quic", quic_mod);` (~line 198):
82
83 ```zig
84 exe_mod.addImport("build_options", version_opts.createModule());
85 ```
86
87 After `mux_mod.addImport("client", client_mod);` (~line 157):
88
89 ```zig
90 mux_mod.addImport("build_options", version_opts.createModule());
91 ```
92
93 - [x] **Step 4: muxd side**
94
95 `src/main.zig`. Add import after the existing ones (~line 8):
96
97 ```zig
98 const build_options = @import("build_options");
99 ```
100
101 `Cmd` (~line 26) becomes:
102
103 ```zig
104 const Cmd = enum { run, dump, stats, proxy, version };
105 ```
106
107 In `parseArgs` (~line 62), before the subcommand chain:
108
109 ```zig
110 if (std.mem.eql(u8, args[1], "--version")) return .{ .ok = .{ .cmd = .version } };
111 ```
112
113 In `main`'s dispatch (~line 196), add an arm (before `.run` so the sock-path work above it stays; the sock path is unused but harmless):
114
115 ```zig
116 .version => {
117 var vbuf: [64]u8 = undefined;
118 const s = std.fmt.bufPrint(&vbuf, "muxd {s}\n", .{build_options.version}) catch unreachable;
119 _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {};
120 return 0;
121 },
122 ```
123
124 Usage text (~line 10): add a final line before the closing `\\`:
125
126 ```zig
127 \\ muxd --version
128 ```
129
130 - [x] **Step 5: mux side**
131
132 `src/mux_main.zig`. Add import at top:
133
134 ```zig
135 const build_options = @import("build_options");
136 ```
137
138 Add `.version` to `ParseResult` (the union at ~line 25):
139
140 ```zig
141 version,
142 ```
143
144 In the parse loop, as the FIRST branch of the flag chain (before `--sock`):
145
146 ```zig
147 if (std.mem.eql(u8, a, "--version")) {
148 return .version;
149 } else if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) {
150 ```
151
152 (the existing `if (std.mem.eql(u8, a, "--sock")` becomes the `else if`.)
153
154 In `main`'s switch (~line 122):
155
156 ```zig
157 .version => {
158 var vbuf: [64]u8 = undefined;
159 const s = std.fmt.bufPrint(&vbuf, "mux {s}\n", .{build_options.version}) catch unreachable;
160 _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {};
161 return 0;
162 },
163 ```
164
165 Usage (~line 8): append `\\ --version prints the version` styled like the neighbors.
166
167 - [x] **Step 6: Run tests, expect pass**
168
169 Run: `make test`
170 Expected: all pass, including the two new ones.
171
172 - [x] **Step 7: Mutation check**
173
174 In `src/main.zig` change `.version` parse line to return `.{ .err = .no_command }`; run `make test`; the muxd test MUST fail. Restore. Same for mux: make `--version` fall through to `usage_error`; test MUST fail; restore.
175
176 - [x] **Step 8: e2e**
177
178 `make build` first (e2e uses built artifacts). In `test/e2e.sh`, after the helper-function block (~line 60), add:
179
180 ```sh
181 # --- M10: --version answers "did the scp land" without a daemon anywhere.
182 "$MUXD" --version | grep -q '^muxd 0\.' || { echo "e2e FAIL: muxd --version"; exit 1; }
183 "$MUX" --version | grep -q '^mux 0\.' || { echo "e2e FAIL: mux --version"; exit 1; }
184 echo "e2e OK: --version on both binaries"
185 ```
186
187 Run: `make build && make e2e`
188 Expected: `e2e OK: --version on both binaries` among the output; suite passes.
189
190 - [x] **Step 9: Commit**
191
192 ```bash
193 git add build.zig src/main.zig src/mux_main.zig test/e2e.sh
194 git commit -m "feat: --version on both binaries, one source in build.zig"
195 ```
196
197 ---
198
199 ### Task 2: `src/xdg.zig` — paths and key creation
200
201 **Files:**
202 - Create: `src/xdg.zig`
203 - Modify: `build.zig` (module creation ~line 107 region; **test loop ~line 233**; imports for `exe_mod` and `mux_mod`)
204
205 - [x] **Step 1: Create `src/xdg.zig` with tests included**
206
207 ```zig
208 //! XDG-derived paths shared by both binaries, plus key file creation.
209 //!
210 //! The `*From` variants are pure — environment handed in, nothing read —
211 //! because that is what makes them testable without setenv, which Zig
212 //! tests cannot safely do in-process. The un-suffixed wrappers read the
213 //! real environment and are one line each, thin enough to trust by
214 //! inspection.
215 const std = @import("std");
216
217 /// `$XDG_CONFIG_HOME/mux/key`, defaulting to `~/.config/mux/key`.
218 /// The one place the default key location is spelled; muxd keygen writes
219 /// it and both binaries' key resolution reads it.
220 pub fn keyPath(alloc: std.mem.Allocator) ![]const u8 {
221 return keyPathFrom(alloc, std.posix.getenv("XDG_CONFIG_HOME"), std.posix.getenv("HOME"));
222 }
223
224 pub fn keyPathFrom(
225 alloc: std.mem.Allocator,
226 xdg_config_home: ?[]const u8,
227 home: ?[]const u8,
228 ) ![]const u8 {
229 if (xdg_config_home) |d| if (d.len > 0)
230 return std.fmt.allocPrint(alloc, "{s}/mux/key", .{d});
231 const h = home orelse return error.NoHome;
232 return std.fmt.allocPrint(alloc, "{s}/.config/mux/key", .{h});
233 }
234
235 /// `$XDG_STATE_HOME/mux/muxd.log`, defaulting to `~/.local/state/mux/muxd.log`.
236 /// Truncated at each spawn by the spawner: it holds the current daemon's
237 /// stdout+stderr, not history.
238 pub fn logPath(alloc: std.mem.Allocator) ![]const u8 {
239 return logPathFrom(alloc, std.posix.getenv("XDG_STATE_HOME"), std.posix.getenv("HOME"));
240 }
241
242 pub fn logPathFrom(
243 alloc: std.mem.Allocator,
244 xdg_state_home: ?[]const u8,
245 home: ?[]const u8,
246 ) ![]const u8 {
247 if (xdg_state_home) |d| if (d.len > 0)
248 return std.fmt.allocPrint(alloc, "{s}/mux/muxd.log", .{d});
249 const h = home orelse return error.NoHome;
250 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/muxd.log", .{h});
251 }
252
253 /// 32 random bytes at `path`, mode 0600, parent directories created.
254 /// Refuses to overwrite: rotation is `rm` + `keygen`, deliberate on both
255 /// counts, so overwriting silently would delete a credential.
256 pub fn writeNewKey(path: []const u8) !void {
257 if (std.fs.path.dirname(path)) |dir| try std.fs.cwd().makePath(dir);
258 const f = std.fs.cwd().createFile(path, .{
259 .exclusive = true,
260 .mode = 0o600,
261 }) catch |err| switch (err) {
262 error.PathAlreadyExists => return error.KeyExists,
263 else => |e| return e,
264 };
265 defer f.close();
266 var key: [32]u8 = undefined;
267 std.crypto.random.bytes(&key);
268 try f.writeAll(&key);
269 }
270
271 test "keyPathFrom: XDG_CONFIG_HOME wins, HOME is the fallback, empty is unset" {
272 const a = std.testing.allocator;
273 const explicit = try keyPathFrom(a, "/tmp/cfg", "/home/u");
274 defer a.free(explicit);
275 try std.testing.expectEqualStrings("/tmp/cfg/mux/key", explicit);
276
277 const fallback = try keyPathFrom(a, null, "/home/u");
278 defer a.free(fallback);
279 try std.testing.expectEqualStrings("/home/u/.config/mux/key", fallback);
280
281 // Empty XDG var means unset, per the basedir spec.
282 const empty = try keyPathFrom(a, "", "/home/u");
283 defer a.free(empty);
284 try std.testing.expectEqualStrings("/home/u/.config/mux/key", empty);
285
286 try std.testing.expectError(error.NoHome, keyPathFrom(a, null, null));
287 }
288
289 test "logPathFrom: same shape against XDG_STATE_HOME" {
290 const a = std.testing.allocator;
291 const explicit = try logPathFrom(a, "/tmp/state", "/home/u");
292 defer a.free(explicit);
293 try std.testing.expectEqualStrings("/tmp/state/mux/muxd.log", explicit);
294
295 const fallback = try logPathFrom(a, null, "/home/u");
296 defer a.free(fallback);
297 try std.testing.expectEqualStrings("/home/u/.local/state/mux/muxd.log", fallback);
298 }
299
300 test "writeNewKey: creates 0600 with 32 bytes, refuses to overwrite" {
301 const testtmp = @import("testtmp");
302 var tmp = try testtmp.TmpDir.make();
303 defer tmp.cleanup();
304
305 var buf: [128]u8 = undefined;
306 const path = try std.fmt.bufPrint(&buf, "{s}/sub/key", .{tmp.path()});
307
308 try writeNewKey(path);
309
310 const st = try std.fs.cwd().statFile(path);
311 try std.testing.expectEqual(@as(u64, 32), st.size);
312 // mode() carries type bits; mask to permissions.
313 const f = try std.fs.cwd().openFile(path, .{});
314 defer f.close();
315 const fst = try f.stat();
316 try std.testing.expectEqual(@as(u32, 0o600), @as(u32, @intCast(fst.mode & 0o777)));
317
318 var first: [32]u8 = undefined;
319 try std.testing.expectEqual(@as(usize, 32), try f.preadAll(&first, 0));
320
321 // Refusal leaves the file byte-identical: a credential is never
322 // silently replaced.
323 try std.testing.expectError(error.KeyExists, writeNewKey(path));
324 var second: [32]u8 = undefined;
325 try std.testing.expectEqual(@as(usize, 32), try f.preadAll(&second, 0));
326 try std.testing.expectEqualSlices(u8, &first, &second);
327 }
328 ```
329
330 - [x] **Step 2: Wire the module in build.zig**
331
332 After the `testtmp_mod` block (~line 111):
333
334 ```zig
335 // XDG-derived paths (key file, daemon log), shared by both binaries.
336 const xdg_mod = b.createModule(.{
337 .root_source_file = b.path("src/xdg.zig"),
338 .target = target,
339 .optimize = optimize,
340 .link_libc = true,
341 });
342 xdg_mod.addImport("testtmp", testtmp_mod);
343 ```
344
345 Add imports:
346
347 ```zig
348 exe_mod.addImport("xdg", xdg_mod); // after exe_mod's other addImport lines
349 mux_mod.addImport("xdg", xdg_mod); // after mux_mod.addImport("client", ...)
350 ```
351
352 **Add `xdg_mod` to the test loop array at ~line 233** (append before the closing `}`) — a module absent from that loop has tests that compile and never run:
353
354 ```zig
355 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod }) |mod| {
356 ```
357
358 - [x] **Step 3: Run tests, expect the three new tests to run and pass**
359
360 Run: `make test 2>&1 | tail -20`
361 Expected: pass. Confirm the tests actually ran: temporarily change `"{s}/mux/key"` to `"{s}/mux/kee"`; `make test` MUST fail in `keyPathFrom`. Restore. (This is the test-loop-omission mutation — it proves the loop registration, not just the test.)
362
363 - [x] **Step 4: Commit**
364
365 ```bash
366 git add src/xdg.zig build.zig
367 git commit -m "feat: xdg module — default key/log paths and key creation"
368 ```
369
370 ---
371
372 ### Task 3: `muxd keygen`
373
374 **Files:**
375 - Modify: `src/main.zig` (usage, `Cmd`, `parseArgs`, dispatch; new `keygen` fn; round-trip test)
376 - Modify: `test/e2e.sh`
377
378 - [x] **Step 1: Write the failing round-trip test**
379
380 `src/main.zig`, test section — the generated key must satisfy the loader the daemon actually uses:
381
382 ```zig
383 test "keygen: a generated key loads through quic.Key.load" {
384 const testtmp = @import("testtmp");
385 const xdg = @import("xdg");
386 var tmp = try testtmp.TmpDir.make();
387 defer tmp.cleanup();
388
389 var buf: [128]u8 = undefined;
390 const path = try std.fmt.bufPrint(&buf, "{s}/key", .{tmp.path()});
391 try xdg.writeNewKey(path);
392 _ = try quic.Key.load(path);
393 }
394 ```
395
396 - [x] **Step 2: Run to verify state**
397
398 Run: `make test`
399 Expected: this test PASSES already (writeNewKey exists from Task 2) — it is a regression pin, not a red test; the red step for this task is the parse test below. Add it now:
400
401 ```zig
402 test "parseArgs: keygen takes no flags" {
403 const r = parse(&.{ "muxd", "keygen" });
404 try std.testing.expect(r == .ok);
405 try std.testing.expect(r.ok.cmd == .keygen);
406 try std.testing.expect(parse(&.{ "muxd", "keygen", "--sock", "/x" }).err == .unknown_arg);
407 }
408 ```
409
410 Run: `make test` — compile error on `.keygen` (the failing state).
411
412 - [x] **Step 3: Implement**
413
414 `src/main.zig`. Import at top: `const xdg = @import("xdg");`
415
416 `Cmd`: add `keygen`:
417
418 ```zig
419 const Cmd = enum { run, dump, stats, proxy, version, keygen };
420 ```
421
422 `parseArgs`, in the subcommand chain:
423
424 ```zig
425 else if (std.mem.eql(u8, args[1], "keygen"))
426 .keygen
427 ```
428
429 Immediately after the `cmd` is resolved (before the flag loop):
430
431 ```zig
432 // keygen configures nothing: its one output is the default path, and a
433 // flag here would be a request this command cannot honor.
434 if (cmd == .keygen and args.len > 2)
435 return .{ .err = .{ .unknown_arg = args[2] } };
436 ```
437
438 Dispatch arm in `main`:
439
440 ```zig
441 .keygen => return keygen(alloc),
442 ```
443
444 New function after `stats()`:
445
446 ```zig
447 fn keygen(alloc: std.mem.Allocator) !u8 {
448 const path = try xdg.keyPath(alloc);
449 defer alloc.free(path);
450 xdg.writeNewKey(path) catch |err| switch (err) {
451 error.KeyExists => {
452 std.debug.print(
453 "muxd keygen: {s} already exists; rotation is `rm` + `keygen`, deliberately\n",
454 .{path},
455 );
456 return 1;
457 },
458 else => |e| return e,
459 };
460 var buf: [std.fs.max_path_bytes + 1]u8 = undefined;
461 const line = std.fmt.bufPrint(&buf, "{s}\n", .{path}) catch unreachable;
462 _ = std.posix.write(std.posix.STDOUT_FILENO, line) catch {};
463 return 0;
464 }
465 ```
466
467 Usage text: add `\\ muxd keygen (write a fresh key to ~/.config/mux/key)`.
468
469 - [x] **Step 4: Run tests, expect pass**
470
471 Run: `make test`
472 Expected: pass.
473
474 - [x] **Step 5: Mutation check (keygen refusal)**
475
476 In `xdg.writeNewKey`, change `.exclusive = true` to `.exclusive = false` and the `error.PathAlreadyExists` arm to unreachable dead code (createFile will no longer produce it). Run `make test`: the `writeNewKey` refusal test from Task 2 MUST fail. Restore.
477
478 - [x] **Step 6: e2e — hermetic XDG plus keygen scenario**
479
480 `test/e2e.sh`: near the top, after `OUT=` (~line 13), add the hermetic config/state homes (WITHOUT this, the suite would read the developer's real `~/.config/mux/key` once Task 5 lands, and "no key configured" scenarios would silently become "key found"):
481
482 ```sh
483 # M10: hermetic XDG homes. Key-default scenarios must see OUR key or none,
484 # never the developer's real ~/.config/mux/key.
485 XDG_CONFIG_HOME="${TMPDIR:-/tmp}/mux-e2e-cfg-$$"
486 XDG_STATE_HOME="${TMPDIR:-/tmp}/mux-e2e-state-$$"
487 export XDG_CONFIG_HOME XDG_STATE_HOME
488 ```
489
490 Add `rm -rf "$XDG_CONFIG_HOME" "$XDG_STATE_HOME"` to the existing cleanup trap.
491
492 Scenario (after the `--version` block):
493
494 ```sh
495 # --- M10: keygen writes 0600, prints the path, refuses a second run.
496 KEYOUT=$("$MUXD" keygen)
497 [ "$KEYOUT" = "$XDG_CONFIG_HOME/mux/key" ] || {
498 echo "e2e FAIL: keygen printed '$KEYOUT'"; exit 1; }
499 PERMS=$(stat -c %a "$KEYOUT")
500 [ "$PERMS" = "600" ] || { echo "e2e FAIL: keygen perms $PERMS, want 600"; exit 1; }
501 SUM1=$(sha256sum "$KEYOUT")
502 if "$MUXD" keygen > /dev/null 2>&1; then
503 echo "e2e FAIL: second keygen did not refuse"; exit 1
504 fi
505 SUM2=$(sha256sum "$KEYOUT")
506 [ "$SUM1" = "$SUM2" ] || { echo "e2e FAIL: refused keygen still changed the key"; exit 1; }
507 echo "e2e OK: keygen creates once, 0600, refuses twice"
508 ```
509
510 Run: `make build && make e2e`
511 Expected: new OK line; suite passes.
512
513 - [x] **Step 7: Commit**
514
515 ```bash
516 git add src/main.zig test/e2e.sh
517 git commit -m "feat: muxd keygen — one command replaces the /dev/urandom incantation"
518 ```
519
520 ---
521
522 ### Task 4: default port 4433, both parsers
523
524 **Files:**
525 - Modify: `src/quic_server.zig` (one pub const — find the top-level decls near `pub const Key`)
526 - Modify: `src/quic_client.zig` (re-export)
527 - Modify: `src/main.zig:151-166` (`splitHostPort`) and its tests (~447–470)
528 - Modify: `src/client.zig:329-351` (`parseQuicAddr`) + new test
529 - Modify: `src/mux_main.zig` usage; `test/e2e.sh:384`
530
531 - [x] **Step 1: Write the failing tests**
532
533 `src/main.zig`, extend the existing `splitHostPort` test — **replace** the two lines currently expecting errors for portless forms (~460 and ~464):
534
535 ```zig
536 // was: expectError(error.MalformedAddress, splitHostPort("127.0.0.1"));
537 // was: expectError(error.MalformedAddress, splitHostPort("[::1]"));
538 ```
539
540 with:
541
542 ```zig
543 // No port names the default. 4433 is mux's convention; an explicit
544 // port always wins.
545 const dflt = try splitHostPort("127.0.0.1");
546 try std.testing.expectEqualStrings("127.0.0.1", dflt.host);
547 try std.testing.expectEqual(quic.default_port, dflt.port);
548
549 const dflt6 = try splitHostPort("[::1]");
550 try std.testing.expectEqualStrings("::1", dflt6.host);
551 try std.testing.expectEqual(quic.default_port, dflt6.port);
552 ```
553
554 Keep every other error expectation exactly as it is — `"127.0.0.1:"` (empty port) stays refused, unbracketed IPv6 stays refused.
555
556 `src/client.zig`, new test at the bottom:
557
558 ```zig
559 test "parseQuicAddr: no port means 4433, explicit port wins" {
560 const d = try parseQuicAddr("127.0.0.1");
561 try std.testing.expectEqual(quic_client.default_port, d.getPort());
562 const e = try parseQuicAddr("127.0.0.1:9");
563 try std.testing.expectEqual(@as(u16, 9), e.getPort());
564 const b = try parseQuicAddr("[::1]");
565 try std.testing.expectEqual(quic_client.default_port, b.getPort());
566 // Unbracketed IPv6 stays ambiguous and refused, with or without ports.
567 try std.testing.expectError(error.MalformedAddress, parseQuicAddr("fe80::1:4433"));
568 }
569 ```
570
571 - [x] **Step 2: Run to verify both fail**
572
573 Run: `make test`
574 Expected: compile error (`default_port` not found) — the failing state.
575
576 - [x] **Step 3: Implement**
577
578 `src/quic_server.zig`, near the other top-level pub decls:
579
580 ```zig
581 /// mux's conventional QUIC port. Both parsers reach for it when the user
582 /// names no port; it lives here because this module is the one thing both
583 /// binaries already import.
584 pub const default_port: u16 = 4433;
585 ```
586
587 `src/quic_client.zig`, near its top-level decls:
588
589 ```zig
590 pub const default_port = quic.default_port;
591 ```
592
593 `src/main.zig` `splitHostPort` (~151) becomes:
594
595 ```zig
596 fn splitHostPort(s: []const u8) !struct { host: []const u8, port: u16 } {
597 if (s.len > 0 and s[0] == '[') {
598 const close = std.mem.indexOfScalar(u8, s, ']') orelse return error.MalformedAddress;
599 if (close + 1 == s.len) return .{ .host = s[1..close], .port = quic.default_port };
600 if (s[close + 1] != ':') return error.MalformedAddress;
601 return .{ .host = s[1..close], .port = try parsePort(s[close + 2 ..]) };
602 }
603 const colon = std.mem.lastIndexOfScalar(u8, s, ':') orelse
604 return .{ .host = s, .port = quic.default_port };
605 // (existing unbracketed-IPv6 comment and check stay verbatim)
606 if (std.mem.indexOfScalar(u8, s[0..colon], ':') != null) return error.MalformedAddress;
607 return .{ .host = s[0..colon], .port = try parsePort(s[colon + 1 ..]) };
608 }
609 ```
610
611 `src/client.zig` `parseQuicAddr` (~329): replace the first three lines of the body:
612
613 ```zig
614 const colon = std.mem.lastIndexOfScalar(u8, host_port, ':') orelse
615 return error.MalformedAddress;
616 var host = host_port[0..colon];
617 const port_s = host_port[colon + 1 ..];
618 ```
619
620 with:
621
622 ```zig
623 // `[::1]` — bracketed, portless: the brackets say where the address
624 // stops, so the port can default.
625 if (host_port.len >= 2 and host_port[0] == '[' and host_port[host_port.len - 1] == ']')
626 return resolveHost(host_port[1 .. host_port.len - 1], quic_client.default_port);
627 const colon = std.mem.lastIndexOfScalar(u8, host_port, ':') orelse
628 return resolveHost(host_port, quic_client.default_port);
629 var host = host_port[0..colon];
630 const port_s = host_port[colon + 1 ..];
631 ```
632
633 and extract the tail of the existing function (from `if (host.len == 0)` through the end) into:
634
635 ```zig
636 fn resolveHost(host: []const u8, port: u16) !std.net.Address {
637 if (host.len == 0) return error.MalformedAddress;
638 if (std.net.Address.parseIp(host, port)) |addr| return addr else |_| {}
639 // Not a literal: resolve it. A remote host is normally a name.
640 const list = try std.net.getAddressList(std.heap.page_allocator, host, port);
641 defer list.deinit();
642 if (list.addrs.len == 0) return error.UnknownHostName;
643 return list.addrs[0];
644 }
645 ```
646
647 with the existing with-port path ending in `return resolveHost(host, port);` after its bracket-stripping/ambiguity checks (delete the now-duplicated tail).
648
649 `src/mux_main.zig` usage line: `quic://HOST:PORT` → `quic://HOST[:PORT] (PORT defaults to 4433)`. Same in `src/main.zig` usage: `--quic HOST[:PORT]`.
650
651 - [x] **Step 4: Fix the e2e refusal that just became valid**
652
653 `test/e2e.sh:384` currently expects a portless `--quic` to be refused:
654
655 ```sh
656 refuse 1 --quic "127.0.0.1" --key "$QKEY"
657 ```
658
659 A portless address is now valid (defaults 4433) — but this invocation still exits 1 for a different reason on any machine where 4433 is free (it would actually try to bind 4433 — NOT acceptable in a test suite). Replace the line with a form that is still malformed:
660
661 ```sh
662 refuse 1 --quic "127.0.0.1:" --key "$QKEY"
663 ```
664
665 - [x] **Step 5: Run tests, expect pass**
666
667 Run: `make test && make build && make e2e`
668 Expected: pass.
669
670 - [x] **Step 6: Mutation check (port default)**
671
672 Change `default_port` to `4434` in quic_server.zig. Run `make test`: BOTH new tests (main.zig and client.zig) must fail — if only one fails, the other is asserting through a stale constant; find out why before restoring. Restore to 4433.
673
674 - [x] **Step 7: Commit**
675
676 ```bash
677 git add src/quic_server.zig src/quic_client.zig src/main.zig src/client.zig src/mux_main.zig test/e2e.sh
678 git commit -m "feat: port 4433 is the default on both ends of quic://"
679 ```
680
681 ---
682
683 ### Task 5: default key path resolution, both binaries
684
685 **Files:**
686 - Modify: `src/main.zig` (`Usage` ~47, `parseArgs` ~123, `usageExit` ~136, `run()` ~204–239, tests ~395–410)
687 - Modify: `src/mux_main.zig` (`ParseResult` ~25–36, parse ~100–104, `main` ~134–145, tests that mention `quic_without_key`)
688
689 - [x] **Step 1: Write the failing parse tests**
690
691 `src/main.zig`: in the existing `--quic and --key are both or neither` test, the line
692
693 ```zig
694 try std.testing.expect(parse(&.{ "muxd", "run", "--quic", "0.0.0.0:4433" }).err == .quic_without_key);
695 ```
696
697 becomes (and retitle the test `"parseArgs: --key without --quic is refused; --quic alone defers to main"`):
698
699 ```zig
700 // --quic without --key is no longer a parse error: main resolves
701 // MUX_KEY_FILE and the default path, and parse cannot see either.
702 const deferred = parse(&.{ "muxd", "run", "--quic", "0.0.0.0:4433" });
703 try std.testing.expect(deferred == .ok);
704 try std.testing.expect(deferred.ok.key == null);
705 ```
706
707 `src/mux_main.zig`: wherever tests expect `.quic_without_key` from a key-less `quic://` parse (search the test block, ~lines 228–277), replace the expectation:
708
709 ```zig
710 // was: expect(parse(&.{ "mux", "quic://a:1" }) == .quic_without_key)
711 const q = parse(&.{ "mux", "quic://a:1" });
712 try std.testing.expect(q == .quic);
713 try std.testing.expect(q.quic.key == null);
714
715 // Empty env var means unset, same as an empty --key would be nonsense.
716 const empty_env = parseEnv(&.{ "mux", "quic://a:1" }, "");
717 try std.testing.expect(empty_env == .quic);
718 try std.testing.expect(empty_env.quic.key == null);
719 ```
720
721 Keep the tests proving `--key` and `MUX_KEY_FILE` still arrive in `.quic.key` when given.
722
723 - [x] **Step 2: Run to verify failure**
724
725 Run: `make test`
726 Expected: the edited tests fail against current behavior.
727
728 - [x] **Step 3: Implement — muxd side**
729
730 `src/main.zig`:
731 - Delete `quic_without_key` from `Usage` and its `usageExit` arm.
732 - Delete line ~123: `if (o.quic != null and o.key == null) return .{ .err = .quic_without_key };` (the `key_without_quic` check on the next line STAYS).
733 - In `run()`, replace the key load block (~218) so resolution happens first:
734
735 ```zig
736 var default_key: ?[]const u8 = null;
737 defer if (default_key) |p| alloc.free(p);
738 const key_path = o.key orelse envKey() orelse blk: {
739 const p = try xdg.keyPath(alloc);
740 default_key = p;
741 std.fs.cwd().access(p, .{}) catch break :blk null;
742 break :blk p;
743 } orelse {
744 const shown = default_key.?; // blk ran iff we got here
745 std.debug.print(
746 "muxd: no key: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default {s})\n",
747 .{shown},
748 );
749 return 2;
750 };
751 quic_key = quic.Key.load(key_path) catch |err| switch (err) {
752 ```
753
754 and change the three error-message arms below it from `o.key.?` to `key_path`. Add near the top of the file:
755
756 ```zig
757 /// MUX_KEY_FILE, with "set but empty" read as unset — an empty path could
758 /// only ever be a mistake, and Key.load would blame a confusing "".
759 fn envKey() ?[]const u8 {
760 const v = std.posix.getenv("MUX_KEY_FILE") orelse return null;
761 return if (v.len == 0) null else v;
762 }
763 ```
764
765 **Note the wire-in:** `defer` on `default_key` must NOT free before `Key.load` uses `key_path` — the code above keeps `default_key` alive for the whole scope; do not "clean it up" into an early free.
766
767 - [x] **Step 4: Implement — mux side**
768
769 `src/mux_main.zig`:
770 - Remove `.quic_without_key` from `ParseResult` and its arm in `main`'s switch.
771 - Parse (~100):
772
773 ```zig
774 if (quic) |hp| {
775 var k = key orelse env_key;
776 if (k) |kk| {
777 if (kk.len == 0) k = null;
778 }
779 return .{ .quic = .{ .host_port = hp, .key = k, .idle_ms = idle_ms } };
780 }
781 ```
782
783 - `main`'s `.quic` arm (~141):
784
785 ```zig
786 .quic => |q| {
787 var key_owned: ?[]const u8 = null;
788 defer if (key_owned) |p| alloc.free(p);
789 const key_path = q.key orelse blk: {
790 const p = try xdg.keyPath(alloc);
791 key_owned = p;
792 std.fs.cwd().access(p, .{}) catch {
793 std.debug.print(
794 "mux: no key: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default {s})\n",
795 .{p},
796 );
797 return 2;
798 };
799 break :blk p;
800 };
801 return client.attach(alloc, null, null, .{
802 .host_port = q.host_port,
803 .key_path = key_path,
804 .idle_ms = q.idle_ms,
805 });
806 },
807 ```
808
809 Add `const xdg = @import("xdg");` at the top. Update the usage text's key line: `quic://HOST[:PORT] uses --key FILE, MUX_KEY_FILE, or ~/.config/mux/key`.
810
811 - [x] **Step 5: Run tests, expect pass**
812
813 Run: `make test`
814 Expected: pass.
815
816 - [x] **Step 6: e2e — the refusals still refuse, hermetically**
817
818 The Task 3 hermetic `XDG_CONFIG_HOME` is what keeps `refuse 2 --quic "127.0.0.1:$QPORT"` (e2e.sh:379) honest — there is a keygen'd key at the default path by now, so that refusal would stop refusing! Move the keygen scenario BELOW the refusal block, or point the refusals at a second empty config home. Do the latter — it keeps scenario order free:
819
820 ```sh
821 # The no-key refusal must not find the suite's own keygen'd key.
822 NOKEY_CFG="${TMPDIR:-/tmp}/mux-e2e-nokey-$$"
823 XDG_CONFIG_HOME="$NOKEY_CFG" refuse 2 --quic "127.0.0.1:$QPORT"
824 ```
825
826 (adjust the `refuse` helper call site accordingly; check how `refuse` invokes `$MUXD` — if it doesn't pass environment through, wrap with `env XDG_CONFIG_HOME="$NOKEY_CFG"`). Add a message assertion right after:
827
828 ```sh
829 XDG_CONFIG_HOME="$NOKEY_CFG" "$MUXD" run --sock "$SOCK4.nokey" --quic "127.0.0.1:$QPORT" 2> "$OUT.nokey" || true
830 grep -q "muxd keygen" "$OUT.nokey" || {
831 echo "e2e FAIL: key-missing message does not name keygen"; cat "$OUT.nokey"; exit 1; }
832 rm -rf "$NOKEY_CFG"
833 ```
834
835 Also delete e2e.sh:380's `refuse 2 --key "$QKEY"`? **No** — `--key` without `--quic` is still a parse refusal; that line stays.
836
837 Run: `make build && make e2e`
838 Expected: pass.
839
840 - [x] **Step 7: Mutation check (resolution order)**
841
842 In muxd's `run()`, swap resolution to `envKey() orelse o.key` (env beats flag). `make test` must fail — if no unit test catches it, ADD one before proceeding: mux_main's parse test already pins `--key` beating env via `parseEnv(&.{ "mux", "quic://a:1", "--key", "/k" }, "/env")` expecting `/k`; write the equivalent if absent. Restore.
843
844 - [x] **Step 8: Commit**
845
846 ```bash
847 git add src/main.zig src/mux_main.zig test/e2e.sh
848 git commit -m "feat: key resolution --key > MUX_KEY_FILE > ~/.config/mux/key, both binaries"
849 ```
850
851 ---
852
853 ### Task 6: `src/spawn.zig` — `ensureDaemon`
854
855 **Files:**
856 - Create: `src/spawn.zig`
857 - Modify: `build.zig` (create `spawn_mod` with `xdg` + `testtmp` imports, `link_libc = true`; import into `exe_mod` as `"spawn"`; **add to test loop**)
858
859 - [x] **Step 1: Create `src/spawn.zig`**
860
861 ```zig
862 //! Get a daemon onto a socket path: probe, spawn detached, poll until it
863 //! answers. `muxd start` is the explicit caller today; attach auto-start
864 //! (banked) becomes a second call site, not a rewrite.
865 const std = @import("std");
866 const xdg = @import("xdg");
867
868 pub const EnsureError = error{ BinaryNotFound, SpawnFailed, NeverAnswered };
869 pub const Ensured = enum { already_running, started };
870
871 /// All stderr output belongs to this struct: the caller decides the prefix
872 /// ("muxd" today, "mux" when auto-start lands) and whether dots animate.
873 /// Silence on the already-running path is part of the contract — any
874 /// output at all means something unusual happened.
875 pub const Progress = struct {
876 fd: std.posix.fd_t,
877 prefix: []const u8,
878 tty: bool,
879
880 fn emit(self: Progress, s: []const u8) void {
881 _ = std.posix.write(self.fd, s) catch {};
882 }
883
884 fn emitFmt(self: Progress, comptime fmt: []const u8, args: anytype) void {
885 var buf: [256]u8 = undefined;
886 const s = std.fmt.bufPrint(&buf, fmt, args) catch return;
887 self.emit(s);
888 }
889 };
890
891 /// Probe `sock_path`; if nothing answers, exec `exe_path run <run_args...>`
892 /// detached (setsid, stdin /dev/null, stdout+stderr truncating the xdg log)
893 /// and poll every 50ms until the socket accepts or `deadline_ms` passes.
894 ///
895 /// On `NeverAnswered` the spawned pid is deliberately NOT killed: a daemon
896 /// that comes up at 2.5s should be there for the retry, not murdered for
897 /// tardiness. The failure line names the log, which holds its stderr.
898 ///
899 /// Two racers both spawning is handled by the daemon itself: the loser
900 /// exits on DaemonAlreadyRunning (server.zig claimSockPath) and the
901 /// loser's poll connects to the winner.
902 pub fn ensureDaemon(
903 alloc: std.mem.Allocator,
904 exe_path: []const u8,
905 run_args: []const [:0]const u8,
906 sock_path: []const u8,
907 progress: Progress,
908 deadline_ms: u32,
909 ) EnsureError!Ensured {
910 if (probe(sock_path)) return .already_running;
911
912 std.posix.access(exe_path, std.posix.X_OK) catch return error.BinaryNotFound;
913
914 const log_path = xdg.logPath(alloc) catch return error.SpawnFailed;
915 defer alloc.free(log_path);
916 if (std.fs.path.dirname(log_path)) |dir|
917 std.fs.cwd().makePath(dir) catch return error.SpawnFailed;
918 const log = std.fs.cwd().createFile(log_path, .{ .truncate = true, .mode = 0o600 }) catch
919 return error.SpawnFailed;
920 defer log.close();
921 const devnull = std.fs.cwd().openFile("/dev/null", .{}) catch return error.SpawnFailed;
922 defer devnull.close();
923
924 // argv for the child: exe run <forwarded...>, all null-terminated.
925 const exe_z = alloc.dupeZ(u8, exe_path) catch return error.SpawnFailed;
926 defer alloc.free(exe_z);
927 var argv = alloc.allocSentinel(?[*:0]const u8, run_args.len + 2, null) catch
928 return error.SpawnFailed;
929 defer alloc.free(argv);
930 argv[0] = exe_z.ptr;
931 argv[1] = "run";
932 for (run_args, 0..) |a, i| argv[i + 2] = a.ptr;
933
934 progress.emitFmt("{s}: starting\u{2026}", .{progress.prefix});
935 if (!progress.tty) progress.emit("\n");
936
937 const t0 = std.time.milliTimestamp();
938 const pid = std.posix.fork() catch {
939 if (progress.tty) progress.emit("\n");
940 return error.SpawnFailed;
941 };
942 if (pid == 0) {
943 // Child: its own session, no controlling terminal, stdio detached.
944 // Nothing here may allocate or return — only exec or _exit.
945 _ = std.os.linux.setsid();
946 std.posix.dup2(devnull.handle, std.posix.STDIN_FILENO) catch std.posix.exit(127);
947 std.posix.dup2(log.handle, std.posix.STDOUT_FILENO) catch std.posix.exit(127);
948 std.posix.dup2(log.handle, std.posix.STDERR_FILENO) catch std.posix.exit(127);
949 const err = std.posix.execveZ(exe_z.ptr, argv.ptr, std.c.environ);
950 _ = err;
951 std.posix.exit(127);
952 }
953
954 // Parent: poll. Dots only on a tty so scripted output stays pinnable.
955 var next_dot: i64 = t0 + 250;
956 while (true) {
957 if (probe(sock_path)) {
958 const secs = @as(f64, @floatFromInt(std.time.milliTimestamp() - t0)) / 1000.0;
959 progress.emitFmt(" up ({d:.1}s) pid={d}\n", .{ secs, pid });
960 return .started;
961 }
962 const now = std.time.milliTimestamp();
963 if (now - t0 >= deadline_ms) {
964 progress.emitFmt(
965 "\n{s}: muxd did not answer within {d}s \u{2014} log: {s}\n",
966 .{ progress.prefix, deadline_ms / 1000, log_path },
967 );
968 return error.NeverAnswered;
969 }
970 if (progress.tty and now >= next_dot) {
971 progress.emit(".");
972 next_dot = now + 250;
973 }
974 // Reap if the child exited (loser of a start race, or a refused
975 // flag): its socket-owner sibling answers the next probe either
976 // way, and an unreaped child would sit as a zombie until we exit.
977 _ = std.posix.waitpid(pid, std.posix.W.NOHANG);
978 std.Thread.sleep(50 * std.time.ns_per_ms);
979 }
980 }
981
982 fn probe(sock_path: []const u8) bool {
983 const s = std.net.connectUnixSocket(sock_path) catch return false;
984 s.close();
985 return true;
986 }
987
988 // ---------------------------------------------------------------------------
989
990 const testtmp = @import("testtmp");
991
992 fn silentProgress() Progress {
993 // Progress that writes to /dev/null keeps test output clean while the
994 // pinned-output cases below capture a pipe instead.
995 const f = std.fs.cwd().openFile("/dev/null", .{ .mode = .write_only }) catch unreachable;
996 return .{ .fd = f.handle, .prefix = "test", .tty = false };
997 }
998
999 test "ensureDaemon: an answering socket is already_running, nothing spawned" {
1000 var tmp = try testtmp.TmpDir.make();
1001 defer tmp.cleanup();
1002 var buf: [128]u8 = undefined;
1003 const sock = try std.fmt.bufPrint(&buf, "{s}/live.sock", .{tmp.path()});
1004
1005 const addr = try std.net.Address.initUnix(sock);
1006 var server = try addr.listen(.{});
1007 defer server.deinit();
1008
1009 // Progress captured through a pipe: already_running must print NOTHING.
1010 const pipe = try std.posix.pipe();
1011 defer std.posix.close(pipe[0]);
1012 const progress: Progress = .{ .fd = pipe[1], .prefix = "test", .tty = false };
1013
1014 const r = try ensureDaemon(
1015 std.testing.allocator,
1016 "/definitely/not/consulted",
1017 &.{},
1018 sock,
1019 progress,
1020 200,
1021 );
1022 try std.testing.expectEqual(Ensured.already_running, r);
1023
1024 std.posix.close(pipe[1]);
1025 var out: [64]u8 = undefined;
1026 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(pipe[0], &out));
1027 }
1028
1029 test "ensureDaemon: missing binary is BinaryNotFound before any fork" {
1030 var tmp = try testtmp.TmpDir.make();
1031 defer tmp.cleanup();
1032 var buf: [128]u8 = undefined;
1033 const sock = try std.fmt.bufPrint(&buf, "{s}/none.sock", .{tmp.path()});
1034 try std.testing.expectError(error.BinaryNotFound, ensureDaemon(
1035 std.testing.allocator,
1036 "/no/such/muxd",
1037 &.{},
1038 sock,
1039 silentProgress(),
1040 200,
1041 ));
1042 }
1043
1044 test "ensureDaemon: a binary that never binds is NeverAnswered, pid left alive" {
1045 var tmp = try testtmp.TmpDir.make();
1046 defer tmp.cleanup();
1047 var pbuf: [128]u8 = undefined;
1048 var sbuf: [128]u8 = undefined;
1049 const stub = try std.fmt.bufPrint(&pbuf, "{s}/stub.sh", .{tmp.path()});
1050 const sock = try std.fmt.bufPrint(&sbuf, "{s}/never.sock", .{tmp.path()});
1051
1052 // A stand-in daemon that stays alive and binds nothing. `exec` so the
1053 // pid ensureDaemon tracked IS the sleeper, not a parent shell of it.
1054 try tmp.dir.writeFile(.{ .sub_path = "stub.sh", .data = "#!/bin/sh\nexec sleep 30\n" });
1055 const f = try tmp.dir.openFile("stub.sh", .{});
1056 try f.chmod(0o755);
1057 f.close();
1058
1059 // Point the log into the tmp dir so the test does not truncate a real
1060 // daemon's log on the machine running the suite.
1061 // (xdg.logPath reads XDG_STATE_HOME at call time; tests cannot setenv,
1062 // so this test tolerates the real path being used — writeNewKey-style
1063 // isolation is not possible here and the log write is harmless: it is
1064 // exactly what a real spawn does.)
1065 const t0 = std.time.milliTimestamp();
1066 try std.testing.expectError(error.NeverAnswered, ensureDaemon(
1067 std.testing.allocator,
1068 stub,
1069 &.{},
1070 sock,
1071 silentProgress(),
1072 300,
1073 ));
1074 // It waited the deadline out rather than bailing early...
1075 try std.testing.expect(std.time.milliTimestamp() - t0 >= 300);
1076
1077 // ...and did NOT kill the spawned process: find it as our child and
1078 // reap it ourselves. waitpid with NOHANG returning pid 0 means "child
1079 // exists, still running" — which is the assertion.
1080 // We do not know the pid (ensureDaemon owns it), so assert the weaker
1081 // but sufficient fact: at least one child of ours is still alive.
1082 // Reap-and-kill happens via kill(0)-scoped... NO: kill by tracked pid
1083 // only. ensureDaemon must therefore RETURN the pid on NeverAnswered —
1084 // see Step 2: the error carries no payload, so the pid is exposed via
1085 // the last_spawned_pid test hook below.
1086 try std.testing.expect(last_spawned_pid != 0);
1087 try std.testing.expectEqual(@as(std.posix.pid_t, 0), std.posix.waitpid(last_spawned_pid, std.posix.W.NOHANG).pid);
1088 std.posix.kill(last_spawned_pid, std.posix.SIG.KILL) catch {};
1089 _ = std.posix.waitpid(last_spawned_pid, 0);
1090 }
1091 ```
1092
1093 - [x] **Step 2: The pid test hook**
1094
1095 The last test needs the spawned pid (kill-by-tracked-pid rule: the test must clean up its sleeper, and only by a pid it was handed). Add to spawn.zig, and set it in `ensureDaemon` right after the fork (parent side):
1096
1097 ```zig
1098 /// Test hook: the pid of the most recent spawn. Tests use it to reap the
1099 /// deliberately-orphaned stub; muxd start reads it for the up-line. Not
1100 /// synchronized — single-threaded callers only, which both callers are.
1101 pub var last_spawned_pid: std.posix.pid_t = 0;
1102 ```
1103
1104 ```zig
1105 // (parent, immediately after fork returns)
1106 last_spawned_pid = pid;
1107 ```
1108
1109 - [x] **Step 3: Wire spawn_mod in build.zig**
1110
1111 After the `xdg_mod` block:
1112
1113 ```zig
1114 // Daemon spawning (probe / detach / poll). muxd start today; attach
1115 // auto-start is a banked second call site.
1116 const spawn_mod = b.createModule(.{
1117 .root_source_file = b.path("src/spawn.zig"),
1118 .target = target,
1119 .optimize = optimize,
1120 .link_libc = true,
1121 });
1122 spawn_mod.addImport("xdg", xdg_mod);
1123 spawn_mod.addImport("testtmp", testtmp_mod);
1124 ```
1125
1126 `exe_mod.addImport("spawn", spawn_mod);` next to its other imports. **Add `spawn_mod` to the test loop array.**
1127
1128 - [x] **Step 4: Run tests, expect pass**
1129
1130 Run: `make test`
1131 Expected: pass. The three spawn tests run (verify with a quick deliberate break: flip `probe`'s `return true` to `return false`; the already_running test must fail; restore).
1132
1133 **Spec divergence, deliberate:** the spec's testing section places
1134 "spawn-success" and "two concurrent starts" at unit level. Both need a
1135 binary that really binds the socket and really refuses a second claim —
1136 that binary is `muxd` itself, which unit tests cannot reach (they don't
1137 know the artifact path). Both cases live in Task 7's e2e instead, against
1138 the real daemon, which is stronger evidence anyway. The unit layer keeps
1139 what is honestly unit-testable: probe/no-spawn, missing binary, never-binds.
1140
1141 - [x] **Step 5: Commit**
1142
1143 ```bash
1144 git add src/spawn.zig build.zig
1145 git commit -m "feat: spawn.ensureDaemon — probe, detach, poll; the start/auto-start core"
1146 ```
1147
1148 ---
1149
1150 ### Task 7: `muxd start`
1151
1152 **Files:**
1153 - Modify: `src/main.zig` (usage, `Cmd`, `parseArgs`, dispatch, new `startCmd`; parse test)
1154 - Modify: `test/e2e.sh` (start scenario: fresh start, marker, rerun no-op, race, default-key QUIC attach)
1155
1156 - [x] **Step 1: Failing parse test**
1157
1158 ```zig
1159 test "parseArgs: start takes run's flags" {
1160 const r = parse(&.{ "muxd", "start", "--sock", "/tmp/x.sock", "--cols", "100" });
1161 try std.testing.expect(r == .ok);
1162 try std.testing.expect(r.ok.cmd == .start);
1163 try std.testing.expectEqualStrings("/tmp/x.sock", r.ok.sock.?);
1164 try std.testing.expectEqual(@as(u16, 100), r.ok.cols);
1165 }
1166 ```
1167
1168 Run: `make test` — compile error on `.start` (failing state).
1169
1170 - [x] **Step 2: Implement**
1171
1172 `src/main.zig`. `Cmd`: add `start`:
1173
1174 ```zig
1175 const Cmd = enum { run, dump, stats, proxy, version, keygen, start };
1176 ```
1177
1178 `parseArgs` chain: `else if (std.mem.eql(u8, args[1], "start")) .start`. The flag loop needs no change — start shares run's flags.
1179
1180 Import: `const spawn = @import("spawn");`
1181
1182 Dispatch — `.start` needs the raw argv to forward, so pass it:
1183
1184 ```zig
1185 .start => return startCmd(alloc, sock_path, args[2..]),
1186 ```
1187
1188 New function:
1189
1190 ```zig
1191 /// `muxd start` = ensureDaemon under an explicit flag. Everything after
1192 /// `start` is forwarded to `run` verbatim — no re-serialization, so a flag
1193 /// that parses here behaves identically there. parseArgs has already
1194 /// validated the flags in THIS process; what it cannot validate (a bad
1195 /// bind address, a missing key file) surfaces in the daemon's log, which
1196 /// the failure path names.
1197 fn startCmd(alloc: std.mem.Allocator, sock_path: []const u8, forwarded: []const [:0]const u8) !u8 {
1198 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
1199 const exe = std.fs.selfExePath(&exe_buf) catch {
1200 std.debug.print("muxd: cannot find own binary via /proc/self/exe\n", .{});
1201 return 1;
1202 };
1203 const progress: spawn.Progress = .{
1204 .fd = std.posix.STDERR_FILENO,
1205 .prefix = "muxd",
1206 .tty = std.posix.isatty(std.posix.STDERR_FILENO),
1207 };
1208 const r = spawn.ensureDaemon(alloc, exe, forwarded, sock_path, progress, 2000) catch |err| switch (err) {
1209 // The failure line, with the log path, was already printed by
1210 // Progress — a second line here would say the same thing worse.
1211 error.NeverAnswered => return 1,
1212 error.BinaryNotFound, error.SpawnFailed => {
1213 std.debug.print("muxd: could not spawn {s}: {s}\n", .{ exe, @errorName(err) });
1214 return 1;
1215 },
1216 };
1217 if (r == .already_running) {
1218 std.debug.print(
1219 "muxd: already running on {s} (stop it first if you meant different flags)\n",
1220 .{sock_path},
1221 );
1222 }
1223 return 0;
1224 }
1225 ```
1226
1227 Usage: add `\\ muxd start [run's flags] (spawn a daemon detached; no-op if one is up)`.
1228
1229 - [x] **Step 3: Run tests, expect pass**
1230
1231 Run: `make test`
1232 Expected: pass.
1233
1234 - [x] **Step 4: e2e scenarios**
1235
1236 `test/e2e.sh`, new block. Declare near the other SOCK vars: `SOCK8="${TMPDIR:-/tmp}/muxd-e2e-start-$$.sock"` and `SPID=""`, add `[ -n "$SPID" ] && kill "$SPID" 2>/dev/null` to the trap (kill by tracked pid) plus `wait` tolerance mirroring the existing daemons' cleanup.
1237
1238 ```sh
1239 # --- M10: muxd start — detached spawn, no-op rerun, race, pinned lines.
1240 "$MUXD" start --sock "$SOCK8" 2> "$OUT.start"
1241 grep -q '^muxd: starting' "$OUT.start" || {
1242 echo "e2e FAIL: start printed no starting line"; cat "$OUT.start"; exit 1; }
1243 grep -q ' up (' "$OUT.start" || {
1244 echo "e2e FAIL: start printed no up line"; cat "$OUT.start"; exit 1; }
1245 SPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.start")
1246 [ -n "$SPID" ] || { echo "e2e FAIL: up line carries no pid"; exit 1; }
1247 kill -0 "$SPID" || { echo "e2e FAIL: started daemon not alive"; exit 1; }
1248 # Non-tty stderr: exactly two lines, no dots.
1249 [ "$(wc -l < "$OUT.start")" = "2" ] || {
1250 echo "e2e FAIL: non-tty start not exactly two lines:"; cat "$OUT.start"; exit 1; }
1251
1252 # The daemon it started serves a session (marker in, marker in dump).
1253 { printf 'printf "start-%%s\\n" works\n'; sleep 2; printf '\034'; } | \
1254 "$MUX" --sock "$SOCK8" > "$OUT.s8" 2>&1
1255 "$MUXD" dump --sock "$SOCK8" | grep -q "start-works" || {
1256 echo "e2e FAIL: auto-started daemon lost the marker"; exit 1; }
1257
1258 # Rerun: silent no-op beyond the already-running line, exit 0, same daemon.
1259 "$MUXD" start --sock "$SOCK8" 2> "$OUT.start2"
1260 grep -q "already running on $SOCK8" "$OUT.start2" || {
1261 echo "e2e FAIL: rerun did not say already running"; cat "$OUT.start2"; exit 1; }
1262 "$MUXD" dump --sock "$SOCK8" | grep -q "start-works" || {
1263 echo "e2e FAIL: rerun replaced the daemon (marker gone)"; exit 1; }
1264
1265 # Race: two concurrent starts, both exit 0, still one session (the marker
1266 # survives — a second daemon on the path would have started a fresh shell).
1267 kill "$SPID" && wait_gone "$SOCK8"
1268 "$MUXD" start --sock "$SOCK8" 2> "$OUT.ra" & RA=$!
1269 "$MUXD" start --sock "$SOCK8" 2> "$OUT.rb" & RB=$!
1270 wait "$RA"; RCA=$?
1271 wait "$RB"; RCB=$?
1272 [ "$RCA" = "0" ] && [ "$RCB" = "0" ] || {
1273 echo "e2e FAIL: race: exits $RCA/$RCB"; cat "$OUT.ra" "$OUT.rb"; exit 1; }
1274 { printf 'printf "race-%%s\\n" one\n'; sleep 2; printf '\034'; } | \
1275 "$MUX" --sock "$SOCK8" > /dev/null 2>&1
1276 "$MUXD" dump --sock "$SOCK8" | grep -q "race-one" || {
1277 echo "e2e FAIL: race: session unusable"; exit 1; }
1278 SPID=$(cat "$OUT.ra" "$OUT.rb" | sed -n 's/.* pid=\([0-9]*\).*/\1/p' | while read -r p; do
1279 kill -0 "$p" 2>/dev/null && echo "$p"; done | head -1)
1280 echo "e2e OK: muxd start — spawn, no-op rerun, race"
1281 ```
1282
1283 Where `wait_gone` is a small helper (add beside the others): poll up to 2s for `connectUnixSocket` failure via `"$MUXD" dump --sock` failing:
1284
1285 ```sh
1286 wait_gone() {
1287 _i=0
1288 while "$MUXD" dump --sock "$1" > /dev/null 2>&1; do
1289 _i=$((_i + 1)); [ "$_i" -lt 40 ] || { echo "e2e FAIL: daemon on $1 never died"; exit 1; }
1290 sleep 0.05
1291 done
1292 }
1293 ```
1294
1295 Then the default-key QUIC flow (key already at the hermetic default from the Task 3 keygen scenario — this scenario must run after it):
1296
1297 ```sh
1298 # --- M10: the goal commands, minus ssh: keygen'd default key on both ends,
1299 # explicit loopback port (4433 on the suite machine is somebody's daemon).
1300 QPORT2=$(( 25000 + ($$ % 4000) ))
1301 SOCK9="${TMPDIR:-/tmp}/muxd-e2e-goal-$$.sock"
1302 "$MUXD" start --sock "$SOCK9" --quic "127.0.0.1:$QPORT2" 2> "$OUT.goal"
1303 GPID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.goal")
1304 { printf 'printf "goal-%%s\\n" quic\n'; sleep 2; printf '\034'; } | \
1305 "$MUX" "quic://127.0.0.1:$QPORT2" > "$OUT.g9" 2>&1
1306 "$MUXD" dump --sock "$SOCK9" | grep -q "goal-quic" || {
1307 echo "e2e FAIL: no-key-flag QUIC attach did not reach the session"
1308 cat "$OUT.g9"; exit 1; }
1309 kill "$GPID" 2>/dev/null || true
1310 echo "e2e OK: keygen + start --quic + mux quic:// with no --key anywhere"
1311 ```
1312
1313 Track `GPID` in the trap like `SPID`.
1314
1315 - [x] **Step 5: Run e2e**
1316
1317 Run: `make build && make e2e`
1318 Expected: both new OK lines; suite passes.
1319
1320 - [x] **Step 6: Mutation checks**
1321
1322 (a) Race/loser handling: in `server.zig`'s `claimSockPath`, invert the live-daemon refusal (`return error.DaemonAlreadyRunning` → `return`); `make build && make e2e`: the race scenario or the rerun scenario MUST fail (two daemons, marker lost). Restore. (b) Pinned lines: drop `pid={d}` from the up-line; e2e MUST fail on the empty `SPID`. Restore.
1323
1324 - [x] **Step 7: Commit**
1325
1326 ```bash
1327 git add src/main.zig test/e2e.sh
1328 git commit -m "feat: muxd start — the setsid-nohup incantation becomes a subcommand"
1329 ```
1330
1331 ---
1332
1333 ### Task 8: honest `--via` failure message
1334
1335 **Files:**
1336 - Modify: `src/client.zig:508,518,573` (the three `exit_msg` sites inside `attach`)
1337 - Modify: `test/e2e.sh`
1338
1339 - [x] **Step 1: Implement (the e2e test is this task's failing test — write it first)**
1340
1341 `test/e2e.sh`:
1342
1343 ```sh
1344 # --- M10: a --via command that dies before the first frame stops claiming
1345 # a connection existed. ssh's own stderr still passes through untouched.
1346 set +e
1347 "$MUX" --via "sh -c 'exit 127'" > "$OUT.via" 2>&1
1348 VRC=$?
1349 set -e
1350 [ "$VRC" = "1" ] || { echo "e2e FAIL: dead --via exit $VRC, want 1"; exit 1; }
1351 grep -q "transport command failed before connecting" "$OUT.via" || {
1352 echo "e2e FAIL: --via death message:"; cat "$OUT.via"; exit 1; }
1353 grep -q "connection to muxd lost" "$OUT.via" && {
1354 echo "e2e FAIL: the old lie is still printed"; exit 1; }
1355 echo "e2e OK: --via failure says what happened"
1356 ```
1357
1358 Run: `make build && make e2e` — MUST fail with the old message (failing state confirmed; this is also the mutation evidence — the old code IS the mutation).
1359
1360 - [x] **Step 2: Implement**
1361
1362 `src/client.zig`, inside `attach` after the `transport` is opened (before the attach-frame write at ~504):
1363
1364 ```zig
1365 // Which message a pre-first-frame death earns. Over --via the transport
1366 // is a command we spawned: if it died before a single protocol frame
1367 // arrived, the honest report is that the command failed — "connection
1368 // lost" implies a connection existed. Local-socket and QUIC attaches
1369 // really did have one (open() succeeded), so theirs keeps the old text.
1370 const pre_frame_msg: []const u8 = if (via != null)
1371 "mux: transport command failed before connecting (is muxd installed on the host?)"
1372 else
1373 "mux: connection to muxd lost";
1374 ```
1375
1376 Replace the string at the three sites:
1377 - `:508` (attach-frame write failure): `exit_msg = pre_frame_msg;`
1378 - `:518` (carry write failure): `exit_msg = pre_frame_msg;`
1379 - `:573-575` (`session_epoch == 0` reconnect bail): `exit_msg = pre_frame_msg;`
1380
1381 Note `:573` is precisely "no frame ever arrived" (`session_epoch` is set by the first snapshot and never reset — the comment above it says so); after the first snapshot, reconnect logic keeps its own messages. Do not touch any other `exit_msg` site.
1382
1383 - [x] **Step 3: Run, expect pass**
1384
1385 Run: `make build && make e2e && make test`
1386 Expected: all pass.
1387
1388 - [x] **Step 4: Commit**
1389
1390 ```bash
1391 git add src/client.zig test/e2e.sh
1392 git commit -m "fix: a --via command that died before connecting no longer reports a lost connection"
1393 ```
1394
1395 ---
1396
1397 ### Task 9: delete the systemd remnants
1398
1399 **Files:**
1400 - Delete: `contrib/muxd.service`, `contrib/muxd.socket`
1401 - Modify: `src/server.zig` (~350, ~405–460, ~507–514, ~532)
1402 - Modify: `README.md` (ssh quick start ~44–63, QUIC quick start ~65–81, multi-client paragraph ~90–95)
1403
1404 - [x] **Step 1: server.zig — remove the activation path**
1405
1406 - Delete `listenFdFromSystemd` (lines ~507–514).
1407 - In `Server.init` (~405): delete the `systemd_fd` read and its comment, delete the whole `if (systemd_fd) |fd| { ... }` return block (~428–441), and change the claim line to unconditional:
1408
1409 ```zig
1410 // Before the shell is spawned, so refusing costs nobody a fork and
1411 // leaves no process to reap.
1412 try claimSockPath(opts.sock_path);
1413 ```
1414
1415 - Delete the `owns_sock_file` field (~350) and its `.owns_sock_file = true` initializer (~455).
1416 - In teardown (~532): delete the line `if (!self.owns_sock_file) break :ours false;`. The dev/ino identity check STAYS — "only unlink the socket you created" guards against a different hazard (another daemon's socket at the same path) than activation was.
1417
1418 - [x] **Step 2: Delete contrib**
1419
1420 ```bash
1421 git rm contrib/muxd.service contrib/muxd.socket
1422 ```
1423
1424 - [x] **Step 3: README**
1425
1426 Replace the ssh quick-start's daemon line (~50–53):
1427
1428 ```markdown
1429 Works anywhere ssh works. On the remote host: put `muxd` on PATH, then:
1430
1431 ​```sh
1432 ssh HOST 'muxd start'
1433 mux HOST # attach; Ctrl-\ detaches, running it again reattaches
1434 ​```
1435 ```
1436
1437 (drop the `loginctl enable-linger` sentence and the `contrib/` mention entirely).
1438
1439 Replace the QUIC quick-start commands (~72–75) with the spec's goal commands:
1440
1441 ```sh
1442 muxd keygen # once
1443 ssh HOST 'mkdir -p ~/.config/mux && cat > ~/.config/mux/key \
1444 && chmod 600 ~/.config/mux/key' < ~/.config/mux/key # once per host
1445 ssh HOST 'muxd start --quic 0.0.0.0' # once per host boot
1446 mux quic://HOST # every attach
1447 ```
1448
1449 In the "Everything else" section, replace the sentence `With linger enabled a session survives logout, though not a reboot.` with:
1450
1451 ```markdown
1452 A session survives logout (this assumes systemd-logind's default
1453 `KillUserProcesses=no`; a box configured to kill user processes at logout
1454 kills the daemon with them), though not a reboot.
1455 ```
1456
1457 - [x] **Step 4: Verify nothing referenced the deleted code**
1458
1459 Run: `grep -rn "LISTEN_FDS\|listenFdFromSystemd\|owns_sock_file\|contrib/" src/ test/ README.md Makefile build.zig`
1460 Expected: no matches. Then `make test && make build && make e2e` — all pass (the server module's existing socket tests cover the always-claim path).
1461
1462 - [x] **Step 5: Commit**
1463
1464 ```bash
1465 git add -A
1466 git commit -m "chore: delete socket activation and contrib units — muxd start supersedes them
1467
1468 Socket activation was verified working 2026-08-09 before removal; this
1469 commit is the known-good pattern to resurrect if a KillUserProcesses=yes
1470 box ever earns it back."
1471 ```
1472
1473 ---
1474
1475 ### Task 10: full-suite verification
1476
1477 - [ ] Run `make test` — expect all pass (count should be ≥ 178 + the ~14 new tests).
1478 - [ ] Run `make build && make e2e` — expect every `e2e OK:` line including the five new scenarios.
1479 - [ ] Run the four goal commands manually against localhost (loopback stand-in for the kill criterion's LAN-box run, which the controller performs at milestone close):
1480
1481 ```sh
1482 export XDG_CONFIG_HOME=$(mktemp -d)
1483 ./zig-out/bin/muxd keygen
1484 ./zig-out/bin/muxd start --sock /tmp/m10-manual.sock --quic 127.0.0.1:24433
1485 # Attach with no --key anywhere; type a marker; detach with Ctrl-\ (0x1c):
1486 { printf 'printf "manual-%%s\n" ok\n'; sleep 2; printf '\034'; } | \
1487 ./zig-out/bin/mux quic://127.0.0.1:24433
1488 ./zig-out/bin/muxd dump --sock /tmp/m10-manual.sock | grep manual-ok
1489 # Clean up BY THE PID printed in the start up-line — never by name:
1490 kill <pid-from-up-line>; rm -f /tmp/m10-manual.sock
1491 ```
1492
1493 - [ ] Report status per the subagent protocol (DONE / DONE_WITH_CONCERNS / …).
1494
1495 ---
1496
1497 ## Explicitly out of scope
1498
1499 - Attach auto-start (`muxd proxy` / local `mux` calling `ensureDaemon`) — banked stage.
1500 - `muxd endpoint`, client-side caching, attach deadlines — banked stage.
1501 - roadmap.md / decisions.md milestone-close updates and the LAN-box kill-criterion run — the controller does these after final review.
1502 - Version bump beyond `0.0.1-3` and tagging.
docs/superpowers/plans/2026-08-09-m11-e2e-hardening.md
Old New
@@ -1,630 +0,0 @@
1 # M11: e2e Hardening Implementation Plan
2
3 **Executed in full; verdicts and deviations recorded in decisions.md M11.**
4
5 > **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.
6
7 **Goal:** the e2e suite asserts "the screen the client painted equals the screen the daemon holds" (render-vs-dump convergence), pins its own scenario count, gains a soak mode — then a mutation campaign breaks the code deliberately and measures what the new suite catches that the old one could not.
8
9 **Architecture:** a fourth test-helper binary (`test/render.zig`) replays a captured client stdout stream through its own ghostty-vt engine (reusing `src/engine.zig` verbatim) and prints the grid in `muxd dump`'s formats; `test/e2e.sh` grows an `assert_converged` helper placed as each scenario's last act; `test/soak.sh` loops the suite. Phase 2 is not code: it is a recorded campaign of one-at-a-time mutations in a worktree, each run against the old suite and the new one.
10
11 **Tech Stack:** Zig 0.15.2 (pinned: `~/Downloads/zig-x86_64-linux-0.15.2/zig`, driven ONLY through `make build` / `make test` / `make e2e` / `make soak`), POSIX sh, ghostty-vt.
12
13 **Spec:** `docs/superpowers/specs/2026-08-09-m11-e2e-hardening-design.md` — read it first.
14
15 ---
16
17 ## Context the engineer must have
18
19 - **Toolchain:** system zig is 0.17-dev and CANNOT build this repo. Use `make` targets only. `make test` does NOT rebuild `zig-out/` binaries; `make build` does. `make e2e` rebuilds and runs the suite (~2 min).
20 - **Non-tty clients never enter the alternate screen.** `client.zig:711` gates `\x1b[?1049h` on `is_tty`, and every e2e client runs with piped stdio. So e2e captures exercise render.zig's "never entered" path; the restore-boundary rule exists for tty-captured streams and is pinned by unit tests, not by e2e.
21 - **Predict stats go to stderr** (`dumpPredictStats` uses `std.debug.print`, `client.zig:1130`). Any capture taken with `2>&1` has the stats line — and any exit message — mixed into the escape stream. Task 3 splits those captures; the stats-reading helpers then read the `.err` sibling.
22 - **Plain text cannot see style.** A confirmed-but-still-underlined prediction and a bled SGR dump identical *plain* text. Convergence therefore compares twice: plain (normalized) and styled (`--vt`, byte-for-byte — both sides come out of the same `TerminalFormatter`, whose replay-then-redump stability is already unit-pinned in `engine.zig` "SGR attributes survive a vt dump round-trip").
23 - **All e2e grids are 80×24.** Non-tty clients default to 80×24 (`client.zig:478`), so render.zig's `--cols/--rows` defaults always apply in the suite.
24 - **Standing rules that bind this milestone:** mutations written first — a checkbox whose mutation cannot fire is refused, not ticked; assert the literal, never the constant the code under test reads; kill daemons only by tracked pid; e2e asserts markers/grids, never bare `$?` for QUIC clients; a control that cannot fail proves nothing.
25 - **Convergence placement:** always a scenario's LAST act, after quiesce and after the client detached. If a convergence check flakes under soak, the fix is a longer pre-detach quiesce in that scenario — never a looser diff.
26 - **If a convergence check fails on LANDING (unmutated code), that is a candidate real defect, not a harness bug to normalize away.** Diagnose with the left-behind grid files first. One known candidate: the raw-mode scenario's expired prediction (`j`) — if its underline glyph is never repainted after expiry, the styled diff will catch a phantom glyph a human would also see. That would be a genuine client bug; fix it in `client.zig`/`predict.zig` (expiry triggers a repaint the way contradiction already does at `client.zig:786`), don't decline the scenario.
27
28 ## File structure
29
30 - Create: `test/render.zig` — the replay helper (binary + unit tests in one file, like `rawmode.zig`).
31 - Create: `test/soak.sh` — N-run loop with failure table.
32 - Modify: `build.zig` — render module/exe, test-loop entry, 5th e2e artifact arg, soak step.
33 - Modify: `Makefile` — `soak` target.
34 - Modify: `test/e2e.sh` — `RENDER="$5"`, `converged_quiet`/`assert_converged`/`ok` helpers, stream splits, ~21 placements, count pins.
35 - Create: `docs/superpowers/plans/2026-08-09-m11-campaign.md` — the campaign table (moves into decisions.md at close).
36
37 ---
38
39 ## Phase 1 — build
40
41 ### Task 1: `test/render.zig` — the replay helper
42
43 **Files:**
44 - Create: `test/render.zig`
45 - Modify: `build.zig` (module + exe + test loop + e2e arg)
46
47 - [x] **Step 1: Write the file — core function, main, and failing tests together** (the tests fail because the file is new; TDD's red step here is the whole file compiling and its tests being exercised by the build)
48
49 ```zig
50 //! e2e render helper: replays a captured mux-client stdout stream through
51 //! its own ghostty-vt engine and prints the final grid in `muxd dump`'s
52 //! text format, so the suite can diff what the client painted against
53 //! what the daemon holds. Third helper beside rawmode/delaypipe.
54 const std = @import("std");
55 const Engine = @import("engine").Engine;
56
57 const alt_exit = "\x1b[?1049l";
58
59 /// The restore-boundary rule: a tty client's exit path leaves the
60 /// alternate screen, discarding the very grid under test. Feed everything
61 /// up to the LAST alt-exit and stop there — the grid at that moment is
62 /// what a human saw last (multiple enter/exit cycles: last exit wins). A
63 /// stream with no alt-exit (the non-tty client, or a client that died
64 /// before its first frame) is fed whole and renders as-is.
65 ///
66 /// Scanning for the literal bytes is sound for streams the mux client
67 /// produced: session content never reaches stdout raw — it is repainted
68 /// from the replica as content-only rows — so the only 1049 sequences in
69 /// a capture are the client's own enter and exit.
70 fn feedForFinalGrid(eng: *Engine, stream: []const u8) void {
71 if (std.mem.lastIndexOf(u8, stream, alt_exit)) |i| {
72 eng.feed(stream[0..i]);
73 } else {
74 eng.feed(stream);
75 }
76 }
77
78 fn writeAll(fd: std.posix.fd_t, bytes: []const u8) !void {
79 var off: usize = 0;
80 while (off < bytes.len) off += try std.posix.write(fd, bytes[off..]);
81 }
82
83 fn usage() u8 {
84 writeAll(
85 std.posix.STDERR_FILENO,
86 "usage: render [--cols N] [--rows M] [--vt] < client-stdout-capture\n",
87 ) catch {};
88 return 2;
89 }
90
91 pub fn main() !u8 {
92 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
93 defer _ = gpa.deinit();
94 const alloc = gpa.allocator();
95
96 var cols: u16 = 80;
97 var rows: u16 = 24;
98 var vt_mode = false;
99
100 const args = try std.process.argsAlloc(alloc);
101 defer std.process.argsFree(alloc, args);
102 var i: usize = 1;
103 while (i < args.len) : (i += 1) {
104 const a = args[i];
105 if (std.mem.eql(u8, a, "--vt")) {
106 vt_mode = true;
107 } else if (std.mem.eql(u8, a, "--cols") and i + 1 < args.len) {
108 i += 1;
109 cols = std.fmt.parseInt(u16, args[i], 10) catch return usage();
110 } else if (std.mem.eql(u8, a, "--rows") and i + 1 < args.len) {
111 i += 1;
112 rows = std.fmt.parseInt(u16, args[i], 10) catch return usage();
113 } else {
114 return usage();
115 }
116 }
117
118 var stream: std.ArrayList(u8) = .empty;
119 defer stream.deinit(alloc);
120 var buf: [16 * 1024]u8 = undefined;
121 while (true) {
122 const n = try std.posix.read(std.posix.STDIN_FILENO, &buf);
123 if (n == 0) break;
124 try stream.appendSlice(alloc, buf[0..n]);
125 }
126
127 const eng = try Engine.init(alloc, .{ .cols = cols, .rows = rows });
128 defer eng.deinit();
129 feedForFinalGrid(eng, stream.items);
130
131 const out: []const u8 = if (vt_mode)
132 try eng.dumpVt(alloc)
133 else
134 try eng.dumpPlain(alloc);
135 defer alloc.free(out);
136 try writeAll(std.posix.STDOUT_FILENO, out);
137 // muxd dump appends one newline after the payload (main.zig dump());
138 // matching it exactly is what makes the two outputs diffable.
139 try writeAll(std.posix.STDOUT_FILENO, "\n");
140 return 0;
141 }
142
143 test "render: a stream that never touches the alternate screen renders as-is" {
144 const alloc = std.testing.allocator;
145 const e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
146 defer e.deinit();
147 feedForFinalGrid(e, "plain text");
148 const s = try e.dumpPlain(alloc);
149 defer alloc.free(s);
150 try std.testing.expectEqualStrings("plain text", s);
151 }
152
153 test "render: the grid is snapshotted at alt-screen exit, not after it" {
154 const alloc = std.testing.allocator;
155 const e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
156 defer e.deinit();
157 feedForFinalGrid(e, "\x1b[?1049hgrid under test\x1b[?1049lprimary junk");
158 const s = try e.dumpPlain(alloc);
159 defer alloc.free(s);
160 // The alt grid at the moment of exit — not the primary screen the
161 // exit would have revealed, and not the bytes painted after it.
162 try std.testing.expectEqualStrings("grid under test", s);
163 }
164
165 test "render: multiple alt cycles — the last exit wins" {
166 const alloc = std.testing.allocator;
167 const e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
168 defer e.deinit();
169 feedForFinalGrid(
170 e,
171 "\x1b[?1049hfirst\x1b[?1049l\x1b[?1049hsecond\x1b[?1049ltrailing",
172 );
173 const s = try e.dumpPlain(alloc);
174 defer alloc.free(s);
175 try std.testing.expectEqualStrings("second", s);
176 }
177
178 test "render: styled state survives replay identically to a direct feed" {
179 const alloc = std.testing.allocator;
180 const a = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
181 defer a.deinit();
182 const b = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
183 defer b.deinit();
184 const styled = "\x1b[1;31mbold red\x1b[0m plain";
185 feedForFinalGrid(a, styled);
186 b.feed(styled);
187 const va = try a.dumpVt(alloc);
188 defer alloc.free(va);
189 const vb = try b.dumpVt(alloc);
190 defer alloc.free(vb);
191 try std.testing.expectEqualStrings(vb, va);
192 }
193 ```
194
195 - [x] **Step 2: Wire into build.zig.** Four edits:
196
197 After the `delaypipe_mod` block (~line 208):
198
199 ```zig
200 // Replays a captured client stdout stream and prints the final grid in
201 // `muxd dump`'s formats — the client half of the M11 render-vs-dump
202 // convergence check. Imports the engine module so both sides of the
203 // diff go through the same ghostty-vt and the same formatter.
204 const render_mod = b.createModule(.{
205 .root_source_file = b.path("test/render.zig"),
206 .target = target,
207 .optimize = optimize,
208 });
209 render_mod.addImport("engine", engine_mod);
210 ```
211
212 After the `delaypipe_exe` install (~line 255):
213
214 ```zig
215 const render_exe = b.addExecutable(.{ .name = "render", .root_module = render_mod });
216 render_exe.use_llvm = true;
217 render_exe.use_lld = true;
218 b.installArtifact(render_exe);
219 ```
220
221 Add `render_mod` to the test-loop array (~line 266) — append after `spawn_mod`. A module absent from that array is a module whose tests silently never run (decisions.md hazard).
222
223 Add the 5th e2e artifact arg after `e2e.addArtifactArg(delaypipe_exe);` (~line 288):
224
225 ```zig
226 e2e.addArtifactArg(render_exe);
227 ```
228
229 - [x] **Step 3: Run the tests.** `make test` — expect the four render tests to pass among the total (193 → 197). If ghostty-vt import fails, compare against how `engine_mod` gets its import — render_mod itself must NOT import ghostty-vt directly; it reaches it through `engine`.
230
231 - [x] **Step 4: Fire the mutations (M9 rule — a test whose mutation doesn't fire doesn't count).** Two, one at a time, in the main checkout, reverted immediately after each:
232 1. In `feedForFinalGrid`, replace the body with `eng.feed(stream);` (drop the boundary). Run `make test`: tests 2 and 3 MUST fail ("primary junk"/"trailing" pollute the grid). Revert.
233 2. Replace `lastIndexOf` with `indexOf`. Run `make test`: test 3 MUST fail (grid says "first"). Revert.
234 Verify `git diff` is empty after the reverts.
235
236 - [x] **Step 5: Commit.**
237
238 ```bash
239 git add test/render.zig build.zig
240 git commit -m "test: render helper replays a client capture into a dump-format grid"
241 ```
242
243 ### Task 2: `assert_converged` + the control + first placement
244
245 **Files:**
246 - Modify: `test/e2e.sh` (header, helpers, base-flow placement)
247
248 - [x] **Step 1: Take the 5th argument.** After `DELAYPIPE="$4"` (line ~10):
249
250 ```sh
251 # M11 convergence: replays a client capture into a grid (test/render.zig).
252 RENDER="$5"
253 ```
254
255 - [x] **Step 2: Add the helpers**, after the `proxy_pid()` function and before `cleanup()`:
256
257 ```sh
258 # --- M11: render-vs-dump convergence -----------------------------------
259 # converged_quiet CLIENT_OUT SOCK — render the captured client stream and
260 # diff it against the daemon's grid, plain and styled. Nonzero on
261 # divergence, leaving CLIENT_OUT.{render,dump,diff,rvt,dvt} behind for
262 # inspection. CLIENT_OUT must be PURE client stdout: a capture taken with
263 # 2>&1 has exit messages and predict stats mixed into the escape stream.
264 converged_quiet() {
265 _co="$1"; _cs="$2"
266 "$RENDER" < "$_co" > "$_co.render" || return 1
267 "$MUXD" dump --sock "$_cs" > "$_co.dump" || return 1
268 # Trailing whitespace is a formatting difference between two correct
269 # grids (padded vs unpadded row ends), not a divergence.
270 sed 's/[[:space:]]*$//' "$_co.render" > "$_co.render.n"
271 sed 's/[[:space:]]*$//' "$_co.dump" > "$_co.dump.n"
272 diff -u "$_co.dump.n" "$_co.render.n" > "$_co.diff" || return 1
273 # Styled, byte-for-byte: both sides come out of the same formatter, so
274 # equal grids are equal bytes. This is the half that sees a bled SGR
275 # or a leftover prediction underline — plain text dumps the same glyph
276 # either way, which is exactly why plain alone cannot carry M9's
277 # overlay-never-becomes-state invariant.
278 "$RENDER" --vt < "$_co" > "$_co.rvt" || return 1
279 "$MUXD" dump --vt --sock "$_cs" > "$_co.dvt" || return 1
280 cmp -s "$_co.dvt" "$_co.rvt" || return 1
281 rm -f "$_co.render" "$_co.dump" "$_co.render.n" "$_co.dump.n" \
282 "$_co.diff" "$_co.rvt" "$_co.dvt"
283 return 0
284 }
285
286 # assert_converged CLIENT_OUT SOCK NAME — a scenario's LAST act, after
287 # quiesce and after the client detached: mid-scenario the dump is still
288 # moving, and another attach would claim the grid.
289 CONV_COUNT=0
290 assert_converged() {
291 CONV_COUNT=$((CONV_COUNT + 1))
292 converged_quiet "$1" "$2" || {
293 echo "e2e FAIL: $3: client render diverges from daemon grid (-daemon +client):"
294 head -40 "$1.diff" 2>/dev/null || \
295 echo "(no plain diff: divergence is styled-only, or the helper failed)"
296 echo "grids left in $1.render / $1.dump / $1.rvt / $1.dvt"
297 exit 1
298 }
299 }
300 ```
301
302 - [x] **Step 3: First placement + the control.** After the base scenario's three checks (immediately after the `kill -0 "$DPID"` detach check, line ~216):
303
304 ```sh
305 # 4. The M11 claim itself: the screen the client painted equals the
306 # screen the daemon holds — same engine, same formatter, both formats.
307 assert_converged "$OUT" "$SOCK" "base attach"
308
309 # The control: a doctored stream must NOT converge. A convergence check
310 # that cannot fail proves nothing (the wan.sh rule, M9).
311 cp "$OUT" "$OUT.doctored"
312 printf '\033[12;1Hconvergence-control-glyphs' >> "$OUT.doctored"
313 if converged_quiet "$OUT.doctored" "$SOCK"; then
314 echo "e2e FAIL: convergence control did not fire on a doctored stream"; exit 1
315 fi
316 rm -f "$OUT.doctored" "$OUT.doctored.render" "$OUT.doctored.dump" \
317 "$OUT.doctored.render.n" "$OUT.doctored.dump.n" "$OUT.doctored.diff" \
318 "$OUT.doctored.rvt" "$OUT.doctored.dvt"
319 echo "e2e OK: convergence control fires on a doctored stream"
320 ```
321
322 Add `"$OUT.doctored"` and its derived names to the `cleanup()` rm list (a run failing between cp and rm must not leak them). Derived convergence files from FAILING assert_converged calls are deliberately left behind as evidence — cleanup does not chase `*.render` etc. for every capture.
323
324 - [x] **Step 4: Run it.** `make e2e` — expect pass, with the new OK line. If the base convergence itself fails, STOP and diagnose from `$OUT.render`/`$OUT.dump` (see the context note: a landing failure is a candidate real defect; likely causes are a format mismatch in render.zig's trailing newline, or a genuine paint bug). Do not weaken the diff.
325
326 - [x] **Step 5: Commit.**
327
328 ```bash
329 git add test/e2e.sh
330 git commit -m "test: e2e asserts render-vs-dump convergence, with a control that fires"
331 ```
332
333 ### Task 3: stream splits + the placement sweep
334
335 **Files:**
336 - Modify: `test/e2e.sh` throughout
337
338 Every capture that today mixes stderr into the client stream gets split (`2>&1` → `2> "FILE.err"`), because the replay engine would otherwise paint exit messages and predict-stats lines into the grid. The stats-reading helpers (`want_stat`, `want_stat_ge`, `predict_stat` call sites) then read the `.err` sibling. Marker greps stay on the stdout file — session output is stdout. Failure diagnostics that `cat` a capture should cat both files.
339
340 - [x] **Step 1: Transport/M10 scenarios — split and place.** Work top-to-bottom; line numbers are pre-edit anchors, verify each against content:
341
342 | # | Scenario (anchor) | Split | Placement (after) |
343 |---|---|---|---|
344 | 1 | M3 reattach `$OUT.re` (~226) | none needed | the `grep -q "60"` check: `assert_converged "$OUT.re" "$SOCK" "reattach after kill"` |
345 | 2 | M5 `$OUT.b` (~239) | none | the B checks + daemon-alive (~250): `assert_converged "$OUT.b" "$SOCK" "two clients"` |
346 | 3 | M6 `$OUT.via` (~265) | none | daemon-alive (~269): `assert_converged "$OUT.via" "$SOCK" "via transport"` |
347 | 4 | M7A `$OUT.m7` (~350) | `2>&1` → `2> "$OUT.m7.err"` | snapshot-equality check (~387): `assert_converged "$OUT.m7" "$SOCK" "delta resume"` |
348 | 5 | M7B `$OUT.m7b` (~403) | → `2> "$OUT.m7b.err"` | `SNAPS_NEW` check (~450): `assert_converged "$OUT.m7b" "$SOCK3" "epoch resync"` |
349 | 6 | M8 flags-ok `$OUT.q` (~573) | none (already pure) | its dump grep + daemon-alive (~580): `assert_converged "$OUT.q" "$SOCK4" "quic daemon serves sockets"` |
350 | 7 | quic attach `$OUT.qc` (~590) | → `2> "$OUT.qc.err"` | its dump grep (~601): `assert_converged "$OUT.qc" "$SOCK4" "quic attach"` |
351 | 8 | quic reattach `$OUT.qc` (~611) | → `2> "$OUT.qc.err"` | the clients=0 check (~635): `assert_converged "$OUT.qc" "$SOCK4" "quic reattach"` |
352 | 9 | wrong-key (~644) | KEEP `2>&1` (greps stderr's "did not answer"; refusal — declined, comment in place) | — |
353 | 10 | quic tear `$OUT.qr` (~701) | → `2> "$OUT.qr.err"` | the delta-not-snapshot counter check (~738): `assert_converged "$OUT.qr" "$SOCK4" "quic delta resume"` |
354 | 11 | quic restart `$OUT.qk` (~753) | → `2> "$OUT.qk.err"` | the `SNAPS_NQ` check (~809), BEFORE `kill "$D4PID"`: `assert_converged "$OUT.qk" "$SOCK4" "quic epoch resync"` |
355 | 12 | envkey `$OUT.env1` (~830) | → `2> "$OUT.env1.err"` | its marker grep, BEFORE `kill "$D9PID"`: `assert_converged "$OUT.env1" "$SOCK9" "env key"` |
356 | 13 | flagwins `$OUT.env2` (~858) | → `2> "$OUT.env2.err"` | its marker grep, BEFORE `kill "$D10PID"`: `assert_converged "$OUT.env2" "$SOCK10" "flag beats env"` |
357 | 14 | start `$OUT.s8` (~898) | → `2> "$OUT.s8.err"` | its dump grep (~901): `assert_converged "$OUT.s8" "$SOCK8" "started daemon"` |
358 | 15 | race client `> /dev/null 2>&1` (~943) | → `> "$OUT.race" 2> /dev/null` | the head-on pin's final grep (~965), BEFORE `kill "$SPID"`: `assert_converged "$OUT.race" "$SOCK8" "start race"` |
359 | 16 | goal `$OUT.g9` (~1011) | → `2> "$OUT.g9.err"` | its dump grep, BEFORE `kill "$GPID"`: `assert_converged "$OUT.g9" "$SOCK11" "goal commands"` |
360
361 Declined, each with a one-line comment AT the site (the spec requires exceptions named in place): `--version`/keygen (no daemon), the `refuse` block (no daemon survives), `--via` death + dead-first-transport (no session), abort-during-reconnect + abort-during-handshake (never attached / reconnecting), wrong-key (refusal), doomed-child (no daemon), `$OUT.kill` (stream truncated by `kill -9` mid-paint — possibly mid-escape-sequence), `$OUT.a` (capture ends before B's marker landed; B's convergence covers this grid).
362
363 - [x] **Step 2: Run.** `make e2e` — green before touching the prediction scenarios. Any divergence: diagnose from the evidence files; a real paint/replica defect gets its own fix commit before proceeding.
364
365 - [x] **Step 3: Commit.**
366
367 ```bash
368 git add test/e2e.sh
369 git commit -m "test: convergence on every transport scenario that ends with a live daemon"
370 ```
371
372 - [x] **Step 4: Prediction scenarios — split, retarget stats, place.**
373
374 | # | Scenario | Split | Stats reads move to | Placement (after) |
375 |---|---|---|---|---|
376 | 17 | line mode `$OUT.p1` (~1065) | → `2> "$OUT.p1.err"` | `want_stat`/`predict_stat` for "line mode" → `$OUT.p1.err` | the daemon-grid `z` check (~1102): `assert_converged "$OUT.p1" "$SOCK5" "line-mode prediction"` |
377 | 18 | burst `$OUT.pb` (~1114) | → `2> "$OUT.pb.err"` | all four burst `want_stat*` → `$OUT.pb.err` | the daemon-grid `burst` check (~1139): `assert_converged "$OUT.pb" "$SOCK5" "burst"` |
378 | 19 | password `$OUT.pw` (~1174) | → `2> "$OUT.pw.err"` | both password `want_stat` → `$OUT.pw.err` | the `pw-len-7` dump check (~1195): `assert_converged "$OUT.pw" "$SOCK6" "password"` |
379 | 20 | raw mode `$OUT.rw` (~1212) | → `2> "$OUT.rw.err"` | all seven `want_stat` + the three `predict_stat` reads → `$OUT.rw.err` | the accounting check (~1240): `assert_converged "$OUT.rw" "$SOCK7" "raw mode"` |
380 | 21 | predict reconnect `$OUT.pr` (~1258) | → `2> "$OUT.pr.err"` | `PR_*` reads + expired `want_stat` → `$OUT.pr.err` | the post-tear dump check (~1303): `assert_converged "$OUT.pr" "$SOCK5" "reconnect flush"` |
381
382 Details that must not be missed:
383 - The early-snapshot copy (`cp "$OUT.p1" "$OUT.p1.early"`) and its two greps are stdout-only — unchanged.
384 - The password secret check becomes both-files: `if grep -q "hunter2" "$OUT.pw" "$OUT.pw.err"; then` — the secret must appear in NEITHER stream the client wrote.
385 - Failure diagnostics: where a scenario currently does `cat "$OUT.pb"` on a bad exit code, make it `cat "$OUT.pb" "$OUT.pb.err" 2>/dev/null`.
386 - `wait_for` calls watch markers (stdout) — unchanged.
387 - Add every new `.err` file and `$OUT.race` to the `cleanup()` rm list.
388 - Raw mode (#20) is the scenario most likely to FAIL on landing — the expired `j` phantom-glyph candidate from the context notes. If it does: that is a finding, handled as a client fix (its own commit, with the convergence check as the regression test), not a declined scenario.
389
390 - [x] **Step 5: Run.** `make e2e` green.
391
392 - [x] **Step 6: Commit.**
393
394 ```bash
395 git add test/e2e.sh
396 git commit -m "test: convergence on the prediction scenarios; stats reads move to split stderr"
397 ```
398
399 ### Task 4: the scenario-count pin
400
401 **Files:**
402 - Modify: `test/e2e.sh`
403
404 - [x] **Step 1: Add `ok` next to the convergence helpers:**
405
406 ```sh
407 # Scenario checkpoints. The suite's final line asserts the COUNT of these
408 # against a literal: adding or removing a scenario means updating that
409 # literal, and the friction is the feature — a scenario that silently
410 # stops running is the failure mode the pin exists for.
411 OK_COUNT=0
412 ok() {
413 OK_COUNT=$((OK_COUNT + 1))
414 echo "e2e OK: $1"
415 }
416 ```
417
418 - [x] **Step 2: Convert every `echo "e2e OK: ..."` line to `ok "..."`.** There are 9 after Task 2 (the original 8 plus the convergence control). `grep -n 'e2e OK' test/e2e.sh` to find them; none may remain as bare echoes except the final summary line (next step).
419
420 - [x] **Step 3: Pin both counts at the end.** Replace the final `echo "e2e OK"` with:
421
422 ```sh
423 # The pins. Literals, not variables set from counting something else —
424 # "assert the literal, never the constant the code under test reads"
425 # (decisions.md, M10). 9 scenario checkpoints; 21 convergence points.
426 # Anyone adding a scenario updates these by hand, on purpose.
427 [ "$OK_COUNT" = "9" ] || {
428 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 9 —"
429 echo " a scenario was added (update the pin) or silently lost"
430 exit 1
431 }
432 [ "$CONV_COUNT" = "21" ] || {
433 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 21"
434 exit 1
435 }
436 echo "e2e OK (9 scenarios, 21 convergence points)"
437 ```
438
439 The literals 9 and 21 are the expected values from Tasks 2–3; if the sweep landed a different count, set the literal to what actually runs (count the call sites) — the pin's job is to freeze reality, not this plan's guess. Record the final numbers in the commit message.
440
441 - [x] **Step 4: Fire the mutation.** Comment out one `ok` call (the keygen one), run `make e2e`: MUST fail with the checkpoint-pin message. Restore, comment out one `assert_converged` call, run: MUST fail with the convergence-pin message. Restore, `git diff` shows only the intended Task 4 edits.
442
443 - [x] **Step 5: Run + commit.** `make e2e` green.
444
445 ```bash
446 git add test/e2e.sh
447 git commit -m "test: pin scenario and convergence counts to literals"
448 ```
449
450 ### Task 5: `test/soak.sh` + `make soak`
451
452 **Files:**
453 - Create: `test/soak.sh` (mode 755)
454 - Modify: `build.zig`, `Makefile`
455
456 - [x] **Step 1: Write `test/soak.sh`:**
457
458 ```sh
459 #!/bin/sh
460 # Runs the full e2e suite SOAK_N times (default 10, ~20min) and reports a
461 # per-failure table in the decisions.md convention: which FAIL line, from
462 # which run. Serial on purpose — each e2e run isolates by its own $$, and
463 # the between-runs hygiene check below assumes nothing else is using the
464 # same temp-file patterns while it looks (a concurrently running suite
465 # would read as a leak).
466 set -u
467 MUXD="$1"; MUX="$2"; RAWMODE="$3"; DELAYPIPE="$4"; RENDER="$5"
468 E2E="$(dirname "$0")/e2e.sh"
469 N="${SOAK_N:-10}"
470 TMP="${TMPDIR:-/tmp}"
471 FAILDIR="$TMP/mux-soak-$$-failures"
472 SUMMARY="$TMP/mux-soak-$$.summary"
473 LOG="$TMP/mux-soak-$$.log"
474 : > "$SUMMARY"
475 FAILED=0
476 i=1
477 while [ "$i" -le "$N" ]; do
478 if "$E2E" "$MUXD" "$MUX" "$RAWMODE" "$DELAYPIPE" "$RENDER" > "$LOG" 2>&1; then
479 echo "soak run $i/$N: PASS"
480 else
481 FAILED=$((FAILED + 1))
482 FL=$(grep 'e2e FAIL' "$LOG" | head -1)
483 FL="${FL:-exited nonzero with no FAIL line}"
484 echo "soak run $i/$N: FAIL — $FL"
485 printf 'run %s: %s\n' "$i" "$FL" >> "$SUMMARY"
486 mkdir -p "$FAILDIR"
487 cp "$LOG" "$FAILDIR/run$i.log"
488 # A failing run deliberately leaves grid evidence behind; sweep it
489 # into the failure dir so the hygiene check below stays meaningful
490 # for the NEXT run.
491 find "$TMP" -maxdepth 1 \( -name 'muxd-e2e-*' -o -name 'mux-e2e-*' \) \
492 -exec mv {} "$FAILDIR/" \; 2>/dev/null
493 fi
494 # Per-run tmp hygiene: a leak in run 3 must not blame run 7.
495 STRAYS=$(find "$TMP" -maxdepth 1 \( -name 'muxd-e2e-*' -o -name 'mux-e2e-*' \) 2>/dev/null | wc -l)
496 if [ "$STRAYS" -ne 0 ]; then
497 echo "soak FAIL: run $i left $STRAYS temp files behind:"
498 find "$TMP" -maxdepth 1 \( -name 'muxd-e2e-*' -o -name 'mux-e2e-*' \)
499 FAILED=$((FAILED + 1))
500 printf 'run %s: left %s temp files\n' "$i" "$STRAYS" >> "$SUMMARY"
501 fi
502 i=$((i + 1))
503 done
504 rm -f "$LOG"
505 echo "---"
506 if [ "$FAILED" -eq 0 ]; then
507 echo "soak OK: $N/$N runs green"
508 rm -f "$SUMMARY"
509 exit 0
510 fi
511 # The table: failure line -> count/N, with run attribution underneath.
512 echo "soak FAIL: $FAILED of $N runs failed (logs in $FAILDIR):"
513 sed 's/^run [0-9]*: //' "$SUMMARY" | sort | uniq -c | sort -rn | \
514 while read -r c l; do echo " $c/$N $l"; done
515 cat "$SUMMARY"
516 exit 1
517 ```
518
519 - [x] **Step 2: Wire the build.** In `build.zig`, after the e2e step:
520
521 ```zig
522 const soak = b.addSystemCommand(&.{"test/soak.sh"});
523 soak.addArtifactArg(exe);
524 soak.addArtifactArg(mux_exe);
525 soak.addArtifactArg(rawmode_exe);
526 soak.addArtifactArg(delaypipe_exe);
527 soak.addArtifactArg(render_exe);
528 const soak_step = b.step("soak", "Run the e2e suite SOAK_N times (default 10)");
529 soak_step.dependOn(&soak.step);
530 ```
531
532 In `Makefile`, add `soak` to `.PHONY` and:
533
534 ```make
535 soak:
536 $(ZIG) build soak
537 ```
538
539 - [x] **Step 3: Smoke it.** `chmod +x test/soak.sh`, then `SOAK_N=1 make soak` — one green run, "soak OK: 1/1". Then verify the failure path fires: `SOAK_N=1` with a deliberately broken pin (temporarily set the OK literal to 99), expect "soak FAIL: 1 of 1" plus the table naming the pin line. Restore the pin, `git diff` clean apart from Task 5 files.
540
541 - [x] **Step 4: Commit.**
542
543 ```bash
544 git add test/soak.sh build.zig Makefile
545 git commit -m "test: make soak repeats the suite and attributes failures to runs"
546 ```
547
548 ### Task 6: kill-criterion leg 1 — the soak run
549
550 - [x] **Step 1:** `make test && make e2e` green (full, fresh).
551 - [x] **Step 2:** `SOAK_N=10 make soak` (~25 min — run it in the background and wait; do not shorten N: the criterion says NOT EXERCISED below 10).
552 - [x] **Step 3:** If any run fails: diagnose per the quiesce rule (lengthen that scenario's pre-detach settle; never loosen a diff), fix, commit the fix, and RESTART the count at run 1 — 10/10 means ten consecutive greens on the final code.
553 - [x] **Step 4:** Record the result (N/N, wall clock per run, any fixes made) in the commit message of a docs touch or in the final close notes — this is kill-criterion evidence.
554
555 ---
556
557 ## Phase 2 — the mutation campaign
558
559 Not code: a recorded experiment. Read the spec's Phase 2 section before starting. Rules that bind every task below: one mutation at a time; mutations live ONLY in the worktree, never the main checkout; restore verified by `git diff --stat` (empty) before the next; kill stranded daemons by tracked pid only; every mutation ends with a row in the table — caught/survived under BOTH suites — and no row may be left undecided.
560
561 ### Task 7: campaign infrastructure + the control run
562
563 - [x] **Step 1: Worktree.** `git worktree add ../mux-m11-campaign main` (or the repo's worktree convention). Copy the QUIC dep outputs so the first build doesn't refetch: `cp -r deps/quic/out deps/quic/work ../mux-m11-campaign/deps/quic/`.
564 - [x] **Step 2: The old suite.** Record OLD_SUITE_SHA = the commit BEFORE Task 2's first e2e edit (find it: `git log --oneline -- test/e2e.sh`, the commit before "render-vs-dump convergence"). Extract: `git show OLD_SUITE_SHA:test/e2e.sh > ../mux-m11-campaign/e2e-old.sh && chmod +x ../mux-m11-campaign/e2e-old.sh`. It takes FOUR args (no RENDER).
565 - [x] **Step 3: The campaign doc.** Create `docs/superpowers/plans/2026-08-09-m11-campaign.md` (in the MAIN checkout — results are not mutations) with the methodology header (old-suite sha, binary provenance, the per-mutation procedure below) and an empty table: `| # | site | mutation | old suite | new suite | disposition |`.
566 - [x] **Step 4: The control run — both suites green on UNMUTATED code.** In the worktree: `~/Downloads/zig-x86_64-linux-0.15.2/zig build`, then `./e2e-old.sh zig-out/bin/muxd zig-out/bin/mux zig-out/bin/rawmode zig-out/bin/delaypipe` and `test/e2e.sh <same four> zig-out/bin/render`. Both must pass — a campaign whose baseline is red measures nothing. Record the control row in the table.
567 - [x] **Step 5: Commit the campaign doc.** `git add docs/superpowers/plans/2026-08-09-m11-campaign.md && git commit -m "docs: M11 campaign methodology and control run"`
568
569 **Per-mutation procedure (referenced by Tasks 8–11):** in the worktree — (1) apply the single edit; (2) rebuild with the pinned zig (`zig build` — remember `make test` does not rebuild binaries); (3) run the OLD suite, record caught (which FAIL line) or survived; (4) run the NEW suite, record the same; (5) `git checkout -- src/ test/` and verify `git diff --stat` empty; (6) append the row to the campaign doc in the main checkout and commit it (`docs: campaign row N`, batched per task is fine). A mutation that fails to COMPILE is not a mutation — rework it until it builds (the M10 lesson: inferred error sets can make an edit uncompilable; pick a different expression of the same break).
570
571 ### Task 8: paint-path mutations (client) — the demonstration case
572
573 These are the mutations the old suite is structurally blind to (markers grep the byte stream; a wrong paint of a right byte passes). The kill criterion requires caught-new/survived-old to be demonstrated here.
574
575 - [x] **Mutation 1 — drop the last row of a delta paint.** In `client.zig`'s delta-paint loop (anchor: the `\x1b[{d};1H\x1b[2K` row paint, ~line 973): skip the final row of each delta's row set.
576 - [x] **Mutation 2 — paint one row off.** Same loop: paint at `row.row + 2` instead of `row.row + 1` (CUP is 1-based; +2 shifts every delta row down one).
577 - [x] **Mutation 3 — skip the clear on a full repaint.** In `paintFull` (anchor: `\x1b[?2026h\x1b[?25l\x1b[H\x1b[2J`, ~line 941): drop the `\x1b[2J`. Stale glyphs survive under new content.
578 - [x] **Mutation 4 — SGR bleed.** In the delta row paint: if the row's bytes start with `\x1b[0m`, strip those 4 bytes before writing, so the previous row's style bleeds in. Expect: plain diff may pass, STYLED diff catches — record which.
579 - [x] **Mutation 5 — omit the synchronized-update wrapper.** Drop `\x1b[?2026h`/`\x1b[?2026l` from the delta paint. Expected honestly: BOTH suites may miss (2026 is a flicker property, not a state property, and it is pinned at the unit layer — client tests assert its presence). If both miss, that is a finding for Task 12: the likely disposition is banked-with-reason, but write it down, don't presume it.
580 - [x] Run the per-mutation procedure for each; commit the rows.
581
582 ### Task 9: replica-sync + prediction-overlay mutations (client)
583
584 - [x] **Mutation 6 — apply a delta but skip feeding the replica engine.** Screen paints from the payload; the replica goes stale; reconcile and any replica-sourced repaint diverge.
585 - [x] **Mutation 7 — ignore a snapshot's cols/rows prefix.** Skip the resize-to-prefix on snapshot apply. Expected honestly: likely BOTH miss (every e2e grid is 80×24 and there is no path to another size in the suite). A finding either way — the disposition question for Task 12 is whether a non-default-size scenario is buildable or the gap is banked with that reason.
586 - [x] **Mutation 8 — accept a delta for a stale seq without requesting resync.** In the client's delta arm (anchor: `last_seq = composed.header.seq`, ~line 772): remove the gap-detection/resync request so an out-of-sequence delta is applied anyway.
587 - [x] **Mutation 9 — leave a confirmed prediction's underline on.** In the overlay's confirm path: keep the confirmed cell in the painted set. The styled diff is the only e2e eye that can see this; counters stay correct.
588 - [x] **Mutation 10 — feed a prediction into the replica engine (the M9 invariant).** Where the overlay records a prediction, also `replica.feed(...)` the predicted byte.
589 - [x] Run the procedure; commit the rows.
590
591 ### Task 10: daemon-side mutations — delta production and seq/epoch (server)
592
593 - [x] **Mutation 11 — skip one dirty row.** In `DeltaTracker.buildDeltaSince` (~line 196) or `update` (~149): omit the highest-numbered dirty row from the payload (the prompt row, typically — marker greps live higher).
594 - [x] **Mutation 12 — emit rows in the wrong order.** Reverse the row order in the delta payload. Expected honestly: rows are absolutely addressed by CUP, so the final grid may be identical and BOTH suites may miss — a disposition-by-reason candidate.
595 - [x] **Mutation 13 — mark a changed row clean.** In the tracker's change detection: never mark row 0 dirty.
596 - [x] **Mutation 14 — reuse a seq.** Don't increment the seq for one update in each pair (every second delta repeats its predecessor's seq).
597 - [x] **Mutation 15 — answer a reconnect with a delta when a snapshot is owed.** In the attach/reconnect handler (~line 1093 area): serve `buildDeltaSince` even when the client's `have_seq` is below the tracker's floor. Note: the old suite has real teeth here (the restart scenarios assert snapshot counters) — this row is the control showing the old suite catching what it was built to catch.
598 - [x] **Mutation 16 — wrong epoch in the snapshot prefix.** Send `epoch + 1` in the snapshot prefix the daemon writes.
599 - [x] Run the procedure; commit the rows.
600
601 ### Task 11: scrollback/clipping mutations (client)
602
603 - [x] **Mutation 17 — clip bound off-by-one.** In the client's paint clipping (anchor: the unit test asserting `\x1b[24;80H` reachable and `\x1b[25;` never emitted, ~line 1780): clamp one row short, so the last grid row is never painted.
604 - [x] **Mutation 18 — scroll position not reset on resync.** In the resync path (anchor: `repaint_after_resync`, ~line 585–632): skip leaving the scroll view on resync. Expected honestly: scroll view is tty-only (Shift+PageUp), so e2e likely can't see it under EITHER suite — unit tests at ~line 1653 pin it. Record and disposition.
605 - [x] Run the procedure; commit the rows. Then remove the campaign worktree: `git worktree remove ../mux-m11-campaign` (after confirming `git -C ../mux-m11-campaign diff --stat` is empty).
606
607 ### Task 12: findings resolution — the recording rule
608
609 - [x] **Step 1:** For every row where BOTH suites missed, decide per the recording rule — no third bucket:
610 - (a) **new assertion**, landed now in `test/e2e.sh` (or the unit layer if that is where the property lives), with the mutation RE-RUN in a fresh worktree to prove the new assertion fires — the checkbox for each such fix is that demonstrated failure; or
611 - (b) **banked**, as a written entry (goes into decisions.md at close) stating exactly why the gap is accepted and what would reopen it.
612 - [x] **Step 2:** Update the campaign table's disposition column for every row; no row may read "noted".
613 - [x] **Step 3:** If new assertions changed the convergence or checkpoint counts, update the Task 4 literals and say so in the commit.
614 - [x] **Step 4:** Re-run `make test && make e2e` green; if e2e assertions were added, re-run `SOAK_N=10 make soak` for the final code (leg 1 must hold on what ships).
615 - [x] **Step 5:** Commit: `git commit -m "test: assertions landed from campaign findings; campaign table complete"`
616
617 ### Task 13: milestone close
618
619 - [x] **Step 1:** `docs/decisions.md` — new M11 section: the campaign table IN FULL (it is the milestone's measurement, as M9's latency table was), the kill-criterion evidence for both legs (soak 10/10 with wall clocks; paint-path caught-new/survived-old rows called out), any defects found en route (the raw-mode phantom-glyph candidate, if it fired), and the banked entries from Task 12 with their reasons.
620 - [x] **Step 2:** `docs/roadmap.md` — M11 section marked complete with a verdict paragraph; ASAN/valgrind and the unit-layer mutation sweep stay banked, each annotated with what the campaign taught about them; delete the campaign scratch doc (`git rm docs/superpowers/plans/2026-08-09-m11-campaign.md`) once its table lives in decisions.md.
621 - [x] **Step 3:** Tick this plan's checkboxes; commit: `git commit -m "docs: M11 close — convergence harness, pins, soak, campaign table"`
622
623 ---
624
625 ## Self-review (performed at write time)
626
627 - **Spec coverage:** §1 render.zig → Task 1 (restore-boundary unit-pinned; `--cols/--rows`; dump-format parity). §2 assert_converged → Tasks 2–3 (normalization, placement discipline, prediction interaction via the styled compare, exceptions named in place). §3 pin → Task 4. §4 soak → Tasks 5–6. Phase 2 minimum set → Tasks 8–11 (all six sites, 18 mutations). Recording rule → Task 12. Kill criterion legs → Tasks 6 and 8/12. Close → Task 13.
628 - **Beyond-spec decisions this plan makes** (flag to the reviewer): the styled (`--vt`) second compare — required for the prediction-underline and SGR-bleed sites, which plain text cannot distinguish; the convergence control scenario; the CONV_COUNT pin (mechanizes "every scenario ends in assert_converged"); stream splits as a precondition for replayability.
629 - **Known honest expectations:** mutations 5, 7, 12, 18 may survive both suites; the plan says so in advance so a both-miss is treated as the recording rule's input, not as a surprise.
630 - **Type consistency:** `feedForFinalGrid`/`converged_quiet`/`assert_converged`/`ok`/`CONV_COUNT`/`OK_COUNT` used consistently; render takes 5th e2e argv slot everywhere it is passed (e2e.addArtifactArg, soak.sh, soak build step, e2e.sh header).
docs/superpowers/plans/2026-08-10-m12-ptyclient.md
Old New
@@ -1,989 +0,0 @@
1 # M12 ptyclient 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 > Executed in full; verdicts and deviations recorded in decisions.md M12.
5
6 **Goal:** A pty-driving e2e client fixture (`test/ptyclient.zig`) that opens the client's tty-gated branches to the suite, plus the two scenarios that pay M11's named debts, graded by resurrecting campaign rows 7 and 18 and the 412f38f revert — all three must go from unscorable to caught.
7
8 **Architecture:** `src/pty.zig` grows a spawn-argv variant (the daemon path is untouched); `test/ptyclient.zig` wraps it in a four-verb script engine (`send`/`expect`/`resize`/`waitexit`) whose master-side reads tee continuously into the same capture files `assert_converged` already consumes. Two new e2e scenarios (tp1 reconnect-while-scrolled, tp2 resize) plus fixture controls; pins bumped as literals; regrade in a detached worktree.
9
10 **Tech Stack:** Zig 0.15.2 (pinned — build ONLY via `make build` / `make test` / `make e2e` / `make soak`; the system zig cannot build this repo), POSIX sh for e2e.
11
12 ---
13
14 ## Zig 0.15.2 API notes (repo idiom — deviations will not compile)
15
16 - `std.ArrayList(u8)` is unmanaged: init with `.empty`, then `list.append(alloc, x)`, `list.appendSlice(alloc, s)`, `list.deinit(alloc)`.
17 - Allocator in mains: `std.heap.DebugAllocator(.{}){}` (see `test/render.zig` for the exact spelling).
18 - `std.posix` for syscalls; `std.time.milliTimestamp()` for deadlines.
19 - New executables need `.use_llvm = true; .use_lld = true;` (self-hosted linker chokes on this system's crt1.o).
20 - e2e is POSIX sh (`#!/bin/sh`, `set -eu`): no bashisms, no `pkill` ever, kill only by tracked pid.
21
22 ## Standing rules that bind every task here
23
24 1. **Write the mutation first** where the plan says so — an assertion is landed only after it has been seen to fail.
25 2. **Assert the literal**, never the constant the code under test reads.
26 3. **No sleeps for synchronization** — every wait is expect/poll with a computed deadline, and the computation is a comment at the site.
27 4. Scenario evidence files are removed on success, left on failure.
28
29 ---
30
31 ### Task 1: `spawnArgv` in src/pty.zig
32
33 **Files:**
34 - Modify: `src/pty.zig` (spawn is at :21–:68; tests + `readUntil` helper live at :122 onward)
35
36 - [ ] **Step 1: Write the failing tests** (append to `src/pty.zig`, after the existing tests; `readUntil` is already defined at file scope and available):
37
38 ```zig
39 test "Pty: spawnArgv runs an argv and propagates exit status" {
40 var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "printf argv-ok; exit 7" };
41 var pty = try Pty.spawnArgv(.{ .cols = 80, .rows = 24, .argv = &argv });
42 defer pty.deinit();
43
44 var out = try readUntil(std.testing.allocator, &pty, "argv-ok", 5000);
45 defer out.deinit(std.testing.allocator);
46 try std.testing.expect(std.mem.indexOf(u8, out.items, "argv-ok") != null);
47
48 // Reap with a bounded poll: the child has already written its last byte,
49 // so 5s is a budget for a loaded box, not for the operation.
50 var waited_ms: u64 = 0;
51 while (pty.checkExited() == null and waited_ms < 5000) {
52 std.Thread.sleep(100 * std.time.ns_per_ms);
53 waited_ms += 100;
54 }
55 try std.testing.expectEqual(@as(?u32, 7), pty.checkExited());
56 }
57
58 test "Pty: spawnArgv applies the requested winsize" {
59 // stty prints "rows cols" as read off its own tty: 31 101 proves the
60 // winsize survived forkpty, not that a default happened to match.
61 var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "stty size" };
62 var pty = try Pty.spawnArgv(.{ .cols = 101, .rows = 31, .argv = &argv });
63 defer pty.deinit();
64 var out = try readUntil(std.testing.allocator, &pty, "31 101", 5000);
65 defer out.deinit(std.testing.allocator);
66 try std.testing.expect(std.mem.indexOf(u8, out.items, "31 101") != null);
67 }
68
69 test "Pty: spawnArgv redirects stderr off the pty when asked" {
70 const pipe = try std.posix.pipe();
71 defer std.posix.close(pipe[0]);
72 var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "echo to-err 1>&2; printf to-out" };
73 var pty = try Pty.spawnArgv(.{
74 .cols = 80,
75 .rows = 24,
76 .argv = &argv,
77 .stderr_fd = pipe[1],
78 });
79 defer pty.deinit();
80 std.posix.close(pipe[1]); // parent's copy, or the read below never EOFs
81
82 var out = try readUntil(std.testing.allocator, &pty, "to-out", 5000);
83 defer out.deinit(std.testing.allocator);
84 try std.testing.expect(std.mem.indexOf(u8, out.items, "to-out") != null);
85 try std.testing.expect(std.mem.indexOf(u8, out.items, "to-err") == null);
86
87 var errbuf: [64]u8 = undefined;
88 const n = try std.posix.read(pipe[0], &errbuf);
89 try std.testing.expect(std.mem.indexOf(u8, errbuf[0..n], "to-err") != null);
90 }
91 ```
92
93 - [ ] **Step 2: Run to verify they fail**
94
95 Run: `make test`
96 Expected: compile error — `Pty` has no member `spawnArgv`.
97
98 - [ ] **Step 3: Implement.** Add to `src/pty.zig` after `spawn`, and refactor `spawn`'s body to call it so there is exactly one child-setup path (the SIG_DFL comment block at :38–:60 moves with the code, verbatim — it records a WAN-measured bug):
99
100 ```zig
101 pub const SpawnArgvOptions = struct {
102 cols: u16,
103 rows: u16,
104 /// Null-terminated argv; argv[0] is an absolute path (execve, no
105 /// PATH search — every caller in this repo holds artifact paths).
106 argv: [*:null]const ?[*:0]const u8,
107 /// When set, the child's stderr goes here instead of the pty slave.
108 /// The e2e fixture uses this to keep predict stats out of the
109 /// capture, matching the suite's `.err` sibling convention.
110 stderr_fd: ?std.posix.fd_t = null,
111 };
112
113 pub fn spawnArgv(opts: SpawnArgvOptions) !Pty {
114 var master: c_int = undefined;
115 var ws: c.struct_winsize = .{
116 .ws_row = opts.rows,
117 .ws_col = opts.cols,
118 .ws_xpixel = 0,
119 .ws_ypixel = 0,
120 };
121
122 const pid = c.forkpty(&master, null, null, &ws);
123 if (pid < 0) return error.ForkPtyFailed;
124
125 if (pid == 0) {
126 _ = c.setenv("TERM", "xterm-256color", 1);
127 // [the existing SIG_DFL comment block and the three sigaction
128 // resets from spawn() move here unchanged]
129 if (opts.stderr_fd) |fd| {
130 std.posix.dup2(fd, 2) catch std.process.exit(126);
131 }
132 const argv0 = opts.argv[0] orelse std.process.exit(127);
133 std.posix.execveZ(argv0, opts.argv, std.c.environ) catch {};
134 std.process.exit(127);
135 }
136
137 return .{ .master = master, .child = pid };
138 }
139 ```
140
141 and `spawn` becomes:
142
143 ```zig
144 pub fn spawn(opts: SpawnOptions) !Pty {
145 var argv = [_:null]?[*:0]const u8{opts.shell.ptr};
146 return spawnArgv(.{ .cols = opts.cols, .rows = opts.rows, .argv = &argv });
147 }
148 ```
149
150 - [ ] **Step 4: Run to verify all pty tests pass** (the two existing Pty tests are the refactor's guard)
151
152 Run: `make test`
153 Expected: PASS, including `Pty: spawn /bin/sh, echo round trip` and the SIGINT test.
154
155 - [ ] **Step 5: Commit**
156
157 ```bash
158 git add src/pty.zig
159 git commit -m "feat: pty.zig spawnArgv — one child-setup path, argv + stderr redirect"
160 ```
161
162 ---
163
164 ### Task 2: ptyclient script engine (pure parts) + build wiring
165
166 **Files:**
167 - Create: `test/ptyclient.zig` (parsing + Expecter + tests this task; main loop is Task 3)
168 - Modify: `build.zig` (module at ~:212 area, exe at ~:271 area, test loop :282, e2e args :298–:305, soak args :309–:314)
169 - Modify: `test/e2e.sh:12` (accept `$6`), `test/soak.sh:9` and its e2e invocation (accept and pass `$6`)
170
171 - [ ] **Step 1: Write the failing tests.** Create `test/ptyclient.zig`:
172
173 ```zig
174 //! e2e fixture: runs the real mux client on the slave side of a pty it
175 //! owns, so `isatty()` answers yes and the tty-gated branches open. Driven
176 //! by a line-oriented script on stdin; everything read from the master
177 //! tees into --out so the convergence machinery consumes the same capture
178 //! files non-tty scenarios produce. Spec: docs/superpowers/specs/
179 //! 2026-08-10-m12-ptyclient-design.md.
180 const std = @import("std");
181 const Pty = @import("pty").Pty;
182
183 /// C-style escapes: \xNN, \n, \r, \t, \\. Anything else after a backslash
184 /// is an error — a typo'd escape must fail loudly, not send mystery bytes.
185 fn decodeEscapes(alloc: std.mem.Allocator, s: []const u8) ![]u8 {
186 var out: std.ArrayList(u8) = .empty;
187 errdefer out.deinit(alloc);
188 var i: usize = 0;
189 while (i < s.len) : (i += 1) {
190 if (s[i] != '\\') {
191 try out.append(alloc, s[i]);
192 continue;
193 }
194 i += 1;
195 if (i >= s.len) return error.BadEscape;
196 switch (s[i]) {
197 'n' => try out.append(alloc, '\n'),
198 'r' => try out.append(alloc, '\r'),
199 't' => try out.append(alloc, '\t'),
200 '\\' => try out.append(alloc, '\\'),
201 'x' => {
202 if (i + 2 >= s.len) return error.BadEscape;
203 const b = std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16) catch
204 return error.BadEscape;
205 try out.append(alloc, b);
206 i += 2;
207 },
208 else => return error.BadEscape,
209 }
210 }
211 return out.toOwnedSlice(alloc);
212 }
213
214 /// Accumulates everything read off the master and matches needles with
215 /// expect(1) semantics: the search starts at a cursor, and a match
216 /// advances the cursor past itself. Without the cursor, a needle painted
217 /// BEFORE the previous verb would satisfy this one — tp1's post-scroll
218 /// expect would pass on bytes from the initial snapshot.
219 const Expecter = struct {
220 buf: std.ArrayList(u8) = .empty,
221 cursor: usize = 0,
222
223 fn feed(self: *Expecter, alloc: std.mem.Allocator, bytes: []const u8) !void {
224 try self.buf.appendSlice(alloc, bytes);
225 }
226
227 fn match(self: *Expecter, needle: []const u8) bool {
228 if (std.mem.indexOfPos(u8, self.buf.items, self.cursor, needle)) |i| {
229 self.cursor = i + needle.len;
230 return true;
231 }
232 return false;
233 }
234
235 fn deinit(self: *Expecter, alloc: std.mem.Allocator) void {
236 self.buf.deinit(alloc);
237 }
238 };
239
240 const Verb = union(enum) {
241 send: []u8,
242 expect: struct { needle: []u8, deadline_ms: u64 },
243 resize: struct { cols: u16, rows: u16 },
244 waitexit: u64,
245 };
246
247 /// One script line -> one verb; blank lines and #-comments are null.
248 /// Payloads may contain spaces: `send` takes the whole rest of the line;
249 /// `expect` takes everything up to the LAST space, then the deadline.
250 fn parseLine(alloc: std.mem.Allocator, raw: []const u8) !?Verb {
251 const line = std.mem.trim(u8, raw, " \t\r");
252 if (line.len == 0 or line[0] == '#') return null;
253 const sp = std.mem.indexOfScalar(u8, line, ' ') orelse return error.BadVerb;
254 const verb = line[0..sp];
255 const rest = line[sp + 1 ..];
256 if (std.mem.eql(u8, verb, "send")) {
257 return .{ .send = try decodeEscapes(alloc, rest) };
258 } else if (std.mem.eql(u8, verb, "expect")) {
259 const last = std.mem.lastIndexOfScalar(u8, rest, ' ') orelse return error.BadVerb;
260 const ms = std.fmt.parseInt(u64, rest[last + 1 ..], 10) catch return error.BadVerb;
261 return .{ .expect = .{
262 .needle = try decodeEscapes(alloc, rest[0..last]),
263 .deadline_ms = ms,
264 } };
265 } else if (std.mem.eql(u8, verb, "resize")) {
266 var it = std.mem.tokenizeScalar(u8, rest, ' ');
267 const cols = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
268 const rows = std.fmt.parseInt(u16, it.next() orelse return error.BadVerb, 10) catch return error.BadVerb;
269 if (it.next() != null) return error.BadVerb;
270 return .{ .resize = .{ .cols = cols, .rows = rows } };
271 } else if (std.mem.eql(u8, verb, "waitexit")) {
272 const ms = std.fmt.parseInt(u64, rest, 10) catch return error.BadVerb;
273 return .{ .waitexit = ms };
274 }
275 return error.BadVerb;
276 }
277
278 test "decodeEscapes: named, hex, literal backslash" {
279 const alloc = std.testing.allocator;
280 const cases = [_]struct { in: []const u8, want: []const u8 }{
281 .{ .in = "hello\\n", .want = "hello\n" },
282 .{ .in = "\\x1b[5;2~", .want = "\x1b[5;2~" },
283 .{ .in = "a\\\\b", .want = "a\\b" },
284 .{ .in = "\\x04", .want = "\x04" },
285 };
286 for (cases) |cs| {
287 const got = try decodeEscapes(alloc, cs.in);
288 defer alloc.free(got);
289 try std.testing.expectEqualSlices(u8, cs.want, got);
290 }
291 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "bad\\q"));
292 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "trunc\\x1"));
293 }
294
295 test "Expecter: a needle split across two feeds still matches" {
296 const alloc = std.testing.allocator;
297 var e: Expecter = .{};
298 defer e.deinit(alloc);
299 try e.feed(alloc, "scroll-mar");
300 try std.testing.expect(!e.match("marker"));
301 try e.feed(alloc, "ker arrived");
302 try std.testing.expect(e.match("marker"));
303 }
304
305 test "Expecter: the cursor consumes matches — old bytes cannot satisfy a new expect" {
306 const alloc = std.testing.allocator;
307 var e: Expecter = .{};
308 defer e.deinit(alloc);
309 try e.feed(alloc, "row-60 painted live");
310 try std.testing.expect(e.match("row-60"));
311 // The same needle again: only NEW bytes may answer.
312 try std.testing.expect(!e.match("row-60"));
313 try e.feed(alloc, " ... row-60 painted by the scroll view");
314 try std.testing.expect(e.match("row-60"));
315 }
316
317 test "parseLine: verbs, spaces in payloads, comments" {
318 const alloc = std.testing.allocator;
319 try std.testing.expect(try parseLine(alloc, "") == null);
320 try std.testing.expect(try parseLine(alloc, "# comment") == null);
321
322 const s = (try parseLine(alloc, "send echo tp2-claim\\n")).?;
323 defer alloc.free(s.send);
324 try std.testing.expectEqualSlices(u8, "echo tp2-claim\n", s.send);
325
326 const x = (try parseLine(alloc, "expect two words 15000")).?;
327 defer alloc.free(x.expect.needle);
328 try std.testing.expectEqualSlices(u8, "two words", x.expect.needle);
329 try std.testing.expectEqual(@as(u64, 15000), x.expect.deadline_ms);
330
331 const r = (try parseLine(alloc, "resize 90 28")).?;
332 try std.testing.expectEqual(@as(u16, 90), r.resize.cols);
333 try std.testing.expectEqual(@as(u16, 28), r.resize.rows);
334
335 const w = (try parseLine(alloc, "waitexit 10000")).?;
336 try std.testing.expectEqual(@as(u64, 10000), w.waitexit);
337
338 try std.testing.expectError(error.BadVerb, parseLine(alloc, "frobnicate x"));
339 try std.testing.expectError(error.BadVerb, parseLine(alloc, "expect nodeadline"));
340 }
341 ```
342
343 - [ ] **Step 2: Wire the module so the tests RUN** (a test never built is not a test — decisions.md M7). In `build.zig`, next to `render_mod` (~:218):
344
345 ```zig
346 // The pty-driving e2e fixture: real client on a pty slave, scripted
347 // from stdin (M12). Imports pty so the product's own module is the one
348 // under it.
349 const ptyclient_mod = b.createModule(.{
350 .root_source_file = b.path("test/ptyclient.zig"),
351 .target = target,
352 .optimize = optimize,
353 .link_libc = true,
354 });
355 ptyclient_mod.addImport("pty", pty_mod);
356 ```
357
358 Add `ptyclient_mod` to the test-loop array at :282. Next to `render_exe` (~:271):
359
360 ```zig
361 const ptyclient_exe = b.addExecutable(.{ .name = "ptyclient", .root_module = ptyclient_mod });
362 ptyclient_exe.use_llvm = true;
363 ptyclient_exe.use_lld = true;
364 b.installArtifact(ptyclient_exe);
365 ```
366
367 Append `e2e.addArtifactArg(ptyclient_exe);` after the render line at :305, and `soak.addArtifactArg(ptyclient_exe);` after :314.
368
369 - [ ] **Step 3: Plumb the sixth argument through the scripts.** In `test/e2e.sh` after line 12 (`RENDER="$5"`):
370
371 ```sh
372 # M12 pty fixture: runs the client on a real pty (test/ptyclient.zig).
373 PTYCLIENT="$6"
374 ```
375
376 In `test/soak.sh:9`: `MUXD="$1"; MUX="$2"; RAWMODE="$3"; DELAYPIPE="$4"; RENDER="$5"; PTYCLIENT="$6"` and the invocation becomes `"$E2E" "$MUXD" "$MUX" "$RAWMODE" "$DELAYPIPE" "$RENDER" "$PTYCLIENT"`.
377
378 - [ ] **Step 4: Run tests and the suite**
379
380 Run: `make test` — expected: PASS including the four new ptyclient tests (main() does not exist yet; a module test build does not need one).
381 Run: `make e2e` — expected: PASS, 10 checkpoints, 22 convergence points (nothing uses `$6` yet; this proves the plumbing broke nothing).
382
383 - [ ] **Step 5: Commit**
384
385 ```bash
386 git add test/ptyclient.zig build.zig test/e2e.sh test/soak.sh
387 git commit -m "feat: ptyclient script engine — escapes, consume-cursor expect, verb parse"
388 ```
389
390 ---
391
392 ### Task 3: ptyclient main loop + fixture controls in e2e
393
394 **Files:**
395 - Modify: `test/ptyclient.zig` (add main + runScript)
396 - Modify: `test/e2e.sh` (controls scenario; place it after the M9 prediction block, before the final pins; bump `OK_COUNT` pin 10 → 11)
397
398 - [ ] **Step 1: Implement the main loop.** Append to `test/ptyclient.zig`:
399
400 ```zig
401 // Exit codes, distinct so a scenario failure names its layer:
402 // 2 usage / setup failure
403 // 3 expect deadline passed
404 // 4 client exited before the script finished
405 // otherwise: the client's own exit status (waitexit propagates it)
406 const EXIT_USAGE: u8 = 2;
407 const EXIT_TIMEOUT: u8 = 3;
408 const EXIT_CHILD_DIED: u8 = 4;
409
410 fn fatal(code: u8, comptime fmt: []const u8, args: anytype) noreturn {
411 std.debug.print("ptyclient: " ++ fmt ++ "\n", args);
412 std.process.exit(code);
413 }
414
415 /// Print bytes with escapes visible: what DID arrive, when a needle did not.
416 fn dumpTail(bytes: []const u8) void {
417 const tail = if (bytes.len > 200) bytes[bytes.len - 200 ..] else bytes;
418 std.debug.print("ptyclient: last {d} bytes received: \"", .{tail.len});
419 for (tail) |b| switch (b) {
420 0x20...0x7e => std.debug.print("{c}", .{b}),
421 '\n' => std.debug.print("\\n", .{}),
422 '\r' => std.debug.print("\\r", .{}),
423 0x1b => std.debug.print("\\x1b", .{}),
424 else => std.debug.print("\\x{x:0>2}", .{b}),
425 };
426 std.debug.print("\"\n", .{});
427 }
428
429 /// Drain whatever the master has right now into the capture + expecter.
430 /// Returns false on EOF/EIO — the child side is gone.
431 fn drain(alloc: std.mem.Allocator, pty: *Pty, out: std.fs.File, exp: *Expecter) !bool {
432 var buf: [4096]u8 = undefined;
433 while (true) {
434 var fds = [_]std.posix.pollfd{
435 .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
436 };
437 const ready = try std.posix.poll(&fds, 0);
438 if (ready == 0) return true;
439 const n = std.posix.read(pty.master, &buf) catch return false;
440 if (n == 0) return false;
441 try out.writeAll(buf[0..n]);
442 try exp.feed(alloc, buf[0..n]);
443 }
444 }
445
446 pub fn main() !void {
447 var dbg = std.heap.DebugAllocator(.{}){};
448 defer _ = dbg.deinit();
449 const alloc = dbg.allocator();
450
451 // --- args: --cols C --rows R --out FILE --err FILE -- argv... ---
452 var cols: u16 = 80;
453 var rows: u16 = 24;
454 var out_path: ?[]const u8 = null;
455 var err_path: ?[]const u8 = null;
456 var child_argv: std.ArrayList(?[*:0]const u8) = .empty;
457 defer child_argv.deinit(alloc);
458
459 const argv = try std.process.argsAlloc(alloc);
460 defer std.process.argsFree(alloc, argv);
461 var i: usize = 1;
462 while (i < argv.len) : (i += 1) {
463 const a = argv[i];
464 if (std.mem.eql(u8, a, "--cols")) {
465 i += 1;
466 if (i >= argv.len) fatal(EXIT_USAGE, "--cols needs a value", .{});
467 cols = std.fmt.parseInt(u16, argv[i], 10) catch
468 fatal(EXIT_USAGE, "--cols: not a number: {s}", .{argv[i]});
469 } else if (std.mem.eql(u8, a, "--rows")) {
470 i += 1;
471 if (i >= argv.len) fatal(EXIT_USAGE, "--rows needs a value", .{});
472 rows = std.fmt.parseInt(u16, argv[i], 10) catch
473 fatal(EXIT_USAGE, "--rows: not a number: {s}", .{argv[i]});
474 } else if (std.mem.eql(u8, a, "--out")) {
475 i += 1;
476 if (i >= argv.len) fatal(EXIT_USAGE, "--out needs a path", .{});
477 out_path = argv[i];
478 } else if (std.mem.eql(u8, a, "--err")) {
479 i += 1;
480 if (i >= argv.len) fatal(EXIT_USAGE, "--err needs a path", .{});
481 err_path = argv[i];
482 } else if (std.mem.eql(u8, a, "--")) {
483 for (argv[i + 1 ..]) |c| try child_argv.append(alloc, c.ptr);
484 break;
485 } else {
486 fatal(EXIT_USAGE, "unknown flag {s} (usage: ptyclient --cols C --rows R --out F --err F -- CMD...)", .{a});
487 }
488 }
489 if (child_argv.items.len == 0)
490 fatal(EXIT_USAGE, "no client command after -- (nothing to run on the pty)", .{});
491 // Sentinel-terminated by the type system, not by a trailing append the
492 // reader has to trust — and it consumes the list, so no raw pointer
493 // into a still-mutable buffer survives to the spawn.
494 const argv_z = try child_argv.toOwnedSliceSentinel(alloc, null);
495 defer alloc.free(argv_z);
496 const op = out_path orelse fatal(EXIT_USAGE, "--out is required (the capture the suite asserts on)", .{});
497 const ep = err_path orelse fatal(EXIT_USAGE, "--err is required (predict stats land there)", .{});
498
499 const out = std.fs.cwd().createFile(op, .{ .truncate = true }) catch |e|
500 fatal(EXIT_USAGE, "cannot create --out {s}: {s}", .{ op, @errorName(e) });
501 defer out.close();
502 const errf = std.fs.cwd().createFile(ep, .{ .truncate = true }) catch |e|
503 fatal(EXIT_USAGE, "cannot create --err {s}: {s}", .{ ep, @errorName(e) });
504 defer errf.close();
505
506 // Whole script up front: the harness feeds it as a heredoc and the
507 // fixture's own progress lines ("done N") are how the harness knows
508 // where the script is — the tp1 tear keys off exactly that.
509 var stdin_buf: std.ArrayList(u8) = .empty;
510 defer stdin_buf.deinit(alloc);
511 var rbuf: [4096]u8 = undefined;
512 while (true) {
513 const n = try std.posix.read(std.posix.STDIN_FILENO, &rbuf);
514 if (n == 0) break;
515 try stdin_buf.appendSlice(alloc, rbuf[0..n]);
516 }
517
518 var pty = Pty.spawnArgv(.{
519 .cols = cols,
520 .rows = rows,
521 .argv = argv_z,
522 .stderr_fd = errf.handle,
523 }) catch |e| fatal(EXIT_USAGE, "pty spawn failed: {s}", .{@errorName(e)});
524 defer pty.deinit(); // kills by tracked pid if the child is still alive
525
526 var exp: Expecter = .{};
527 defer exp.deinit(alloc);
528
529 var lines = std.mem.splitScalar(u8, stdin_buf.items, '\n');
530 var verb_no: usize = 0;
531 while (lines.next()) |raw| {
532 // The error name matters: a doubled space (empty needle) is
533 // invisible in a heredoc, and only BadVerb-vs-BadEscape tells the
534 // operator whether to look at structure or at an escape.
535 const verb = (parseLine(alloc, raw) catch |e|
536 fatal(EXIT_USAGE, "bad script line ({s}): {s}", .{ @errorName(e), raw })) orelse continue;
537 defer verb.deinit(alloc);
538 verb_no += 1;
539 switch (verb) {
540 .send => |bytes| {
541 // ONE write, asserted: the client's scroll-key parser
542 // exact-matches a whole read, so a short write here would
543 // silently turn one keystroke into two.
544 const n = std.posix.write(pty.master, bytes) catch |e|
545 fatal(EXIT_CHILD_DIED, "verb {d}: write to the client's pty failed: {s}", .{ verb_no, @errorName(e) });
546 if (n != bytes.len)
547 fatal(EXIT_USAGE, "verb {d}: short write ({d} of {d}) — send payloads must fit one write", .{ verb_no, n, bytes.len });
548 },
549 .expect => |x| {
550 const start = std.time.milliTimestamp();
551 while (!exp.match(x.needle)) {
552 if (std.time.milliTimestamp() - start > x.deadline_ms) {
553 std.debug.print("ptyclient: verb {d}: expect \"{s}\" did not arrive within {d}ms\n", .{ verb_no, x.needle, x.deadline_ms });
554 dumpTail(exp.buf.items);
555 std.process.exit(EXIT_TIMEOUT);
556 }
557 var fds = [_]std.posix.pollfd{
558 .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
559 };
560 _ = try std.posix.poll(&fds, 50);
561 if (!try drain(alloc, &pty, out, &exp)) {
562 if (exp.match(x.needle)) break; // arrived with the last gasp
563 std.debug.print("ptyclient: verb {d}: client closed the pty before \"{s}\" matched\n", .{ verb_no, x.needle });
564 dumpTail(exp.buf.items);
565 std.process.exit(EXIT_CHILD_DIED);
566 }
567 }
568 },
569 .resize => |r| {
570 pty.resize(r.cols, r.rows) catch |e|
571 fatal(EXIT_CHILD_DIED, "verb {d}: TIOCSWINSZ failed: {s}", .{ verb_no, @errorName(e) });
572 },
573 .waitexit => |deadline_ms| {
574 const start = std.time.milliTimestamp();
575 while (true) {
576 const alive = try drain(alloc, &pty, out, &exp);
577 if (pty.checkExited()) |status| {
578 if (status != 0)
579 fatal(@intCast(@min(status, 255)), "client exited {d}", .{status});
580 break;
581 }
582 if (std.time.milliTimestamp() - start > deadline_ms)
583 fatal(EXIT_TIMEOUT, "verb {d}: client still running after {d}ms", .{ verb_no, deadline_ms });
584 if (alive) {
585 var fds = [_]std.posix.pollfd{
586 .{ .fd = pty.master, .events = std.posix.POLL.IN, .revents = 0 },
587 };
588 _ = try std.posix.poll(&fds, 50);
589 } else {
590 std.Thread.sleep(20 * std.time.ns_per_ms);
591 }
592 }
593 },
594 }
595 // Progress line per verb: the harness coordinates the tp1 tear by
596 // watching for "done N" in the fixture's log. std.debug.print is
597 // stderr and unbuffered, which is exactly what a barrier needs.
598 std.debug.print("ptyclient: done {d}\n", .{verb_no});
599 }
600 }
601 ```
602
603 Note: progress lines and diagnostics go to the fixture's **stderr** (std.debug.print); the harness redirects fixture stdout+stderr to one log file, so `wait_for LOG "done N"` works either way. The client's capture (`--out`) and client stderr (`--err`) are separate files — the fixture's own chatter never lands in either.
604
605 - [ ] **Step 2: Write the failing controls scenario.** In `test/e2e.sh`, after the M9 prediction block (after the `reconnect flush` scenario, ~line 1504) and before the final pins, add:
606
607 ```sh
608 # ---- M12: ptyclient fixture controls ----------------------------------
609 # Before any scenario trusts the fixture, prove both directions: a
610 # roundtrip over plain /bin/cat (the pty line discipline's own echo
611 # answers — no mux anywhere, so a failure here is the FIXTURE'S), and an
612 # expect that cannot match, which must time out, exit nonzero, and show
613 # what it did see. A check that cannot fail proves nothing.
614 PCLOG="$OUT.pc.log"
615 set +e
616 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc" --err "$OUT.pc.err" -- /bin/cat > "$PCLOG" 2>&1 <<'EOF'
617 send hello\n
618 expect hello 10000
619 send \x04
620 waitexit 10000
621 EOF
622 RC=$?
623 set -e
624 [ "$RC" -eq 0 ] || {
625 echo "e2e FAIL: ptyclient roundtrip over cat exited $RC:"; cat "$PCLOG"; exit 1; }
626 grep -q "hello" "$OUT.pc" || {
627 echo "e2e FAIL: ptyclient capture missing the pty echo"; cat -v "$OUT.pc"; exit 1; }
628 # The must-fail leg. 500ms: nothing is being waited FOR — the needle never
629 # arrives by construction — so the deadline only bounds the control's cost.
630 set +e
631 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pc2" --err "$OUT.pc2.err" -- /bin/cat > "$PCLOG.2" 2>&1 <<'EOF'
632 expect never-going-to-match 500
633 EOF
634 RC=$?
635 set -e
636 [ "$RC" -ne 0 ] || {
637 echo "e2e FAIL: ptyclient expect control did not fire on an impossible needle"; exit 1; }
638 grep -q "did not arrive" "$PCLOG.2" || {
639 echo "e2e FAIL: ptyclient timeout fired but never said what it saw"; cat "$PCLOG.2"; exit 1; }
640 rm -f "$OUT.pc" "$OUT.pc.err" "$OUT.pc2" "$OUT.pc2.err" "$PCLOG" "$PCLOG.2"
641 ok "ptyclient controls: pty echo roundtrips, impossible expect fails loudly"
642 ```
643
644 Bump the `OK_COUNT` pin at :1512 from `"10"` to `"11"` (and its message text).
645
646 - [ ] **Step 3: Run and verify**
647
648 Run: `make test` — expected: PASS (unit layer unchanged by main()).
649 Run: `make e2e` — expected: PASS, `e2e OK: ptyclient controls...` line present, 11 checkpoints, 22 convergence points.
650
651 - [ ] **Step 4: Prove the roundtrip control can fail** (mutation-first, then revert): temporarily change the controls scenario's `expect hello 10000` to `expect goodbye 700`, run `make e2e`, and confirm the suite FAILS with the ptyclient timeout naming what it did see. Revert the temporary change. This is a run-and-revert step, not a commit.
652
653 - [ ] **Step 5: Commit**
654
655 ```bash
656 git add test/ptyclient.zig test/e2e.sh
657 git commit -m "feat: ptyclient main loop + fixture controls in the suite"
658 ```
659
660 ---
661
662 ### Task 4: tp2 — resize under a real tty (row 7's branch)
663
664 **Files:**
665 - Modify: `test/e2e.sh` — `converged_quiet`/`assert_converged` gain optional size args (:140–:183); tp2 scenario after the ptyclient controls; new daemon vars at the top (~:60) and in `cleanup()`; pins 11 → 12 ok, 22 → 24 convergence.
666
667 - [ ] **Step 1: Teach the convergence helpers about size.** Replace `converged_quiet`'s two `"$RENDER"` invocations so both legs pass the grid size through (defaulting to nothing, which keeps every existing call byte-identical in behavior):
668
669 ```sh
670 # converged_quiet CLIENT_OUT SOCK [COLS ROWS] — the optional size is for
671 # pty scenarios whose grids are not the non-tty 80x24 default; render must
672 # replay into the same dimensions the daemon holds or the diff compares
673 # two honest grids of different shapes.
674 converged_quiet() {
675 _co="$1"; _cs="$2"; _sz=""
676 [ $# -ge 4 ] && _sz="--cols $3 --rows $4"
677 # shellcheck disable=SC2086 — $_sz is two flags or nothing, never data
678 "$RENDER" $_sz < "$_co" > "$_co.render" || return 1
679 ...
680 "$RENDER" --vt $_sz < "$_co" > "$_co.rvt" || return 1
681 ...
682 ```
683
684 (only the two `$RENDER` lines change; the rest of the helper stays as-is). `assert_converged` forwards the size explicitly:
685
686 ```sh
687 assert_converged() {
688 CONV_COUNT=$((CONV_COUNT + 1))
689 if [ $# -ge 5 ]; then
690 converged_quiet "$1" "$2" "$4" "$5"
691 else
692 converged_quiet "$1" "$2"
693 fi || {
694 ... (existing failure block, unchanged, still using $3 as NAME)
695 ```
696
697 - [ ] **Step 2: Declare the tp2 daemon at the top of the file** (near the other socket declarations, ~:60):
698
699 ```sh
700 # M12 pty scenarios. Each needs a daemon whose grid size it owns: tp2
701 # resizes the grid twice and tp1's session is a scrollback-generating
702 # wrapper, so neither can share the long-lived /bin/sh daemon.
703 SOCK12="${TMPDIR:-/tmp}/muxd-e2e-tp2-$$.sock"
704 D12PID=""
705 ```
706
707 and in `cleanup()` alongside the other daemon kills: `[ -n "${D12PID:-}" ] && kill "$D12PID" 2>/dev/null` (match the file's existing kill idiom exactly) plus `rm -f "$OUT".tp2* `.
708
709 - [ ] **Step 3: Write the scenario** (after the ptyclient controls block):
710
711 ```sh
712 # ---- M12 tp2: resize under a real tty (campaign row 7's branch) --------
713 # Two clients, two convergence points, because convergence is a scenario's
714 # LAST act after detach: tp2a exercises the snapshot resize prefix (a
715 # 100x30 tty attaching to an 80x24 grid resizes the replica DOWN, then the
716 # claim's answering snapshot resizes it back UP); tp2b exercises the winch
717 # path mid-session and is the leg that catches row 7 — its 95-wide row
718 # wraps differently in a replica whose width is stale, and the tail of the
719 # wrap never paints at all when the prefix was ignored.
720 "$MUXD" run --sock "$SOCK12" --shell /bin/sh > "$OUT.tp2.d" 2>&1 &
721 D12PID=$!
722 i=0
723 while [ ! -S "$SOCK12" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done
724 [ -S "$SOCK12" ] || { echo "e2e FAIL: tp2 daemon never bound"; cat "$OUT.tp2.d"; exit 1; }
725
726 set +e
727 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.tp2a" --err "$OUT.tp2a.err" -- \
728 "$MUX" --sock "$SOCK12" > "$OUT.tp2a.log" 2>&1 <<'EOF'
729 expect \x1b[?1049h 10000
730 send echo tp2-claim\n
731 expect tp2-claim 10000
732 send \x1c
733 waitexit 10000
734 EOF
735 RC=$?
736 set -e
737 [ "$RC" -eq 0 ] || {
738 echo "e2e FAIL: tp2a ptyclient exited $RC:"; cat "$OUT.tp2a.log"; exit 1; }
739 assert_converged "$OUT.tp2a" "$SOCK12" "pty attach at 100x30" 100 30
740
741 set +e
742 "$PTYCLIENT" --cols 100 --rows 30 --out "$OUT.tp2b" --err "$OUT.tp2b.err" -- \
743 "$MUX" --sock "$SOCK12" > "$OUT.tp2b.log" 2>&1 <<'EOF'
744 expect \x1b[?1049h 10000
745 send echo tp2-live\n
746 expect tp2-live 10000
747 resize 90 28
748 expect tp2-live 10000
749 send printf '%095d\\n' 7\n
750 expect 00007 10000
751 send \x1c
752 waitexit 10000
753 EOF
754 RC=$?
755 set -e
756 [ "$RC" -eq 0 ] || {
757 echo "e2e FAIL: tp2b ptyclient exited $RC:"; cat "$OUT.tp2b.log"; exit 1; }
758 # The second `expect tp2-live` is the resize's answering snapshot: the
759 # cursor consumed the first paint, so only the REPAINT can satisfy it.
760 # `00007` is the wrap tail — a 95-wide row at 90 cols breaks into 90 zeros
761 # and "00007"; in an unresized 100-wide replica the row never wraps and
762 # the tail is clipped, so the needle never arrives.
763 assert_converged "$OUT.tp2b" "$SOCK12" "pty resize mid-session" 90 28
764 rm -f "$OUT.tp2a" "$OUT.tp2a.err" "$OUT.tp2a.log" \
765 "$OUT.tp2b" "$OUT.tp2b.err" "$OUT.tp2b.log" "$OUT.tp2.d"
766 ok "a pty client resizes: snapshot prefix applied, winch follows the tty"
767 ```
768
769 Bump pins: `OK_COUNT` 11 → 12, `CONV_COUNT` 22 → 24 (update both literals and both message texts at :1512–:1520).
770
771 - [ ] **Step 4: Run**
772
773 Run: `make e2e`
774 Expected: PASS, 12 checkpoints, 24 convergence points. If `expect \x1b[?1049h` times out, the client did not think it had a tty — that is a fixture bug, not a scenario tuning problem; stop and fix before touching deadlines.
775
776 - [ ] **Step 5: Commit**
777
778 ```bash
779 git add test/e2e.sh
780 git commit -m "test: tp2 — a pty client attaches at 100x30 and resizes to 90x28"
781 ```
782
783 ---
784
785 ### Task 5: tp1 — reconnect while scrolled (row 18's branch + the 412f38f pin)
786
787 **Files:**
788 - Modify: `test/e2e.sh` — tp1 scenario after tp2; `SOCK13`/`D13PID`/`TP1SH` declarations at top and in `cleanup()`; doctored pty-capture control; pins 12 → 13 ok, 24 → 25 convergence.
789
790 - [ ] **Step 1: Declarations** (top of file, next to SOCK12):
791
792 ```sh
793 SOCK13="${TMPDIR:-/tmp}/muxd-e2e-tp1-$$.sock"
794 D13PID=""
795 TP1SH="${TMPDIR:-/tmp}/mux-e2e-tp1-$$.sh"
796 TP1PID=""
797 ```
798
799 `cleanup()` additions (existing idiom): kill `$D13PID` and `$TP1PID` if set, `rm -f "$TP1SH" "$OUT".tp1*`.
800
801 - [ ] **Step 2: Write the scenario:**
802
803 ```sh
804 # ---- M12 tp1: reconnect while scrolled (row 18 + the 412f38f pin) ------
805 # The session is a wrapper that fills scrollback BY ITSELF and then execs
806 # cat: no pre-tear typing means no pre-tear predictions, so the predict
807 # counters at exit belong entirely to the post-resync keystrokes — which
808 # is exactly what the 412f38f pin needs to isolate. cat leaves the pty
809 # canonical+echo, the .always tier, so every real keystroke predicts.
810 cat > "$TP1SH" <<'EOF'
811 #!/bin/sh
812 seq 1 100
813 exec /bin/cat
814 EOF
815 chmod +x "$TP1SH"
816 "$MUXD" run --sock "$SOCK13" --shell "$TP1SH" > "$OUT.tp1.d" 2>&1 &
817 D13PID=$!
818 i=0
819 while [ ! -S "$SOCK13" ] && [ "$i" -lt 50 ]; do sleep 0.1; i=$((i+1)); done
820 [ -S "$SOCK13" ] || { echo "e2e FAIL: tp1 daemon never bound"; cat "$OUT.tp1.d"; exit 1; }
821 # Attach only after seq has finished: the client must be served a snapshot
822 # of the TAIL, so "60" exists nowhere in its capture until the scroll view
823 # paints it — that absence is what the third expect's cursor semantics
824 # turn into proof.
825 i=0
826 until "$MUXD" dump --sock "$SOCK13" | grep -q "100"; do
827 i=$((i+1)); [ "$i" -lt 100 ] || { echo "e2e FAIL: tp1 session never finished seq"; exit 1; }
828 sleep 0.1
829 done
830
831 set +e
832 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.tp1" --err "$OUT.tp1.err" -- \
833 env MUX_PREDICT_STATS=1 "$MUX" --via "$MUXD proxy --sock $SOCK13" > "$OUT.tp1.log" 2>&1 <<'EOF' &
834 expect 100 15000
835 send \x1b[5;2~
836 expect 60 10000
837 expect reconnecting 20000
838 expect 100 20000
839 send tp1
840 expect tp1 10000
841 send \x1c
842 waitexit 15000
843 EOF
844 TP1PID=$!
845 set -e
846
847 # The tear goes between verb 3 (scroll view on screen) and verb 4: the
848 # fixture's "done 3" line is the barrier. 20s deadline on the barrier
849 # itself = the suite's standard loaded-box budget (wait_for's default),
850 # not an estimate of the operation, which is milliseconds on this path.
851 wait_for "$OUT.tp1.log" "done 3" 20 || {
852 echo "e2e FAIL: tp1 never reached the scroll view:"; cat "$OUT.tp1.log"; exit 1; }
853 TP1PROXY=$(proxy_pid "$SOCK13")
854 [ -n "$TP1PROXY" ] || { echo "e2e FAIL: tp1: no proxy to tear"; exit 1; }
855 kill -9 "$TP1PROXY"
856 kill -0 "$D13PID" || { echo "e2e FAIL: tp1 tear killed the daemon, not the proxy"; exit 1; }
857
858 set +e
859 wait "$TP1PID"
860 RC=$?
861 TP1PID=""
862 set -e
863 [ "$RC" -eq 0 ] || {
864 echo "e2e FAIL: tp1 ptyclient exited $RC:"; cat "$OUT.tp1.log"
865 cat -v "$OUT.tp1.err" 2>/dev/null; exit 1; }
866
867 # Verb 4 ([reconnecting] banner) plus verb 5 ("100" repainted, cursor past
868 # every earlier occurrence) are row 18's catch: a resync that fails to
869 # leave the scroll view suppresses the live repaint, and "100" never
870 # arrives again. The counters are the 412f38f pin: the only real
871 # keystrokes the whole scenario sends are the three post-resync ones, so
872 # `made` is 0 with the fix reverted and the scroll-suppressed overlay
873 # refusing to predict. Floors, not exact counts, for made/confirmed: the
874 # pty_mode frame's arrival relative to the first keystroke is timing, and
875 # the mutation drives the value to 0, which any floor >= 1 catches.
876 want_stat_ge "$OUT.tp1.err" made 1 "pty scroll reconnect"
877 want_stat_ge "$OUT.tp1.err" confirmed 1 "pty scroll reconnect"
878 want_stat "$OUT.tp1.err" contradicted 0 "pty scroll reconnect"
879 assert_converged "$OUT.tp1" "$SOCK13" "pty scroll reconnect"
880
881 # The doctored control, extended to a pty capture: same rule as the base
882 # scenario's — a convergence check that cannot fail proves nothing, and
883 # this capture (alt screen, banner paints, scroll view) is a different
884 # byte shape from any non-tty one, so it earns its own control.
885 cp "$OUT.tp1" "$OUT.tp1.doc"
886 printf '\033[10;1Hpty-doctor-glyphs' >> "$OUT.tp1.doc"
887 if converged_quiet "$OUT.tp1.doc" "$SOCK13"; then
888 echo "e2e FAIL: convergence control did not fire on a doctored pty capture"; exit 1
889 fi
890 rm -f "$OUT.tp1.doc" "$OUT.tp1.doc.render" "$OUT.tp1.doc.dump" \
891 "$OUT.tp1.doc.render.n" "$OUT.tp1.doc.dump.n" "$OUT.tp1.doc.diff" \
892 "$OUT.tp1.doc.rvt" "$OUT.tp1.doc.dvt"
893 rm -f "$OUT.tp1" "$OUT.tp1.err" "$OUT.tp1.log" "$OUT.tp1.d" "$TP1SH"
894 ok "reconnect while scrolled: view restored, prediction resumed"
895 ```
896
897 Bump pins: `OK_COUNT` 12 → 13, `CONV_COUNT` 24 → 25.
898
899 - [ ] **Step 3: Run**
900
901 Run: `make e2e`
902 Expected: PASS, 13 checkpoints, 25 convergence points. Two known-delicate spots, with their intended readings: `expect 60` timing out means the scroll view never painted (check `$OUT.tp1.log`'s tail dump for what did arrive — if it shows scrollback rows of different numbers, the page geometry assumption is off; adjust the needle to a number visibly in the dumped rows, NOT the deadline). `expect reconnecting` timing out means the banner never painted — check that the proxy pid was found and killed.
903
904 - [ ] **Step 4: Run the whole suite twice more** (`make e2e` twice) — tp1 has a real tear in it; two more green runs before commit is cheap insurance against landing a knife-edge. Full soak comes in Task 7.
905
906 - [ ] **Step 5: Commit**
907
908 ```bash
909 git add test/e2e.sh
910 git commit -m "test: tp1 — reconnect while scrolled restores the view and prediction"
911 ```
912
913 ---
914
915 ### Task 6: Leg 2 — the regrade
916
917 **Files:**
918 - Create: `docs/superpowers/plans/2026-08-10-m12-regrade.md` (scratch ledger; folded into decisions.md in Task 8 and deleted)
919
920 Run each resurrection in a detached worktree so main never carries a mutation:
921
922 ```bash
923 git worktree add --detach /tmp/mux-m12-regrade HEAD
924 ```
925
926 Build inside the worktree with the pinned toolchain (`make -C /tmp/mux-m12-regrade build` — confirm the Makefile's ZIG path is absolute; it is), then run the suite out of that worktree's binaries: `make -C /tmp/mux-m12-regrade e2e`.
927
928 - [ ] **Step 1: Resurrection A — campaign row 7** (snapshot's cols/rows prefix ignored). In the worktree's `src/client.zig`, find the snapshot-prefix apply (`try replica.resize(prefix.cols, prefix.rows);`, at :739 on main today) and neuter it exactly as the campaign did:
929
930 ```zig
931 // MUTATION row 7: prefix ignored
932 // try replica.resize(prefix.cols, prefix.rows);
933 ```
934
935 Run the worktree's e2e. Required: **FAIL**, expected at tp2b (either `expect 00007` timing out or `pty resize mid-session: client render diverges`). Record the exact FAIL line in the regrade ledger. Restore the file (`git -C /tmp/mux-m12-regrade checkout -- src/client.zig`).
936
937 - [ ] **Step 2: Resurrection B — campaign row 18** (resync no longer leaves scroll view). Delete the line `scroll_pages = 0;` at the resync path (client.zig:609 on main today — the one whose comment says "A resync repaints live state"). Note: with `scroll_pages` stuck nonzero, the post-resync live repaint is suppressed by the `scroll_pages == 0` paint guards. Run the worktree's e2e. Required: **FAIL**, expected at tp1 (`expect 100` verb 5 timing out, or the stats floors at 0 — either line counts; record which fired). Restore.
938
939 - [ ] **Step 3: Resurrection C — the 412f38f revert.** Delete the line `overlay.setScrollMode(false);` at client.zig:621 (leave `scroll_pages = 0;` in place — this is the fix's own line, not row 18's). Run the worktree's e2e. Required: **FAIL** at tp1's counter floors: `pty scroll reconnect: made=0, want >=1`. Record. Restore.
940
941 - [ ] **Step 4: The baseline control.** Run the worktree's e2e once with NO mutation. Required: **PASS** — a regrade whose baseline is red measures nothing. Record.
942
943 - [ ] **Step 5: Write the ledger** (`docs/superpowers/plans/2026-08-10-m12-regrade.md`): a four-row table — resurrection, prior score (from the M11 table: rows 7/18 "survived/ungradeable", 412f38f "no pin existed"), the exact FAIL line now, verdict. Then remove the worktree:
944
945 ```bash
946 git worktree remove --force /tmp/mux-m12-regrade
947 ```
948
949 - [ ] **Step 6: Commit**
950
951 ```bash
952 git add docs/superpowers/plans/2026-08-10-m12-regrade.md
953 git commit -m "test: M12 regrade — rows 7 and 18 and the 412f38f revert, all caught"
954 ```
955
956 ---
957
958 ### Task 7: Soak
959
960 - [ ] **Step 1:** `make test` and `make e2e` once, green, on the shipping tree.
961 - [ ] **Step 2:** `SOAK_N=10 make soak` (~25 min; run it in the background and check the result when it finishes). Required: **10/10**, and the per-run hygiene check clean — the pty scenarios must remove their evidence on success (tp1/tp2 files, TP1SH) or run 2 will blame run 1.
962 - [ ] **Step 3:** If any run fails: STOP, read the failure table and the swept evidence in the FAILDIR, and debug the root cause (superpowers:systematic-debugging). A timing knife-edge found here gets the M11 treatment — compute the margin, do not pad the number. Re-run the full soak from run 1 after any fix.
963 - [ ] **Step 4:** No commit (soak changes nothing); record the 10/10 line for Task 8.
964
965 ---
966
967 ### Task 8: Close the milestone in the docs
968
969 **Files:**
970 - Modify: `docs/roadmap.md` (M12 section; renumber the friction bundle M13; move the pty fixture out of "Test debt, banked by M11")
971 - Modify: `docs/decisions.md` (M12 section at the end: verdict, regrade table from the scratch ledger, any en-route findings)
972 - Modify: `docs/superpowers/specs/2026-08-10-m12-ptyclient-design.md` (header note: executed; deviations recorded in decisions.md M12)
973 - Delete: `docs/superpowers/plans/2026-08-10-m12-regrade.md` (its table moves to decisions.md)
974
975 - [ ] **Step 1:** Write the decisions.md M12 section: verdict paragraph (both legs, with the numbers: 13 checkpoints / 25 convergence points / soak 10/10 / regrade 3-of-3 caught with the baseline control green), the regrade table, and a findings paragraph for anything the implementation surfaced (an empty findings section is a claim, so if it is empty, say the run was quiet and why that is plausible). Style: match the M11 section's voice; do not overclaim — tp2a is exercised-but-not-the-catcher, say so.
976 - [ ] **Step 2:** roadmap.md: M12 marked complete with a verdict paragraph in the house style; "Trial friction" tier retitled as **M13** candidates, text otherwise intact; the pty-fixture entry under "Test debt, banked by M11" replaced with a one-line pointer to M12 (the other two debt entries stay). The unit-sweep and ASAN entries do not move.
977 - [ ] **Step 3:** Spec header gains: `**Status:** executed 2026-08-10 — verdicts and deviations in decisions.md M12.`
978 - [ ] **Step 4:** `git rm docs/superpowers/plans/2026-08-10-m12-regrade.md`, commit everything:
979
980 ```bash
981 git add docs/roadmap.md docs/decisions.md docs/superpowers/specs/2026-08-10-m12-ptyclient-design.md
982 git commit -m "docs: M12 complete — the tty gate is open and graded"
983 ```
984
985 ---
986
987 ## Deviation rule
988
989 Any deviation an implementer makes from a code block above (API realities, review feedback) is fine if the tests still express the plan's intent — but it must be reported back in the completion summary so the controller can fold it into decisions.md at close. Silent drift between plan and tree is the failure mode.
docs/superpowers/plans/2026-08-10-m13-trial-friction.md
Old New
@@ -1,875 +0,0 @@
1 # M13 Trial Friction 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 > **Executed in full, 2026-08-10.** Deviations and amendments recorded in decisions.md M13; the plan was amended twice mid-flight (stopCmd loop shape, log truncation) and those amendments are part of the record.
6
7 **Goal:** Attach auto-start (proxy + local mux), `muxd stop` as a protocol verb, and the error-message audit — the M13 bundle per `docs/superpowers/specs/2026-08-10-m13-trial-friction-design.md`.
8
9 **Architecture:** Both auto-start call sites reuse `spawn.ensureDaemon` unchanged (M10 built it for exactly this); `muxd stop` sends a new `stop_req` frame whose handler sets the daemon's existing SIGTERM `shutdown_flag`, so no new shutdown code exists; the audit rewords seven messages from a closed table in the spec.
10
11 **Tech Stack:** Zig 0.15.2 (pinned: `~/Downloads/zig-x86_64-linux-0.15.2/zig` via Makefile — the system zig CANNOT build this repo). Build/test ONLY via `make build`, `make test`, `make e2e`, `make soak`. `make test` does NOT compile the executables — an exe-only compile error needs `make build` to surface.
12
13 **Standing rules that bind every task:**
14 - Zig 0.15.2 idioms: unmanaged `ArrayList` (`.empty`, `deinit(alloc)`), `std.heap.DebugAllocator`. Fork-children exit ONLY via `std.os.linux.exit_group` (decisions.md 7b4208f) — no new fork code in this plan, but don't undo it.
15 - Every new assertion is written **mutation-first**: write the test, break the code the prescribed way, SEE the test fail, restore, see it pass. A mutant that fails to *compile* is a build failure masquerading as a kill — if a mutation orphans a variable capture, discard the capture in the same edit.
16 - No wait is a sleep: every wait is expect/poll-with-deadline.
17 - Resource rules: no synthetic load; nothing in this plan needs it. Any spawned process is killed by tracked pid, never by name; cleanup is verified by observation (a `ps`/`kill -0` check), never by the cleanup command's claim.
18 - Commits go to `main` (established M9–M12 practice for this repo).
19
20 ---
21
22 ## Task 1: `spawn.findInPath` — PATH resolution for the mux call site
23
24 **Files:**
25 - Modify: `src/spawn.zig` (helper after `probe`, tests at the bottom; also make `probe` pub — Task 4's mux call site needs it)
26
27 `mux` and `muxd` are separate binaries, so local `mux` must find `muxd` before it can spawn one. House pattern: the function takes the PATH *string* as a parameter (like `parseArgs` takes argv and `pickKey` takes its three sources) so tests never touch the process environment.
28
29 - [ ] **Step 1: Write the failing tests** (append to `src/spawn.zig`)
30
31 ```zig
32 test "findInPath: first executable hit wins; non-executables are skipped" {
33 const alloc = std.testing.allocator;
34 var tmp = try testtmp.TmpDir.make();
35 defer tmp.cleanup();
36
37 var abuf: [280]u8 = undefined;
38 var bbuf: [280]u8 = undefined;
39 const dir_a = try std.fmt.bufPrint(&abuf, "{s}/a", .{tmp.path()});
40 const dir_b = try std.fmt.bufPrint(&bbuf, "{s}/b", .{tmp.path()});
41 try std.fs.cwd().makePath(dir_a);
42 try std.fs.cwd().makePath(dir_b);
43
44 // a/muxd exists but is NOT executable; b/muxd is. The search must skip
45 // the first and return the second — access(X_OK) is the filter, not
46 // mere existence.
47 var pbuf: [560]u8 = undefined;
48 const not_exec = try std.fmt.bufPrint(&pbuf, "{s}/muxd", .{dir_a});
49 (try std.fs.cwd().createFile(not_exec, .{ .mode = 0o600 })).close();
50 var pbuf2: [560]u8 = undefined;
51 const exec = try std.fmt.bufPrint(&pbuf2, "{s}/muxd", .{dir_b});
52 (try std.fs.cwd().createFile(exec, .{ .mode = 0o700 })).close();
53
54 var envbuf: [1200]u8 = undefined;
55 const path_env = try std.fmt.bufPrint(&envbuf, "{s}:{s}", .{ dir_a, dir_b });
56
57 const found = (try findInPath(alloc, path_env, "muxd")).?;
58 defer alloc.free(found);
59 try std.testing.expectEqualStrings(exec, found);
60 }
61
62 test "findInPath: nothing executable anywhere is null, not an error" {
63 const alloc = std.testing.allocator;
64 var tmp = try testtmp.TmpDir.make();
65 defer tmp.cleanup();
66 try std.testing.expectEqual(
67 @as(?[]const u8, null),
68 try findInPath(alloc, tmp.path(), "muxd"),
69 );
70 }
71
72 test "findInPath: empty PATH segments are skipped, never read as cwd" {
73 const alloc = std.testing.allocator;
74 // POSIX reads an empty segment as the current directory. An attach
75 // must never execute a ./muxd it happens to be standing next to, so
76 // the helper skips them — an all-empty PATH finds nothing even when
77 // the cwd contains an executable by that name.
78 try std.testing.expectEqual(
79 @as(?[]const u8, null),
80 try findInPath(alloc, "::", "muxd"),
81 );
82 try std.testing.expectEqual(
83 @as(?[]const u8, null),
84 try findInPath(alloc, "", "muxd"),
85 );
86 }
87 ```
88
89 - [ ] **Step 2: Run to verify they fail to compile (no `findInPath` yet)**
90
91 Run: `make test`
92 Expected: compile error, `findInPath` not found.
93
94 - [ ] **Step 3: Implement** (in `src/spawn.zig`, after `probe`)
95
96 ```zig
97 /// Walk a colon-separated `path_env` for an executable `name`; the first
98 /// hit wins, execvp's own rule. Caller owns the returned path. Takes the
99 /// PATH string rather than reading the environment so tests stay
100 /// environment-free — the parseArgs discipline, applied here.
101 ///
102 /// Empty segments (`::`, leading/trailing `:`) mean the current directory
103 /// to POSIX; they are SKIPPED instead — an attach must never execute a
104 /// `./muxd` it happens to be standing next to.
105 pub fn findInPath(
106 alloc: std.mem.Allocator,
107 path_env: []const u8,
108 name: []const u8,
109 ) error{OutOfMemory}!?[]const u8 {
110 var it = std.mem.splitScalar(u8, path_env, ':');
111 while (it.next()) |dir| {
112 if (dir.len == 0) continue;
113 const candidate = try std.fs.path.join(alloc, &.{ dir, name });
114 std.posix.access(candidate, std.posix.X_OK) catch {
115 alloc.free(candidate);
116 continue;
117 };
118 return candidate;
119 }
120 return null;
121 }
122 ```
123
124 Also change `fn probe(` to `pub fn probe(` (same file) — Task 4's mux call site probes before declaring "no daemon and no muxd". No behavior change; nothing else to test.
125
126 - [ ] **Step 4: Run to verify pass**
127
128 Run: `make test`
129 Expected: PASS, including the three new tests, zero leaks.
130
131 - [ ] **Step 5: Mutation check, each mutant separately**
132
133 1. **Skip-non-executable pin.** Change `std.posix.access(candidate, std.posix.X_OK)` to `std.posix.access(candidate, std.posix.F_OK)` — the first test must fail (it finds `a/muxd`, the non-executable) and, since the second test's fixture carries a non-executable `muxd` of its own, the second fails with it (non-null where null is expected). Two failures is the recorded expectation, not drift. Restore, re-run, PASS.
134
135 2. **First-hit-wins pin.** Replace the early `return candidate` with a loop that accumulates the last match (`if (best) |b| alloc.free(b); best = candidate;`) and returns it after the loop — the first test must fail, finding `c/muxd` where `b/muxd` is expected. Restore, re-run, PASS. This is why the fixture needs a *third* directory with an executable: with only one executable in PATH, last-hit-wins returns the same path first-hit-wins does and the ordering claim in the test's own name is unpinned.
136
137 3. **Implicit-cwd pin.** Delete `if (dir.len == 0) continue;` — the empty-segments test must fail, finding the bare `muxd` that `path.join` produces from an empty segment. Restore, re-run, PASS. That test must `chdir` into a directory holding an executable `muxd`; from anywhere else it passes with or without the skip, because a helper that searched the cwd would find nothing there either.
138
139 - [ ] **Step 6: Commit**
140
141 ```bash
142 git add src/spawn.zig
143 git commit -m "feat: spawn.findInPath — PATH search for the mux auto-start call site"
144 ```
145
146 ---
147
148 ## Task 2: `stop_req` protocol frame + daemon dispatch arms
149
150 **Files:**
151 - Modify: `src/protocol.zig:18` (new MsgType), `src/server.zig` (`serviceObserver` switch ~:1188, `handleFrame` switch ~:1091, tests at the bottom)
152
153 A connection starts life as an **observer** and is promoted to a client slot only on `.attach` — `muxd stop` never attaches, so its frame lands in `serviceObserver`'s switch. The arm also goes in `handleFrame` (an attached client's dispatch) for symmetry with `debug_dump`/`stats_req`, which answer in both places; each arm gets its own mutation-covered test. `MsgType` is non-exhaustive (`_,`), so old daemons receive the new byte as a well-defined value that falls into their `else => {}` — the old-daemon timeout story in the spec rests on that.
154
155 - [ ] **Step 1: Add the frame type** (`src/protocol.zig`, client→daemon block, after `stats_req`)
156
157 ```zig
158 stop_req = 0x07, // payload: empty; daemon shuts down as if signalled
159 ```
160
161 And the wire round-trip pin (append to `src/protocol.zig`'s tests):
162
163 ```zig
164 test "stop_req round-trips through writeFrame/readFrame" {
165 const alloc = std.testing.allocator;
166 const p = try std.posix.pipe();
167 defer std.posix.close(p[0]);
168 defer std.posix.close(p[1]);
169 try writeFrame(p[1], .stop_req, "");
170 const f = (try readFrame(alloc, p[0])).?;
171 defer f.deinit(alloc);
172 try std.testing.expectEqual(MsgType.stop_req, f.type);
173 try std.testing.expectEqual(@as(usize, 0), f.payload.len);
174 }
175 ```
176
177 - [ ] **Step 2: Write the failing tests** (append to `src/server.zig`, near the existing `Server:` tests)
178
179 ```zig
180 test "Server: stop_req from a bare connection requests shutdown; run returns 130" {
181 const alloc = std.testing.allocator;
182
183 var tmp = try TmpDir.make();
184 defer tmp.cleanup();
185 var buf: [128]u8 = undefined;
186 const sock_path = try std.fmt.bufPrint(&buf, "{s}/stop.sock", .{tmp.path()});
187
188 // The flag is global by necessity (a signal handler shares it); reset
189 // so this test neither inherits a stale request nor leaves one behind.
190 shutdown_flag.store(false, .release);
191 defer shutdown_flag.store(false, .release);
192
193 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
194 defer srv.deinit();
195
196 const c = try std.net.connectUnixSocket(sock_path);
197 defer c.close();
198 // No attach first: the frame must be honored from the OBSERVER
199 // dispatch, which is where a bare `muxd stop` connection lives.
200 try proto.writeFrame(c.handle, .stop_req, "");
201
202 // Bounded, so the mutation run FAILS here instead of hanging the
203 // suite: 100 iterations x 50ms is the deadline, the flag is the exit.
204 var i: usize = 0;
205 while (i < 100 and !shutdown_flag.load(.acquire)) : (i += 1) {
206 _ = try srv.pumpOnce(50);
207 }
208 try std.testing.expect(shutdown_flag.load(.acquire));
209
210 // And the run loop turns the flag into the same exit a signal gets.
211 try std.testing.expectEqual(@as(u8, 130), try srv.run());
212 }
213
214 test "Server: stop_req from an attached client is honored too" {
215 const alloc = std.testing.allocator;
216
217 var tmp = try TmpDir.make();
218 defer tmp.cleanup();
219 var buf: [128]u8 = undefined;
220 const sock_path = try std.fmt.bufPrint(&buf, "{s}/stop2.sock", .{tmp.path()});
221
222 shutdown_flag.store(false, .release);
223 defer shutdown_flag.store(false, .release);
224
225 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
226 defer srv.deinit();
227
228 const c = try std.net.connectUnixSocket(sock_path);
229 defer c.close();
230 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
231 // Two pumps: one to accept+promote, one to be safe about ordering; the
232 // stop frame below is what the assertion actually watches.
233 _ = try srv.pumpOnce(50);
234 _ = try srv.pumpOnce(50);
235 try proto.writeFrame(c.handle, .stop_req, "");
236
237 var i: usize = 0;
238 while (i < 100 and !shutdown_flag.load(.acquire)) : (i += 1) {
239 _ = try srv.pumpOnce(50);
240 }
241 try std.testing.expect(shutdown_flag.load(.acquire));
242 }
243 ```
244
245 - [ ] **Step 3: Run to verify both fail**
246
247 Run: `make test`
248 Expected: both new tests FAIL at `expect(shutdown_flag...)` after their bounded loops (the frame is currently ignored by `else => {}`). NOT a hang — if it hangs, the bound is wrong; fix the test.
249
250 - [ ] **Step 4: Add the two dispatch arms**
251
252 In `serviceObserver`'s switch (before its `else => {}`), alongside `.detach`:
253
254 ```zig
255 .stop_req => shutdown_flag.store(true, .release),
256 ```
257
258 In `handleFrame`'s switch (before its `else => {}`), alongside `.detach`:
259
260 ```zig
261 .stop_req => shutdown_flag.store(true, .release),
262 ```
263
264 - [ ] **Step 5: Run to verify pass**
265
266 Run: `make test`
267 Expected: PASS.
268
269 - [ ] **Step 6: Mutation check, each arm separately**
270
271 Delete the `serviceObserver` arm only → test 1 must fail, test 2 still pass. Restore. Delete the `handleFrame` arm only → test 2 must fail, test 1 still pass. Restore, `make test` PASS.
272
273 - [ ] **Step 7: Commit**
274
275 ```bash
276 git add src/protocol.zig src/server.zig
277 git commit -m "feat: stop_req protocol verb — daemon shuts down as if signalled"
278 ```
279
280 ---
281
282 ## Task 3: `muxd stop` — the client side of the verb
283
284 **Files:**
285 - Modify: `src/main.zig` (usage text, `Cmd` enum, parse chain, dispatch, new `stopCmd`, parse tests)
286
287 - [ ] **Step 1: Write the failing parse test** (append near the existing `parseArgs` tests in `src/main.zig`)
288
289 ```zig
290 test "parseArgs: stop is a command and takes --sock" {
291 const r = parse(&.{ "muxd", "stop" });
292 try std.testing.expect(r == .ok);
293 try std.testing.expect(r.ok.cmd == .stop);
294 try std.testing.expect(r.ok.sock == null);
295
296 const s = parse(&.{ "muxd", "stop", "--sock", "/tmp/x.sock" });
297 try std.testing.expect(s.ok.cmd == .stop);
298 try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?);
299 }
300 ```
301
302 - [ ] **Step 2: Run to verify it fails to compile** (`.stop` not in `Cmd`)
303
304 Run: `make test`
305 Expected: compile error on `.stop`.
306
307 - [ ] **Step 3: Implement**
308
309 Usage block — add after the `muxd stats` line:
310
311 ```zig
312 \\ muxd stop [--sock PATH] (ask the daemon on PATH to exit)
313 ```
314
315 `Cmd` enum:
316
317 ```zig
318 const Cmd = enum { run, dump, stats, proxy, version, keygen, start, stop };
319 ```
320
321 Parse chain — after the `"start"` arm:
322
323 ```zig
324 else if (std.mem.eql(u8, args[1], "stop"))
325 .stop
326 ```
327
328 Dispatch switch in `main` — add alongside `.stats`:
329
330 ```zig
331 .stop => return stopCmd(alloc, sock_path),
332 ```
333
334 New function, after `stats`:
335
336 ```zig
337 /// Ask the daemon on `sock_path` to exit, then wait for the socket to stop
338 /// answering. Exit 0 covers both "stopped" and "nothing there" — the state
339 /// the user asked for is the state they got, which is what makes the verb
340 /// safe to script (`muxd start`'s re-runnability, mirrored).
341 fn stopCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
342 const stream = std.net.connectUnixSocket(sock_path) catch {
343 std.debug.print("muxd stop: nothing listening on {s}\n", .{sock_path});
344 return 0;
345 };
346 // A daemon that dies between connect and write reached the asked-for
347 // state on its own; the poll below confirms it either way.
348 proto.writeFrame(stream.handle, .stop_req, "") catch {};
349 stream.close();
350
351 // Probe-first, deadline-second — ensureDaemon's shape (spawn.zig:134),
352 // so the final window before the deadline is still probed and the
353 // failure line is never printed about an interval nobody checked.
354 // spawn.probe is the connect-refusal test: a live listener's backlog
355 // accepts even when its event loop is wedged (up to backlog depth —
356 // past ~128 pending connects AF_UNIX blocks rather than refuses, so
357 // this loop would wait, not lie), and only the shutdown unlink can
358 // produce a refusal, which makes it the true signal.
359 const stop_deadline_ms: i64 = 2000;
360 const t0 = std.time.milliTimestamp();
361 while (true) {
362 if (!spawn.probe(sock_path)) {
363 std.debug.print("muxd: stopped\n", .{});
364 return 0;
365 }
366 if (std.time.milliTimestamp() - t0 >= stop_deadline_ms) break;
367 std.Thread.sleep(50 * std.time.ns_per_ms);
368 }
369 // The log clause appears only when the path resolves: an absent HOME
370 // (a container, a systemd unit) must not replace the one finding that
371 // matters — the daemon did not stop — with an error trace. And the
372 // hedge stays in the words: a foreground `muxd run` logs to its own
373 // stderr, so naming the xdg path unconditionally would guess.
374 const log: ?[]const u8 = xdg.logPath(alloc) catch null;
375 defer if (log) |l| alloc.free(l);
376 if (log) |l| {
377 std.debug.print(
378 "muxd stop: {s} still answering after {d}s (if it was started detached, its log is {s})\n",
379 .{ sock_path, @divTrunc(stop_deadline_ms, 1000), l },
380 );
381 } else {
382 std.debug.print(
383 "muxd stop: {s} still answering after {d}s\n",
384 .{ sock_path, @divTrunc(stop_deadline_ms, 1000) },
385 );
386 }
387 return 1;
388 }
389 ```
390
391 (This block was amended after the Task 3 quality review: the original
392 deadline-before-probe loop left the final 50ms window unprobed — the
393 failure line could describe an interval nobody checked — and the `try`
394 on `xdg.logPath` could replace the verdict with an error trace under an
395 absent HOME. The loop now reuses `spawn.probe`, which also retires a
396 reimplementation and a twice-written deadline.)
397
398 Also add the runtime unit test the review asked for (the nothing-listening
399 branch has an exact precedent at proxy.zig:347 and costs no processes) —
400 append near the parse tests:
401
402 ```zig
403 test "stopCmd: a socket path with nothing on it is exit 0, not a failure" {
404 // In-body import per the keygen test's precedent: main.zig has no
405 // file-level testtmp import.
406 const testtmp = @import("testtmp");
407 var tmp = try testtmp.TmpDir.make();
408 defer tmp.cleanup();
409 var buf: [280]u8 = undefined;
410 const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()});
411 try std.testing.expectEqual(@as(u8, 0), try stopCmd(std.testing.allocator, sock));
412 }
413 ```
414
415 Mutation for it: flip the nothing-listening branch's `return 0` to
416 `return 1` — the test must fail. Restore, PASS.
417
418 - [ ] **Step 4: Run tests, then build**
419
420 Run: `make test` — expected PASS (parse test green).
421 Run: `make build` — expected clean (`make test` alone would not compile `stopCmd`; this is the exe-only-compile-error rule).
422
423 - [ ] **Step 5: Hand-verify the arc once** (also proves Task 2 end-to-end)
424
425 ```bash
426 SOCK=/tmp/m13-hand-$$.sock
427 ./zig-out/bin/muxd start --sock "$SOCK" --shell /bin/sh
428 ./zig-out/bin/muxd stop --sock "$SOCK" # expect: muxd: stopped, exit 0
429 ./zig-out/bin/muxd stop --sock "$SOCK" # expect: nothing listening, exit 0
430 ls "$SOCK" 2>&1 # expect: No such file or directory
431 ```
432
433 Verify by observation before moving on: the pid from the start up-line must be gone (`kill -0 PID` fails). Report the observed state, not the command's claim.
434
435 - [ ] **Step 6: Commit**
436
437 ```bash
438 git add src/main.zig
439 git commit -m "feat: muxd stop — connect, stop_req, poll until the socket refuses"
440 ```
441
442 ---
443
444 ## Task 4: auto-start call sites — `muxd proxy` and local `mux`
445
446 **Files:**
447 - Modify: `src/main.zig` (`.proxy` dispatch arm), `src/mux_main.zig` (`.attach` branch), `build.zig` (mux module imports)
448
449 The ensure call for the proxy lives in `main.zig`, NOT `proxy.zig` — the pump's import list is deliberately bare (M6 thesis, first comment in the file). Both sites spawn `muxd run --sock <path>` bare: never `--quic`, no opt-out (spec decisions). Silence contract: the already-running path prints nothing.
450
451 - [ ] **Step 1: build.zig wiring**
452
453 After `mux_mod.addImport("xdg", xdg_mod);` (~line 184):
454
455 ```zig
456 mux_mod.addImport("spawn", spawn_mod);
457 ```
458
459 - [ ] **Step 2: `muxd proxy` call site** (`src/main.zig`, replace the `.proxy => return proxy.run(sock_path),` dispatch arm)
460
461 ```zig
462 .proxy => {
463 // Attach auto-start (M13): the user asked for a session, not a
464 // daemon. Bare `run` on purpose — a QUIC listener must be asked
465 // for, never appear because someone attached. Same helper,
466 // deadline, and silence contract as `muxd start`.
467 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
468 const exe = std.fs.selfExePath(&exe_buf) catch {
469 std.debug.print("muxd proxy: cannot find own binary via /proc/self/exe\n", .{});
470 return 1;
471 };
472 const sock_z = try alloc.dupeZ(u8, sock_path);
473 defer alloc.free(sock_z);
474 const run_args = [_][:0]const u8{ "--sock", sock_z };
475 const progress: spawn.Progress = .{
476 .fd = std.posix.STDERR_FILENO,
477 .prefix = "muxd proxy",
478 .tty = std.posix.isatty(std.posix.STDERR_FILENO),
479 };
480 _ = spawn.ensureDaemon(alloc, exe, &run_args, sock_path, progress, 2000, null) catch |err| switch (err) {
481 // The failure line, with the log path, was already printed
482 // by Progress — a second line would say the same thing worse.
483 error.NeverAnswered => return 1,
484 error.BinaryNotFound, error.SpawnFailed => {
485 std.debug.print("muxd proxy: could not spawn {s}: {s}\n", .{ exe, @errorName(err) });
486 return 1;
487 },
488 };
489 return proxy.run(sock_path);
490 },
491 ```
492
493 - [ ] **Step 3: local `mux` call site** (`src/mux_main.zig`)
494
495 Add the import at the top, with the others:
496
497 ```zig
498 const spawn = @import("spawn");
499 ```
500
501 Replace the `.attach` branch's socket path resolution and return (the block from `if (t.via) |cmd| ...` through `return client.attach(alloc, sock_path, null, null);`) with:
502
503 ```zig
504 .attach => |t| {
505 if (t.via) |cmd| return client.attach(alloc, null, cmd, null);
506 const sock_path = if (t.sock) |s|
507 try alloc.dupe(u8, s)
508 else if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir|
509 try std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir})
510 else
511 try std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()});
512 defer alloc.free(sock_path);
513
514 // Attach auto-start (M13): give the attach a daemon to land on.
515 // Unix-socket transport only — quic:// has nothing local to
516 // spawn, and --via's auto-starter is the remote proxy. Bare
517 // `run`: a listener must be asked for, never be a side effect.
518 const muxd_path = try spawn.findInPath(
519 alloc,
520 std.posix.getenv("PATH") orelse "",
521 "muxd",
522 );
523 defer if (muxd_path) |p| alloc.free(p);
524 if (muxd_path) |exe| {
525 const sock_z = try alloc.dupeZ(u8, sock_path);
526 defer alloc.free(sock_z);
527 const run_args = [_][:0]const u8{ "--sock", sock_z };
528 const progress: spawn.Progress = .{
529 .fd = std.posix.STDERR_FILENO,
530 .prefix = "mux",
531 .tty = std.posix.isatty(std.posix.STDERR_FILENO),
532 };
533 _ = spawn.ensureDaemon(alloc, exe, &run_args, sock_path, progress, 2000, null) catch |err| switch (err) {
534 // ensureDaemon's own failure line named the log.
535 error.NeverAnswered => return 1,
536 error.BinaryNotFound, error.SpawnFailed => {
537 std.debug.print("mux: could not spawn {s}: {s}\n", .{ exe, @errorName(err) });
538 return 1;
539 },
540 };
541 } else if (!spawn.probe(sock_path)) {
542 // No muxd anywhere AND nothing serving: only now is the
543 // missing binary the user's problem, and both facts fit in
544 // one honest line. A live daemon needs no binary on PATH.
545 std.debug.print("mux: no daemon on {s} and no muxd in PATH to start one\n", .{sock_path});
546 return 1;
547 }
548 return client.attach(alloc, sock_path, null, null);
549 },
550 ```
551
552 - [ ] **Step 4: Build and test**
553
554 Run: `make build` — expected clean.
555 Run: `make test` — expected PASS (no unit-level change; the call sites are pinned by e2e in Task 6).
556
557 - [ ] **Step 5: Hand-verify both sites once** (cold socket each time; `SHELL=/bin/sh` so the spawned daemon is deterministic)
558
559 ```bash
560 S1=/tmp/m13-as1-$$.sock
561 { printf 'echo via-ok\n'; sleep 2; printf '\034'; } | \
562 SHELL=/bin/sh ./zig-out/bin/mux --via "./zig-out/bin/muxd proxy --sock $S1" 2>&1 | head -5
563 ./zig-out/bin/muxd dump --sock "$S1" | grep via-ok # marker arrived
564 ./zig-out/bin/muxd stop --sock "$S1" # muxd: stopped
565 S2=/tmp/m13-as2-$$.sock
566 { printf 'echo loc-ok\n'; sleep 2; printf '\034'; } | \
567 SHELL=/bin/sh PATH="$PWD/zig-out/bin:$PATH" ./zig-out/bin/mux --sock "$S2" 2>&1 | head -5
568 ./zig-out/bin/muxd dump --sock "$S2" | grep loc-ok
569 ./zig-out/bin/muxd stop --sock "$S2"
570 ```
571
572 Expected: both markers present, both stops report `muxd: stopped`. Afterwards verify by observation: `ps` shows no muxd from these sockets. (Note: the local leg here runs `mux` non-tty — auto-start still fires because it precedes `client.attach`; the tty-gated branches are Task 6's pty leg's business, not this smoke check's.)
573
574 - [ ] **Step 6: Commit**
575
576 ```bash
577 git add build.zig src/main.zig src/mux_main.zig
578 git commit -m "feat: attach auto-start — muxd proxy and local mux ensure a daemon"
579 ```
580
581 ---
582
583 ## Task 5: error-message audit — the six rewords
584
585 **Files:**
586 - Modify: `src/client.zig` (:65-68 `lostMsg` + its test ~:1902, :438), `src/main.zig` (:384, :402, :446-449, :358), `test/e2e.sh` (:458, :483 greps)
587
588 The closed table lives in the spec; this task implements exactly its Reword rows. proxy.zig:20 is a KEEP (survey correction — already guess-free). Anything discovered beyond the table: stop, record it as a spec amendment, don't silently reword.
589
590 - [ ] **Step 1: Update the `lostMsg` unit test FIRST** (`src/client.zig` ~:1902) — this is the mutation-first order for a string change: new expectation, see it fail, then change the string.
591
592 ```zig
593 test "lostMsg: only a --via transport that never connected gets the new wording" {
594 // No guessed cause: ssh's own stderr passes through and names the real
595 // one (host key, DNS, refused, missing binary). The lower layer spoke;
596 // this line must not talk over it.
597 try std.testing.expectEqualStrings(
598 "mux: transport command failed before a session started",
599 lostMsg("ssh box muxd proxy", 0),
600 );
601 // ...the rest of the test's cases stay byte-identical...
602 try std.testing.expectEqualStrings("mux: connection to muxd lost", lostMsg("ssh box muxd proxy", 7));
603 try std.testing.expectEqualStrings("mux: connection to muxd lost", lostMsg(null, 0));
604 }
605 ```
606
607 Run: `make test` — expected: FAILS on the first expectation (old string still in `lostMsg`).
608
609 - [ ] **Step 2: Reword `lostMsg`** (`src/client.zig:67`)
610
611 ```zig
612 if (via != null and session_epoch == 0)
613 return "mux: transport command failed before a session started";
614 ```
615
616 Also update the function's doc comment: drop the "(is muxd installed...)" framing; the causes list ("ssh refused, the host is unreachable, or `muxd` is not on its PATH") stays — it explains the epoch-0 case; the *message* just no longer picks one.
617
618 Run: `make test` — expected PASS.
619
620 - [ ] **Step 3: The remaining five rewords** (exact strings; each is one line)
621
622 `src/client.zig:438`:
623 ```zig
624 std.debug.print("mux: cannot connect to {s}\n", .{sock_path.?});
625 ```
626
627 `src/main.zig:384` (dump):
628 ```zig
629 std.debug.print("muxd dump: cannot connect to {s} (no daemon; `muxd start` starts one)\n", .{sock_path});
630 ```
631
632 `src/main.zig:402` (stats):
633 ```zig
634 std.debug.print("muxd stats: cannot connect to {s} (no daemon; `muxd start` starts one)\n", .{sock_path});
635 ```
636
637 `src/main.zig:446-449` (start already-running):
638 ```zig
639 std.debug.print(
640 "muxd: already running on {s} (`muxd stop` it first if you meant different flags)\n",
641 .{sock_path},
642 );
643 ```
644
645 `src/main.zig:358` (not-a-socket refusal):
646 ```zig
647 std.debug.print("muxd: {s} exists and is not a socket (move it, or name another with --sock)\n", .{sock_path});
648 ```
649
650 - [ ] **Step 4: Update the two existing behavioral pins + add the can-fail control** (`test/e2e.sh:458` and `:483`)
651
652 Both greps change from the old wording to the new:
653
654 ```sh
655 grep -q "transport command failed before a session started" "$OUT.via" || {
656 ```
657 ```sh
658 grep -q "transport command failed before a session started" "$OUT.dead" || {
659 ```
660
661 Directly after the `:483` grep, add the control — the OLD wording must be absent from the very capture the new-wording grep just passed on (a pin that cannot fail proves nothing; this one can, and a reverted reword fires it):
662
663 ```sh
664 grep -q "is muxd installed on the host" "$OUT.dead" && {
665 echo "e2e FAIL: old lostMsg wording still emitted alongside the new pin"
666 cat "$OUT.dead"; exit 1; } || true
667 ```
668
669 - [ ] **Step 5: Verify**
670
671 Run: `make test` — PASS. Run: `make build && make e2e` — expected: `e2e OK (13 scenarios, 25 convergence points)` (pins unchanged until Task 6; the reworded start line still matches e2e's existing `already running on $SOCK8` substring grep at :1118).
672
673 - [ ] **Step 6: Commit**
674
675 ```bash
676 git add src/client.zig src/main.zig test/e2e.sh
677 git commit -m "fix: error audit — say what happened, never guess what a lower layer named"
678 ```
679
680 ---
681
682 ## Task 6: e2e — the auto-start + stop arc, the pty local-mux leg, the pins
683
684 **Files:**
685 - Modify: `test/e2e.sh` (SOCK14/SOCK15 decls near the SOCK13 block, cleanup additions, two scenarios before the final pins, pin literals 13→15 / 25→27)
686
687 Conventions that bind here (read the `muxd start` scenario at :1078-1179 first — it is the template): markers through the session, never `$?`; daemons killed by tracked pid off the up-line; `SHELL=/bin/sh` on every auto-starting invocation (the spawned daemon resolves `$SHELL`, main.zig:335); waits are `wait_for`/polls, never sleeps-as-sync (the `sleep 2` inside printf-pipe blocks is input pacing the template already uses, not synchronization).
688
689 - [ ] **Step 1: Declarations** (after the SOCK13/TP1 block, ~line 77)
690
691 ```sh
692 # M13 auto-start + stop. Two paths: the proxy arc's daemon is spawned BY
693 # the proxy (pid read off the up-line on stderr), the pty leg's by local
694 # mux under the M12 fixture. Both are torn down by `muxd stop` — the verb
695 # under test is also the cleanup, and the trap only backstops it.
696 SOCK14="${TMPDIR:-/tmp}/muxd-e2e-astart-$$.sock"
697 SOCK15="${TMPDIR:-/tmp}/muxd-e2e-aspty-$$.sock"
698 APID=""
699 ```
700
701 - [ ] **Step 2: Cleanup additions** (in `cleanup()`: one kill line with the others, and the new files in the `rm -f` list)
702
703 ```sh
704 [ -n "$APID" ] && kill "$APID" 2>/dev/null || true
705 # The pty leg's daemon is spawned by mux, so the suite never holds its
706 # pid: the verb under test is also the backstop. Harmless when the
707 # scenario already stopped them — stop-when-nothing is exit 0.
708 [ -n "${MUXD:-}" ] && "$MUXD" stop --sock "$SOCK14" >/dev/null 2>&1 || true
709 [ -n "${MUXD:-}" ] && "$MUXD" stop --sock "$SOCK15" >/dev/null 2>&1 || true
710 ```
711
712 Add to the `rm -f` list:
713 ```sh
714 "$SOCK14" "$SOCK15" "$OUT.as" "$OUT.as.err" "$OUT.as2" "$OUT.as2.err" \
715 "$OUT.stop" "$OUT.stop2" "$OUT.pa" "$OUT.pa.err" "$OUT.pa.log" \
716 ```
717
718 - [ ] **Step 3: The proxy arc** (insert before the final pins block, after the M12 scenarios)
719
720 ```sh
721 # --- M13: attach auto-start (proxy) + muxd stop ------------------------
722 #
723 # Nothing is serving SOCK14: the attach itself must produce the daemon.
724 # SHELL pinned because the auto-started daemon gets no --shell flag and
725 # resolves $SHELL — the suite must not inherit the developer's.
726 { printf 'printf "auto-%%s\\n" start\n'; sleep 2; printf '\034'; } | \
727 SHELL=/bin/sh timeout 30 "$MUX" --via "$MUXD proxy --sock $SOCK14" \
728 > "$OUT.as" 2> "$OUT.as.err"
729 "$MUXD" dump --sock "$SOCK14" | grep -q "auto-start" || {
730 echo "e2e FAIL: auto-started daemon lost the marker"
731 cat "$OUT.as.err"; exit 1; }
732 grep -q '^muxd proxy: starting' "$OUT.as.err" || {
733 echo "e2e FAIL: cold attach printed no starting line"; cat "$OUT.as.err"; exit 1; }
734 APID=$(sed -n 's/.* pid=\([0-9]*\).*/\1/p' "$OUT.as.err" | head -1)
735 [ -n "$APID" ] || { echo "e2e FAIL: proxy up-line carries no pid"; cat "$OUT.as.err"; exit 1; }
736 kill -0 "$APID" || { echo "e2e FAIL: auto-started daemon not alive"; exit 1; }
737 assert_converged "$OUT.as" "$SOCK14" "auto-start via proxy"
738
739 # Silence is the fast path: a warm attach must print NO spawn progress.
740 { printf 'printf "auto-%%s\\n" again\n'; sleep 2; printf '\034'; } | \
741 SHELL=/bin/sh timeout 30 "$MUX" --via "$MUXD proxy --sock $SOCK14" \
742 > "$OUT.as2" 2> "$OUT.as2.err"
743 grep -q 'starting' "$OUT.as2.err" && {
744 echo "e2e FAIL: warm attach printed spawn progress"
745 cat "$OUT.as2.err"; exit 1; } || true
746 "$MUXD" dump --sock "$SOCK14" | grep -q "auto-again" || {
747 echo "e2e FAIL: warm attach did not reach the same daemon"; exit 1; }
748 assert_converged "$OUT.as2" "$SOCK14" "auto-start warm attach"
749
750 # The verb under test is the teardown: stopped line, exit 0, socket gone,
751 # and the daemon OBSERVED dead by pid — never the command's claim alone.
752 "$MUXD" stop --sock "$SOCK14" 2> "$OUT.stop"
753 grep -q '^muxd: stopped' "$OUT.stop" || {
754 echo "e2e FAIL: stop did not report stopped"; cat "$OUT.stop"; exit 1; }
755 [ ! -S "$SOCK14" ] || { echo "e2e FAIL: stop left the socket behind"; exit 1; }
756 _i=0
757 while kill -0 "$APID" 2>/dev/null; do
758 _i=$((_i + 1)); [ "$_i" -lt 40 ] || {
759 echo "e2e FAIL: stop reported stopped but pid $APID still runs"; exit 1; }
760 sleep 0.05
761 done
762 APID=""
763
764 # Idempotence control: stop with nothing there is exit 0 and says so.
765 set +e
766 "$MUXD" stop --sock "$SOCK14" 2> "$OUT.stop2"
767 RC_STOP=$?
768 set -e
769 [ "$RC_STOP" = "0" ] || {
770 echo "e2e FAIL: stop-when-nothing exited $RC_STOP, want 0"; cat "$OUT.stop2"; exit 1; }
771 grep -q "nothing listening on $SOCK14" "$OUT.stop2" || {
772 echo "e2e FAIL: stop-when-nothing said the wrong thing"; cat "$OUT.stop2"; exit 1; }
773 ok "attach auto-start via proxy; muxd stop tears it down"
774 ```
775
776 - [ ] **Step 4: The pty local-mux leg** (directly after Step 3's block)
777
778 ```sh
779 # --- M13: local mux auto-start, under the pty fixture ------------------
780 #
781 # A local attach is tty-gated territory, so it runs on the M12 fixture.
782 # PATH is prefixed with the build dir: findInPath must resolve exactly
783 # the muxd under test, and the suite proves the resolution by the daemon
784 # existing afterwards.
785 SHELL=/bin/sh PATH="$(dirname "$MUXD"):$PATH" timeout 30 \
786 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.pa" --err "$OUT.pa.err" \
787 -- "$MUX" --sock "$SOCK15" 2> "$OUT.pa.log" <<'PTYEOF'
788 send printf "pty-%s\n" auto\n
789 expect pty-auto 15000
790 send \x1c
791 waitexit 10000
792 PTYEOF
793 "$MUXD" dump --sock "$SOCK15" | grep -q "pty-auto" || {
794 echo "e2e FAIL: local auto-start lost the marker"
795 cat "$OUT.pa.log" "$OUT.pa.err" 2>/dev/null; exit 1; }
796 grep -q '^mux: starting' "$OUT.pa.err" || {
797 echo "e2e FAIL: local auto-start printed no mux-prefixed starting line"
798 cat "$OUT.pa.err"; exit 1; }
799 assert_converged "$OUT.pa" "$SOCK15" "local mux auto-start"
800 "$MUXD" stop --sock "$SOCK15" 2>/dev/null
801 [ ! -S "$SOCK15" ] || { echo "e2e FAIL: stop left the pty leg's socket"; exit 1; }
802 ok "local mux auto-start under the pty fixture"
803 ```
804
805 - [ ] **Step 5: Bump the pins** (the final block's literals and message)
806
807 ```sh
808 [ "$OK_COUNT" = "15" ] || {
809 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 15 —"
810 echo " a scenario was added (update the pin) or silently lost"
811 exit 1
812 }
813 [ "$CONV_COUNT" = "27" ] || {
814 echo "e2e FAIL: $CONV_COUNT convergence points ran, the pin says 27"
815 exit 1
816 }
817 echo "e2e OK (15 scenarios, 27 convergence points)"
818 ```
819
820 (If `assert_converged` counts differently than expected when actually run, the literal is set to the OBSERVED count after reading why — the pin asserts the literal, never a computed value. Two new `assert_converged` + one from the warm attach = 3 new convergence points would make it 28; count what actually runs, then set the literal and say so in the commit message.)
821
822 - [ ] **Step 6: Run the suite, then soak**
823
824 Run: `make build && make e2e`
825 Expected: `e2e OK (15 scenarios, N convergence points)` with N per Step 5's count.
826 Run: `SOAK_N=10 make soak`
827 Expected: 10/10 green.
828
829 - [ ] **Step 7: Commit**
830
831 ```bash
832 git add test/e2e.sh
833 git commit -m "test: e2e — auto-start arcs (proxy + pty), muxd stop teardown, pins bumped"
834 ```
835
836 ---
837
838 ## Task 7: the regrade — Leg 2 of the kill criterion
839
840 Three resurrections, run out of the same binaries, each of which must be CAUGHT at its predicted check. Baseline green first (`make build && make e2e` on shipping code), per standing rule. Each mutation is reverted before the next; after all three, `make e2e` green again.
841
842 - [ ] **Step 1: Baseline** — `make build && make e2e` green on clean HEAD.
843
844 - [ ] **Step 2: Revert the proxy call site** — in `src/main.zig`, change the `.proxy` arm back to `.proxy => return proxy.run(sock_path),` (delete the ensure block).
845 Predicted catch: the M13 proxy arc fails at its FIRST assertion — the dump grep finds no daemon and `$OUT.as.err` shows `muxd proxy: cannot connect to <sock>`. Run `make build && make e2e`, record the actual failing line verbatim. Restore.
846
847 - [ ] **Step 3: Revert the `stop_req` dispatch arms** — delete both `.stop_req =>` arms in `src/server.zig`.
848 Predicted catch: unit layer first (`make test`: both Task 2 tests fail); at e2e, the stop leg times out — `$OUT.stop` carries `still answering after 2s` where the `^muxd: stopped` grep was pinned, exit 1. Run both, record, restore.
849
850 - [ ] **Step 4: Revert the `lostMsg` reword** — restore the old string at `src/client.zig:67`.
851 Predicted catch: `make test` fails the lostMsg unit test; `make e2e` fails at the :458 grep (new wording absent). Run, record, restore.
852
853 - [ ] **Step 5: Confirm clean** — `git status` clean, `make build && make e2e` green, `make test` green.
854
855 - [ ] **Step 6: No commit** (nothing changed); the record goes into Task 8's decisions.md section, with the verbatim failure lines.
856
857 ---
858
859 ## Task 8: docs close
860
861 **Files:**
862 - Modify: `docs/roadmap.md` (M13 verdict; candidates list re-cut), `docs/decisions.md` (M13 section), `docs/superpowers/specs/2026-08-10-m13-trial-friction-design.md` (status line)
863
864 - [ ] **Step 1: roadmap.md** — move M13 to done with a 3-5 line verdict (auto-start both sites, stop verb, audit's seven rewords, suite at 15 scenarios); re-cut the candidates section: remaining trial-friction items gone, next candidates = ssh→QUIC handoff, first-backoff tuning, unit-layer mutation sweep (all already sketched there — reorder, don't rewrite).
865
866 - [ ] **Step 2: decisions.md** — new dated M13 section in the established format: verdict line, what shipped (with the no-QUIC and no-opt-out decisions and their reasons), the regrade table with verbatim failure lines from Task 7, findings (the observer-dispatch discovery — a bare connection's frames land in serviceObserver; the non-exhaustive-enum old-daemon story; the survey corrections), method note (subagent pipeline, mutation-first).
867
868 - [ ] **Step 3: spec status line** — update to `**Status:** executed 2026-08-10 — verdicts and deviations in decisions.md M13.`
869
870 - [ ] **Step 4: Commit**
871
872 ```bash
873 git add docs/roadmap.md docs/decisions.md docs/superpowers/specs/2026-08-10-m13-trial-friction-design.md
874 git commit -m "docs: M13 closed — auto-start, muxd stop, error audit; regrade 3/3 caught"
875 ```
docs/superpowers/plans/2026-08-11-m14-ssh-quic-handoff.md
Old New
@@ -1,1105 +0,0 @@
1 # M14: ssh→QUIC Handoff 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:** `mux HOST` fetches QUIC coordinates over ssh once, caches them, and attaches over pure QUIC thereafter — falling back to the same ssh, one deadline later, when QUIC can't get through.
6
7 **Architecture:** `muxd endpoint` = `muxd proxy` plus a mandatory one-line announce (`endpoint <port> <hex-key>` or `endpoint none`) printed before any frame traffic; the daemon gains a lazy-bind protocol verb (`endpoint_req`/`endpoint_reply`) that stands up an ephemeral-port QUIC listener on request; the client's bare-HOST arm becomes a handoff recipe (cache-hit QUIC-first, else ssh-fetch-then-QUIC, else the already-open ssh pipe) that Transport.open owns so M7's reconnect re-runs it.
8
9 **Tech Stack:** Zig 0.15.2 (pinned — see Toolchain below), existing quic_server/quic_client modules (ngtcp2+wolfSSL PSK), POSIX.
10
11 **Spec:** `docs/superpowers/specs/2026-08-11-m14-ssh-quic-handoff-design.md` — read it first; it holds the locked decisions and the reasoning. The spec was amended during planning (commit dd6c511): the announce line is mandatory, `endpoint none` is the explicit negative.
12
13 ---
14
15 ## Context for every task (read before starting any of them)
16
17 **Toolchain.** The system `zig` CANNOT build this repo. Use only:
18 - `make build` — builds both binaries into `zig-out/bin/`
19 - `make test` — unit tests. Does NOT compile the executables: an error in `main.zig`/`mux_main.zig` non-test code can survive `make test`. Run `make build` too before claiming success.
20 - `make e2e` — end-to-end suite (needs `make build` first)
21 - `make soak SOAK_N=10` — e2e repeated
22
23 **Resource rules (non-negotiable).** No synthetic load. Track every spawned process by pid; kill by tracked pid, never by name (never `pkill`). Verify cleanup by OBSERVATION (`ps` for the exact comm, `kill -0`), and report the observed state, never the cleanup command's claim. `muxd stop --sock PATH` is the sanctioned daemon teardown.
24
25 **Conventions.** Error messages: say what happened, name the way out, don't guess a cause a lower layer already named. Prefix `muxd endpoint:` for this command's own refusals/reports. Tests must be able to fail: no asserting a constant against itself (see the "spelled out rather than written" comments in main.zig's tests). Orientation comments explain why, in the codebase's voice. Every commit message line below is a suggestion; keep the `feat:`/`test:` prefix discipline.
26
27 **Key existing surfaces you will touch (verified against source at plan time):**
28 - `protocol.zig:11` `MsgType` — `stop_req = 0x07` is the last request verb; server→client types occupy 0x81–0x88 and 0xff. Non-exhaustive (`_`).
29 - `server.zig:1090` `handleFrame` (attached clients; replies via `_ = self.queueFrame(i, .type, payload)` — see `.stats_req` at :1143) and `server.zig:1187` `serviceObserver` (bare connections; replies via `proto.writeFrame(fd, ...) catch self.dropObserver(i)` — see :1231). `stop_req` appears in BOTH (:1174, :1236); `endpoint_req` must too.
30 - `server.zig:387` `quic_listener: ?*quic.Listener = null` — already optional, re-checked every poll iteration (fd array rebuilt each pass, :549–564), so lazy bind is "set the field", no loop surgery.
31 - `server.zig:900` `quicHandler()`, `:913` `attachQuic()`.
32 - `quic_server.zig:599` `Listener`, `:644` `bind(alloc, bind_addr, key, idle_ms) !*Listener` (returns with a drop-everything handler; call `setHandler` after), `:675` `g_listener_live` — a second live Listener in one process returns `error.ListenerAlreadyRunning` (matters for unit tests: deinit between tests). `:734` `pollFd()`. Bound-port discovery idiom: `std.posix.getsockname(l.fd, @ptrCast(&actual), &len)` — copy the exact pattern from quic_server.zig's own tests (:1654, :2294).
33 - `quic_server.zig:53` `Key = struct { bytes: [key_len]u8, ... }` with `pub fn load(path)`; `quic_client.zig:32` re-exports it (`pub const Key = quic.Key`). `bytes` is pub — a Key can be constructed from parsed hex directly.
34 - `main.zig:47` `Cmd` enum, `:83` `parseArgs`, `:238` `uses_socket` switch (`else => true` — a new socket-using command needs no edit there), `:266` the `.proxy` arm (ensureForAttach then `proxy.run`), `:469` `stopCmd` (the observer-connect + bounded-poll shape to mirror).
35 - `mux_main.zig:174` the `.host` arm; `:126` main's switch; parse returns `.host` for a bare word.
36 - `client.zig:88` `Transport` (`.child` for via, `.quic` for QUIC), `:109` `Transport.open(alloc, sock_path, via, quic, carry)` — via branch spawns `/bin/sh -c CMD` with piped stdin/stdout, INHERITED stderr (ssh's diagnostics reach the user; the endpoint announce must therefore go to stdout). `:291` `waitReady(cl, idle_ms, alloc, carry)` — the wait budget is a parameter; pass the handoff deadline there while the connection keeps its normal idle_ms. `:351` `parseQuicAddr`, `:375` `resolveHost(host, port)`. `:388` `pub fn attach(alloc, sock_path, via, quic)`. `:1274` reconnect re-opens with the same recipe.
37 - `proxy.zig` `pub fn run(sock_path) !u8` — pumps STDIN/STDOUT ↔ socket. Import list deliberately protocol-free; do not add imports to proxy.zig.
38 - `spawn.zig` `ensureForAttach(alloc, exe, sock_path, prefix) !bool`, `probe(sock_path) bool`, `start_deadline_ms = 2000`.
39 - `xdg.zig` — the `*From` pattern: pure variant takes env values as params, thin wrapper reads getenv. Every new path helper follows it.
40 - `build.zig:112–185` module graph. New module `handoff_mod` (Task 3) gets `testtmp`; `client_mod` and `mux_mod` both add `handoff`.
41 - `test/e2e.sh` — 2193 lines; helpers `wait_pid_gone PID LABEL` (:157), `assert_converged` (:217); scenario/convergence-point counts pinned as literals near :245 (adding scenarios means updating the literal — the friction is the feature); hermetic XDG homes at :17 (you will add `XDG_CACHE_HOME` there); cleanup trap around :256–330 (register new SOCKs/pids there).
42
43 **File map (what each task creates/modifies):**
44
45 | File | Tasks | Responsibility |
46 |---|---|---|
47 | `src/protocol.zig` | 1 | two new MsgType values |
48 | `src/server.zig` | 2 | endpoint_req in both dispatches; lazy bind; owned-listener deinit |
49 | `src/handoff.zig` (new) | 3 | announce format/parse, cache I/O, dial-host strip, deadline constant |
50 | `src/xdg.zig` | 3 | `hostCachePath` beside keyPath/logPath |
51 | `build.zig` | 3 | handoff module wiring |
52 | (measurement, no src) | 4 | pin `handoff.deadline_ms` from evidence |
53 | `src/main.zig` | 5 | `endpoint` subcommand |
54 | `src/client.zig`, `src/mux_main.zig` | 6 | HandoffTarget, Transport.open handoff branch, .host arm |
55 | `test/e2e.sh` | 7 | ssh shim + five scenarios |
56 | `docs/*`, LAN box | 8 | kill criterion, close-out |
57
58 ---
59
60 ### Task 1: Protocol verbs `endpoint_req` / `endpoint_reply`
61
62 **Files:**
63 - Modify: `src/protocol.zig:11-31` (MsgType), tests at the round-trip section (search for the `stop_req` round-trip test added in M13)
64
65 - [ ] **Step 1: Write the failing test**
66
67 Beside the existing stop_req round-trip test in `src/protocol.zig`:
68
69 ```zig
70 test "endpoint_req/endpoint_reply round-trip like every other verb" {
71 const alloc = std.testing.allocator;
72 const p = try std.posix.pipe();
73 defer std.posix.close(p[0]);
74
75 try writeFrame(p[1], .endpoint_req, "");
76 // The reply carries a u16 LE port; 0 is the daemon's "could not".
77 var port_payload: [2]u8 = undefined;
78 std.mem.writeInt(u16, &port_payload, 43210, .little);
79 try writeFrame(p[1], .endpoint_reply, &port_payload);
80 std.posix.close(p[1]);
81
82 const req = (try readFrame(alloc, p[0])).?;
83 defer req.deinit(alloc);
84 try std.testing.expectEqual(MsgType.endpoint_req, req.type);
85 try std.testing.expectEqual(@as(usize, 0), req.payload.len);
86
87 const rep = (try readFrame(alloc, p[0])).?;
88 defer rep.deinit(alloc);
89 try std.testing.expectEqual(MsgType.endpoint_reply, rep.type);
90 try std.testing.expectEqual(@as(u16, 43210), std.mem.readInt(u16, rep.payload[0..2], .little));
91 }
92 ```
93
94 (Adapt the pipe/read idiom to match the file's existing round-trip tests exactly — read them first; they may use a different helper shape.)
95
96 - [ ] **Step 2: Run it, verify it fails**
97
98 Run: `make test`
99 Expected: compile error — `endpoint_req` not a member of `MsgType`.
100
101 - [ ] **Step 3: Add the enum members**
102
103 In `MsgType`, after `stop_req = 0x07`:
104
105 ```zig
106 endpoint_req = 0x08, // payload: empty; asks for the QUIC port, binding a listener lazily if needed
107 ```
108
109 and in the server→client block, after `pty_mode = 0x88`:
110
111 ```zig
112 endpoint_reply = 0x89, // payload: u16 LE port; 0 = no listener could be produced (reason in daemon log)
113 ```
114
115 - [ ] **Step 4: Run tests, verify pass**
116
117 Run: `make test && make build`
118 Expected: all pass, both binaries build.
119
120 - [ ] **Step 5: Encode/decode pair** *(amended after implementation: the raw
121 `readInt` the original plan pinned bypasses this file's convention — every
122 multi-byte payload has a length-checking `encode*/decode*` pair with a
123 wrong-length refusal test, and a short reply must be `error`, not a
124 comptime-bounds panic)*
125
126 ```zig
127 pub fn encodeEndpointReply(port: u16) [2]u8 {
128 var b: [2]u8 = undefined;
129 std.mem.writeInt(u16, &b, port, .little);
130 return b;
131 }
132
133 /// The one caller that treats 0 as "no listener" does so at its own call
134 /// site; here 0 is just a value — the length is the only thing a decoder
135 /// can refuse without guessing.
136 pub fn decodeEndpointReply(payload: []const u8) !u16 {
137 if (payload.len != 2) return error.BadPayload;
138 return std.mem.readInt(u16, payload[0..2], .little);
139 }
140 ```
141
142 Plus the matching wrong-length test (empty, 1 byte, 3 bytes → `error.BadPayload`; adapt the error name to the file's existing decoders). Tasks 2 and 5 use this pair, not raw writeInt/readInt.
143
144 - [ ] **Step 6: Commit**
145
146 ```bash
147 git add src/protocol.zig
148 git commit -m "feat: endpoint_req/endpoint_reply protocol verbs for the QUIC handoff"
149 ```
150
151 ---
152
153 ### Task 2: Daemon lazy QUIC bind
154
155 **Files:**
156 - Modify: `src/server.zig` — new fields + three functions near the quic section (~:850–920), dispatch arms in `handleFrame` (:1090) and `serviceObserver` (:1187), `deinit`, unit tests at the bottom beside the stop_req tests (:4253)
157
158 - [ ] **Step 1: Write the failing tests**
159
160 Beside the stop_req server tests. They drive the whole surface; read them as the contract:
161
162 ```zig
163 test "Server: endpoint_req binds a listener lazily and replies its port; a second req replies the same port" {
164 // Shape copied from the stop_req bare-connection test (:4253): tmp
165 // socket, Server.init, connect, write frame, bounded pumpOnce loop.
166 // Differences: we hand the server a key via lazyBindQuic's testable
167 // seam rather than the environment, and we read a reply.
168 const testtmp = @import("testtmp");
169 var tmp = try testtmp.TmpDir.make();
170 defer tmp.cleanup();
171 var buf: [128]u8 = undefined;
172 const sock = try std.fmt.bufPrint(&buf, "{s}/e.sock", .{tmp.path()});
173 var kbuf: [128]u8 = undefined;
174 const key_path = try std.fmt.bufPrint(&kbuf, "{s}/key", .{tmp.path()});
175 try xdg.writeNewKey(key_path);
176
177 var srv = try Server.init(std.testing.allocator, .{
178 .sock_path = sock,
179 .shell = "/bin/sh",
180 .cols = 80,
181 .rows = 24,
182 });
183 defer srv.deinit(); // must also deinit the lazily-bound listener (owned)
184
185 const c = try std.net.connectUnixSocket(sock);
186 defer c.close();
187 try proto.writeFrame(c.handle, .endpoint_req, "");
188
189 var port: u16 = 0;
190 var i: usize = 0;
191 while (i < 100 and port == 0) : (i += 1) {
192 srv.pumpOnce(50) catch {};
193 // Non-blocking peek for the reply; adapt to however the stats
194 // observer tests read replies in this file.
195 if (proto.readFrame(std.testing.allocator, c.handle) catch null) |f| {
196 defer f.deinit(std.testing.allocator);
197 if (f.type == .endpoint_reply)
198 port = std.mem.readInt(u16, f.payload[0..2], .little);
199 }
200 }
201 try std.testing.expect(port != 0);
202
203 // Same connection asks again: same port, no second listener.
204 try proto.writeFrame(c.handle, .endpoint_req, "");
205 var port2: u16 = 0;
206 i = 0;
207 while (i < 100 and port2 == 0) : (i += 1) {
208 srv.pumpOnce(50) catch {};
209 if (proto.readFrame(std.testing.allocator, c.handle) catch null) |f| {
210 defer f.deinit(std.testing.allocator);
211 if (f.type == .endpoint_reply)
212 port2 = std.mem.readInt(u16, f.payload[0..2], .little);
213 }
214 }
215 try std.testing.expectEqual(port, port2);
216 }
217
218 test "Server: endpoint_req with no resolvable key replies 0 and the daemon keeps running" {
219 // Same rig, no key file anywhere, env resolution forced empty through
220 // the *From seam. Expect port 0 in the reply and a daemon that still
221 // answers (write stats_req after, get stats_reply).
222 }
223 ```
224
225 Two caveats the implementer must handle, both discovered at plan time:
226 1. `readFrame` on a blocking socket will block; the existing observer tests in this file already solve reply-reading — copy their idiom instead of inventing one (if none reads replies inside a pumpOnce loop, set the test socket non-blocking and treat EWOULDBLOCK as "not yet").
227 2. `g_listener_live` (quic_server.zig:675) is process-global: the lazy-bind test MUST tear its listener down (via `srv.deinit`) before any other test binds one, and vice versa — if an existing `--quic` server test runs in the same binary, ordering hazards show up as `error.ListenerAlreadyRunning`. If that bites, fold both assertions into one test.
228
229 The environment seam: like xdg's `*From` pattern, the key resolution takes env values as parameters so tests never setenv. The no-key test calls the `From` variant with all-null env against a HOME-less resolution and expects 0.
230
231 - [ ] **Step 2: Run tests, verify they fail**
232
233 Run: `make test`
234 Expected: compile error — no `endpoint_req` arm / helpers missing.
235
236 - [ ] **Step 3: Implement**
237
238 New Server field beside `quic_listener` (:387):
239
240 ```zig
241 /// True when the listener was bound lazily by endpoint_req, and is
242 /// therefore ours to deinit. A listener handed in by main.zig (the
243 /// explicit --quic path) has its own deferred deinit out there, and
244 /// freeing it twice would be a use-after-free at shutdown.
245 quic_owned: bool = false,
246 ```
247
248 In `Server.deinit`, AFTER client slots are torn down (mirror the ordering main.zig's defers guarantee for the explicit path — the listener must outlive the slots that close through it):
249
250 ```zig
251 if (self.quic_owned) {
252 if (self.quic_listener) |l| l.deinit();
253 self.quic_listener = null;
254 self.quic_owned = false;
255 }
256 ```
257
258 Three functions near `quicHandler`/`attachQuic` (~:900):
259
260 ```zig
261 /// The endpoint_req answer: the bound QUIC port, standing a listener up
262 /// on demand if none exists. 0 means "could not", and the reason goes to
263 /// the daemon log (stderr) rather than into the frame — the asker can
264 /// do nothing with it but relay, and `muxd endpoint`'s announce-none
265 /// already tells the client everything it can act on.
266 fn endpointPort(self: *Server) u16 {
267 return self.endpointPortFrom(
268 std.posix.getenv("MUX_KEY_FILE"),
269 std.posix.getenv("XDG_CONFIG_HOME"),
270 std.posix.getenv("HOME"),
271 );
272 }
273
274 /// Env handed in, nothing read: the xdg *From pattern, for the same
275 /// reason — tests cannot setenv. Key resolution is MUX_KEY_FILE then
276 /// the default path; there is no --key half because a daemon being
277 /// asked lazily is one that was never handed a flag.
278 fn endpointPortFrom(
279 self: *Server,
280 env_key: ?[]const u8,
281 xdg_config_home: ?[]const u8,
282 home: ?[]const u8,
283 ) u16 {
284 if (self.quic_listener) |q| return boundUdpPort(q);
285 const env: ?[]const u8 = if (env_key) |v|
286 (if (v.len == 0) null else v)
287 else
288 null;
289 var owned: ?[]const u8 = null;
290 defer if (owned) |p| self.alloc.free(p);
291 const key_path = env orelse blk: {
292 const dflt = xdg.keyPathFrom(self.alloc, xdg_config_home, home) catch break :blk null;
293 owned = dflt;
294 break :blk if (std.fs.cwd().access(dflt, .{})) |_| dflt else |_| @as(?[]const u8, null);
295 } orelse {
296 // Amended after quality review: the original single line covered
297 // two situations (no resolvable path vs. resolved-but-absent)
298 // and named neither path — failing the "name the way out" rule.
299 if (owned) |p|
300 std.debug.print("muxd: endpoint_req: no key at {s} (run `muxd keygen`)\n", .{p})
301 else
302 std.debug.print("muxd: endpoint_req: no key to listen with and no HOME to find one under (run `muxd keygen`)\n", .{});
303 return 0;
304 };
305 // Amended after quality review: Key.load's three named errors get the
306 // same actionable prose main.zig already wrote (chmod 600 it / not a
307 // key / no such file); only genuinely-unnamed errors fall back to
308 // @errorName. The original snippet leaked raw identifiers.
309 const key = quic.Key.load(key_path) catch |err| {
310 switch (err) {
311 error.KeyFileMissing => std.debug.print("muxd: endpoint_req: no such key file: {s}\n", .{key_path}),
312 error.KeyFilePermissive => std.debug.print("muxd: endpoint_req: {s} is readable by group or other; chmod 600 it\n", .{key_path}),
313 error.KeyFileMalformed => std.debug.print("muxd: endpoint_req: {s} is not a key: want 32 raw bytes or 64 hex characters\n", .{key_path}),
314 else => |e| std.debug.print("muxd: endpoint_req: cannot load key {s}: {s}\n", .{ key_path, @errorName(e) }),
315 }
316 return 0;
317 };
318 return self.lazyBindQuic(key) catch |err| {
319 std.debug.print("muxd: endpoint_req: cannot bind udp: {s}\n", .{@errorName(err)});
320 return 0;
321 };
322 }
323
324 /// Bind 0.0.0.0 on a kernel-assigned port and wire it in. The poll loop
325 /// re-reads `quic_listener` every iteration, so there is no loop surgery
326 /// here — setting the field IS the integration.
327 fn lazyBindQuic(self: *Server, key: quic.Key) !u16 {
328 const addr = try std.net.Address.parseIp("0.0.0.0", 0);
329 const l = try quic.Listener.bind(self.alloc, addr, key, quic.default_idle_ms);
330 l.setHandler(self.quicHandler());
331 self.quic_listener = l;
332 self.quic_owned = true;
333 return boundUdpPort(l);
334 }
335 ```
336
337 And a file-scope helper (copy the getsockname idiom from quic_server.zig's tests at :1654/:2294 — the sockaddr type and cast must match theirs exactly):
338
339 ```zig
340 fn boundUdpPort(l: *quic.Listener) u16 {
341 var addr: std.net.Address = undefined;
342 var len: std.posix.socklen_t = @sizeOf(std.net.Address);
343 std.posix.getsockname(l.pollFd(), @ptrCast(&addr.any), &len) catch return 0;
344 return addr.getPort();
345 }
346 ```
347
348 Dispatch arms. In `handleFrame`, after `.stop_req` (:1174):
349
350 ```zig
351 // The other half of the observer arm below, here for the same
352 // reason stop_req's is: the verb means the same thing on any
353 // connection. Replies through the queue like stats does.
354 .endpoint_req => {
355 const payload = proto.encodeEndpointReply(self.endpointPort());
356 _ = self.queueFrame(i, .endpoint_reply, &payload);
357 },
358 ```
359
360 In `serviceObserver`, after `.stop_req` (:1236):
361
362 ```zig
363 // Where `muxd endpoint` actually lands, since it never attaches.
364 .endpoint_req => {
365 const payload = proto.encodeEndpointReply(self.endpointPort());
366 proto.writeFrame(fd, .endpoint_reply, &payload) catch self.dropObserver(i);
367 },
368 ```
369
370 `server.zig` must import `xdg` if it doesn't already (check the imports at the top; add `const xdg = @import("xdg");` and `server_mod.addImport("xdg", xdg_mod)` in build.zig if missing).
371
372 - [ ] **Step 4: Run tests, verify pass**
373
374 Run: `make test && make build`
375 Expected: pass. If `ListenerAlreadyRunning` shows up in unrelated quic tests, revisit caveat 2 in Step 1.
376
377 - [ ] **Step 5: Mutation check**
378
379 Revert the `serviceObserver` arm only (comment it out), rebuild, re-run the lazy-bind unit test. Expected: the test times out/fails — proving the observer dispatch (the one `muxd endpoint` will actually hit) is load-bearing, not just the client arm. Restore.
380
381 - [ ] **Step 6: Commit**
382
383 ```bash
384 git add src/server.zig build.zig
385 git commit -m "feat: lazy QUIC bind — endpoint_req stands up an ephemeral-port listener on demand"
386 ```
387
388 ---
389
390 ### Task 3: `handoff.zig` + cache path helper
391
392 **Files:**
393 - Create: `src/handoff.zig`
394 - Modify: `src/xdg.zig` (hostCachePath beside keyPath), `build.zig` (module wiring)
395
396 - [ ] **Step 1: Write the failing tests**
397
398 `src/handoff.zig` is pure helpers — everything testable without a network:
399
400 ```zig
401 //! The ssh→QUIC handoff's shared vocabulary: the announce line `muxd
402 //! endpoint` prints and `mux` parses, the per-host cache file that
403 //! remembers it, and the strip that turns an ssh destination into a
404 //! dialable host. Pure by design — no sockets, no processes — so the
405 //! whole surface tests without a daemon.
406 const std = @import("std");
407
408 /// The QUIC attach budget per attempt. Provisional until the Task 4
409 /// measurement pins it; see the decisions.md M14 entry for the numbers.
410 pub const deadline_ms: u32 = 2000;
411
412 pub const Endpoint = struct { port: u16, key: [32]u8 };
413
414 pub const announce_none = "endpoint none\n";
415
416 /// `endpoint <port> <64 hex chars>\n`. One writer (muxd endpoint), two
417 /// readers (mux parsing the ssh pipe, and the cache file, which stores
418 /// exactly this line so there is one grammar, not two).
419 pub fn formatAnnounce(buf: []u8, ep: Endpoint) ![]const u8 {
420 var hex: [64]u8 = undefined;
421 _ = std.fmt.bufPrint(&hex, "{x}", .{&ep.key}) catch unreachable; // adapt: lower-hex of 32 bytes
422 return std.fmt.bufPrint(buf, "endpoint {d} {s}\n", .{ ep.port, hex });
423 }
424
425 /// Null = `endpoint none` (an explicit negative, not a parse failure).
426 /// Anything that is not one of the two grammars is an error: the only
427 /// writer is our own binary, so junk means the pipe is not carrying what
428 /// we were promised.
429 pub fn parseAnnounce(line: []const u8) !?Endpoint {
430 // trim one trailing \n / \r\n; require "endpoint " prefix;
431 // "none" → null; else <port> <hex64> → Endpoint.
432 // port 0 is refused: the daemon's "could not" must arrive as `none`,
433 // never as a dialable-looking zero.
434 }
435
436 /// `user@host` → `host`; a bare host passes through. The FIRST '@' splits:
437 /// ssh itself takes everything before the last '@' as the user, but a
438 /// host containing '@' is not dialable anyway and falls through to ssh.
439 pub fn dialHost(host: []const u8) []const u8 {
440 const at = std.mem.lastIndexOfScalar(u8, host, '@') orelse return host;
441 return host[at + 1 ..];
442 }
443
444 /// Read one newline-terminated line from `fd`, byte at a time, into `buf`.
445 /// Byte-at-a-time is deliberate: the frame stream begins immediately after
446 /// the newline, and a buffered read would steal its first bytes.
447 pub fn readLine(fd: std.posix.fd_t, buf: []u8) ![]const u8 { ... }
448
449 /// The announce line, verbatim, at `path`: file 0600, parents 0700 (the
450 /// key travels in it). Overwrites — a cache is the latest truth, unlike a
451 /// key file.
452 pub fn writeCache(path: []const u8, ep: Endpoint) !void { ... }
453
454 /// Endpoint from the cache, with Key.load's permission discipline: a
455 /// group/other-readable cache is refused before it is read.
456 pub fn readCache(path: []const u8) !Endpoint { ... }
457 ```
458
459 Tests to write first (all in `src/handoff.zig`):
460
461 ```zig
462 test "announce: format→parse round-trip, none included" {
463 var buf: [128]u8 = undefined;
464 const ep = Endpoint{ .port = 4433, .key = [_]u8{0xAB} ** 32 };
465 const line = try formatAnnounce(&buf, ep);
466 const back = (try parseAnnounce(line)).?;
467 try std.testing.expectEqual(ep.port, back.port);
468 try std.testing.expectEqualSlices(u8, &ep.key, &back.key);
469 try std.testing.expect((try parseAnnounce("endpoint none\n")) == null);
470 }
471
472 test "announce: junk is an error, not a guess" {
473 // no prefix; missing port; port 0; odd-length hex; hex too long;
474 // non-hex characters; empty line. Each expects an error.
475 }
476
477 test "dialHost strips the user, leaves a bare host alone" {
478 try std.testing.expectEqualStrings("box", dialHost("ubuntu@box"));
479 try std.testing.expectEqualStrings("box", dialHost("box"));
480 }
481
482 test "cache: write→read round-trip; 0600 file in 0700 dir; permissive file refused" {
483 // testtmp dir; writeCache; stat modes like xdg.zig's writeNewKey test;
484 // readCache round-trips; chmod 0644 the file → readCache errors.
485 }
486
487 test "readLine stops at the newline and leaves the next byte unread" {
488 // pipe(); write "endpoint none\nX"; CLOSE THE WRITE END before the
489 // trailing read (amended during Task 3: with it open, a byte-stealing
490 // readLine makes the trailing read hang forever — the stolen 'X'
491 // never arrives — burning a timeout instead of printing an assertion;
492 // closing first leaves the buffered bytes readable and the catch
493 // legible. Applies to ANY "did not consume too much" test over a
494 // live pipe); readLine returns "endpoint none"; a following 1-byte
495 // read must yield 'X' — this is the property the whole
496 // announce-then-frames protocol stands on.
497 }
498 ```
499
500 And in `src/xdg.zig`, the cache path helper (with its `*From` twin and tests mirroring `keyPathFrom`'s):
501
502 ```zig
503 /// `$XDG_CACHE_HOME/mux/hosts/<host>`, defaulting to
504 /// `~/.cache/mux/hosts/<host>`. Where `mux HOST` remembers the last
505 /// announce. A host containing a path separator is refused — it would
506 /// name a different file than it means — and the caller attaches
507 /// uncached rather than failing.
508 pub fn hostCachePath(alloc: std.mem.Allocator, host: []const u8) ![]const u8 {
509 return hostCachePathFrom(alloc, host, std.posix.getenv("XDG_CACHE_HOME"), std.posix.getenv("HOME"));
510 }
511
512 pub fn hostCachePathFrom(
513 alloc: std.mem.Allocator,
514 host: []const u8,
515 xdg_cache_home: ?[]const u8,
516 home: ?[]const u8,
517 ) ![]const u8 {
518 if (std.mem.indexOfScalar(u8, host, '/') != null) return error.UncacheableHost;
519 if (xdg_cache_home) |d| if (d.len > 0)
520 return std.fmt.allocPrint(alloc, "{s}/mux/hosts/{s}", .{ d, host });
521 const h = home orelse return error.NoHome;
522 return std.fmt.allocPrint(alloc, "{s}/.cache/mux/hosts/{s}", .{ h, host });
523 }
524 ```
525
526 Test: XDG wins / HOME fallback / empty-is-unset / NoHome — copy `keyPathFrom`'s test shape — plus `error.UncacheableHost` for `"a/b"`.
527
528 - [ ] **Step 2: Wire the module, run tests, verify they fail then pass**
529
530 `build.zig`, beside spawn_mod (:129):
531
532 ```zig
533 const handoff_mod = b.createModule(.{
534 .root_source_file = b.path("src/handoff.zig"),
535 .target = target,
536 .optimize = optimize,
537 });
538 handoff_mod.addImport("testtmp", testtmp_mod);
539 ```
540
541 Add `handoff_mod` to the test loop (find where spawn_mod/xdg_mod are iterated for tests) and `client_mod.addImport("handoff", handoff_mod);` plus `mux_mod.addImport("handoff", handoff_mod);` (used in Task 6 — wiring now keeps this task the only build.zig touch).
542
543 Run: `make test` — fails on the unimplemented bodies; implement; `make test && make build` — pass.
544
545 - [ ] **Step 3: Commit**
546
547 ```bash
548 git add src/handoff.zig src/xdg.zig build.zig
549 git commit -m "feat: handoff vocabulary — announce line, per-host cache, dial-host strip"
550 ```
551
552 ---
553
554 ### Task 4: Measure the deadline before trusting it
555
556 No product code. Deliverables: numbers in a decisions.md draft note, and `handoff.deadline_ms`'s comment updated to cite them. The spec (Component 4) requires this BEFORE the client task leans on the number.
557
558 - [ ] **Step 1: Local wrong-key measurement**
559
560 ```bash
561 make build
562 B=zig-out/bin
563 T=$(mktemp -d)
564 export XDG_CONFIG_HOME="$T/cfg"
565 $B/muxd keygen # key A (daemon's)
566 head -c 32 /dev/urandom > "$T/wrong.key"; chmod 600 "$T/wrong.key" # key B
567 $B/muxd run --sock "$T/m.sock" --quic 127.0.0.1:14433 &
568 DPID=$!
569 sleep 0.5
570 # Wrong key: time-to-failure and the message it fails with.
571 time $B/mux "quic://127.0.0.1:14433" --key "$T/wrong.key" --quic-idle-ms 8000 </dev/null; echo "rc=$?"
572 # Control — right key must attach (proves the rig, then detach via EOF):
573 # (non-tty attach: expect it to converge; Ctrl-\ equivalent is closing stdin)
574 $B/muxd stop --sock "$T/m.sock"
575 wait $DPID 2>/dev/null; kill -0 $DPID 2>/dev/null && kill $DPID # observe, then belt-and-braces by tracked pid
576 ps -o pid,comm -p $DPID 2>/dev/null || echo "daemon gone (observed)"
577 ```
578
579 Question being answered: does a wrong PSK fail FAST (a distinguishable TLS alert before the idle bound) or only at the waitReady budget? The code comment at client.zig:291 predicts "no distinguishable rejection"; confirm or refute with the observed wall time (if it fails at ~8s with the 8000 budget, the budget is the only bound; if it fails in ms, the deadline can be generous).
580
581 - [ ] **Step 2: Unreachable-port measurements, local + LAN**
582
583 Local (ICMP refusal — the fast case): same rig, dial a port nothing holds (`quic://127.0.0.1:14434`), time it.
584
585 LAN blackhole (no ICMP — the slow case), on the M8 box `ubuntu@192.168.0.109` (has sudo; inbound UDP verified in M8):
586
587 ```bash
588 # On the LAN box: drop inbound UDP on a test port, no reject (that's the point):
589 ssh ubuntu@192.168.0.109 'sudo iptables -A INPUT -p udp --dport 14433 -j DROP'
590 # From here: time the dial (expect silence bounded only by the budget):
591 time $B/mux "quic://192.168.0.109:14433" --key "$T/wrong.key" --quic-idle-ms 4000 </dev/null
592 # ALWAYS remove the rule, then verify by listing:
593 ssh ubuntu@192.168.0.109 'sudo iptables -D INPUT -p udp --dport 14433 -j DROP; sudo iptables -L INPUT -n | grep 14433 || echo "rule gone (observed)"'
594 ```
595
596 - [ ] **Step 3: Pin the number**
597
598 Decide `handoff.deadline_ms` from the evidence: it must comfortably cover a real handshake on the LAN (M8 measured attach times exist in decisions.md) while bounding the worst silent case (wrong key and blackhole are both expected to run to the budget — if so, 2000 stands; say so). Update the constant's comment in `src/handoff.zig` with the measured numbers and their dates. Write the raw numbers into a scratch note for Task 8's decisions.md entry.
599
600 - [ ] **Step 4: Commit**
601
602 ```bash
603 git add src/handoff.zig
604 git commit -m "docs: pin handoff deadline from measured wrong-key and blackhole behavior"
605 ```
606
607 ---
608
609 ### Task 5: `muxd endpoint`
610
611 **Files:**
612 - Modify: `src/main.zig` — usage (:13), Cmd (:47), parseArgs (:88), main's switch (:250), new `endpointCmd` beside `stopCmd`, tests at the bottom
613
614 - [ ] **Step 1: Write the failing parse tests**
615
616 ```zig
617 test "parseArgs: endpoint is a command and takes --sock" {
618 const r = parse(&.{ "muxd", "endpoint" });
619 try std.testing.expect(r == .ok);
620 try std.testing.expect(r.ok.cmd == .endpoint);
621 const s = parse(&.{ "muxd", "endpoint", "--sock", "/tmp/x.sock" });
622 try std.testing.expectEqualStrings("/tmp/x.sock", s.ok.sock.?);
623 }
624 ```
625
626 Run `make test`: compile error (no `.endpoint`).
627
628 - [ ] **Step 2: Parse + usage**
629
630 Cmd: `const Cmd = enum { run, dump, stats, proxy, version, keygen, start, stop, endpoint };`
631 parseArgs: an `endpoint` branch beside `stop`'s. Usage block, after the proxy line:
632
633 ```
634 \\ muxd endpoint [--sock PATH] (proxy that first announces QUIC port+key)
635 ```
636
637 `uses_socket` (:238) already covers it via `else => true` — no edit; note that in the commit message body if the reviewer asks.
638
639 - [ ] **Step 3: Implement `endpointCmd`**
640
641 Dispatched from main's switch: `.endpoint => return endpointCmd(alloc, sock_path),`
642
643 ```zig
644 /// `muxd proxy` with a one-line preamble: ensure a daemon, ensure a key,
645 /// ask the daemon for its QUIC port, announce `endpoint <port> <hex-key>`
646 /// (or `endpoint none`) as the FIRST stdout bytes, then become the exact
647 /// proxy byte pump. The announce is mandatory in both directions — the
648 /// client blocks on one line, so silence here would hang it (see the M14
649 /// spec's amendment note).
650 fn endpointCmd(alloc: std.mem.Allocator, sock_path: []const u8) !u8 {
651 var exe_buf: [std.fs.max_path_bytes]u8 = undefined;
652 const exe = std.fs.selfExePath(&exe_buf) catch {
653 std.debug.print("muxd endpoint: cannot find own binary via /proc/self/exe\n", .{});
654 return 1;
655 };
656 if (!try spawn.ensureForAttach(alloc, exe, sock_path, "muxd endpoint")) return 1;
657
658 // The key this process announces and the key the daemon's lazy bind
659 // resolves must be the same file, so the resolution is the same:
660 // MUX_KEY_FILE, then the default path. Only the default is created
661 // when absent (the mosh-server move) — a set-but-missing MUX_KEY_FILE
662 // names a file the user manages, and inventing one there would be a
663 // credential appearing where they didn't ask for it.
664 var key: ?quic.Key = null;
665 if (envKey()) |p| {
666 key = quic.Key.load(p) catch null;
667 if (key == null)
668 std.debug.print("muxd endpoint: cannot use MUX_KEY_FILE {s}; staying on ssh\n", .{p});
669 } else if (xdg.keyPath(alloc)) |dflt| {
670 defer alloc.free(dflt);
671 // Amended after Task 5: the original swallowed every create error
672 // (`else => {}`), so an unwritable config dir was later reported as
673 // "no such key file" — the symptom, not the cause. KeyExists stays
674 // silent (the ordinary case); any other create failure is kept and
675 // reported if the load then also fails.
676 var create_err: ?anyerror = null;
677 xdg.writeNewKey(dflt) catch |err| switch (err) {
678 error.KeyExists => {},
679 else => |e| create_err = e,
680 };
681 key = quic.Key.load(dflt) catch null;
682 if (key == null)
683 std.debug.print("muxd endpoint: no usable key at {s}; staying on ssh\n", .{dflt});
684 } else |_| {
685 std.debug.print("muxd endpoint: no HOME to resolve a key path; staying on ssh\n", .{});
686 }
687
688 var port: u16 = 0;
689 if (key != null) port = askEndpointPort(alloc, sock_path);
690
691 var line_buf: [128]u8 = undefined;
692 const line: []const u8 = if (port != 0)
693 handoff.formatAnnounce(&line_buf, .{ .port = port, .key = key.?.bytes }) catch handoff.announce_none
694 else
695 handoff.announce_none;
696 proto.writeAllFd(std.posix.STDOUT_FILENO, line) catch return 1;
697
698 return proxy.run(sock_path);
699 }
700
701 /// One observer round-trip: endpoint_req, then a bounded wait for the
702 /// reply. The bound converts an old daemon's silence (non-exhaustive
703 /// enum: it ignores the verb) into the announce-none path instead of a
704 /// hang. Reuses spawn.start_deadline_ms — the same "how long can a
705 /// daemon reasonably take" number, not a new one.
706 fn askEndpointPort(alloc: std.mem.Allocator, sock_path: []const u8) u16 {
707 const stream = std.net.connectUnixSocket(sock_path) catch return 0;
708 defer stream.close();
709 proto.writeFrame(stream.handle, .endpoint_req, "") catch return 0;
710 const deadline = std.time.milliTimestamp() + spawn.start_deadline_ms;
711 while (std.time.milliTimestamp() < deadline) {
712 var fds = [_]std.posix.pollfd{.{ .fd = stream.handle, .events = std.posix.POLL.IN, .revents = 0 }};
713 const remaining: i32 = @intCast(@max(1, deadline - std.time.milliTimestamp()));
714 _ = std.posix.poll(&fds, remaining) catch return 0;
715 if (fds[0].revents == 0) continue;
716 const f = (proto.readFrame(alloc, stream.handle) catch return 0) orelse return 0;
717 defer f.deinit(alloc);
718 if (f.type != .endpoint_reply) continue;
719 // Wrong length = not a reply we understand = the announce-none path.
720 return proto.decodeEndpointReply(f.payload) catch 0;
721 }
722 return 0;
723 }
724 ```
725
726 `main.zig` adds `const handoff = @import("handoff");` — and build.zig needs `exe_mod`'s module (the muxd root) to import handoff: add `muxd_mod.addImport("handoff", handoff_mod);` next to wherever main.zig's module gets `spawn` (adapt the actual module variable name from build.zig).
727
728 - [ ] **Step 4: Unit test for the announce-vs-key seam**
729
730 The port round-trip needs a daemon (e2e's job). What units CAN pin here: `endpointCmd`'s key resolution never invents a file at MUX_KEY_FILE — but that path reads env, which tests can't set. Keep the parse tests + this one behavioral test instead:
731
732 ```zig
733 test "askEndpointPort: a socket nobody serves answers 0, quickly" {
734 const testtmp = @import("testtmp");
735 var tmp = try testtmp.TmpDir.make();
736 defer tmp.cleanup();
737 var buf: [128]u8 = undefined;
738 const sock = try std.fmt.bufPrint(&buf, "{s}/absent.sock", .{tmp.path()});
739 const t0 = std.time.milliTimestamp();
740 try std.testing.expectEqual(@as(u16, 0), askEndpointPort(std.testing.allocator, sock));
741 // connect-refusal, not the 2s reply bound: the deadline exists for a
742 // live-but-silent daemon, and must not tax an absent one.
743 try std.testing.expect(std.time.milliTimestamp() - t0 < 500);
744 }
745 ```
746
747 - [ ] **Step 5: Run, verify, commit**
748
749 Run: `make test && make build`
750 Expected: pass.
751
752 ```bash
753 git add src/main.zig build.zig
754 git commit -m "feat: muxd endpoint — announce QUIC coordinates, then pump like proxy"
755 ```
756
757 ---
758
759 ### Task 6: Client handoff (Transport recipe + .host arm)
760
761 **Files:**
762 - Modify: `src/client.zig` — `HandoffTarget`, `Transport.open` signature + handoff branch, `attach` signature, `session`/reconnect threading (:388, :452, :459, :1274), tests
763 - Modify: `src/mux_main.zig` — `.host` arm (:174), usage (:12), the other `client.attach` call sites (:168, :179, :182, :224)
764
765 - [ ] **Step 1: Write the failing test**
766
767 The via-shaped tests in client.zig already fake transports with `/bin/sh -c` scripts; the handoff branch is testable the same way, no ssh and no daemon:
768
769 ```zig
770 test "handoff: endpoint-none rides the open pipe with no deadline paid" {
771 // A fake `ssh HOST muxd endpoint` that announces none and then echoes
772 // frames back would need a daemon to be a full session; what THIS test
773 // pins is Transport.open's decision: announce `none` → child-backed
774 // transport (t.quic == null, t.child != null), and fast.
775 const alloc = std.testing.allocator;
776 var carry: std.ArrayList(u8) = .empty;
777 defer carry.deinit(alloc);
778 const t0 = std.time.milliTimestamp();
779 var t = try Transport.open(alloc, null, null, null, .{
780 .host = "fake",
781 .ssh_cmd = "printf 'endpoint none\\n'; cat >/dev/null",
782 .cache_path = null,
783 .deadline_ms = 200,
784 }, &carry);
785 defer t.close();
786 try std.testing.expect(t.quic == null);
787 try std.testing.expect(t.child != null);
788 try std.testing.expect(std.time.milliTimestamp() - t0 < 1000);
789 }
790
791 test "handoff: dead coordinates fall back to the pipe and say so once" {
792 // Announce port 1 on loopback (nothing there; measured in Task 4:
793 // even a refused loopback port runs the FULL budget — drain() swallows
794 // the queued ECONNREFUSED — hence the small deadline_ms, and the test
795 // must assert elapsed >= deadline to prove the budget was spent, or an
796 // implementation that never dialled would pass) with a well-formed
797 // key: expect the child transport
798 // and a bounded wait. The stderr line itself is pinned in e2e where
799 // stderr is capturable; here the pin is the fallback DECISION.
800 const alloc = std.testing.allocator;
801 var carry: std.ArrayList(u8) = .empty;
802 defer carry.deinit(alloc);
803 var t = try Transport.open(alloc, null, null, null, .{
804 .host = "127.0.0.1",
805 .ssh_cmd = "printf 'endpoint 1 <64-char hex literal>\\n'; cat >/dev/null" // literal, not $(seq): no seq dependency; no trailing sleep — cat exits when the client closes stdin, a sleep would outlive the test,
806 .cache_path = null,
807 .deadline_ms = 300,
808 }, &carry);
809 defer t.close();
810 try std.testing.expect(t.quic == null);
811 try std.testing.expect(t.child != null);
812 }
813 ```
814
815 (Names/shape must match how this file's existing Transport tests spawn and clean up — read a couple first; each existing via test tracks and reaps its child through `t.close()`.)
816
817 Run `make test`: compile error — Transport.open takes 5 args.
818
819 - [ ] **Step 2: Implement**
820
821 In client.zig, near QuicTarget's definition:
822
823 ```zig
824 /// The bare-HOST recipe: everything a (re)connect needs to run the
825 /// ssh→QUIC handoff again. Lives at the Transport layer, not in
826 /// mux_main, precisely so the reconnect loop re-runs the WHOLE flow —
827 /// a daemon restart invalidates the cached port, and only a fresh
828 /// ssh fetch can heal that.
829 pub const HandoffTarget = struct {
830 /// The word the user typed. Ssh's business entirely (aliases,
831 /// user@, ProxyJump); QUIC dials `handoff.dialHost(host)`.
832 host: []const u8,
833 /// `ssh <host> muxd endpoint`, prebuilt by mux_main (it has the
834 /// allocator and does this once).
835 ssh_cmd: []const u8,
836 /// Where the last announce is remembered. Null = never cache
837 /// (uncacheable host or no resolvable cache dir) — every attach
838 /// is cold, which costs time and stays correct.
839 cache_path: ?[]const u8,
840 /// Per-attempt QUIC budget. A field rather than the constant so
841 /// tests can shrink it; prod passes handoff.deadline_ms.
842 deadline_ms: u32 = handoff.deadline_ms,
843 idle_ms: u32 = quic_idle_ms_default,
844 };
845 ```
846
847 `Transport.open` gains `hand: ?HandoffTarget` between `quic` and `carry`; first line of the body: `if (hand) |h| return openHandoff(alloc, h, carry);`. Then:
848
849 ```zig
850 /// The handoff, in transport terms. Warm: cached coordinates, dial
851 /// direct, no ssh at all. Cold: one ssh child whose first stdout line
852 /// is the mandatory announce; QUIC success kills it, QUIC failure
853 /// keeps it — the pipe that carried the announce IS the fallback
854 /// transport, which is what makes a UDP-blocked network cost one
855 /// deadline instead of two.
856 fn openHandoff(
857 alloc: std.mem.Allocator,
858 h: HandoffTarget,
859 carry: ?*std.ArrayList(u8),
860 ) !Transport {
861 if (h.cache_path) |cp| {
862 if (handoff.readCache(cp)) |ep| {
863 if (openQuicEndpoint(alloc, h, ep, carry)) |t| {
864 return t;
865 } else |err| switch (err) {
866 error.UserAbort => return err,
867 // Every cached-path failure falls through to ssh,
868 // which is authoritative. Resolve failures land here
869 // too, instantly — no deadline burned on DNS.
870 else => {},
871 }
872 } else |_| {} // no cache yet, or an unreadable one: cold path
873 }
874
875 var child = std.process.Child.init(&.{ "/bin/sh", "-c", h.ssh_cmd }, alloc);
876 child.stdin_behavior = .Pipe;
877 child.stdout_behavior = .Pipe;
878 child.stderr_behavior = .Inherit; // ssh's and endpoint's own lines reach the user
879 try child.spawn();
880 errdefer {
881 _ = child.kill() catch {};
882 }
883
884 // Amended after Task 3's quality review: readAnnounce composes
885 // readLine + parseAnnounce and owns its own announce_max_len buffer,
886 // so the sizing decision cannot drift out here.
887 const ep = (try handoff.readAnnounce(child.stdout.?.handle)) orelse {
888 // `endpoint none`: the remote said, explicitly, that ssh is
889 // the session. Silent by design — no coordinates were ever in
890 // play, so there is nothing to report failing.
891 return .{
892 .conn = .{ .r = child.stdout.?.handle, .w = child.stdin.?.handle },
893 .child = child,
894 };
895 };
896 if (h.cache_path) |cp| handoff.writeCache(cp, ep) catch {};
897
898 if (openQuicEndpoint(alloc, h, ep, carry)) |t| {
899 // QUIC carries the session; the coordination ssh's job is done.
900 _ = child.kill() catch {};
901 _ = child.wait() catch {};
902 return t;
903 } else |err| switch (err) {
904 error.UserAbort => {
905 _ = child.kill() catch {};
906 _ = child.wait() catch {};
907 return err;
908 },
909 else => {
910 std.debug.print(
911 "mux: quic://{s}:{d} unreachable, attaching over ssh\n",
912 .{ handoff.dialHost(h.host), ep.port },
913 );
914 return .{
915 .conn = .{ .r = child.stdout.?.handle, .w = child.stdin.?.handle },
916 .child = child,
917 };
918 },
919 }
920 }
921
922 /// The quic:// open, from announce coordinates instead of a key file.
923 /// waitReady's budget is the handoff deadline; the CONNECTION keeps
924 /// the ordinary idle_ms — a 2s attach budget must not become a 2s
925 /// idle timeout on the session it opens.
926 fn openQuicEndpoint(
927 alloc: std.mem.Allocator,
928 h: HandoffTarget,
929 ep: handoff.Endpoint,
930 carry: ?*std.ArrayList(u8),
931 ) !Transport {
932 const addr = try resolveHost(handoff.dialHost(h.host), ep.port);
933 const key = quic_client.Key{ .bytes = ep.key };
934 const cl = try quic_client.Client.connect(alloc, addr, key, h.idle_ms);
935 errdefer cl.deinit();
936 try waitReady(cl, h.deadline_ms, alloc, carry);
937 return .{
938 .conn = .{ .r = cl.pollFd(), .w = -1 },
939 .quic = cl,
940 .alloc = alloc,
941 };
942 }
943 ```
944
945 Threading: `attach()` gains `hand: ?HandoffTarget` (after `quic`); pass through to `Transport.open` and `session()`; `session()` stores it and the reconnect call at :1274 passes it. The attach-failure message arm: when `hand != null` and open fails (child spawn failed, malformed announce), print `mux: cannot reach {s} over ssh\n` with `h.host` — the shape of the existing via message. Update EVERY `Transport.open(` and `client.attach(`/`attach(` call site (grep; includes client.zig's own reconnect tests) with `null` for the new param except where the test is about it.
946
947 `client.zig` adds `const handoff = @import("handoff");`.
948
949 mux_main.zig `.host` arm becomes:
950
951 ```zig
952 .host => |h| {
953 // The handoff recipe: ssh fetches (and, cold, carries); QUIC
954 // gets tried first from the cache. Building the pieces here
955 // keeps client.zig allocator-free at the recipe level, the
956 // same split as the ssh proxy sugar it replaces.
957 const cmd = try std.fmt.allocPrint(alloc, "ssh {s} muxd endpoint", .{h});
958 defer alloc.free(cmd);
959 // Uncacheable host or unresolvable cache dir: attach uncached
960 // — always cold, never wrong.
961 const cache: ?[]const u8 = xdg.hostCachePath(alloc, h) catch null;
962 defer if (cache) |c| alloc.free(c);
963 return client.attach(alloc, null, null, null, .{
964 .host = h,
965 .ssh_cmd = cmd,
966 .cache_path = cache,
967 });
968 },
969 ```
970
971 Other three `client.attach` call sites gain a trailing `null`. Usage line (:13) becomes:
972
973 ```
974 \\ HOST attaches over ssh and hands off to QUIC when the daemon offers it
975 \\ (muxd must be on HOST's PATH; cached coordinates make later attaches
976 \\ skip ssh entirely)
977 ```
978
979 - [ ] **Step 3: Run, verify, commit**
980
981 Run: `make test && make build`
982 Expected: pass, including the two new Transport tests.
983
984 ```bash
985 git add src/client.zig src/mux_main.zig
986 git commit -m "feat: mux HOST hands off to QUIC — cache-first, ssh-fetch, pipe fallback"
987 ```
988
989 ---
990
991 ### Task 7: e2e — ssh shim + five scenarios
992
993 **Files:**
994 - Modify: `test/e2e.sh` — shim setup near the fixture section, `SOCK16`/`SOCK17` + cleanup registration, five scenario blocks after the M13 blocks (~:2180), scenario/pin literals
995
996 Read the M13 blocks (SOCK14/SOCK15, ~:2020–2180) first and match their idiom exactly: markers through the session, `assert_converged`, labelled `set +e`/RC capture for expected-nonzero commands, pids tracked and `wait_pid_gone`'d, `muxd stop --sock` teardown with the labelled-RC shape.
997
998 - [ ] **Step 1: Fixture — the ssh shim and hermetic cache**
999
1000 With the other hermetic XDG homes (~:17), add `XDG_CACHE_HOME` (a fresh dir per run). Near the fixture section:
1001
1002 ```bash
1003 # M14: the ssh shim. `ssh HOST CMD...` → drop HOST, run CMD locally, so
1004 # the client's real handoff code runs end to end with no network. Every
1005 # invocation appends its pid to SSHIM_PIDLOG: scenarios assert ssh
1006 # presence/absence by that log (a fact), never by `ps | grep ssh` (which
1007 # would count the operator's own sessions).
1008 SSHIM_DIR="${TMPDIR:-/tmp}/muxd-e2e-sshim-$$"
1009 export SSHIM_PIDLOG="$SSHIM_DIR/pids"
1010 mkdir -p "$SSHIM_DIR"
1011 : > "$SSHIM_PIDLOG"
1012 cat > "$SSHIM_DIR/ssh" <<'SHIM'
1013 #!/bin/sh
1014 echo $$ >> "${SSHIM_PIDLOG:?}"
1015 shift
1016 exec "$@"
1017 SHIM
1018 chmod +x "$SSHIM_DIR/ssh"
1019 SOCK16="${TMPDIR:-/tmp}/muxd-e2e-handoff-$$.sock"
1020 SOCK17="${TMPDIR:-/tmp}/muxd-e2e-hkey-$$.sock"
1021 ```
1022
1023 Register `SOCK16`/`SOCK17` stops and `rm -rf "$SSHIM_DIR"` in the cleanup trap, same pattern as SOCK14/15 (:291, :327). Scenarios invoke the client as:
1024
1025 ```bash
1026 PATH="$SSHIM_DIR:$(dirname "$MUXD"):$PATH" "$MUX" fakehost ...
1027 ```
1028
1029 so the shim shadows real ssh and `muxd` resolves to the built binary.
1030
1031 - [ ] **Step 2: Scenario blocks** (names are the suite's labels; adapt wording to house style)
1032
1033 **(a) Cold handoff.** No daemon on SOCK16, no cache. `mux fakehost` with `--sock`? — no: the handoff path is bare-HOST only, and the remote sock is the default. So run the whole scenario under `XDG_RUNTIME_DIR` pointed at a scenario dir, making SOCK16 the DEFAULT socket for both sides (`SOCK16="$RUNDIR/muxd.sock"`). *(Amended during Task 7: `fakehost` is a defect here too, not just in (d) — resolveHost fails on an unresolvable name, so the cold scenario's QUIC leg could never fire and its central assertion could not be reached. Use `mux-e2e@127.0.0.1`: dials loopback, keys a cache file distinct from (d)'s bare `127.0.0.1`, and exercises dialHost's user-strip end to end.)* Assert:
1034 - session converges with a marker (`assert_converged`),
1035 - the cache file `"$XDG_CACHE_HOME/mux/hosts/fakehost"` exists and its port matches a fresh `endpoint` fetch,
1036 - the shim pid recorded in `SSHIM_PIDLOG` is GONE (`wait_pid_gone`) while a second marker still round-trips — bytes can only be riding QUIC (the pipe's owner is dead; observation, not inference),
1037 - exactly one pid appears in the log.
1038
1039 **(b) Warm handoff.** Same daemon still up, cache present from (a). Snapshot `wc -l < "$SSHIM_PIDLOG"`; attach again, marker, converge; assert the pidlog line count is UNCHANGED — no ssh process ever existed (the observation rule: report the observed count).
1040
1041 **(c) Stale-cache self-heal.** Overwrite the cache with a well-formed announce naming an unbound port (e.g. port 1). Attach; assert marker converges, NO fallback line in captured stderr (grep for `unreachable, attaching over ssh` absent — and grep the line PRESENT in scenario (d)'s output so the absent-grep can fail), the cache file afterward holds the real port again, and the pidlog grew by exactly one (the healing fetch).
1042
1043 **(d) Fallback line (key mismatch).** Second daemon on SOCK17 (its own `XDG_RUNTIME_DIR`), started with `--quic 127.0.0.1:<fixed port> --key <a second keyfile>` — a key that is NOT the hermetic default. `mux fakehost2` (endpoint announces the DEFAULT key, daemon replies its configured port; handshake cannot complete). Assert: stderr carries `mux: quic://fakehost2:<port> unreachable, attaching over ssh`, the session STILL converges (marker via the pipe), and wall time for the attach exceeds neither ~deadline+slack nor the suite's patience (bound both sides: `>= deadline` proves the budget was spent, `< deadline+3s` proves it didn't hang). Note: `dialHost("fakehost2")` — the fallback line's host is the fake name; the QUIC dial itself will fail on RESOLUTION of `fakehost2`... which would skip the deadline entirely and break this scenario's point. Fix the scenario, not the code: name the fake host `127.0.0.1` here (`mux 127.0.0.1` — a bare word, still the host arm; the shim ignores it). Cache dir will then key on `127.0.0.1`, distinct from (a)'s `fakehost`.
1044
1045 **(e) Announce-less.** Against SOCK16's daemon: run the attach with `MUX_KEY_FILE=` and BOTH `XDG_CONFIG_HOME` and `HOME` pointed at a fresh dir that CANNOT produce a key — ~~chmod 555 the mux/ dir~~ *(defective: writeNewKey's makePrivateParent chmods the parent to 0700, and the OWNER may do that — it restores its own write bit; found during Task 7)* — a config home that is a regular FILE fails at makePath with NotDir and needs no restore. Assert: session converges over the pipe, stderr has `muxd endpoint: ` staying-on-ssh line, NO `unreachable` fallback line, pidlog grew by one, and the attach's wall time is well under the deadline (prove no budget was spent: `< 1s` beyond the suite's normal attach allowance).
1046
1047 Every daemon these scenarios start: track pid, tear down with the labelled `set +e` / `RC_STOP` `muxd stop` shape from the M13 blocks, then `wait_pid_gone`.
1048
1049 - [ ] **Step 3: Update the pin literals**
1050
1051 The scenario-count and convergence-point literals (~:245): +5 scenarios, and count the new greps you added; the suite prints the totals — run it, read the observed number, pin that.
1052
1053 - [ ] **Step 4: Run**
1054
1055 Run: `make build && make e2e`, then `make soak SOAK_N=10`
1056 Expected: all green, 10/10. Scenario (d) adds ~deadline seconds of wall time; that is the cost of pinning the budget and is accepted — note it in the scenario's comment.
1057
1058 - [ ] **Step 5: Commit**
1059
1060 ```bash
1061 git add test/e2e.sh
1062 git commit -m "test: e2e — ssh shim + five handoff scenarios (cold, warm, self-heal, fallback line, announce-none)"
1063 ```
1064
1065 ---
1066
1067 ### Task 8: Kill criterion on the LAN box, docs, close-out
1068
1069 - [ ] **Step 1: Regrade (before the LAN box)**
1070
1071 Three seeded reverts, run one at a time, each restored before the next; each catch must be LEGIBLE at the predicted check (the failing scenario's own output must name it — an anonymous `set -e` abort is a process failure even when red):
1072
1073 1. Revert the `serviceObserver` `endpoint_req` arm → predicted: Task 2's lazy-bind unit test fails first; e2e (a) fails at the QUIC-carried assertion (announce says none — cache file grep fails).
1074 2. Revert the announce-before-pump ordering in `endpointCmd` (move the announce after `proxy.run` — it never prints) → predicted: e2e (a) hangs at attach and fails at its bounded wait with the scenario's own timeout message, not the trap's.
1075 3. Revert the fallback stderr line (delete the `std.debug.print` in `openHandoff`'s failure arm) → predicted: e2e (d) fails its `unreachable` grep while (c)'s absent-grep control still passes.
1076
1077 - [ ] **Step 2: LAN box kill criterion** (`ubuntu@192.168.0.109`; deploy the musl build the way M6/M8 did — see decisions.md)
1078
1079 1. Remote state zeroed: `muxd stop` on the default sock, `rm ~/.config/mux/key`, local cache entry for the host removed.
1080 2. **Cold:** `mux ubuntu@192.168.0.109` → session lands on QUIC (verify: on the box, `ss -uapn | grep muxd` shows the ephemeral listener with traffic; locally the ssh process is gone while the session lives — observe `ps` by tracked pid).
1081 3. **Warm:** second attach → converges with NO ssh spawned (observe: `ps` before/after for the exact ssh comm+args, count unchanged).
1082 4. **Blocked:** `sudo iptables -A INPUT -p udp --dport <announced port> -j DROP` on the box → attach from here: fallback line printed, session works over ssh, wall time ≈ one deadline. REMOVE the rule and verify by listing (observation rule).
1083 5. All three with zero manual steps beyond muxd on the remote PATH.
1084
1085 - [ ] **Step 3: Docs + memory**
1086
1087 - `docs/roadmap.md`: M14 into "Now: M1–M14"; remove the handoff bullet from candidates.
1088 - `README.md`: the HOST line (~:56) still says `mux HOST` is sugar for `--via "ssh HOST muxd proxy"` — rewrite for the handoff; KEEP the adjacent shell-interpolation warning (still true). `muxd proxy` at ~:115 stays accurate. *(Added after Task 6's quality review caught the staleness.)*
1089 - `docs/decisions.md`: M14 section after M13 (file is oldest-first): verdict, the regrade table verbatim, measured deadline numbers from Task 4, what shipped, findings, banked items (per-client keys/certs-TOFU still parked; negative caching still refused; endpoint-none stderr wording; **the Task 4 incidental**: quic_client.zig's `ConnectionRefused => self.dead = true` branch at ~:360 is effectively unreachable — on a connected UDP socket the queued ICMP error is consumed by the `sendto` in `drain()`, whose `catch return` swallows it, so even a refused loopback port runs the full deadline; fixing it would make unreachable ports fail in ~1 RTT and is an M15 candidate. Also: the WAN-box RTT on record (memory ~290ms vs decisions.md:314's 15.7–16.5ms baseline) disagrees — verify against the live box before next use. **From Task 6's quality review**: Transport.open now takes four mutually-exclusive nullables, two sharing a type — `union(enum) { sock, via, quic, hand }` would make the invariant unstatable-wrong; M15 candidate).
1090 - Memory: append M14 to `mux-m1-status.md`, update `MEMORY.md` index line.
1091
1092 - [ ] **Step 4: Final commit + push**
1093
1094 ```bash
1095 git add docs/ && git commit -m "docs: M14 closed — ssh→QUIC handoff"
1096 git push
1097 ```
1098
1099 ---
1100
1101 ## Plan self-review (done at write time)
1102
1103 - **Spec coverage:** Component 1 → Task 5; Component 2 → Tasks 1–2; Component 3 → Tasks 3+6; Component 4 → Task 4; e2e five scenarios → Task 7; kill criterion + close-out → Task 8. Announce-mandatory amendment (dd6c511) reflected in Tasks 5 and 6.
1104 - **Known soft spots for implementers to verify against reality (flag, don't silently absorb):** exact reply-reading idiom in server tests (Task 2 caveat 1); `g_listener_live` test-ordering (caveat 2); `Progress`/tty wiring inside `ensureForAttach` already handled by the helper (Task 5 needs no Progress of its own); the precise `std.fmt` hex spelling for 32 bytes (`{x}` on a byte-slice pointer differs across std versions — check quic_server.zig's own hex handling); scenario (d)'s host-naming trap is already resolved in-plan (use `127.0.0.1`).
1105 - **Type consistency:** `handoff.Endpoint{ port: u16, key: [32]u8 }` used by Tasks 3, 5 (`key.?.bytes`), 6 (`quic_client.Key{ .bytes = ep.key }`); `HandoffTarget.deadline_ms` defaults to `handoff.deadline_ms` (pinned Task 4); announce grammar shared via `formatAnnounce`/`parseAnnounce` everywhere — no second parser anywhere.
docs/superpowers/plans/2026-08-12-m15-refactor.md
Old New
@@ -1,440 +0,0 @@
1 # M15 Refactor 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:** Execute the M15 refactor spec (docs/superpowers/specs/2026-08-12-m15-refactor-design.md): close the latent defects, turn documented invariants into types, give every duplicated policy one owner. Exactly two user-visible behavior changes (refused-port fast-fail; `quic://` attach budget split off `idle_ms`), both re-measured.
6
7 **Architecture:** Thirteen tasks. Tier 1 (tasks 1–4) is defects and free wins; Tier 2 (tasks 5–12) is unions and extractions, ordered so extracted APIs are the clean shapes; task 13 is the milestone close (LAN measurement, regrade, soak, docs). Every task ends with `make test` AND `make e2e` green and a commit. Line numbers are from tree e4d4eb6 — re-verify before editing; earlier tasks shift later tasks' lines.
8
9 **Tech Stack:** Zig 0.15.2 (pinned; ONLY `make build` / `make test` / `make e2e` / `make soak SOAK_N=n` — never invoke zig directly, never the system zig). Tests are in-file `test` blocks; e2e is test/e2e.sh via `make e2e`.
10
11 **Standing rules for every task (from decisions.md, non-negotiable):**
12 - Kill only by tracked pid; never pkill/pgrep -f kill. Verify cleanup BY OBSERVATION (exact-comm ps / kill -0) and report the observed count, not the cleanup command's claim.
13 - Hermetic env for anything spawning daemons: fresh XDG_* under a tmp dir; NEVER touch ~/.config/mux or ~/.cache/mux.
14 - Assert the literal, never the constant the code under test reads.
15 - A moved test moves VERBATIM; if a literal must change outside tasks 4's named re-pins, stop and report — that is a task defect, not an amendment.
16 - New pins get a mutation check: break the code by hand, watch the pin fail LEGIBLY (the failure must print, and print before anything the same break could hang), revert.
17
18 ---
19
20 ### Task 1: `frameBytes` → `refusalFrame` (latent framing defect)
21
22 **Files:**
23 - Modify: `src/server.zig:1064-1072` (fn), `src/server.zig:896` (sole caller, in `quicOnOpen`)
24
25 The current function stamps `payload.len` into the header but writes only `payload[0]` — correct only for len==1, unpinned.
26
27 - [ ] **Step 1: Write the failing pin** (append near the other pure-fn unit tests, ~server.zig:4006):
28
29 ```zig
30 test "refusalFrame is the exact refusal wire bytes" {
31 // Literal, not computed from proto constants: the frame is a
32 // cross-process contract and this pin must fail if either side of
33 // it drifts. type=exit_status (0x86), len=1 LE, payload=1.
34 const f = Server.refusalFrame();
35 try std.testing.expectEqualSlices(u8, &.{ 0x82, 1, 0, 0, 0, 1 }, &f);
36 }
37 ```
38
39 First read server.zig:890-900 to confirm which MsgType and payload byte `quicOnOpen` actually sends for the refusal (the test above assumes `.exit_status` with payload `1` — verify against the real call `frameBytes(.exit_status, &.{1})` or whatever is there, and pin THOSE literal bytes; if the verb differs, the literal in this test changes to match reality, before it is ever run).
40
41 > **Amended after Task 1 (2026-08-12):** the plan originally wrote `0x86` here, guessed from memory; the implementer's Step-1 verification caught it — `exit_status = 0x82` (protocol.zig:24); `0x86` is `stats_reply`. The verify-before-running guard is why the wrong literal never reached a test run. Corrected above; the comment in Step 1's snippet also said 0x86 and is corrected in spirit by this note.
42
43 - [ ] **Step 2: Run `make test`** — expect FAIL: `refusalFrame` not defined.
44 - [ ] **Step 3: Replace the function:**
45
46 ```zig
47 /// The one frame the QUIC path speaks before a client slot exists:
48 /// the full-session refusal. No parameters — the old generic form
49 /// claimed to frame any payload while writing only its first byte,
50 /// which is a framing desync for every length but 1.
51 fn refusalFrame() [6]u8 {
52 var buf: [6]u8 = undefined;
53 buf[0] = @intFromEnum(proto.MsgType.exit_status);
54 std.mem.writeInt(u32, buf[1..5], 1, .little);
55 buf[5] = 1;
56 return buf;
57 }
58 ```
59
60 (Substitute the verb/payload confirmed in Step 1.) Update the caller at :896 to `Server.refusalFrame()` (drop the arguments). If the caller passed a payload other than a single byte, STOP — that is the latent defect firing in production; report instead of papering over.
61
62 - [ ] **Step 4: Run `make test`** — PASS. Run `make e2e` — the QUIC cap-drop scenario still passes (pins unchanged).
63 - [ ] **Step 5: Mutation check:** change `buf[5] = 1` to `buf[5] = 0`, run `make test`, confirm the new pin fails printing the byte diff; revert.
64 - [ ] **Step 6: Commit** `refactor: frameBytes → refusalFrame; pin the refusal wire bytes`.
65
66 ---
67
68 ### Task 2: small items batch
69
70 **Files:**
71 - Modify: `src/client.zig` (:486, :568, :1193, :1529 — detach literal; :780-790 — SIGPIPE; :310-324 — doc comment)
72 - Modify: `src/proxy.zig` (only if the import route is chosen), `build.zig` (client_mod import; deps caching), `src/pty.zig` (dead `read`), `src/server.zig` (:1110-1122 rename)
73
74 - [ ] **Step 1: detach key constant.** In client.zig near the top (after the imports): `const detach_key: u8 = 0x1c; // Ctrl-\, the detach chord (see module doc).` Replace the four `0x1c` literals. `make test` — the abort-key tests (:2069) still pass (they type the byte, not the name).
75 - [ ] **Step 2: SIGPIPE single owner.** client.zig:780-790 duplicates `proxy.ignoreSigpipe`. Client does not import proxy today. Choose the smaller diff: add `proxy_mod` as an import of `client_mod` in build.zig ONLY if that does not drag protocol into an import cycle — check build.zig's module graph first; if it would, instead move the function to `src/sig.zig` (8 lines, imported by both). Either way exactly one body remains. `make test && make e2e`.
76 - [ ] **Step 3: Move `close`'s doc comment** (client.zig:310-316) down to sit above `close` (:410). Pure comment move.
77 - [ ] **Step 4: `Pty.read` dead code.** Confirm by grep that production never calls it (server reads `self.pty.master` raw at server.zig:610; `Pty.read`'s only callers are pty.zig tests). Zig refuses dead pub? — `read` is likely `pub`; if deleting it breaks a pty.zig test, rewrite that test against the raw fd the way production reads. Prefer DELETE over routing the daemon through it (the daemon's read loop is pinned by the whole suite; don't churn it for symmetry).
78 - [ ] **Step 5: `sendPtyModeTo` → `ensurePtyModeSent`** (server.zig:1110-1122): rename only, keep the documented block-with-mutation body; update the two call sites. Zig compile errors will enumerate them.
79
80 > **Amended after Task 2's quality review (2026-08-12): REVERSED.** The rename was applied and then reverted on review evidence: the function queues a frame unconditionally, so "ensure" claims an idempotence it does not have, while the original name described the send accurately; the hidden `mode_sent`-population the rename meant to expose is already carried by six lines of comment at the mutation site itself, which is where a reader meets it. The survey's alternative fix (lift the mutation out of the block) remains available to a future task but was not worth the churn here.
81 - [ ] **Step 6: deps caching.** build.zig:16-31: replace `run.has_side_effects = true` with declared file outputs for the three archives the script produces, so the graph caches. Read deps/quic/build-deps.sh:1-40 first to get the exact out/ paths. Verify: `make build` twice; second run must NOT re-execute the script (time it or check its stdout absent). If Zig 0.15.2's Step.Run output API fights back (addOutputFileArg does not fit a script that writes fixed paths), the honest fallback is to keep `has_side_effects` and note why in a comment — do not force it; report which way it went.
82 - [ ] **Step 7 (amended in after Task 1, 2026-08-12): pin writeFrame's wire bytes.** Task 1's follow-up found that protocol.zig:361 `test "appendFrame encodes the same bytes writeFrame sends"` never calls writeFrame — its name and the doc claim at protocol.zig:59 ("a golden test pins that") are both false; writeFrame is only round-tripped (:406), which passes even when both sides drift together. Fix: in that test, ALSO drive `writeFrame` into a pipe (copy the file's existing pipe-based test idiom), read the bytes back, and `expectEqualSlices` against BOTH the existing byte literal and appendFrame's output — making the test's name true. Mutation check: flip writeFrame's length endianness LOCALLY in a scratch copy of the header write **inside the test's expectation first** is not possible — instead verify legibility by changing the test's own literal, watching it fail printing bytes, reverting. Do NOT mutation-check by flipping protocol.zig's real endianness: Task 1's follow-up proved that wedges the whole suite silently (600s, zero-byte output).
83 - [ ] **Step 8: `make test && make e2e`, commit** `refactor: small-items batch — detach_key, one SIGPIPE owner, dead Pty.read, ensurePtyModeSent, deps caching, writeFrame golden pin`.
84
85 ---
86
87 ### Task 3: `keepAliveNs` — one copy
88
89 **Files:**
90 - Modify: `src/quic_server.zig:1279-1281` (keep, widen doc), `src/quic_client.zig:483-494` (delete fn + move its test)
91
92 - [ ] **Step 1:** In quic_server.zig make `keepAliveNs` `pub` (it may already be); ensure signature `pub fn keepAliveNs(idle_ms: u64) u64`.
93 - [ ] **Step 2:** Move quic_client.zig's test block (:487-494, the 5s/500ms/1ms/1ms literal pins) into quic_server.zig next to the function, verbatim. Delete quic_client's copy of the fn; its call site (:301) becomes `quic.keepAliveNs(idle_ms)` — quic_client already imports the module as `quic`. The u32 arg coerces to u64 implicitly.
94 - [ ] **Step 3: `make test`** — the moved pins pass in their new home. **Mutation check:** delete the `@max(1, ...)` guard in the surviving copy; the moved test must fail on the 1ms pins; revert.
95 - [ ] **Step 4: `make e2e`, commit** `refactor: one keepAliveNs, the tested one`.
96
97 ---
98
99 ### Task 4: refused-port fast-fail + `QuicTarget.deadline_ms` (the two behavior changes, one measurement round)
100
101 **Files:**
102 - Modify: `src/quic_client.zig` (:355-368 readable, :450 drain), `src/client.zig` (:79-83 QuicTarget, :171 waitReady call, :1996-2035 re-pin), `src/handoff.zig` (:11-34 prose), `test/e2e.sh` (scenario (d) floor), `src/quic_server.zig` (:1246-1252 comment only)
103
104 - [ ] **Step 1: Shared refusal classification.** In quic_client.zig add:
105
106 ```zig
107 /// The one verdict both socket paths share: ECONNREFUSED on a
108 /// connected UDP socket is an ICMP unreachable — a dead transport,
109 /// not a blip. recv sees it after a failed flight; send sees it
110 /// when the queued error is delivered on the NEXT syscall, which
111 /// on a quiet connection is drain's send. Both must agree or the
112 /// refusal is only noticed on whichever path runs second.
113 fn sendRecvFailed(self: *Client, err: anyerror) void {
114 switch (err) {
115 error.ConnectionRefused => self.dead = true,
116 else => {},
117 }
118 }
119 ```
120
121 `readable()`'s switch collapses to `catch |err| { if (err == error.WouldBlock) return; self.sendRecvFailed(err); return; }` — keep the existing ICMP comment on the helper, not the call site. `drain()`'s :450 becomes:
122
123 ```zig
124 _ = std.posix.send(self.fd, buf[0..@intCast(n)], 0) catch |err| {
125 self.sendRecvFailed(err);
126 return;
127 };
128 ```
129
130 - [ ] **Step 2: quic_server.zig:1246-1252** — add one comment line: the listener's socket is unconnected, ICMP is not delivered there; this asymmetry is real, not drift.
131 - [ ] **Step 3: `QuicTarget.deadline_ms`.** client.zig:79-83:
132
133 ```zig
134 pub const QuicTarget = struct {
135 host_port: []const u8,
136 key_path: []const u8,
137 idle_ms: u32 = quic_idle_ms_default,
138 /// The attach budget, split off idle_ms the way HandoffTarget
139 /// already does: the time we give a handshake is not the time we
140 /// give a quiet session. See handoff.deadline_ms for the number's
141 /// derivation.
142 deadline_ms: u32 = handoff.deadline_ms,
143 };
144 ```
145
146 and :171 becomes `try waitReady(cl, q.deadline_ms, alloc, carry);`. Update quic_server.zig:35-45's `default_idle_ms` doc comment: strike the attach-budget job, leaving two.
147
148 > **Amended after Task 4 (2026-08-12):** the strike instruction was based on a false premise — `default_idle_ms`'s doc already claimed only two jobs (idle timeout, keepalive cadence); it never named the attach budget. The doc that HAD gone stale was `waitReady`'s ("bounded by the same idle timeout the connection itself uses ... one knob for both"), which the implementer fixed instead, renaming its parameter `idle_ms` → `budget_ms` to match both call sites now passing a budget. Accepted as the correct reading of the intent.
149
150 - [ ] **Step 4: Re-pin "dead coordinates" (client.zig:1996-2035).** The `elapsed >= 300` lower bound rested on the swallowed refusal. Rewrite per the test's own instruction: keep the upper bound (`elapsed < 2000` — the budget was NOT fully spent is now the point), and witness the dial by decision: `t.quic == null and t.child != null` must already be asserted — keep it, and ADD a lower bound of a few ms only if measurement in Step 6 shows the refused dial has a stable floor; otherwise the paired assertions (dial attempted, fell back, under budget) are the pin. Update the test's comment to tell the new true story.
151 - [ ] **Step 5: e2e scenario (d).** Find the `elapsed >= floor` assertion (grep `floor` in test/e2e.sh around the key-mismatch/blocked scenarios). Scenario (d) blackholes; blackhole still runs the full budget, so its floor SURVIVES — verify which scenario's bound referenced the refused-port behavior (the survey says only the client.zig unit bound leans on the quirk; e2e (d)'s floor rests on blackhole). If e2e numbers hold, no edit; record that finding in the commit message rather than editing blind.
152 - [ ] **Step 6: Measure all three failure classes locally** (hermetic XDG, tracked pids, observed cleanup):
153 - refused: dial a bound-then-closed loopback port (nothing listening) — expect ~1 RTT (single-digit ms), NOT ~2000ms;
154 - blackhole: dial a `iptables`-free blackhole substitute — an unroutable RFC5737 address like 192.0.2.1:4433 with a short-write timeout — expect full deadline;
155 - wrong PSK: against a real local muxd --quic with a different key — expect full deadline.
156 Three runs each, medians. These numbers rewrite handoff.zig:11-34: "every failure runs the budget out" becomes the two-class truth (refusal fails fast as of M15; silence still costs the budget). The 2000ms value itself is expected to stand.
157 - [ ] **Step 7: `make test && make e2e`** — full pass with the re-pins. **Mutation check (this is regrade mutant (a) rehearsed):** revert Step 1's drain arm to `catch return`, run `make test`; the re-pinned dead-coordinates test must FAIL LEGIBLY (elapsed blows past the new expectation or the fast-fail witness trips) and must be ordered before anything the regression could hang. Revert.
158 - [ ] **Step 8: Commit** `fix: refused QUIC dial fails in ~1 RTT; quic:// attach budget is deadline_ms, not idle_ms` — commit message carries the three measured medians.
159
160 ---
161
162 ### Task 5: extract `src/paint.zig`
163
164 **Files:**
165 - Create: `src/paint.zig`
166 - Modify: `src/client.zig` (remove :1258-1327, :1486-1507, :1621-1635 + their 7 tests :2126, :2144, :2241, :2254, :2287, :2315, :2341), `build.zig` (new module, wired like handoff_mod at :168-193)
167
168 - [ ] **Step 1:** Create src/paint.zig with a module doc ("painting the replica to a tty: clipped renders, delta rows, banner, scrollback — pure fd-out, no transport knowledge"). Move `clampCursor`, `renderClipped`, `paintDeltaClipped`, `bannerText`, `paintBanner`, `renderScrollback` verbatim, `pub` as needed. They reference the engine/replica types — carry the same imports client.zig uses for them.
169 - [ ] **Step 2:** `SyncPaint` owner for the bracket pair that currently exists in three copies (:1272, :1298, :1374):
170
171 ```zig
172 /// The synchronized-update bracket. Exists exactly once because a
173 /// dropped half is invisible to both e2e suites (the bytes still
174 /// paint, just tearably) — see the wrapper test, which is the only
175 /// eye this has.
176 pub const sync_begin = "\x1b[?2026h\x1b[?25l";
177 pub const sync_end = ...; // take the literal from client.zig verbatim
178 ```
179
180 Replace the three inline copies with the named pair. The third copy is in `paintOverlay`, which STAYS in client.zig (prediction glue) — it imports paint's constants.
181
182 - [ ] **Step 3:** Move the seven tests verbatim. Wire build.zig: `paint_mod` created and imported by `client_mod` and the test loop (mirror `handoff_mod` exactly — including the e2e/soak exe wiring if handoff has it).
183 - [ ] **Step 4: `make test`** — moved tests pass unmoved. `make e2e` — convergence counts unchanged (paint behavior identical). **Mutation check:** drop `sync_end` from `renderClipped`'s epilogue; the wrapper test (:2341's move) must fail; revert.
184
185 > **Amended after Task 5 (2026-08-12): the specified mutation SURVIVES.** The wrapper test owns `paintDeltaClipped` only; `renderClipped`'s epilogue was pinned by nothing, and both of its tests assert prologue+content+cursor. The implementer proved the survival, ran the mutation the wrapper test does own (caught legibly), and correctly declined to add a new literal under the verbatim rule. Rider authorized (Task 5b): one epilogue assertion in a renderClipped test, mutation-checked; plus a comment in `renderScrollback` naming its DELIBERATE bracket asymmetry (commits with cursor hidden — no `\x1b[?25h` — until the next renderClipped restores it), which is the fourth bracket site and stays as bytes.
186 - [ ] **Step 5: Commit** `refactor: extract src/paint.zig; the sync-update bracket exists once`.
187
188 ---
189
190 ### Task 6: `Target`/`Link` unions
191
192 **Files:**
193 - Modify: `src/client.zig` (Transport :124-443, attach :645-757, session signature :759-773, reconnect :1545-1619, lostMsg :48-73, 5 tests :1953, :1981, :2018, :2090, :2115), `src/mux_main.zig` (:171, :190, :197, :239 call sites)
194
195 The survey's measured edit list. Two unions because a `hand` RECIPE produces either a quic or a pipe LINK:
196
197 - [ ] **Step 1: Define the types** (replacing the four-nullable convention):
198
199 ```zig
200 /// What the user asked for — the recipe a (re)connect runs.
201 pub const Target = union(enum) {
202 sock: []const u8,
203 via: []const u8,
204 quic: QuicTarget,
205 hand: HandoffTarget,
206 };
207
208 /// What the open produced — the live wire. Distinct from Target
209 /// because a hand recipe yields either quic or pipe, decided inside
210 /// openHandoff.
211 const Link = union(enum) {
212 fd, // unix socket: conn.r == conn.w, nothing else to own
213 pipe: std.process.Child,
214 quic: *quic_client.Client,
215 };
216 ```
217
218 `Transport` keeps `conn` and `qout`/`alloc` and replaces `child`/`quic` optionals with `link: Link`.
219
220 - [ ] **Step 2: `open(alloc, target: Target, carry) !Transport`** — the body becomes `switch (target)` with the four existing arms verbatim (hand first is no longer load-bearing but keep the comment's point where it lands). `close`, `writeFrame`, `readFrame`, `service`, `timeoutMs`, `flushQuic` become exhaustive `switch (self.link)`; `buffersFrames` folds into its caller's switch. The close-idempotence sentinel (`conn.r == -1`) survives as-is.
221 - [ ] **Step 3: Signatures collapse.** `attach(alloc, target: Target)`; `session(..., target: Target, ...)`; `reconnect(..., target: Target, ...)`. `lostMsg(target, epoch)` keys on `target == .via`. reconnect's quiet-hand becomes `var t = target; if (t == .hand) t.hand.report_fallback = false;` (:1586-1587's semantics, now typed).
222 - [ ] **Step 4: mux_main.zig** — it already holds `ParseResult` as a union (:28-44); each of the four `client.attach` calls passes the corresponding `Target` variant directly instead of destructuring to nullables. Its 10 parseArgs tests are untouched (they pin the parse).
223 - [ ] **Step 5: Rewrite the 5 Transport.open call-shape tests** — assertions survive verbatim (`t.link == .quic` replaces `t.quic != null` etc.; timing bounds unchanged). The two live-link inspections in tests (:1989/1990, :2027/2028) become union tags.
224 - [ ] **Step 6: `make test && make e2e`** — counts unchanged. **Mutation check (regrade mutant (b) rehearsed):** swap the `.sock` and `.via` arms' bodies in `open`; the endpoint-none transport-decision test (:1966's successor) or the sock test must fail legibly; revert.
225
226 > **Amended after Task 6 (2026-08-12): the rehearsal FAILED ITS PREMISE — no unit test pins the sock/via dispatch.** The swap survives `make test` (the close-idempotence test's `-1` sentinel is satisfied by either link; no unit test opens a `.via` target at all), and `make e2e` catches it only ILLEGIBLY: the client pipeline dies under `set -eu` before the script names a scenario — no `e2e FAIL:` line. Equally unpinned before the union transform; not introduced by it. **Rider 6b authorized:** a unit pin on the dispatch decision (open `.via` with a trivial command → assert `t.link == .pipe`; open `.sock` against a live listener → assert `t.link == .fd`), mutation-checked with the arm swap, which must then fail printing. **Task 13's regrade mutant (b) is valid only after 6b lands.** The e2e illegibility (a scenario that dies without naming itself) is recorded for the close-out as a legibility finding in the M13/M14 doctrine, out of this task's scope.
227 - [ ] **Step 7: Commit** `refactor: Transport recipe and live link become unions — invariants unstatable-wrong`.
228
229 ---
230
231 ### Task 7: `openErrorMsg` + client spawn/dial dedup
232
233 **Files:**
234 - Modify: `src/client.zig` (attach's catch block :662-753 → pure fn; spawn sites :178-190, :226-249, :274-279; quic dial sites in `open` and `openQuicEndpoint`)
235
236 - [ ] **Step 1: Pure error policy.** Extract attach's 90-line reporting switch:
237
238 ```zig
239 /// The message attach prints when open fails — or null, meaning the
240 /// user aborted and the exit is a silent 0. Pure so it can be pinned
241 /// like lostMsg; attach owns only the printing.
242 fn openErrorMsg(buf: []u8, target: Target, err: anyerror) ?[]const u8 {
243 ...
244 }
245 ```
246
247 Move the eight message constructions and the two UserAbort→null paths verbatim; `announceFailed`'s reflective classification is called from here. attach shrinks to open / print-or-return-0 / session.
248
249 > **Amended after Task 7 (2026-08-12): the prescribed signature rested on a false premise.** The UserAbort paths were never silent — they print `mux: aborted before attaching` AND exit 0 — so `?[]const u8` with null-means-silent could not represent the behavior without dropping a user-facing literal. Shipped as `fn openFailure(buf, target, err) OpenFailure` where `OpenFailure = struct { msg, exit: u8 }`, renamed because a `...Msg` returning an exit code is a small lie. The class inventory was 12, not 8 (both announceFailed reflection sides, both resolve errors, and the err-independent via/sock arms each counted). Also pinned as-found, not "fixed": `.via`/`.sock` arms exit 1 for every error including UserAbort — they have no dial to interrupt and never inspected err.
250
251 - [ ] **Step 2: Pin it.** One test per message class (eight arms), literal strings, plus `UserAbort → null` for both the quic and hand arms. Use the real error values the arms name (e.g. `error.ConnectionRefused`, `error.AnnounceMalformed` — read the moved code for the exact set). These are the first pins this policy has ever had.
252 - [ ] **Step 3: Dedup child spawning.** `fn spawnPipe(alloc, cmd: []const u8) !std.process.Child` (the six-line init+behaviors+spawn block, stderr .Inherit comment travels) and `fn pipeTransport(child) Transport` (the wrap literal, three copies today). Replace all three sites.
253 - [ ] **Step 4: Dedup QUIC dialing.** `fn quicTransport(alloc, addr, key, idle_ms, deadline_ms, carry) !Transport` used by `open`'s `.quic` arm and `openQuicEndpoint`. CAUTION (survey-flagged, load-bearing): the `waitReady` call is the sole source of `error.UserAbort` named by two switch arms in `openHandoff` — the helper MUST keep it or the error-set narrowing breaks the build loudly; that is expected and correct, not a bug to work around.
254 - [ ] **Step 5: `make test && make e2e`.** **Mutation check:** swap two message strings in `openErrorMsg`; the new pins fail printing both strings; revert.
255 - [ ] **Step 6: Commit** `refactor: attach error policy is a pinned pure function; one spawner, one dialer`.
256
257 ---
258
259 ### Task 8: extract `src/quic.zig`
260
261 **Files:**
262 - Create: `src/quic.zig`
263 - Modify: `src/quic_server.zig`, `src/quic_client.zig`, `build.zig` (:91-96 quic_mod repoint + new quic_server_mod)
264
265 - [ ] **Step 1:** Move to src/quic.zig: the `c` cImport block, `Key` (+ its 3 load tests), `key_len`, `default_port`, `default_idle_ms`, `psk_identity`, `psk_ciphersuite`, `alpn`, `max_udp`, `egress_cap`, `Egress` (+ 3 tests), `WriteAction`, `accountWrite` (+ test), `timestampNs`, `keepAliveNs` (+ moved test from task 3), `randCb`, `getNewCidCb`. All are `pub` already — a move. THE CIMPORT MUST EXIST IN EXACTLY ONE FILE afterward; quic_server.zig and quic_client.zig both use `quic.c` (the client already spells `quic.timestampNs()` — the name reads correctly today because build.zig aliases the server file as `quic`).
266 - [ ] **Step 2:** Add the one new helper (kills five path literals — the ONLY egress-adjacent change this milestone allows):
267
268 ```zig
269 pub fn pathFrom(local: anytype, local_len: c.socklen_t, remote: anytype, remote_len: c.socklen_t) c.ngtcp2_path {
270 return .{
271 .local = .{ .addr = @ptrCast(local), .addrlen = local_len },
272 .remote = .{ .addr = @ptrCast(remote), .addrlen = remote_len },
273 .user_data = null,
274 };
275 }
276 ```
277
278 Replace the five literals (quic_server.zig:981, :1159, :1473, :1545; quic_client.zig:276, :371). Loop bodies otherwise UNTOUCHED.
279
280 - [ ] **Step 3:** build.zig: `quic_mod` points at src/quic.zig; new `quic_server_mod` for src/quic_server.zig importing `quic`; every current importer of the old `quic` module re-checked — server.zig imports the LISTENER (grep `@import("quic")` across src/ and follow what each use needs: `Listener` moves to `quic_server_mod` imports, vocabulary stays on `quic`). Expect server.zig to need both imports.
281 - [ ] **Step 4: `make build` is the proof** (the survey's warning: one type universe — verify by building, not reading). Then `make test && make e2e`: all moved tests green in their new homes, counts otherwise unchanged.
282 - [ ] **Step 5: Commit** `refactor: src/quic.zig owns the shared QUIC vocabulary; quic_server is just the listener`.
283
284 ---
285
286 ### Task 9: server QUIC ownership union
287
288 **Files:**
289 - Modify: `src/server.zig` (fields :401-409; sites :522-526, :583, :592, :595-606, :664, :773-776, :785-789, :793, :949-950, :981, :1055-1061 — grep `quic_listener` and `quic_owned` for the full set)
290
291 - [ ] **Step 1:**
292
293 ```zig
294 /// Who owns the QUIC listener. borrowed = attachQuic'd by a caller
295 /// whose deinit it is; owned = lazyBindQuic bound it and deinit
296 /// returns it. The old ?*Listener + bool pair could type the
297 /// unrepresentable (null, owned) state; this cannot.
298 quic: union(enum) {
299 none,
300 borrowed: *quic_server.Listener,
301 owned: *quic_server.Listener,
302 } = .none,
303
304 fn quicListener(self: *Server) ?*quic_server.Listener {
305 return switch (self.quic) {
306 .none => null,
307 .borrowed, .owned => |l| l,
308 };
309 }
310 ```
311
312 - [ ] **Step 2:** `deinit`'s ownership block switches on `.owned` only; `attachQuic` requires `.none` (the runtime assert at :949 becomes `std.debug.assert(self.quic == .none)` — same defense, now against a smaller state space); `lazyBindQuic` sets `.owned`; every read site goes through `quicListener()` or an exhaustive switch, whichever reads better at that site.
313 - [ ] **Step 3: `make test && make e2e`.** Both ownership arms are already literal-pinned (borrowed survives deinit :3876; owned released via bind latch :4605/:4664-4669; prefers-existing :4672) — those tests must pass UNCHANGED. If any needs an edit beyond `.quic_owned`→union spelling, stop and report.
314 - [ ] **Step 4: Commit** `refactor: server QUIC ownership is a union — (null, owned) is now untypable`.
315
316 ---
317
318 ### Task 10: extract `src/delta.zig` and `src/sockpath.zig`
319
320 **Files:**
321 - Create: `src/delta.zig`, `src/sockpath.zig`
322 - Modify: `src/server.zig` (DeltaTracker :89-235 + tests :3804-3875; claimSockPath :461-504, dev/ino fields :367-378, init's fstatat :445-448, deinit's ours block :527-536 + tests :3610-3738, :4397-4448), `build.zig` (two modules)
323
324 - [ ] **Step 1: delta.zig.** Move `DeltaTracker` verbatim (+ its two unit tests). Absorb the 3-clause serve predicate (server.zig:1610-1612) as `pub fn canServe(self: *const DeltaTracker, have_seq: u64) bool` with the clauses verbatim and the comment moved; server.zig:1610 calls it. `Stats` stays in server.zig (it counts more than deltas).
325 - [ ] **Step 2: sockpath.zig.**
326
327 ```zig
328 /// Identity of the file we bound: device+inode at claim time. Exists
329 /// as a type because comparing the wrong two of these caused a real
330 /// field incident (three daemons, one path — see decisions.md M7).
331 pub const PathId = struct {
332 dev: u64,
333 ino: u64,
334 pub fn of(path: []const u8) !PathId { ... } // the init fstatat, moved
335 pub fn stillAt(self: PathId, path: []const u8) bool { ... } // the deinit ours-check, moved
336 };
337 pub fn claim(path: []const u8) !void { ... } // claimSockPath, moved verbatim
338 ```
339
340 Server holds `path_id: ?sockpath.PathId`; deinit's block becomes `if (self.path_id) |id| if (id.stillAt(self.sock_path)) unlink...` — semantics identical, the comment about the incident travels to the type.
341
342 - [ ] **Step 3:** Move the eight tests verbatim (four claim tests :3610-3738; two unlink-identity tests :4397-4448; two DeltaTracker tests). Wire both modules in build.zig for server_mod + test loop.
343 - [ ] **Step 4: `make test && make e2e`** — all moved pins green, counts unchanged. **Mutation check:** invert `stillAt`'s comparison; the replaced-socket test must fail stating the socket was wrongly deleted/kept; revert.
344
345 > **Amended after Task 10 (2026-08-12): the specified mutation was not the predicate's negation.** Flipping both operands of the conjunction (`==`→`!=` twice) is absorbed on a single filesystem — `dev` is equal in both scenarios, so the mutant predicate is constant-false and the replaced-socket pin PASSES (survivor left alone is what it asserts); the clean-exit pin caught it instead. The true inversion (`!(a and b)`) fails both identity pins printing, which the implementer ran unprompted. Operator lesson recorded: **flipping the operands of a conjunction is not negating it** — mutate predicates by wrapping in `!`, or the environment's shared structure can absorb the flip.
346 - [ ] **Step 5: Commit** `refactor: DeltaTracker and socket-path identity extract with their tests`.
347
348 ---
349
350 ### Task 11: key-refusal single owner + main.zig dedup
351
352 **Files:**
353 - Modify: `src/quic.zig` (new fn beside Key), `src/main.zig` (:317-337, :744-763 → call it; dump/stats :425-465 → oneShotQuery; logHint :514-531 + :640-654), `src/server.zig` (:1016-1044 → call it), `src/client.zig` (:667-678 region — now inside openErrorMsg — → call it)
354
355 - [ ] **Step 1:** Beside `Key` in quic.zig:
356
357 ```zig
358 /// The middle sentence of every key refusal, in every binary — one
359 /// owner because four literal copies were held in sync by prose
360 /// comments, and their catch-alls had already drifted. Callers add
361 /// their own prefix ("muxd:", "muxd endpoint:", "mux:", "muxd:
362 /// endpoint_req:") and suffix ("; staying on ssh" or nothing).
363 pub fn keyRefusalBody(buf: []u8, err: anyerror, path: []const u8) []const u8 {
364 ...
365 }
366 ```
367
368 Take the three sentences from main.zig:317-337 as canonical (`no such key file: {s}` / `{s} is readable by group or other; chmod 600 it` / `{s} is not a key: want 32 raw bytes or 64 hex characters`) plus ONE catch-all — pick `cannot read {s}: {s}` and record in the commit that `cannot load key` was the drifted copy. All four sites format through it, keeping their exact prefixes/suffixes.
369
370 - [ ] **Step 2: Pin it** — one test, all four bodies literal, in quic.zig. **This is regrade mutant (c)'s target**: rehearse by breaking one sentence, watching the pin print the diff, reverting.
371 - [ ] **Step 3:** Verify e2e's loose pin still matches (`^muxd endpoint: .*staying on ssh` — grep test/e2e.sh) and `make e2e` proves it.
372 - [ ] **Step 4: oneShotQuery.** main.zig:425-465 (dump/stats, byte-identical modulo three tokens) → `fn oneShotQuery(alloc, sock_path, verb: []const u8, req: proto.MsgType, req_payload: []const u8, want: proto.MsgType) !u8`; dump and stats become two-line callers. `askEndpointPort` STAYS (documented different shape). Add the missing nobody-serving unit test mirroring stopCmd's (:1190-1197).
373 - [ ] **Step 5: logHint.** The twice-written log-path clause (:514-531, :640-654) → one `fn logHint(...)` used by both; the "Both halves are stopCmd's" comment (:638-639) dies with the duplication.
374 - [ ] **Step 6: `make test && make e2e`, commit** `refactor: key refusals have one owner and a pin; dump/stats/logHint dedup`.
375
376 ---
377
378 ### Task 12: main.zig subcommand spec table
379
380 **Files:**
381 - Modify: `src/main.zig` (usage :14-27 stays literal; Cmd :49; if/else chain :90-107; keygen exception :111-112; takes_value :126-132; uses_socket :242-245)
382
383 - [ ] **Step 1:**
384
385 ```zig
386 const Spec = struct {
387 name: []const u8,
388 cmd: Cmd,
389 uses_socket: bool,
390 flags: enum { none, all },
391 };
392 const specs = [_]Spec{
393 .{ .name = "run", .cmd = .run, .uses_socket = true, .flags = .all },
394 // ... one row per existing subcommand, values read off the
395 // current chain/switches — transcribe, don't infer.
396 };
397 ```
398
399 parse resolves the subcommand by linear lookup; `uses_socket` reads off the spec; keygen's hand-rolled no-flags exception becomes `.flags = .none` enforced in one place. The DISPATCH `switch (o.cmd)` stays a switch (real bodies; exhaustiveness is wanted).
400
401 - [ ] **Step 2: The missing leg gets a test:** every `Cmd` name appears in the usage literal:
402
403 ```zig
404 test "usage names every subcommand" {
405 inline for (specs) |s| {
406 try std.testing.expect(std.mem.indexOf(u8, usage, s.name) != null);
407 }
408 }
409 ```
410
411 (Note: this test intentionally reads `specs` — it pins the CROSS-CHECK, not a literal; the literal usage text itself stays hand-tuned and unpinned, as the spec decided.)
412
413 - [ ] **Step 3: `make test`** — the 8 existing parseArgs behavior tests (:879-1089, incl. the inline-for over value-taking flags) pass UNCHANGED; if one needs edits, the table transcription is wrong, not the test. **Mutation check:** remove one row's name from usage; the new test fails naming the missing subcommand; revert.
414 - [ ] **Step 4: `make e2e`, commit** `refactor: subcommand spec table — adding a verb is one row plus a body`.
415
416 ---
417
418 ### Task 13: milestone close — measure, regrade, soak, docs
419
420 **Files:**
421 - Modify: `docs/decisions.md` (new M15 section), `docs/roadmap.md` (M15 entry; retire the two shipped banked items; Tier 3 items stay listed), `README.md` only if any user-visible text changed (the two timings might warrant one line)
422
423 - [ ] **Step 1: LAN measurements** (box ubuntu@192.168.0.109, daemon already running `--quic 0.0.0.0:4433`; key fetched to a scratch dir — NEVER local ~/.config/mux; hermetic XDG for clients; tracked pids; observed cleanup):
424 - refused: `mux quic://192.168.0.109:<closed-port>` → expect fast fail ≪ 2000ms;
425 - dead-host budget: `mux quic://192.0.2.1:4433` → expect ~2000ms (was ~15s pre-M15);
426 - control: successful dial to :4433 still attaches.
427 Three reps each, medians, recorded in decisions.md.
428 - [ ] **Step 2: Regrade — 3 mutants, each must COMPILE, each catch LEGIBLE and ordered before anything it could hang:**
429 (a) drain's refusal arm → `catch return` (revert of task 4 step 1);
430 (b) swap two `Target` dispatch arms in `Transport.open` (task 6's rehearsal);
431 (c) break one `keyRefusalBody` sentence (task 11's rehearsal). **Amended after Task 11: the mutation must edit ONLY the implementation line in keyRefusalBody — a naive sed on the sentence also rewrites the quic.zig pin's identical literal and self-heals into a passing suite.** Task 11's rehearsal measured the blast radius: two layers catch it (the quic.zig pin and a client openFailure pin), both printing.
432 For each: apply, `make test` (and `make e2e` only if the unit layer misses it — record which layer caught it and that the failure PRINTED), revert, verify clean tree between mutants (`git diff --stat` empty).
433 - [ ] **Step 3: `SOAK_N=10 make soak`** on the final tree — 10/10 required. A wedged run prints nothing: monitor by wall clock and report the observed table.
434 - [ ] **Step 4: Verify the kill criterion's size clause:** `wc -l src/server.zig src/client.zig` both strictly smaller than at e4d4eb6 (4708 / 2406); record the numbers.
435
436 > **Amended at close (2026-08-12): the clause was mis-specified.** Whole-file `wc -l` charges a file for the pins the milestone deliberately added to it. client.zig closed +100 whole-file with implementation flat (1640→1647) and tests +93 (the dispatch pin, twelve message classes, truncation). server.zig −234, quic_server.zig −440. The honest clause: implementation lines smaller-or-flat; test lines free to grow. Recorded in decisions.md M15 rather than quietly restated.
437 - [ ] **Step 5: decisions.md M15 section** — the survey provenance, the two behavior changes with their measured numbers (local three-class + LAN), the regrade table, findings that emerged en route, and the explicit non-goals kept banked (egress unification, addCSourceFiles, Tier 3, predict `retired` decision).
438 - [ ] **Step 6: roadmap.md** — M15 marked complete with verdict; drain()/ECONNREFUSED and Transport-union rows retired; Tier 3 items enter the candidates list; predict `retired` adoption/deletion added as a decision item.
439 - [ ] **Step 7: Commit docs** `docs: M15 closed — <one-line verdict with numbers>`. Do NOT push — the lead pushes after independent verification.
440 - [ ] **Step 8 (user-requested 2026-08-12): post a build.** After the close is verified and pushed, cut a release: bump the version in build.zig (single source; currently 0.0.1-3) → 0.0.1-4, commit, tag `v0.0.1-4`, build the static musl tarball (both binaries, same recipe as v0.0.1-3), sha256 it, and hand the artifact + checksum to the user for publishing (publishing itself is the user's step, per the v0.0.1-3 precedent). Release notes = the M15 verdict line + the two behavior changes with numbers.
docs/superpowers/plans/2026-08-13-agent-surface.md
Old New
@@ -1,1953 +0,0 @@
1 # Agent Surface 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:** Native LLM/agent integration for mux: OSC 133 command boundaries with exit codes, server-side await, structured status, and a standalone `muxa` binary speaking unix socket and QUIC.
6
7 **Architecture:** The daemon's ghostty-vt stream is wrapped in a mux-owned handler that intercepts semantic-prompt (OSC 133) actions and queues mark events (engine = mechanism). The server folds those events into a per-session command state machine, pushes `cmd_state`, and holds `await_req` open server-side with pgid/settle fallbacks (server = policy). Shell integration is injected at spawn because muxd forks the shell itself. `muxa` is a new thin client binary emitting JSON.
8
9 **Tech Stack:** Zig 0.15.2 (pinned: `~/Downloads/zig-x86_64-linux-0.15.2/zig`, LLVM+LLD — see docs/decisions.md), ghostty-vt vendored dep, existing `protocol.zig` frame style.
10
11 **Spec:** `docs/superpowers/specs/2026-08-13-agent-surface-design.md` — read it first.
12
13 > **Status (2026-08-13, all twelve tasks shipped):** this file is the
14 > historical execution record — what was planned and in what order — and it
15 > is deliberately not updated to match what landed. Where the two disagree,
16 > the code and `docs/decisions.md` are the truth. The divergences worth
17 > knowing before reading below: `returned_seq` was deleted in favour of one
18 > owner, `last_return`, which carries the watermark inside the answer;
19 > `checkAwaits` runs before the QUIC `drainAll` at the end of the pump, not
20 > after it; `muxa status` nests the cursor in its JSON; and the bash shim's
21 > final shape (array-aware `PROMPT_COMMAND`, membership guard, trap
22 > installed last) postdates the text in Task 7.
23
24 **Conventions for every task:**
25 - `ZIG=~/Downloads/zig-x86_64-linux-0.15.2/zig` — run from the worktree root (`.worktrees/agent-surface`).
26 - Full suite: `$ZIG build test`. It must pass before every commit.
27 - House doctrine: pinned regression tests go BEFORE any test the same hang could wedge (see MEMORY: a wedged zig test step prints nothing).
28 - Commit messages follow the repo style: lowercase type prefix, sentence explaining the why. Every commit ends with the `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` trailer.
29
30 ---
31
32 ## File structure (locked in)
33
34 | File | Responsibility |
35 |---|---|
36 | `src/protocol.zig` (modify) | New MsgTypes + `CmdState`/`AwaitReq`/`StatusReply` encode/decode. Wire format only. |
37 | `src/engine.zig` (modify) | `MuxHandler` wrapping the stock ghostty handler; queues `MarkEvent`s with rows. No policy. |
38 | `src/cmd.zig` (create) | Pure command state machine: MarkEvents in, transitions out. No I/O. |
39 | `src/pty.zig` (modify) | `fgPgid()` (TIOCGPGRP on the master) + optional child env pairs for injection. |
40 | `src/shellint.zig` (create) | Shell detection, shim/script file creation, env pairs. No daemon knowledge. |
41 | `src/server.zig` (modify) | Integration: drain mark events, stamp seq, push `cmd_state`, hold awaits, answer `status_req`. |
42 | `src/main.zig` (modify) | Injection wiring + `MUX_SHELL_INTEGRATION` opt-out for `muxd run`/`start`. |
43 | `src/muxa.zig` (create) | Standalone agent binary: verbs `status`/`capture`/`send`/`run`/`await`, JSON out, unix+QUIC. |
44 | `test/agent.sh` (create) | Binary-level e2e: shell session marks, TUI ephemeral session, QUIC. |
45 | `build.zig` (modify) | `muxa` executable + module wiring + `cmd`/`shellint` test steps. |
46
47 ---
48
49 ### Task 1: Protocol frames (`cmd_state`, `await_req`/`await_reply`, `status_req`/`status_reply`)
50
51 **Files:**
52 - Modify: `src/protocol.zig`
53
54 - [ ] **Step 1: Write the failing tests** — append to `src/protocol.zig`:
55
56 ```zig
57 test "cmd_state encode/decode round trip and golden bytes" {
58 const s = CmdState{
59 .phase = .returned,
60 .mechanism = .marks,
61 .exit_code = 1,
62 .start_row = 80,
63 .end_row = 92,
64 .seq = 258,
65 };
66 const buf = encodeCmdState(s);
67 try std.testing.expectEqualSlices(u8, &[_]u8{
68 2, // phase returned
69 0, // mechanism marks
70 1, // has_exit
71 1, // exit_code
72 0x50, 0, 0, 0, // start_row 80
73 0x5C, 0, 0, 0, // end_row 92
74 0x02, 0x01, 0, 0, 0, 0, 0, 0, // seq 258
75 }, &buf);
76 const back = try decodeCmdState(&buf);
77 try std.testing.expectEqual(s, back);
78 }
79
80 test "cmd_state with no exit code round-trips null, not zero" {
81 const s = CmdState{ .phase = .running, .mechanism = .pgid, .exit_code = null, .start_row = 5, .end_row = 0, .seq = 9 };
82 const back = try decodeCmdState(&encodeCmdState(s));
83 try std.testing.expectEqual(@as(?u8, null), back.exit_code);
84 }
85
86 test "cmd_state rejects wrong length and unknown enum bytes" {
87 try std.testing.expectError(error.BadPayload, decodeCmdState(&[_]u8{0} ** (cmd_state_len - 1)));
88 try std.testing.expectError(error.BadPayload, decodeCmdState(&[_]u8{0} ** (cmd_state_len + 1)));
89 var bad = encodeCmdState(.{ .phase = .at_prompt, .mechanism = .settle, .exit_code = null, .start_row = 0, .end_row = 0, .seq = 0 });
90 bad[0] = 9; // phase out of range
91 try std.testing.expectError(error.BadPayload, decodeCmdState(&bad));
92 bad[0] = 0;
93 bad[1] = 9; // mechanism out of range
94 try std.testing.expectError(error.BadPayload, decodeCmdState(&bad));
95 }
96
97 test "await_req encode/decode round trip" {
98 const r = try decodeAwaitReq(&encodeAwaitReq(.{ .since_seq = 77, .settle_ms = 500, .timeout_ms = 30_000 }));
99 try std.testing.expectEqual(@as(u64, 77), r.since_seq);
100 try std.testing.expectEqual(@as(u32, 500), r.settle_ms);
101 try std.testing.expectEqual(@as(u32, 30_000), r.timeout_ms);
102 try std.testing.expectError(error.BadPayload, decodeAwaitReq(&[_]u8{0} ** 15));
103 }
104
105 test "await_reply is a CmdState plus a reason byte" {
106 const s = CmdState{ .phase = .returned, .mechanism = .settle, .exit_code = null, .start_row = 0, .end_row = 3, .seq = 4 };
107 const buf = encodeAwaitReply(s, .settled);
108 try std.testing.expectEqual(@as(usize, await_reply_len), buf.len);
109 const back = try decodeAwaitReply(&buf);
110 try std.testing.expectEqual(AwaitReason.settled, back.reason);
111 try std.testing.expectEqual(s, back.state);
112 var bad = buf;
113 bad[cmd_state_len] = 9;
114 try std.testing.expectError(error.BadPayload, decodeAwaitReply(&bad));
115 }
116
117 test "status_reply encode/decode round trip" {
118 const s = StatusReply{
119 .cols = 120,
120 .rows = 40,
121 .cursor_x = 3,
122 .cursor_y = 5,
123 .history_rows = 77,
124 .alt_screen = true,
125 .mode = .{ .icanon = true, .echo = true },
126 .cmd = .{ .phase = .at_prompt, .mechanism = .marks, .exit_code = 0, .start_row = 1, .end_row = 2, .seq = 6 },
127 };
128 const back = try decodeStatusReply(&encodeStatusReply(s));
129 try std.testing.expectEqual(s, back);
130 try std.testing.expectError(error.BadPayload, decodeStatusReply(&[_]u8{0} ** (status_reply_len - 1)));
131 }
132 ```
133
134 - [ ] **Step 2: Run to verify failure**
135
136 Run: `$ZIG build test 2>&1 | tail -5`
137 Expected: compile error — `CmdState` not defined.
138
139 - [ ] **Step 3: Implement.** In the `MsgType` enum, add after `endpoint_req = 0x08`:
140
141 ```zig
142 await_req = 0x09, // payload: u64 LE since_seq, u32 LE settle_ms, u32 LE timeout_ms
143 status_req = 0x0a, // payload: empty
144 ```
145
146 and after `endpoint_reply = 0x89` (the 0x83 hole stays untouched — it is historical):
147
148 ```zig
149 cmd_state = 0x8a, // payload: CmdState (see encodeCmdState); pushed on marks-regime transitions
150 await_reply = 0x8b, // payload: CmdState ++ 1 byte AwaitReason
151 status_reply = 0x8c, // payload: StatusReply (see encodeStatusReply)
152 ```
153
154 Then add below `decodeEndpointReply` (house style: explicit encode/decode over fixed LE buffers):
155
156 ```zig
157 /// Where the command-boundary signal came from, weakest-last. `marks` is the
158 /// only mechanism that can carry an exit code; consumers must check it
159 /// before trusting one.
160 pub const Mechanism = enum(u8) { marks = 0, pgid = 1, settle = 2 };
161
162 pub const CmdPhase = enum(u8) { at_prompt = 0, running = 1, returned = 2 };
163
164 /// One snapshot of the session's command state machine. Rows are absolute
165 /// screen-space rows (0 = oldest retained history row) — meaningless while
166 /// the alt screen is active, and shifted once the scrollback ring prunes,
167 /// so spans should be fetched promptly. `seq` is the delta-tracker seq
168 /// stamped after the post-feed update (await ordering, nothing else).
169 pub const CmdState = struct {
170 phase: CmdPhase,
171 mechanism: Mechanism,
172 exit_code: ?u8,
173 start_row: u32,
174 end_row: u32,
175 seq: u64,
176 };
177
178 pub const cmd_state_len = 20;
179
180 pub fn encodeCmdState(s: CmdState) [cmd_state_len]u8 {
181 var buf: [cmd_state_len]u8 = undefined;
182 buf[0] = @intFromEnum(s.phase);
183 buf[1] = @intFromEnum(s.mechanism);
184 buf[2] = @intFromBool(s.exit_code != null);
185 buf[3] = s.exit_code orelse 0;
186 std.mem.writeInt(u32, buf[4..8], s.start_row, .little);
187 std.mem.writeInt(u32, buf[8..12], s.end_row, .little);
188 std.mem.writeInt(u64, buf[12..20], s.seq, .little);
189 return buf;
190 }
191
192 fn enumFromByte(comptime E: type, b: u8) !E {
193 return std.meta.intToEnum(E, b) catch error.BadPayload;
194 }
195
196 pub fn decodeCmdState(payload: []const u8) !CmdState {
197 if (payload.len != cmd_state_len) return error.BadPayload;
198 return .{
199 .phase = try enumFromByte(CmdPhase, payload[0]),
200 .mechanism = try enumFromByte(Mechanism, payload[1]),
201 .exit_code = if (payload[2] != 0) payload[3] else null,
202 .start_row = std.mem.readInt(u32, payload[4..8], .little),
203 .end_row = std.mem.readInt(u32, payload[8..12], .little),
204 .seq = std.mem.readInt(u64, payload[12..20], .little),
205 };
206 }
207
208 pub const AwaitReq = struct { since_seq: u64, settle_ms: u32, timeout_ms: u32 };
209
210 pub const await_req_len = 16;
211
212 pub fn encodeAwaitReq(r: AwaitReq) [await_req_len]u8 {
213 var buf: [await_req_len]u8 = undefined;
214 std.mem.writeInt(u64, buf[0..8], r.since_seq, .little);
215 std.mem.writeInt(u32, buf[8..12], r.settle_ms, .little);
216 std.mem.writeInt(u32, buf[12..16], r.timeout_ms, .little);
217 return buf;
218 }
219
220 pub fn decodeAwaitReq(payload: []const u8) !AwaitReq {
221 if (payload.len != await_req_len) return error.BadPayload;
222 return .{
223 .since_seq = std.mem.readInt(u64, payload[0..8], .little),
224 .settle_ms = std.mem.readInt(u32, payload[8..12], .little),
225 .timeout_ms = std.mem.readInt(u32, payload[12..16], .little),
226 };
227 }
228
229 pub const AwaitReason = enum(u8) { returned = 0, settled = 1, timeout = 2 };
230
231 pub const await_reply_len = cmd_state_len + 1;
232
233 pub fn encodeAwaitReply(s: CmdState, reason: AwaitReason) [await_reply_len]u8 {
234 var buf: [await_reply_len]u8 = undefined;
235 buf[0..cmd_state_len].* = encodeCmdState(s);
236 buf[cmd_state_len] = @intFromEnum(reason);
237 return buf;
238 }
239
240 pub const AwaitReply = struct { state: CmdState, reason: AwaitReason };
241
242 pub fn decodeAwaitReply(payload: []const u8) !AwaitReply {
243 if (payload.len != await_reply_len) return error.BadPayload;
244 return .{
245 .state = try decodeCmdState(payload[0..cmd_state_len]),
246 .reason = try enumFromByte(AwaitReason, payload[cmd_state_len]),
247 };
248 }
249
250 /// One structured snapshot for `muxa status`. The grid facts a driving
251 /// agent needs before deciding how to interact: size, cursor, whether a
252 /// TUI holds the screen, who echoes keystrokes, and the command state.
253 pub const StatusReply = struct {
254 cols: u16,
255 rows: u16,
256 cursor_x: u16,
257 cursor_y: u16,
258 history_rows: u32,
259 alt_screen: bool,
260 mode: PtyModeFlags,
261 cmd: CmdState,
262 };
263
264 pub const status_reply_len = 14 + cmd_state_len;
265
266 pub fn encodeStatusReply(s: StatusReply) [status_reply_len]u8 {
267 var buf: [status_reply_len]u8 = undefined;
268 std.mem.writeInt(u16, buf[0..2], s.cols, .little);
269 std.mem.writeInt(u16, buf[2..4], s.rows, .little);
270 std.mem.writeInt(u16, buf[4..6], s.cursor_x, .little);
271 std.mem.writeInt(u16, buf[6..8], s.cursor_y, .little);
272 std.mem.writeInt(u32, buf[8..12], s.history_rows, .little);
273 buf[12] = @intFromBool(s.alt_screen);
274 buf[13] = @bitCast(s.mode);
275 buf[14..].* = encodeCmdState(s.cmd);
276 return buf;
277 }
278
279 pub fn decodeStatusReply(payload: []const u8) !StatusReply {
280 if (payload.len != status_reply_len) return error.BadPayload;
281 return .{
282 .cols = std.mem.readInt(u16, payload[0..2], .little),
283 .rows = std.mem.readInt(u16, payload[2..4], .little),
284 .cursor_x = std.mem.readInt(u16, payload[4..6], .little),
285 .cursor_y = std.mem.readInt(u16, payload[6..8], .little),
286 .history_rows = std.mem.readInt(u32, payload[8..12], .little),
287 .alt_screen = payload[12] != 0,
288 .mode = try decodePtyMode(payload[13..14]),
289 .cmd = try decodeCmdState(payload[14..]),
290 };
291 }
292 ```
293
294 Note: `decodePtyMode` takes a slice; passing `payload[13..14]` reuses its length check. If the compiler wants `payload[14..]` as `*const [cmd_state_len]u8` for `decodeCmdState`, pass `payload[14..][0..cmd_state_len]`.
295
296 - [ ] **Step 4: Run tests**
297
298 Run: `$ZIG build test 2>&1 | tail -5`
299 Expected: PASS (exit 0, no failures reported).
300
301 - [ ] **Step 5: Commit**
302
303 ```bash
304 git add src/protocol.zig
305 git commit -m "feat(protocol): cmd_state, await, and status frames for the agent surface"
306 ```
307
308 ---
309
310 ### Task 2: `Pty.fgPgid` — the kernel's foreground answer
311
312 **Files:**
313 - Modify: `src/pty.zig`
314
315 - [ ] **Step 1: Write the failing test** — append to `src/pty.zig`:
316
317 ```zig
318 test "Pty: fgPgid tracks the foreground job" {
319 const alloc = std.testing.allocator;
320 var pty = try Pty.spawn(.{ .cols = 80, .rows = 24, .shell = "/bin/sh" });
321 defer pty.deinit();
322
323 // Prove the shell is up before asking anything of the pgid.
324 _ = try std.posix.write(pty.master, "printf 'ready-%s\\n' PGID\n");
325 var ready = try readUntil(alloc, &pty, "ready-PGID", 5000);
326 defer ready.deinit(alloc);
327 try std.testing.expect(std.mem.indexOf(u8, ready.items, "ready-PGID") != null);
328
329 // At the prompt, the foreground pgid is the shell's own process group.
330 // sh is the session leader post-forkpty, so its pgid == its pid.
331 try std.testing.expectEqual(pty.child, try pty.fgPgid());
332
333 // A foreground job moves the fg pgid off the shell... eventually: an
334 // interactive sh creates a new process group for the job. Poll for the
335 // change rather than racing it.
336 _ = try std.posix.write(pty.master, "sleep 2\n");
337 var moved = false;
338 var waited_ms: u64 = 0;
339 while (waited_ms < 3000) : (waited_ms += 50) {
340 if (try pty.fgPgid() != pty.child) {
341 moved = true;
342 break;
343 }
344 std.Thread.sleep(50 * std.time.ns_per_ms);
345 }
346 // Dash and busybox sh run foreground jobs in the shell's own group when
347 // job control is off (non-interactive stdin heuristics differ), so a
348 // never-moved pgid is a legal outcome for the fallback design — but on
349 // a pty, POSIX shells enable job control. Assert movement; if this
350 // flakes on some /bin/sh, relax to a log + skip, not a green lie.
351 try std.testing.expect(moved);
352
353 // ...and returns to the shell when the job ends.
354 waited_ms = 0;
355 while (waited_ms < 5000) : (waited_ms += 100) {
356 if (try pty.fgPgid() == pty.child) break;
357 std.Thread.sleep(100 * std.time.ns_per_ms);
358 }
359 try std.testing.expectEqual(pty.child, try pty.fgPgid());
360 }
361 ```
362
363 - [ ] **Step 2: Run to verify failure**
364
365 Run: `$ZIG build test 2>&1 | tail -5`
366 Expected: compile error — `fgPgid` not defined.
367
368 - [ ] **Step 3: Implement** — add to `Pty` after `mode()`:
369
370 ```zig
371 /// The foreground process group of the session, read off the master
372 /// with TIOCGPGRP. When it equals `child` (the shell, session leader
373 /// post-forkpty), no foreground job is running — the kernel's own
374 /// "the command returned", available with zero shell cooperation.
375 /// No exit code and no output span; that is what marks are for.
376 pub fn fgPgid(self: *const Pty) !std.posix.pid_t {
377 var pgid: c.pid_t = 0;
378 if (c.ioctl(self.master, c.TIOCGPGRP, &pgid) < 0) return error.IoctlFailed;
379 return @intCast(pgid);
380 }
381 ```
382
383 (`c` already cImports `sys/ioctl.h`; TIOCGPGRP comes with it via termios/ioctls. If the compile misses `c.pid_t`, use `c_int` for the local.)
384
385 - [ ] **Step 4: Run tests**
386
387 Run: `$ZIG build test 2>&1 | tail -5`
388 Expected: PASS.
389
390 - [ ] **Step 5: Commit**
391
392 ```bash
393 git add src/pty.zig
394 git commit -m "feat(pty): fgPgid reads the foreground job off the master"
395 ```
396
397 ---
398
399 ### Task 3: Engine — `MuxHandler` wrapper, mark events with rows
400
401 **Files:**
402 - Modify: `src/engine.zig`
403
404 Background (verified against the vendored dep, `~/.cache/zig/p/ghostty-1.3.2-dev-5UdBC7VOBgVv0iA*/src/terminal/`): ghostty-vt parses OSC 133 into a `semantic_prompt` action whose value has `.action` (enum incl. `fresh_line_new_prompt`='A', `end_input_start_output`='C', `end_command`='D') and `readOption(.exit_code)` → `?i32`. The stock `Handler.vt` forwards it to `terminal.semanticPrompt` and drops the code. `vt.Stream(H)` is generic over the handler (exported as `vt.Stream`); the handler needs `deinit` and a `vt(comptime action, value)` method.
405
406 - [ ] **Step 1: Write the failing tests** — append to `src/engine.zig`:
407
408 ```zig
409 test "Engine: OSC 133 marks surface as events with rows and exit codes" {
410 const alloc = std.testing.allocator;
411 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
412 defer e.deinit();
413
414 e.feed("$ "); // a prompt on row 0
415 e.feed("\x1b]133;C\x07"); // command starts
416 e.feed("output line\r\n");
417 e.feed("\x1b]133;D;1\x07"); // command returns, exit 1
418 e.feed("\x1b]133;A\x07"); // next prompt
419
420 const evs = e.markEvents();
421 try std.testing.expectEqual(@as(usize, 3), evs.len);
422
423 try std.testing.expectEqual(Engine.MarkEvent.Kind.command_start, evs[0].kind);
424 try std.testing.expectEqual(@as(u32, 0), evs[0].row);
425 try std.testing.expectEqual(@as(?u8, null), evs[0].exit_code);
426
427 try std.testing.expectEqual(Engine.MarkEvent.Kind.command_end, evs[1].kind);
428 try std.testing.expectEqual(@as(u32, 1), evs[1].row); // cursor moved past the output line
429 try std.testing.expectEqual(@as(?u8, 1), evs[1].exit_code);
430
431 try std.testing.expectEqual(Engine.MarkEvent.Kind.prompt_start, evs[2].kind);
432
433 e.clearMarkEvents();
434 try std.testing.expectEqual(@as(usize, 0), e.markEvents().len);
435 }
436
437 test "Engine: mark rows are absolute screen rows, not viewport rows" {
438 const alloc = std.testing.allocator;
439 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
440 defer e.deinit();
441
442 // Scroll 100 lines into history, then mark: the row must include them.
443 var i: usize = 1;
444 while (i <= 100) : (i += 1) {
445 var line: [32]u8 = undefined;
446 e.feed(std.fmt.bufPrint(&line, "line-{d}\r\n", .{i}) catch unreachable);
447 }
448 const hist = e.historyRows(); // 77 per the historyRows test
449 e.feed("\x1b]133;C\x07");
450 const evs = e.markEvents();
451 try std.testing.expectEqual(@as(usize, 1), evs.len);
452 try std.testing.expectEqual(hist + e.cursorPos().y, evs[0].row);
453 }
454
455 test "Engine: a D mark with no exit code yields a null code" {
456 const alloc = std.testing.allocator;
457 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
458 defer e.deinit();
459 e.feed("\x1b]133;C\x07\x1b]133;D\x07");
460 const evs = e.markEvents();
461 try std.testing.expectEqual(@as(usize, 2), evs.len);
462 try std.testing.expectEqual(@as(?u8, null), evs[1].exit_code);
463 }
464
465 test "Engine: non-133 OSC and the ignored 133 subcommands emit no events" {
466 const alloc = std.testing.allocator;
467 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
468 defer e.deinit();
469 e.feed("\x1b]0;a title\x07"); // OSC 0, not semantic
470 e.feed("\x1b]133;B\x07\x1b]133;P\x07\x1b]133;L\x07"); // B/P/L: not ours
471 try std.testing.expectEqual(@as(usize, 0), e.markEvents().len);
472 }
473 ```
474
475 - [ ] **Step 2: Run to verify failure**
476
477 Run: `$ZIG build test 2>&1 | tail -5`
478 Expected: compile error — `markEvents` not defined.
479
480 - [ ] **Step 3: Implement.** In `src/engine.zig`:
481
482 Replace the `stream: vt.TerminalStream` field declaration with:
483
484 ```zig
485 stream: MuxStream,
486 ```
487
488 Add above the `Engine` struct:
489
490 ```zig
491 /// The stock ghostty-vt handler forwards OSC 133 into the terminal and
492 /// drops the exit code on the floor; there is no semantic-prompt callback
493 /// in its Effects. So mux brings its own handler: intercept the one action
494 /// we care about, forward everything (including that one) to the stock
495 /// handler so terminal state stays identical.
496 pub const MuxHandler = struct {
497 inner: vt.TerminalStream.Handler,
498
499 pub fn deinit(self: *MuxHandler) void {
500 self.inner.deinit();
501 }
502
503 pub fn vt(
504 self: *MuxHandler,
505 comptime action: vt.StreamAction.Tag,
506 value: vt.StreamAction.Value(action),
507 ) void {
508 if (comptime action == .semantic_prompt) self.onSemanticPrompt(value);
509 self.inner.vt(action, value);
510 }
511
512 fn engineOf(self: *MuxHandler) *Engine {
513 const stream_ptr: *MuxStream = @fieldParentPtr("handler", self);
514 return @alignCast(@fieldParentPtr("stream", stream_ptr));
515 }
516
517 fn onSemanticPrompt(self: *MuxHandler, value: anytype) void {
518 const kind: Engine.MarkEvent.Kind = switch (value.action) {
519 .fresh_line_new_prompt => .prompt_start, // 'A'
520 .end_input_start_output => .command_start, // 'C'
521 .end_command => .command_end, // 'D'
522 else => return, // L/N/P/B/I: prompt furniture, not boundaries
523 };
524 const eng = self.engineOf();
525 const exit_code: ?u8 = if (kind == .command_end)
526 if (value.readOption(.exit_code)) |code|
527 @intCast(@as(u32, @bitCast(code)) & 0xff)
528 else
529 null
530 else
531 null;
532 eng.mark_events.append(eng.alloc, .{
533 .kind = kind,
534 .row = eng.historyRows() + eng.cursorPos().y,
535 .exit_code = exit_code,
536 }) catch {};
537 }
538 };
539
540 pub const MuxStream = vt.Stream(MuxHandler);
541 ```
542
543 In `Engine`, add the field and the event type:
544
545 ```zig
546 /// OSC 133 mark events observed since the last clear. Drained by the
547 /// server after each feed, exactly like pty_out.
548 mark_events: std.ArrayList(MarkEvent),
549
550 pub const MarkEvent = struct {
551 pub const Kind = enum(u8) { prompt_start, command_start, command_end };
552 kind: Kind,
553 /// Absolute screen-space row (historyRows + cursor.y) at mark time.
554 row: u32,
555 /// Only ever set on command_end, and only when the mark carried one.
556 exit_code: ?u8,
557 };
558 ```
559
560 In `init`, add `.mark_events = .empty,` to the struct literal, and replace the two stream lines with:
561
562 ```zig
563 self.stream = .initAlloc(alloc, .{ .inner = .{ .terminal = &self.term } });
564 self.stream.handler.inner.effects.write_pty = &onWritePty;
565 ```
566
567 In `deinit`, add `self.mark_events.deinit(self.alloc);` alongside `pty_out`.
568
569 Add the accessors next to `ptyOutput`/`clearPtyOutput`:
570
571 ```zig
572 pub fn markEvents(self: *const Engine) []const MarkEvent {
573 return self.mark_events.items;
574 }
575
576 pub fn clearMarkEvents(self: *Engine) void {
577 self.mark_events.clearRetainingCapacity();
578 }
579 ```
580
581 Update `onWritePty` — the callback now receives the inner handler:
582
583 ```zig
584 fn onWritePty(handler: *vt.TerminalStream.Handler, data: [:0]const u8) void {
585 const mh: *MuxHandler = @fieldParentPtr("inner", handler);
586 const stream_ptr: *MuxStream = @fieldParentPtr("handler", mh);
587 // @alignCast for wasm32 — same reasoning as before this change.
588 const self: *Engine = @alignCast(@fieldParentPtr("stream", stream_ptr));
589 self.pty_out.appendSlice(self.alloc, data) catch {};
590 }
591 ```
592
593 Compile notes for the executor: (a) `vt.StreamAction` is exported by the dep's `lib_vt.zig`; if `StreamAction.Tag`/`Value` names differ, mirror the stock handler's signature in `stream_terminal.zig` (`Action.Tag`, `Action.Value(action)`) via `vt.TerminalStream.Action`. (b) The exit code option is `i32`; shells send 0–255 and 128+signal, so the `& 0xff` clamp matches the existing `exit_status` frame's clamp. (c) The wasm build (`src/wasm_core.zig`) links the same engine module — run `$ZIG build` (not just test) to catch a wasm-side breakage; the reviewer's alignment note in `onWritePty` is exactly the wasm case.
594
595 - [ ] **Step 4: Run tests and full build**
596
597 Run: `$ZIG build test 2>&1 | tail -5 && $ZIG build 2>&1 | tail -3`
598 Expected: both PASS (the second builds the wasm core too).
599
600 - [ ] **Step 5: Commit**
601
602 ```bash
603 git add src/engine.zig
604 git commit -m "feat(engine): mux-owned stream handler surfaces OSC 133 marks as row-stamped events"
605 ```
606
607 ---
608
609 ### Task 4: `src/cmd.zig` — the pure command state machine
610
611 **Files:**
612 - Create: `src/cmd.zig`
613 - Modify: `build.zig` (module + test wiring)
614
615 - [ ] **Step 1: Write the file with failing tests.** Create `src/cmd.zig`:
616
617 ```zig
618 //! The session's command state machine: MarkEvents in, transitions out.
619 //! Pure — no I/O, no clock, no seq. The server stamps seqs and decides who
620 //! hears about a transition; this module only decides what the marks mean.
621 //! Trust rule (spec): a D only counts if it closes a seen C; stray marks
622 //! reset to at_prompt rather than being believed.
623 const std = @import("std");
624 const proto = @import("protocol");
625 const Engine = @import("engine").Engine;
626
627 pub const Tracker = struct {
628 phase: proto.CmdPhase = .at_prompt,
629 /// Sticky: once any C has been seen, this session speaks marks and the
630 /// pgid fallback stops being consulted while a command is open.
631 marks_seen: bool = false,
632 start_row: u32 = 0,
633 end_row: u32 = 0,
634 exit_code: ?u8 = null,
635
636 pub const Transition = enum { running, returned, reset };
637
638 pub fn apply(self: *Tracker, ev: Engine.MarkEvent) ?Transition {
639 switch (ev.kind) {
640 .command_start => {
641 self.marks_seen = true;
642 self.phase = .running;
643 self.start_row = ev.row;
644 self.exit_code = null;
645 return .running;
646 },
647 .command_end => {
648 if (self.phase != .running) {
649 // A D with no open C: a nested program echoing marks it
650 // has no business emitting. Reset, believe nothing.
651 self.phase = .at_prompt;
652 return .reset;
653 }
654 self.phase = .returned;
655 self.end_row = ev.row;
656 self.exit_code = ev.exit_code;
657 return .returned;
658 },
659 .prompt_start => {
660 // 'A' after a return is the prompt redrawing: back to rest.
661 // 'A' mid-run (Ctrl-C redraw) also lands here — the shell
662 // is telling us the command is over even without a D.
663 if (self.phase == .running) {
664 self.phase = .returned;
665 self.end_row = ev.row;
666 self.exit_code = null; // interrupted: no honest code
667 return .returned;
668 }
669 self.phase = .at_prompt;
670 return null;
671 },
672 }
673 }
674
675 /// True while marks say a command is open — the window in which the
676 /// pgid fallback must NOT race the marks to a verdict.
677 pub fn marksOpen(self: *const Tracker) bool {
678 return self.marks_seen and self.phase == .running;
679 }
680 };
681
682 test "C then D is running then returned, with rows and code" {
683 var t = Tracker{};
684 try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 10, .exit_code = null }));
685 try std.testing.expectEqual(proto.CmdPhase.running, t.phase);
686 try std.testing.expectEqual(@as(?Tracker.Transition, .returned), t.apply(.{ .kind = .command_end, .row = 14, .exit_code = 1 }));
687 try std.testing.expectEqual(proto.CmdPhase.returned, t.phase);
688 try std.testing.expectEqual(@as(u32, 10), t.start_row);
689 try std.testing.expectEqual(@as(u32, 14), t.end_row);
690 try std.testing.expectEqual(@as(?u8, 1), t.exit_code);
691 }
692
693 test "a stray D resets and is not believed" {
694 var t = Tracker{};
695 try std.testing.expectEqual(@as(?Tracker.Transition, .reset), t.apply(.{ .kind = .command_end, .row = 3, .exit_code = 0 }));
696 try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
697 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
698 }
699
700 test "A closes an open command without a code (Ctrl-C at a prompt redraw)" {
701 var t = Tracker{};
702 _ = t.apply(.{ .kind = .command_start, .row = 5, .exit_code = null });
703 try std.testing.expectEqual(@as(?Tracker.Transition, .returned), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null }));
704 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
705 try std.testing.expectEqual(proto.CmdPhase.returned, t.phase);
706 // The next A settles back to rest with no transition.
707 try std.testing.expectEqual(@as(?Tracker.Transition, null), t.apply(.{ .kind = .prompt_start, .row = 6, .exit_code = null }));
708 try std.testing.expectEqual(proto.CmdPhase.at_prompt, t.phase);
709 }
710
711 test "marksOpen guards the pgid race window" {
712 var t = Tracker{};
713 try std.testing.expect(!t.marksOpen());
714 _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
715 try std.testing.expect(t.marksOpen());
716 _ = t.apply(.{ .kind = .command_end, .row = 1, .exit_code = 0 });
717 try std.testing.expect(!t.marksOpen());
718 // Sticky across the next prompt: the session still speaks marks.
719 _ = t.apply(.{ .kind = .prompt_start, .row = 1, .exit_code = null });
720 try std.testing.expect(t.marks_seen);
721 }
722
723 test "back-to-back commands: second C reopens cleanly" {
724 var t = Tracker{};
725 _ = t.apply(.{ .kind = .command_start, .row = 0, .exit_code = null });
726 _ = t.apply(.{ .kind = .command_end, .row = 2, .exit_code = 0 });
727 try std.testing.expectEqual(@as(?Tracker.Transition, .running), t.apply(.{ .kind = .command_start, .row = 3, .exit_code = null }));
728 try std.testing.expectEqual(@as(u32, 3), t.start_row);
729 try std.testing.expectEqual(@as(?u8, null), t.exit_code);
730 }
731 ```
732
733 - [ ] **Step 2: Wire the module in `build.zig`.** Find where `delta_mod` is created (grep `delta_mod`) and mirror it: create `cmd_mod` from `src/cmd.zig` with imports `protocol` and `engine`; add `exe_mod.addImport("cmd", cmd_mod);` next to the server's other imports (the server module imports it in Task 5 — adding the import now is harmless); add a test step for it exactly where `delta` tests are registered (grep for the existing `addTest`/test-step pattern and copy it; ORDER: put the cmd test registration BEFORE the server/e2e tests per house doctrine — a wedged later test must not hide this one).
734
735 - [ ] **Step 3: Run tests**
736
737 Run: `$ZIG build test 2>&1 | tail -5`
738 Expected: PASS, with the new cmd tests included.
739
740 - [ ] **Step 4: Commit**
741
742 ```bash
743 git add src/cmd.zig build.zig
744 git commit -m "feat(cmd): pure command state machine over OSC 133 mark events"
745 ```
746
747 ---
748
749 ### Task 5: Server integration — drain events, stamp seq, push `cmd_state`, answer `status_req`
750
751 **Files:**
752 - Modify: `src/server.zig`
753
754 The server's existing test harness (in-file tests around lines 2300–4400 drive `pumpOnce` with fake clients over socketpairs — read two of them first and copy their setup pattern exactly).
755
756 - [ ] **Step 1: Write failing tests** in `src/server.zig`'s test section, using the harness pattern found there (a started `Server` on a temp sock path, a connected client fd that attaches, `srv.pumpOnce(0)` driven in a loop). The behaviors to pin:
757
758 ```zig
759 // Test A: feeding marks through the pty produces a cmd_state push.
760 // - write "\x1b]133;C\x07" then "\x1b]133;D;0\x07" into the pty slave side
761 // (the harness's existing way of making session output — grep for how
762 // existing tests inject pty bytes; several do it via the spawned shell,
763 // the cleanest is the harness that uses /bin/cat as the shell and writes
764 // through the master's slave pair).
765 // - pump until the attached client's fd yields a frame of type .cmd_state
766 // with phase running, then one with phase returned and exit_code 0.
767 // - assert the returned frame's seq is > the running frame's seq is NOT
768 // required (same feed can share a seq); assert returned.seq >= running.seq
769 // and returned.seq <= srv.tracker.seq (stamped after the post-feed update).
770
771 // Test B: status_req round-trips on an attached client AND on an observer.
772 // - client sends .status_req, reads .status_reply; decode; assert cols/rows
773 // match the attach size, alt_screen false, cmd.phase at_prompt initially.
774 // - a fresh un-attached connection (observer) sends .status_req and gets a
775 // .status_reply via blocking writeFrame, same as stats_req does.
776 ```
777
778 Write these as real Zig tests by copying the harness helpers the neighboring tests use (the executor has the file in front of them; the helpers already exist — do not invent new scaffolding).
779
780 - [ ] **Step 2: Run to verify failure**
781
782 Run: `$ZIG build test 2>&1 | tail -5`
783 Expected: FAIL — no `.cmd_state` frame arrives / `.status_req` is ignored (falls into `else => {}`).
784
785 - [ ] **Step 3: Implement.** In `src/server.zig`:
786
787 Add imports/fields:
788
789 ```zig
790 const cmdmod = @import("cmd");
791 ```
792
793 In `Server` struct, next to `tracker`:
794
795 ```zig
796 /// The session's command state machine (OSC 133). Seq-stamped copies of
797 /// its transitions are what cmd_state/await_reply/status_reply carry.
798 cmd: cmdmod.Tracker = .{},
799 /// tracker.seq at the moment of the last `returned` transition —
800 /// "a return happened at or before this seq". Awaits compare their
801 /// since_seq against this.
802 returned_seq: u64 = 0,
803 ```
804
805 In `pumpOnce`, immediately after the `self.sendUpdate();` line inside the pty-read arm, add:
806
807 ```zig
808 self.drainMarkEvents();
809 ```
810
811 Add the method (near `sendUpdate`):
812
813 ```zig
814 /// Fold the engine's OSC 133 events into the command tracker and tell
815 /// attached clients about transitions. Runs after sendUpdate so
816 /// tracker.seq already covers the same pty chunk — the off-by-one the
817 /// spec pins (seq sampled post-feed).
818 fn drainMarkEvents(self: *Server) void {
819 for (self.eng.markEvents()) |ev| {
820 const tr = self.cmd.apply(ev) orelse continue;
821 if (tr == .returned) self.returned_seq = self.tracker.seq;
822 if (tr == .reset) continue; // believed nothing, tell no one
823 const payload = proto.encodeCmdState(self.cmdState(.marks));
824 for (0..max_clients) |i| {
825 _ = self.queueFrame(i, .cmd_state, &payload);
826 }
827 }
828 self.eng.clearMarkEvents();
829 }
830
831 /// The current command state as a wire struct. `mechanism` is the
832 /// caller's claim about how the verdict was reached: marks pushes say
833 /// .marks; await resolutions say what actually resolved them.
834 fn cmdState(self: *Server, mechanism: proto.Mechanism) proto.CmdState {
835 return .{
836 .phase = self.cmd.phase,
837 .mechanism = mechanism,
838 .exit_code = self.cmd.exit_code,
839 .start_row = self.cmd.start_row,
840 .end_row = self.cmd.end_row,
841 .seq = self.returned_seq,
842 };
843 }
844
845 fn buildStatusReply(self: *Server) proto.StatusReply {
846 const cur = self.eng.cursorPos();
847 return .{
848 .cols = self.colsNow(),
849 .rows = self.rowsNow(),
850 .cursor_x = cur.x,
851 .cursor_y = cur.y,
852 .history_rows = self.eng.historyRows(),
853 .alt_screen = self.eng.onAltScreen(),
854 .mode = self.readPtyMode() orelse .{ .icanon = true, .echo = true },
855 .cmd = self.cmdState(if (self.cmd.marks_seen) .marks else .pgid),
856 };
857 }
858 ```
859
860 In `handleFrame`'s switch (attached clients), add before `else => {}`:
861
862 ```zig
863 .status_req => {
864 const payload = proto.encodeStatusReply(self.buildStatusReply());
865 _ = self.queueFrame(i, .status_reply, &payload);
866 },
867 ```
868
869 In `serviceObserver`'s switch, add before `else => {}` (blocking reply, same rationale as the stats/endpoint observer arms):
870
871 ```zig
872 .status_req => {
873 const payload = proto.encodeStatusReply(self.buildStatusReply());
874 proto.writeFrame(fd, .status_reply, &payload) catch self.dropObserver(i);
875 },
876 ```
877
878 - [ ] **Step 4: Run tests**
879
880 Run: `$ZIG build test 2>&1 | tail -5`
881 Expected: PASS.
882
883 - [ ] **Step 5: Commit**
884
885 ```bash
886 git add src/server.zig
887 git commit -m "feat(server): command tracker integration — cmd_state pushes and status_req"
888 ```
889
890 ---
891
892 ### Task 6: Server awaits — `await_req` held open with marks/pgid/settle resolution
893
894 **Files:**
895 - Modify: `src/server.zig`
896
897 - [ ] **Step 1: Write failing tests** (same harness pattern as Task 5):
898
899 ```zig
900 // Test A (marks): client attaches, sends .await_req{since_seq = srv.tracker.seq,
901 // settle_ms = 0, timeout_ms = 5000}. Pump. No reply yet. Feed
902 // "\x1b]133;C\x07out\r\n\x1b]133;D;3\x07" through the pty. Pump until the
903 // client reads .await_reply; decode; assert reason .returned, exit_code 3,
904 // mechanism .marks.
905
906 // Test B (immediate answer): after Test A's return, a second .await_req with
907 // since_seq = 0 (older than returned_seq) is answered on the SAME pump —
908 // the reconnect-idempotency contract.
909
910 // Test C (settle): with a shell that emits no marks (/bin/cat harness),
911 // send .await_req{since_seq = current, settle_ms = 200, timeout_ms = 5000};
912 // write "quiet\r\n" into the pty; pump for ~600ms of wall time; assert
913 // .await_reply arrives with reason .settled, mechanism .settle, null exit.
914
915 // Test D (timeout): .await_req{settle_ms = 0, timeout_ms = 150} against a
916 // silent session; pump ~500ms; assert reason .timeout.
917
918 // Test E (pgid): /bin/sh harness without integration. Send input "sleep 1\n"
919 // via .input, then .await_req{since_seq = current, settle_ms = 0,
920 // timeout_ms = 10_000}. Pump; assert .await_reply reason .returned,
921 // mechanism .pgid, exit_code null, within ~3s.
922 ```
923
924 - [ ] **Step 2: Run to verify failure**
925
926 Run: `$ZIG build test 2>&1 | tail -5`
927 Expected: FAIL — await_req falls into `else => {}`, no reply ever comes (tests bound their pumping, so they fail rather than hang).
928
929 - [ ] **Step 3: Implement.** In `ClientSlot`, add:
930
931 ```zig
932 /// An await_req held open. At most one per client: a second one
933 /// replaces the first (the client is a serial CLI; queueing two would
934 /// be inventing a use case).
935 await_state: ?AwaitState = null,
936 ```
937
938 Add near `ClientSlot`:
939
940 ```zig
941 const AwaitState = struct {
942 since_seq: u64,
943 settle_ms: u32,
944 timeout_ms: u32,
945 /// milliTimestamp at acceptance; timeout measures from here.
946 started_ms: i64,
947 /// pgid fallback edge detector: set once the fg pgid has been seen off
948 /// the shell, so "back on the shell" means returned, not never-left.
949 saw_busy: bool = false,
950 };
951 ```
952
953 In `Server`, add a field (next to `mode_sent`):
954
955 ```zig
956 /// milliTimestamp of the last byte the pty produced; the settle floor.
957 last_pty_ms: i64 = 0,
958 ```
959
960 In `pumpOnce`, in the pty-read arm (where `n > 0`), add `self.last_pty_ms = std.time.milliTimestamp();` before `self.eng.feed(...)`. At the end of `pumpOnce`, just before `return null;`, add `self.checkAwaits();`.
961
962 In `handleFrame`, add:
963
964 ```zig
965 .await_req => {
966 const req = proto.decodeAwaitReq(frame.payload) catch return;
967 if (self.clients[i] == null) return;
968 self.clients[i].?.await_state = .{
969 .since_seq = req.since_seq,
970 .settle_ms = req.settle_ms,
971 .timeout_ms = req.timeout_ms,
972 .started_ms = std.time.milliTimestamp(),
973 };
974 // A return that already happened answers immediately —
975 // this is what makes a reconnect re-issue safe.
976 self.checkAwaits();
977 },
978 ```
979
980 Add the resolution method:
981
982 ```zig
983 /// Resolve any awaits that can be answered this pump. Granularity is
984 /// the run loop's 100ms tick — nothing here blocks, and no deadline
985 /// folding into poll is needed at that resolution.
986 fn checkAwaits(self: *Server) void {
987 const now = std.time.milliTimestamp();
988 for (0..max_clients) |i| {
989 if (self.clients[i] == null) continue;
990 const a = &(self.clients[i].?.await_state orelse continue);
991
992 // 1. Marks: a return newer than since_seq answers with the
993 // full story. Strictly greater: since_seq is "what I have".
994 if (self.cmd.phase == .returned and self.returned_seq > a.since_seq) {
995 self.answerAwait(i, self.cmdState(.marks), .returned);
996 continue;
997 }
998
999 // 2. pgid: only when marks do not hold the floor. The shell is
1000 // the session leader, so its pid is the resting pgid.
1001 if (!self.cmd.marksOpen()) {
1002 if (self.pty.fgPgid()) |pg| {
1003 if (pg != self.pty.child) {
1004 a.saw_busy = true;
1005 } else if (a.saw_busy) {
1006 var st = self.cmdState(.pgid);
1007 st.phase = .returned;
1008 st.exit_code = null;
1009 st.seq = self.tracker.seq;
1010 self.answerAwait(i, st, .returned);
1011 continue;
1012 }
1013 } else |_| {}
1014 }
1015
1016 // 3. Settle: output silence, if the caller asked for a floor.
1017 if (a.settle_ms > 0 and self.last_pty_ms > 0 and
1018 now - self.last_pty_ms >= a.settle_ms and
1019 now - a.started_ms >= a.settle_ms)
1020 {
1021 var st = self.cmdState(.settle);
1022 st.exit_code = null;
1023 st.seq = self.tracker.seq;
1024 self.answerAwait(i, st, .settled);
1025 continue;
1026 }
1027
1028 // 4. Timeout: the bound the client set on the whole wait.
1029 if (a.timeout_ms > 0 and now - a.started_ms >= a.timeout_ms) {
1030 var st = self.cmdState(if (self.cmd.marks_seen) .marks else .pgid);
1031 st.seq = self.tracker.seq;
1032 self.answerAwait(i, st, .timeout);
1033 }
1034 }
1035 }
1036
1037 fn answerAwait(self: *Server, i: usize, st: proto.CmdState, reason: proto.AwaitReason) void {
1038 if (self.clients[i] == null) return;
1039 self.clients[i].?.await_state = null;
1040 const payload = proto.encodeAwaitReply(st, reason);
1041 _ = self.queueFrame(i, .await_reply, &payload);
1042 }
1043 ```
1044
1045 Note on the optional-pointer idiom in `checkAwaits`: `&(x orelse continue)` does not produce a pointer into the optional in Zig — write it as:
1046
1047 ```zig
1048 const slot = &self.clients[i].?;
1049 if (slot.await_state == null) continue;
1050 const a = &slot.await_state.?;
1051 ```
1052
1053 - [ ] **Step 4: Run tests**
1054
1055 Run: `$ZIG build test 2>&1 | tail -5`
1056 Expected: PASS. The settle/pgid tests take a few wall-clock seconds; that is the test, not a hang — but keep each bounded under 15s.
1057
1058 - [ ] **Step 5: Commit**
1059
1060 ```bash
1061 git add src/server.zig
1062 git commit -m "feat(server): server-side awaits — marks first, pgid edge, settle floor, timeout bound"
1063 ```
1064
1065 ---
1066
1067 ### Task 7: Shell integration — scripts, shims, spawn-time injection
1068
1069 **Files:**
1070 - Create: `src/shellint.zig`
1071 - Modify: `src/pty.zig` (child env pairs), `src/server.zig` (Options + spawn), `src/main.zig` (opt-out wiring)
1072 - Modify: `build.zig` (module + tests)
1073
1074 - [ ] **Step 1: Create `src/shellint.zig` with its tests.** The scripts are marks-only — the OSC 133 subset mux consumes, derived from ghostty's shell-integration approach (guarded D so a bare first prompt emits no code, BASH_COMMAND guard so the DEBUG trap skips the prompt hook itself):
1075
1076 ```zig
1077 //! Shell integration: OSC 133 marks injected at spawn. muxd forks the
1078 //! session shell itself, so injection is env + argv at spawn time — no
1079 //! rc-file edits, ever. Detection is by shell basename; unknown shells get
1080 //! nothing and the session runs on the pgid/settle fallbacks.
1081 const std = @import("std");
1082
1083 pub const zsh_zshrc =
1084 \\# mux shell integration (zsh): OSC 133 marks. Sourced via a ZDOTDIR
1085 \\# shim; restores the user's ZDOTDIR (or unsets it) then runs their rc.
1086 \\if [[ -n "$MUX_ORIG_ZDOTDIR" ]]; then
1087 \\ export ZDOTDIR="$MUX_ORIG_ZDOTDIR"
1088 \\ unset MUX_ORIG_ZDOTDIR
1089 \\else
1090 \\ unset ZDOTDIR
1091 \\fi
1092 \\[[ -f "${ZDOTDIR:-$HOME}/.zshrc" ]] && source "${ZDOTDIR:-$HOME}/.zshrc"
1093 \\autoload -Uz add-zsh-hook
1094 \\_mux_preexec() { _mux_ran=1; printf '\e]133;C\a'; }
1095 \\_mux_precmd() {
1096 \\ local code=$?
1097 \\ [[ -n "$_mux_ran" ]] && printf '\e]133;D;%s\a' "$code"
1098 \\ _mux_ran=""
1099 \\ printf '\e]133;A\a'
1100 \\}
1101 \\add-zsh-hook preexec _mux_preexec
1102 \\add-zsh-hook precmd _mux_precmd
1103 \\
1104 ;
1105
1106 pub const bash_init =
1107 \\# mux shell integration (bash): OSC 133 marks. Passed via --init-file;
1108 \\# sources the user's normal rc first so their config still runs.
1109 \\[[ -f "$HOME/.bashrc" ]] && source "$HOME/.bashrc"
1110 \\_mux_ran=""
1111 \\_mux_preexec() {
1112 \\ [[ -n "$COMP_LINE" ]] && return
1113 \\ [[ "$BASH_COMMAND" == _mux_precmd* ]] && return
1114 \\ _mux_ran=1
1115 \\ printf '\e]133;C\a'
1116 \\}
1117 \\_mux_precmd() {
1118 \\ local code=$?
1119 \\ [[ -n "$_mux_ran" ]] && printf '\e]133;D;%s\a' "$code"
1120 \\ _mux_ran=""
1121 \\ printf '\e]133;A\a'
1122 \\}
1123 \\trap '_mux_preexec' DEBUG
1124 \\PROMPT_COMMAND="_mux_precmd${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
1125 \\
1126 ;
1127
1128 pub const fish_conf =
1129 \\# mux shell integration (fish): OSC 133 marks, via vendor_conf.d.
1130 \\function _mux_preexec --on-event fish_preexec
1131 \\ printf '\e]133;C\a'
1132 \\end
1133 \\function _mux_postexec --on-event fish_postexec
1134 \\ printf '\e]133;D;%s\a' $status
1135 \\end
1136 \\function _mux_prompt --on-event fish_prompt
1137 \\ printf '\e]133;A\a'
1138 \\end
1139 \\
1140 ;
1141
1142 pub const Kind = enum { zsh, bash, fish, other };
1143
1144 pub fn detect(shell_path: []const u8) Kind {
1145 const base = std.fs.path.basename(shell_path);
1146 if (std.mem.eql(u8, base, "zsh")) return .zsh;
1147 if (std.mem.eql(u8, base, "bash")) return .bash;
1148 if (std.mem.eql(u8, base, "fish")) return .fish;
1149 return .other;
1150 }
1151
1152 pub const EnvPair = struct { key: [:0]const u8, value: [:0]const u8 };
1153
1154 /// Everything the spawn needs: the argv to exec and env pairs to set in
1155 /// the child. `dir` must outlive the spawn (paths point into it).
1156 pub const Injection = struct {
1157 /// Extra argv AFTER the shell path (bash --init-file <shim>); empty
1158 /// for env-only injections (zsh, fish) and for .other.
1159 extra_argv: []const [:0]const u8,
1160 env: []const EnvPair,
1161 };
1162
1163 /// Prepare shim files under `dir` (created private, 0700) for `shell_path`
1164 /// and return what spawn must add. All returned slices are allocated from
1165 /// `arena` — hand it an arena that lives as long as the daemon.
1166 pub fn prepare(
1167 arena: std.mem.Allocator,
1168 dir: []const u8,
1169 shell_path: []const u8,
1170 ) !Injection {
1171 switch (detect(shell_path)) {
1172 .zsh => {
1173 try std.fs.cwd().makePath(dir);
1174 const rc_path = try std.fs.path.join(arena, &.{ dir, ".zshrc" });
1175 try writeFilePrivate(rc_path, zsh_zshrc);
1176 var env: std.ArrayList(EnvPair) = .empty;
1177 const dir_z = try arena.dupeZ(u8, dir);
1178 try env.append(arena, .{ .key = "ZDOTDIR", .value = dir_z });
1179 // Only when the daemon itself carried one: exporting an empty
1180 // ZDOTDIR would break zsh's fallback to $HOME (spec footnote).
1181 if (std.posix.getenv("ZDOTDIR")) |orig| {
1182 try env.append(arena, .{
1183 .key = "MUX_ORIG_ZDOTDIR",
1184 .value = try arena.dupeZ(u8, orig),
1185 });
1186 }
1187 return .{ .extra_argv = &.{}, .env = try env.toOwnedSlice(arena) };
1188 },
1189 .bash => {
1190 try std.fs.cwd().makePath(dir);
1191 const init_path = try std.fs.path.join(arena, &.{ dir, "bash-init.sh" });
1192 try writeFilePrivate(init_path, bash_init);
1193 const init_z = try arena.dupeZ(u8, init_path);
1194 const argv = try arena.alloc([:0]const u8, 2);
1195 argv[0] = "--init-file";
1196 argv[1] = init_z;
1197 return .{ .extra_argv = argv, .env = &.{} };
1198 },
1199 .fish => {
1200 const vendor = try std.fs.path.join(arena, &.{ dir, "fish", "vendor_conf.d" });
1201 try std.fs.cwd().makePath(vendor);
1202 const conf_path = try std.fs.path.join(arena, &.{ vendor, "mux.fish" });
1203 try writeFilePrivate(conf_path, fish_conf);
1204 const orig = std.posix.getenv("XDG_DATA_DIRS") orelse "/usr/local/share:/usr/share";
1205 const merged = try std.fmt.allocPrintSentinel(arena, "{s}:{s}", .{ dir, orig }, 0);
1206 return .{
1207 .extra_argv = &.{},
1208 .env = &.{.{ .key = "XDG_DATA_DIRS", .value = merged }},
1209 };
1210 },
1211 .other => return .{ .extra_argv = &.{}, .env = &.{} },
1212 }
1213 }
1214
1215 fn writeFilePrivate(path: []const u8, contents: []const u8) !void {
1216 const f = try std.fs.cwd().createFile(path, .{ .mode = 0o600 });
1217 defer f.close();
1218 try f.writeAll(contents);
1219 }
1220
1221 test "detect goes by basename" {
1222 try std.testing.expectEqual(Kind.zsh, detect("/usr/bin/zsh"));
1223 try std.testing.expectEqual(Kind.bash, detect("/bin/bash"));
1224 try std.testing.expectEqual(Kind.fish, detect("/opt/homebrew/bin/fish"));
1225 try std.testing.expectEqual(Kind.other, detect("/bin/sh"));
1226 try std.testing.expectEqual(Kind.other, detect("/usr/bin/nu"));
1227 }
1228
1229 test "prepare zsh writes the shim and sets ZDOTDIR" {
1230 var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
1231 defer arena_state.deinit();
1232 const arena = arena_state.allocator();
1233 var tmp = std.testing.tmpDir(.{});
1234 defer tmp.cleanup();
1235 const dir = try tmp.dir.realpathAlloc(arena, ".");
1236
1237 const inj = try prepare(arena, dir, "/usr/bin/zsh");
1238 try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len);
1239 try std.testing.expect(inj.env.len >= 1);
1240 try std.testing.expectEqualStrings("ZDOTDIR", inj.env[0].key);
1241 const rc = try tmp.dir.readFileAlloc(arena, ".zshrc", 64 * 1024);
1242 try std.testing.expect(std.mem.indexOf(u8, rc, "133;D;%s") != null);
1243 try std.testing.expect(std.mem.indexOf(u8, rc, "add-zsh-hook") != null);
1244 }
1245
1246 test "prepare bash returns --init-file argv" {
1247 var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
1248 defer arena_state.deinit();
1249 const arena = arena_state.allocator();
1250 var tmp = std.testing.tmpDir(.{});
1251 defer tmp.cleanup();
1252 const dir = try tmp.dir.realpathAlloc(arena, ".");
1253
1254 const inj = try prepare(arena, dir, "/bin/bash");
1255 try std.testing.expectEqual(@as(usize, 2), inj.extra_argv.len);
1256 try std.testing.expectEqualStrings("--init-file", inj.extra_argv[0]);
1257 const script = try std.fs.cwd().readFileAlloc(arena, inj.extra_argv[1], 64 * 1024);
1258 try std.testing.expect(std.mem.indexOf(u8, script, "PROMPT_COMMAND") != null);
1259 try std.testing.expect(std.mem.indexOf(u8, script, "trap '_mux_preexec' DEBUG") != null);
1260 }
1261
1262 test "prepare other injects nothing" {
1263 var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
1264 defer arena_state.deinit();
1265 const arena = arena_state.allocator();
1266 const inj = try prepare(arena, "/nonexistent-never-created", "/bin/sh");
1267 try std.testing.expectEqual(@as(usize, 0), inj.extra_argv.len);
1268 try std.testing.expectEqual(@as(usize, 0), inj.env.len);
1269 }
1270 ```
1271
1272 (API check for the executor: `allocPrintSentinel` is the 0.15 name for sentinel-terminated allocPrint; if absent, use `std.fmt.allocPrintZ`. `readFileAlloc` argument order changed across 0.15 — follow whatever `src/xdg.zig` or neighboring code uses.)
1273
1274 - [ ] **Step 2: Extend `Pty` for env pairs.** In `src/pty.zig`, add to `SpawnArgvOptions`:
1275
1276 ```zig
1277 /// Set in the child between fork and exec, after TERM. Injection's
1278 /// door: the daemon's env is the only source of a child's env.
1279 env: []const struct { key: [:0]const u8, value: [:0]const u8 } = &.{},
1280 ```
1281
1282 and in the child block, right after the TERM `setenv`:
1283
1284 ```zig
1285 for (opts.env) |kv| _ = c.setenv(kv.key.ptr, kv.value.ptr, 1);
1286 ```
1287
1288 Add a test pinning it:
1289
1290 ```zig
1291 test "Pty: spawnArgv env pairs reach the child" {
1292 var argv = [_:null]?[*:0]const u8{ "/bin/sh", "-c", "printf 'env-%s' \"$MUX_T\"" };
1293 var pty = try Pty.spawnArgv(.{
1294 .cols = 80,
1295 .rows = 24,
1296 .argv = &argv,
1297 .env = &.{.{ .key = "MUX_T", .value = "ok" }},
1298 });
1299 defer pty.deinit();
1300 var out = try readUntil(std.testing.allocator, &pty, "env-ok", 5000);
1301 defer out.deinit(std.testing.allocator);
1302 try std.testing.expect(std.mem.indexOf(u8, out.items, "env-ok") != null);
1303 }
1304 ```
1305
1306 - [ ] **Step 3: Wire into the daemon.** In `src/server.zig`:
1307 - `Server.Options` gains `shell_integration: bool = true`.
1308 - In `Server.init`, replace the plain `Pty.spawn` with: when `opts.shell_integration`, build the shim dir path `{dirname(sock_path)}/mux-shellint-{pid}` (the sock dir is already private, runtime-appropriate, and per-user; pid keeps two daemons apart), call `shellint.prepare` with an arena owned by the Server (add a `shellint_arena: std.heap.ArenaAllocator` field, deinit'd in `deinit`, plus a `shellint_dir: ?[]const u8` field so `deinit` can `std.fs.cwd().deleteTree` it best-effort), assemble argv `[shell] ++ extra_argv` as `[*:null]` and call `Pty.spawnArgv` with the env pairs. `.other` injections are empty — the call is then equivalent to today's spawn.
1309 - In `src/main.zig`'s `run` path, thread the option: `shell_integration` is true unless the daemon's env says `MUX_SHELL_INTEGRATION=0` (`std.posix.getenv`) — the spec is explicit that the opt-out is read from the daemon's env.
1310 - `build.zig`: create `shellint_mod`, import into `server` deps and `exe_mod`; register its tests BEFORE server tests.
1311
1312 - [ ] **Step 4: End-to-end injection test** (in `src/server.zig` tests, guarded): spawn a real Server with `shell = "/bin/bash"` (skip with `error.SkipZigTest` if `/bin/bash` is absent), attach a fake client, write `"true\n"` via `.input`, pump, and assert a `.cmd_state` frame with phase `.returned` and exit_code 0 arrives within 10s of pumping. This proves marks flow end-to-end: injection → bash → pty → engine → tracker → wire. Repeat for zsh if `/usr/bin/zsh` exists (skip otherwise).
1313
1314 - [ ] **Step 5: Run tests**
1315
1316 Run: `$ZIG build test 2>&1 | tail -5`
1317 Expected: PASS (bash test live on this box; zsh/fish arms skip where absent).
1318
1319 - [ ] **Step 6: Commit**
1320
1321 ```bash
1322 git add src/shellint.zig src/pty.zig src/server.zig src/main.zig build.zig
1323 git commit -m "feat(shellint): OSC 133 marks injected at spawn — ZDOTDIR shim, --init-file, vendor_conf.d"
1324 ```
1325
1326 ---
1327
1328 ### Task 8: `muxa` skeleton — arg parsing, unix transport, `status` and `capture`
1329
1330 **Files:**
1331 - Create: `src/muxa.zig`
1332 - Modify: `build.zig`
1333
1334 - [ ] **Step 1: Create `src/muxa.zig`.** Structure (complete file; JSON is hand-escaped — no std.json dependency to fight 0.15 API drift over):
1335
1336 ```zig
1337 //! muxa: the agent-facing mux client. Every verb prints one JSON object on
1338 //! stdout and exits 0 on success; failures print {"error": "..."} and exit
1339 //! nonzero. Attaches at 0x0 always — an agent must never claim the grid
1340 //! out from under the human's size (load-bearing spec rule).
1341 const std = @import("std");
1342 const proto = @import("protocol");
1343
1344 const usage =
1345 \\usage: muxa <verb> [--sock PATH] [--settle MS] [--timeout MS] [--vt] [args]
1346 \\verbs:
1347 \\ status session snapshot as JSON
1348 \\ capture current grid as text (--vt for styled)
1349 \\ send BYTES raw bytes to the pty (C-style escapes: \n \r \t \e \xNN)
1350 \\ run CMDLINE send CMDLINE + newline, await return, report exit/output
1351 \\ await wait for the current/next command to return
1352 \\
1353 ;
1354
1355 const Opts = struct {
1356 verb: enum { status, capture, send, run, @"await" },
1357 sock: ?[]const u8 = null,
1358 settle_ms: u32 = 0,
1359 timeout_ms: u32 = 30_000,
1360 vt: bool = false,
1361 arg: ?[]const u8 = null,
1362 };
1363
1364 fn parseArgs(args: []const [:0]const u8) ?Opts {
1365 if (args.len < 2) return null;
1366 const verb = std.meta.stringToEnum(@FieldType(Opts, "verb"), args[1]) orelse return null;
1367 var o: Opts = .{ .verb = verb };
1368 var i: usize = 2;
1369 while (i < args.len) : (i += 1) {
1370 const a = args[i];
1371 if (std.mem.eql(u8, a, "--sock")) {
1372 i += 1;
1373 if (i >= args.len) return null;
1374 o.sock = args[i];
1375 } else if (std.mem.eql(u8, a, "--settle")) {
1376 i += 1;
1377 if (i >= args.len) return null;
1378 o.settle_ms = std.fmt.parseInt(u32, args[i], 10) catch return null;
1379 } else if (std.mem.eql(u8, a, "--timeout")) {
1380 i += 1;
1381 if (i >= args.len) return null;
1382 o.timeout_ms = std.fmt.parseInt(u32, args[i], 10) catch return null;
1383 } else if (std.mem.eql(u8, a, "--vt")) {
1384 o.vt = true;
1385 } else if (o.arg == null and a.len > 0 and a[0] != '-') {
1386 o.arg = a;
1387 } else return null;
1388 }
1389 return o;
1390 }
1391
1392 /// JSON string escape, the six mandatory escapes + control bytes as \u00XX.
1393 fn jsonEscape(writer: anytype, s: []const u8) !void {
1394 try writer.writeByte('"');
1395 for (s) |b| switch (b) {
1396 '"' => try writer.writeAll("\\\""),
1397 '\\' => try writer.writeAll("\\\\"),
1398 '\n' => try writer.writeAll("\\n"),
1399 '\r' => try writer.writeAll("\\r"),
1400 '\t' => try writer.writeAll("\\t"),
1401 0x00...0x08, 0x0b, 0x0c, 0x0e...0x1f => try writer.print("\\u{x:0>4}", .{b}),
1402 else => try writer.writeByte(b),
1403 };
1404 try writer.writeByte('"');
1405 }
1406
1407 test "jsonEscape pins the escapes" {
1408 var buf: [128]u8 = undefined;
1409 var fbs = std.io.fixedBufferStream(&buf);
1410 try jsonEscape(fbs.writer(), "a\"b\\c\nd\x1be");
1411 try std.testing.expectEqualStrings("\"a\\\"b\\\\c\\nd\\u001be\"", fbs.getWritten());
1412 }
1413
1414 /// Decode C-style escapes for `send`. Caller frees.
1415 fn decodeEscapes(alloc: std.mem.Allocator, s: []const u8) ![]u8 {
1416 var out: std.ArrayList(u8) = .empty;
1417 errdefer out.deinit(alloc);
1418 var i: usize = 0;
1419 while (i < s.len) : (i += 1) {
1420 if (s[i] != '\\' or i + 1 >= s.len) {
1421 try out.append(alloc, s[i]);
1422 continue;
1423 }
1424 i += 1;
1425 switch (s[i]) {
1426 'n' => try out.append(alloc, '\n'),
1427 'r' => try out.append(alloc, '\r'),
1428 't' => try out.append(alloc, '\t'),
1429 'e' => try out.append(alloc, 0x1b),
1430 '\\' => try out.append(alloc, '\\'),
1431 'x' => {
1432 if (i + 2 >= s.len) return error.BadEscape;
1433 try out.append(alloc, try std.fmt.parseInt(u8, s[i + 1 .. i + 3], 16));
1434 i += 2;
1435 },
1436 else => return error.BadEscape,
1437 }
1438 }
1439 return out.toOwnedSlice(alloc);
1440 }
1441
1442 test "decodeEscapes covers the sequences send needs" {
1443 const alloc = std.testing.allocator;
1444 const got = try decodeEscapes(alloc, "q\\n\\e[A\\x03");
1445 defer alloc.free(got);
1446 try std.testing.expectEqualSlices(u8, "q\n\x1b[A\x03", got);
1447 try std.testing.expectError(error.BadEscape, decodeEscapes(alloc, "\\q"));
1448 }
1449
1450 test "parseArgs verbs and flags" {
1451 const a1 = [_][:0]const u8{ "muxa", "status" };
1452 try std.testing.expectEqual(@FieldType(Opts, "verb").status, parseArgs(&a1).?.verb);
1453 const a2 = [_][:0]const u8{ "muxa", "run", "--timeout", "5000", "make test" };
1454 const o2 = parseArgs(&a2).?;
1455 try std.testing.expectEqual(@as(u32, 5000), o2.timeout_ms);
1456 try std.testing.expectEqualStrings("make test", o2.arg.?);
1457 const a3 = [_][:0]const u8{ "muxa", "bogus" };
1458 try std.testing.expectEqual(@as(?Opts, null), parseArgs(&a3));
1459 }
1460 ```
1461
1462 Then the connection + main. Unix transport first (QUIC is Task 10):
1463
1464 ```zig
1465 const Conn = struct {
1466 fd: std.posix.fd_t,
1467
1468 fn open(sock_path: []const u8) !Conn {
1469 const addr = try std.net.Address.initUnix(sock_path);
1470 const s = try std.net.connectUnixSocket(sock_path);
1471 _ = addr;
1472 return .{ .fd = s.handle };
1473 }
1474
1475 fn close(self: *Conn) void {
1476 std.posix.close(self.fd);
1477 }
1478
1479 fn sendFrame(self: *Conn, t: proto.MsgType, payload: []const u8) !void {
1480 try proto.writeFrame(self.fd, t, payload);
1481 }
1482
1483 /// Read frames until one of type `want` arrives (snapshots, deltas and
1484 /// pushes stream past an attached client; skip what we did not ask
1485 /// for). Bounded by `deadline_ms` wall time via poll.
1486 fn awaitFrame(
1487 self: *Conn,
1488 alloc: std.mem.Allocator,
1489 want: proto.MsgType,
1490 deadline_ms: i64,
1491 ) !proto.Frame {
1492 while (true) {
1493 const now = std.time.milliTimestamp();
1494 if (now >= deadline_ms) return error.Timeout;
1495 var fds = [_]std.posix.pollfd{
1496 .{ .fd = self.fd, .events = std.posix.POLL.IN, .revents = 0 },
1497 };
1498 const n = try std.posix.poll(&fds, @intCast(@min(deadline_ms - now, 250)));
1499 if (n == 0) continue;
1500 const frame = try proto.readFrame(alloc, self.fd) orelse return error.DaemonGone;
1501 if (frame.type == want) return frame;
1502 frame.deinit(alloc);
1503 }
1504 }
1505 };
1506
1507 fn fail(msg: []const u8, detail: []const u8) u8 {
1508 const err = std.fs.File.stderr();
1509 _ = err;
1510 var buf: [512]u8 = undefined;
1511 var fbs = std.io.fixedBufferStream(&buf);
1512 const w = fbs.writer();
1513 w.writeAll("{\"error\":") catch {};
1514 jsonEscape(w, msg) catch {};
1515 if (detail.len > 0) {
1516 w.writeAll(",\"detail\":") catch {};
1517 jsonEscape(w, detail) catch {};
1518 }
1519 w.writeAll("}\n") catch {};
1520 std.fs.File.stdout().writeAll(fbs.getWritten()) catch {};
1521 return 1;
1522 }
1523
1524 pub fn main() !u8 {
1525 var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator);
1526 defer arena_state.deinit();
1527 const alloc = arena_state.allocator();
1528
1529 const args = try std.process.argsAlloc(alloc);
1530 const o = parseArgs(args) orelse {
1531 std.fs.File.stderr().writeAll(usage) catch {};
1532 return 2;
1533 };
1534
1535 const sock_path = o.sock orelse blk: {
1536 if (std.posix.getenv("XDG_RUNTIME_DIR")) |dir|
1537 break :blk try std.fmt.allocPrint(alloc, "{s}/muxd.sock", .{dir});
1538 break :blk try std.fmt.allocPrint(alloc, "/tmp/muxd-{d}.sock", .{std.os.linux.getuid()});
1539 };
1540
1541 var conn = Conn.open(sock_path) catch
1542 return fail("cannot connect", sock_path);
1543 defer conn.close();
1544
1545 const deadline = std.time.milliTimestamp() + o.timeout_ms;
1546 return switch (o.verb) {
1547 .status => verbStatus(alloc, &conn, deadline),
1548 .capture => verbCapture(alloc, &conn, o.vt, deadline),
1549 .send => verbSend(alloc, &conn, o.arg orelse return fail("send needs BYTES", "")),
1550 .run => verbRun(alloc, &conn, o, deadline),
1551 .@"await" => verbAwait(alloc, &conn, o, deadline),
1552 };
1553 }
1554
1555 fn verbStatus(alloc: std.mem.Allocator, conn: *Conn, deadline: i64) u8 {
1556 conn.sendFrame(.status_req, "") catch return fail("send failed", "");
1557 const frame = conn.awaitFrame(alloc, .status_reply, deadline) catch
1558 return fail("no reply — daemon too old or hung", "");
1559 defer frame.deinit(alloc);
1560 const s = proto.decodeStatusReply(frame.payload) catch return fail("bad status_reply", "");
1561 printStatus(s) catch return 1;
1562 return 0;
1563 }
1564
1565 fn printStatus(s: proto.StatusReply) !void {
1566 var buf: [512]u8 = undefined;
1567 var fbs = std.io.fixedBufferStream(&buf);
1568 const w = fbs.writer();
1569 try w.print(
1570 "{{\"cols\":{d},\"rows\":{d},\"cursor_x\":{d},\"cursor_y\":{d}," ++
1571 "\"history_rows\":{d},\"alt_screen\":{},\"icanon\":{},\"echo\":{}," ++
1572 "\"cmd\":{{\"phase\":\"{s}\",\"mechanism\":\"{s}\",\"exit_code\":",
1573 .{
1574 s.cols, s.rows, s.cursor_x,
1575 s.cursor_y, s.history_rows, s.alt_screen,
1576 s.mode.icanon, s.mode.echo,
1577 @tagName(s.cmd.phase), @tagName(s.cmd.mechanism),
1578 },
1579 );
1580 if (s.cmd.exit_code) |c| try w.print("{d}", .{c}) else try w.writeAll("null");
1581 try w.print(
1582 ",\"start_row\":{d},\"end_row\":{d},\"seq\":{d}}}}}\n",
1583 .{ s.cmd.start_row, s.cmd.end_row, s.cmd.seq },
1584 );
1585 try std.fs.File.stdout().writeAll(fbs.getWritten());
1586 }
1587
1588 fn verbCapture(alloc: std.mem.Allocator, conn: *Conn, vt: bool, deadline: i64) u8 {
1589 conn.sendFrame(.debug_dump, &.{@intFromBool(vt)}) catch return fail("send failed", "");
1590 const frame = conn.awaitFrame(alloc, .dump_reply, deadline) catch
1591 return fail("no reply — daemon too old or hung", "");
1592 defer frame.deinit(alloc);
1593 var out: std.ArrayList(u8) = .empty;
1594 defer out.deinit(alloc);
1595 const w = out.writer(alloc);
1596 w.writeAll("{\"grid\":") catch return 1;
1597 jsonEscape(w, frame.payload) catch return 1;
1598 w.writeAll("}\n") catch return 1;
1599 std.fs.File.stdout().writeAll(out.items) catch return 1;
1600 return 0;
1601 }
1602
1603 fn attachZero(conn: *Conn) !void {
1604 // cols=rows=0: applySize refuses <2, the slot stays 0x0, claimGrid
1605 // reads that as "makes no claim" — the human's grid never moves.
1606 try conn.sendFrame(.attach, &proto.encodeAttach(0, 0, 0, 0));
1607 }
1608
1609 fn verbSend(alloc: std.mem.Allocator, conn: *Conn, arg: []const u8) u8 {
1610 const bytes = decodeEscapes(alloc, arg) catch return fail("bad escape in BYTES", arg);
1611 defer alloc.free(bytes);
1612 attachZero(conn) catch return fail("attach failed", "");
1613 conn.sendFrame(.input, bytes) catch return fail("send failed", "");
1614 conn.sendFrame(.detach, "") catch {};
1615 std.fs.File.stdout().writeAll("{\"sent\":true}\n") catch return 1;
1616 return 0;
1617 }
1618 ```
1619
1620 `verbRun`/`verbAwait` are Task 9 — for this task, stub them honestly:
1621
1622 ```zig
1623 fn verbRun(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) u8 {
1624 _ = alloc;
1625 _ = conn;
1626 _ = o;
1627 _ = deadline;
1628 return fail("run: not implemented yet", "");
1629 }
1630
1631 fn verbAwait(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) u8 {
1632 _ = alloc;
1633 _ = conn;
1634 _ = o;
1635 _ = deadline;
1636 return fail("await: not implemented yet", "");
1637 }
1638 ```
1639
1640 (0.15 I/O note for the executor: `std.fs.File.stdout()` / `.stderr()` and `std.io.fixedBufferStream` — follow whatever `src/main.zig` uses for its printing if these names differ; the repo compiles against 0.15.2, so its own idiom is the API truth.)
1641
1642 - [ ] **Step 2: Wire the binary in `build.zig`** next to `mux_exe`: `muxa_mod` (root `src/muxa.zig`, imports `protocol`), `b.addExecutable(.{ .name = "muxa", .root_module = muxa_mod })`, `use_llvm`/`use_lld` true, `b.installArtifact`. Register `muxa_mod` tests before the e2e tests.
1643
1644 - [ ] **Step 3: Run unit tests, then a live smoke.**
1645
1646 Run: `$ZIG build test 2>&1 | tail -5` — expected PASS.
1647
1648 Live smoke:
1649
1650 ```bash
1651 $ZIG build
1652 SOCK=/tmp/muxa-smoke-$$.sock
1653 ./zig-out/bin/muxd start --sock $SOCK
1654 sleep 0.5
1655 ./zig-out/bin/muxa status --sock $SOCK
1656 ./zig-out/bin/muxa capture --sock $SOCK
1657 ./zig-out/bin/muxa send 'echo hi\n' --sock $SOCK
1658 sleep 0.5
1659 ./zig-out/bin/muxa capture --sock $SOCK # grid JSON must contain "hi"
1660 ./zig-out/bin/muxd stop --sock $SOCK
1661 ```
1662
1663 Expected: valid single-line JSON from each verb; the second capture contains `hi`.
1664
1665 - [ ] **Step 4: Commit**
1666
1667 ```bash
1668 git add src/muxa.zig build.zig
1669 git commit -m "feat(muxa): agent client skeleton — status, capture, send over the unix socket"
1670 ```
1671
1672 ---
1673
1674 ### Task 9: `muxa run` and `muxa await`
1675
1676 **Files:**
1677 - Modify: `src/muxa.zig`
1678
1679 - [ ] **Step 1: Implement `verbAwait` and `verbRun`** (replace the stubs):
1680
1681 ```zig
1682 fn doAwait(
1683 alloc: std.mem.Allocator,
1684 conn: *Conn,
1685 o: Opts,
1686 since_seq: u64,
1687 deadline: i64,
1688 ) !proto.AwaitReply {
1689 try conn.sendFrame(.await_req, &proto.encodeAwaitReq(.{
1690 .since_seq = since_seq,
1691 .settle_ms = o.settle_ms,
1692 .timeout_ms = o.timeout_ms,
1693 }));
1694 const frame = try conn.awaitFrame(alloc, .await_reply, deadline);
1695 defer frame.deinit(alloc);
1696 return try proto.decodeAwaitReply(frame.payload);
1697 }
1698
1699 fn currentSeq(alloc: std.mem.Allocator, conn: *Conn, deadline: i64) !u64 {
1700 try conn.sendFrame(.status_req, "");
1701 const frame = try conn.awaitFrame(alloc, .status_reply, deadline);
1702 defer frame.deinit(alloc);
1703 const s = try proto.decodeStatusReply(frame.payload);
1704 return s.cmd.seq;
1705 }
1706
1707 fn printAwaitReply(r: proto.AwaitReply, output: ?[]const u8, duration_ms: i64) !void {
1708 var out: std.ArrayList(u8) = .empty;
1709 var alloc_state = std.heap.ArenaAllocator.init(std.heap.page_allocator);
1710 defer alloc_state.deinit();
1711 const alloc = alloc_state.allocator();
1712 defer out.deinit(alloc);
1713 const w = out.writer(alloc);
1714 try w.print(
1715 "{{\"reason\":\"{s}\",\"phase\":\"{s}\",\"mechanism\":\"{s}\",\"exit_code\":",
1716 .{ @tagName(r.reason), @tagName(r.state.phase), @tagName(r.state.mechanism) },
1717 );
1718 if (r.state.exit_code) |c| try w.print("{d}", .{c}) else try w.writeAll("null");
1719 try w.print(
1720 ",\"start_row\":{d},\"end_row\":{d},\"duration_ms\":{d}",
1721 .{ r.state.start_row, r.state.end_row, duration_ms },
1722 );
1723 if (output) |text| {
1724 try w.writeAll(",\"output\":");
1725 try jsonEscape(w, text);
1726 }
1727 try w.writeAll("}\n");
1728 try std.fs.File.stdout().writeAll(out.items);
1729 }
1730
1731 fn verbAwait(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) u8 {
1732 attachZero(conn) catch return fail("attach failed", "");
1733 const started = std.time.milliTimestamp();
1734 const since = currentSeq(alloc, conn, deadline) catch
1735 return fail("no reply — daemon too old or hung", "");
1736 const r = doAwait(alloc, conn, o, since, deadline) catch
1737 return fail("no reply — daemon too old or hung", "");
1738 printAwaitReply(r, null, std.time.milliTimestamp() - started) catch return 1;
1739 return if (r.reason == .timeout) 3 else 0;
1740 }
1741
1742 /// Fetch the output span [start_row, end_row) as plain rows. Only valid in
1743 /// the marks regime; other mechanisms have no honest span (spec).
1744 fn fetchSpan(
1745 alloc: std.mem.Allocator,
1746 conn: *Conn,
1747 start_row: u32,
1748 end_row: u32,
1749 deadline: i64,
1750 ) ![]const u8 {
1751 if (end_row <= start_row) return "";
1752 const count: u16 = @intCast(@min(end_row - start_row, std.math.maxInt(u16)));
1753 try conn.sendFrame(.fetch_scrollback, &proto.encodeScrollbackReq(start_row, count));
1754 const frame = try conn.awaitFrame(alloc, .scrollback_chunk, deadline);
1755 defer frame.deinit(alloc);
1756 if (frame.payload.len < 6) return error.BadPayload;
1757 // Styled rows follow the 6-byte echo of the request; strip SGR down to
1758 // text so the agent gets what a human read, not escape soup.
1759 return stripSgr(alloc, frame.payload[6..]);
1760 }
1761
1762 /// Remove ESC-[...m/ESC-]...BEL/ESC-\ sequences, keep text and newlines.
1763 fn stripSgr(alloc: std.mem.Allocator, styled: []const u8) ![]const u8 {
1764 var out: std.ArrayList(u8) = .empty;
1765 errdefer out.deinit(alloc);
1766 var i: usize = 0;
1767 while (i < styled.len) {
1768 const b = styled[i];
1769 if (b == 0x1b and i + 1 < styled.len) {
1770 const kind = styled[i + 1];
1771 if (kind == '[') {
1772 i += 2;
1773 while (i < styled.len and !isCsiFinal(styled[i])) i += 1;
1774 i += 1; // the final byte
1775 continue;
1776 } else if (kind == ']') {
1777 i += 2;
1778 while (i < styled.len and styled[i] != 0x07) : (i += 1) {
1779 if (styled[i] == 0x1b and i + 1 < styled.len and styled[i + 1] == '\\') {
1780 i += 1;
1781 break;
1782 }
1783 }
1784 i += 1;
1785 continue;
1786 }
1787 i += 2;
1788 continue;
1789 }
1790 try out.append(alloc, b);
1791 i += 1;
1792 }
1793 return out.toOwnedSlice(alloc);
1794 }
1795
1796 fn isCsiFinal(b: u8) bool {
1797 return b >= 0x40 and b <= 0x7e;
1798 }
1799
1800 test "stripSgr leaves text, drops SGR and OSC" {
1801 const alloc = std.testing.allocator;
1802 const got = try stripSgr(alloc, "\x1b[0m\x1b[1;31mred\x1b[0m ok\n\x1b]0;title\x07plain");
1803 defer alloc.free(got);
1804 try std.testing.expectEqualStrings("red ok\nplain", got);
1805 }
1806
1807 fn verbRun(alloc: std.mem.Allocator, conn: *Conn, o: Opts, deadline: i64) u8 {
1808 const cmdline = o.arg orelse return fail("run needs CMDLINE", "");
1809 attachZero(conn) catch return fail("attach failed", "");
1810 const started = std.time.milliTimestamp();
1811 const since = currentSeq(alloc, conn, deadline) catch
1812 return fail("no reply — daemon too old or hung", "");
1813
1814 const line = std.fmt.allocPrint(alloc, "{s}\n", .{cmdline}) catch return 1;
1815 defer alloc.free(line);
1816 conn.sendFrame(.input, line) catch return fail("send failed", "");
1817
1818 const r = doAwait(alloc, conn, o, since, deadline) catch
1819 return fail("no reply — daemon too old or hung", "");
1820
1821 // Output span only in the marks regime — pgid/settle have no rows.
1822 var output: ?[]const u8 = null;
1823 if (r.state.mechanism == .marks and r.reason == .returned) {
1824 output = fetchSpan(alloc, conn, r.state.start_row, r.state.end_row, deadline) catch null;
1825 }
1826 defer if (output) |text| if (text.len > 0) alloc.free(text);
1827 printAwaitReply(r, output, std.time.milliTimestamp() - started) catch return 1;
1828 return if (r.reason == .timeout) 3 else 0;
1829 }
1830 ```
1831
1832 Executor note on `run`'s default settle: an agent calling `run` against a marks-less session with `--settle 0` would ride to the timeout. That is per spec (settle is opt-in), and `status` tells the agent which regime it is in first. Do not silently default settle on.
1833
1834 - [ ] **Step 2: Run unit tests + live smoke**
1835
1836 Run: `$ZIG build test 2>&1 | tail -5` — PASS.
1837
1838 ```bash
1839 $ZIG build
1840 SOCK=/tmp/muxa-run-$$.sock
1841 MUX_SHELL_INTEGRATION=1 ./zig-out/bin/muxd start --sock $SOCK --shell /bin/bash
1842 sleep 1
1843 ./zig-out/bin/muxa run 'false' --sock $SOCK # {"reason":"returned",...,"exit_code":1,...,"mechanism":"marks"}
1844 ./zig-out/bin/muxa run 'echo span-test' --sock $SOCK # output contains "span-test"
1845 ./zig-out/bin/muxa await --settle 300 --sock $SOCK # settles quickly at an idle prompt... reason "settled"
1846 ./zig-out/bin/muxd stop --sock $SOCK
1847 ```
1848
1849 Expected: exit codes and mechanisms as annotated.
1850
1851 - [ ] **Step 3: Commit**
1852
1853 ```bash
1854 git add src/muxa.zig
1855 git commit -m "feat(muxa): run and await — exit codes over marks, spans from scrollback"
1856 ```
1857
1858 ---
1859
1860 ### Task 10: `muxa` over QUIC
1861
1862 **Files:**
1863 - Modify: `src/muxa.zig`, `build.zig`
1864
1865 - [ ] **Step 1: Extend `Conn` to a tagged union** over the existing socket arm and a QUIC arm using `quic_client.Client` (`connect/send/pump/pollFd/inbound/consume/isReady` — a cleanly reusable API; do NOT borrow `client.zig`'s reconnect loop, which is entangled with the attach replica). Add flags `--quic HOST:PORT` and `--key PATH` to `parseArgs` (key resolution via `xdg.resolveKeyPath`/`pickKey` exactly as `src/main.zig` does — copy its order: `--key` > `$MUX_KEY_FILE` > XDG default). The QUIC arm's `sendFrame` wraps `proto.appendFrame` into a buffer then `client.send`; `awaitFrame` pumps + polls `pollFd` with the same deadline discipline, delimiting frames out of `inbound()` with `proto.frame_header_len` exactly the way `server.zig`'s `pushInbound` does (copy that loop). On `error.ConnectionLost` mid-await: reconnect once, re-attach 0x0, re-issue the await with the ORIGINAL `since_seq` (the idempotency contract), and continue the deadline — not reset it.
1866
1867 - [ ] **Step 2: build.zig**: add `quic_client` + `xdg` + `quic` imports to `muxa_mod`, and `linkQuic(b, muxa_exe, quic)` like `mux_exe` has.
1868
1869 - [ ] **Step 3: Live QUIC smoke**
1870
1871 ```bash
1872 $ZIG build
1873 KEYDIR=$(mktemp -d)
1874 MUX_KEY_FILE=$KEYDIR/key ./zig-out/bin/muxd keygen 2>/dev/null || ./zig-out/bin/muxd keygen
1875 SOCK=/tmp/muxa-quic-$$.sock
1876 ./zig-out/bin/muxd start --sock $SOCK --quic 127.0.0.1:14433 --shell /bin/bash
1877 sleep 1
1878 ./zig-out/bin/muxa run 'echo quic-ok' --quic 127.0.0.1:14433
1879 ./zig-out/bin/muxd stop --sock $SOCK
1880 ```
1881
1882 Expected: the run JSON reports exit 0 and output containing `quic-ok`. (Key path plumbing: use whatever `muxd keygen` wrote — check `muxd keygen`'s output line for the path and pass `--key` if the default resolution does not find it.)
1883
1884 - [ ] **Step 4: Run full suite, commit**
1885
1886 ```bash
1887 $ZIG build test 2>&1 | tail -5
1888 git add src/muxa.zig build.zig
1889 git commit -m "feat(muxa): QUIC transport — same verbs, remote daemons, one reconnect re-issue"
1890 ```
1891
1892 ---
1893
1894 ### Task 11: End-to-end script — shell marks, ephemeral TUI, version-skew honesty
1895
1896 **Files:**
1897 - Create: `test/agent.sh` (executable)
1898
1899 - [ ] **Step 1: Write `test/agent.sh`** modeled on `test/e2e.sh`'s conventions (read its header first: how it finds binaries, traps cleanup, counts failures). Scenarios, each against `./zig-out/bin`:
1900
1901 1. **Marks session**: `muxd start --sock $S --shell /bin/bash` → `muxa run 'exit 3'`-style checks: `run 'true'` exit_code 0, `run 'false'` exit_code 1, `run 'echo out-$$'` output contains the marker, mechanism `marks` on all three.
1902 2. **Ephemeral TUI (the spec's field specimen)**: `muxd start --sock $S -- <TUI>` — use `vi` if present else `less /etc/hostname`: `muxa status` shows `alt_screen true` and mechanism not `marks`; `muxa send 'q'` (or `:q\n` for vi); then `muxa status` fails with connection refused OR the daemon exits — assert the session ended and `muxa` printed a JSON error object, not a stack trace. (Note: `muxd start`'s `--` argv support — if `muxd start` cannot take an argv today, spawn via `--shell /usr/bin/vi`; the shell flag execs any binary, which is exactly what `Pty.spawn` does with it.)
1903 3. **Settle**: `run 'sleep 1' --settle 300 --timeout 10000` against `--shell /bin/sh` (no marks): reason `settled` or `returned` (pgid may win the race — both are honest; assert NOT `timeout`).
1904 4. **Alt-screen guard**: in the TUI session, `muxa run 'true' --timeout 1500` must come back reason `timeout` (exit 3) rather than fabricating a return.
1905
1906 - [ ] **Step 2: Run it**
1907
1908 ```bash
1909 $ZIG build && bash test/agent.sh
1910 ```
1911
1912 Expected: all scenarios green, script exits 0.
1913
1914 - [ ] **Step 3: Commit**
1915
1916 ```bash
1917 git add test/agent.sh
1918 git commit -m "test(agent): e2e — marks exit codes, ephemeral TUI drive, settle vs timeout honesty"
1919 ```
1920
1921 ---
1922
1923 ### Task 12: Docs and close-out
1924
1925 **Files:**
1926 - Modify: `docs/roadmap.md` (agent surface entry: shipped, what is deferred — MCP wrapper, scoped auth, input attribution, event subscriptions)
1927 - Modify: `docs/decisions.md` (three decisions worth recording: rows-not-seqs for spans; the MuxHandler wrap instead of patching the dep; awaits at 100ms tick granularity instead of poll folding)
1928 - Modify: `README.md` if it lists binaries (add `muxa` one-liner)
1929
1930 - [ ] **Step 1: Write the entries** — follow each file's existing voice and format; state what was deliberately NOT built and why (the spec's Non-goals section is the source).
1931
1932 - [ ] **Step 2: Full suite + build one last time**
1933
1934 ```bash
1935 $ZIG build test 2>&1 | tail -5 && $ZIG build 2>&1 | tail -3 && bash test/agent.sh
1936 ```
1937
1938 Expected: everything green.
1939
1940 - [ ] **Step 3: Commit**
1941
1942 ```bash
1943 git add docs/roadmap.md docs/decisions.md README.md
1944 git commit -m "docs: agent surface shipped — decisions recorded, deferrals named"
1945 ```
1946
1947 ---
1948
1949 ## Plan self-review notes (already applied)
1950
1951 - **Spec coverage**: injection (T7), interception (T3), state machine (T4), cmd_state/status (T1/T5), awaits with all three mechanisms + since_seq idempotency (T6), muxa verbs incl. 0x0 attach (T8/T9), QUIC + own reconnect loop (T10), ephemeral TUI as first-class (T11), version-skew timeout-is-the-detection (muxa's "no reply — daemon too old or hung", T8), docs (T12). Alt-screen row caveat is enforced by T11 scenario 4 rather than a row check — rows are simply absent outside marks.
1952 - **Known soft spots the executor must resolve against the live code, not guess**: the server test harness helpers (T5/T6 — copy neighbors), 0.15 stdout/ArrayList-writer idioms in muxa (follow `src/main.zig`), `muxd start -- argv` support in T11 (fallback given inline).
1953 - **Type consistency**: `proto.CmdState`/`CmdPhase`/`Mechanism` defined once in T1 and used by that name in T4–T9; `Engine.MarkEvent` defined in T3, consumed in T4/T5; `AwaitState` lives only in server.zig.
docs/superpowers/plans/2026-08-13-m18-multi-session.md
Old New
@@ -1,860 +0,0 @@
1 # M18: Multi-Session Daemon 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:** One daemon holds up to `max_sessions` independent sessions (engine + pty + tracker + epoch + command tracker each), named at attach, so the web wall can show the same host twice — over one socket path, one QUIC port, one key.
6
7 **Architecture:** A connection *is* a session: the attach payload grows an optional name tail (empty = default = today's behavior, which is also the cross-version story), and everything after attach means exactly what it means today — no frame tagging, no QUIC stream surgery (the transport stays one-bidi-stream-per-connection; same-host tiles are connections to the same port). Attach-or-create, tmux-style; a session dies when its shell exits; the daemon exits when the last one does. Panes/layout belong to the client forever — a session is one PTY and the protocol never learns about layout.
8
9 **Tech Stack:** Zig 0.15.2 (pinned — see Toolchain below), existing modules only; no new modules, no build.zig graph changes.
10
11 **Spec:** No separate spec doc — the design was locked in conversation 2026-08-13. The locked decisions are the "Design decisions" section below; treat them as the spec.
12
13 **Amended 2026-08-14:** rebased onto post-agent-surface main (v0.0.1-5 + M-agent: `muxa`, OSC 133 command tracker, server-side awaits, shell-integration shims). Deltas folded in: decisions 13–15, the `cmd`/`last_return` fields in Task 2's sweep, session-scoped cmd pushes and awaits in Task 3, name tails on `status_req`/`await_req` plus `muxa --session` in Task 5, and every line reference re-verified against the merged tree.
14
15 ---
16
17 ## Design decisions (locked — do not relitigate during execution)
18
19 1. **Session = engine + pty + tracker + epoch + mode_sent + command tracker + name.** One PTY per session, forever. Layout is the client's.
20 2. **Purpose is the web wall showing one host twice.** That bounds everything: no list verb (the wall names its sessions explicitly; attach-or-create makes discovery unnecessary), no kill verb (a session ends when its shell exits), no rename, no per-session shell/scrollback config. All banked.
21 3. **Wire:** attach payload = the existing 20 bytes + the remainder as a UTF-8 session name. Empty/absent name → the default session, named `"0"`. An M17 client sends exactly 20 bytes and lands in the default session; an M18 client attaching to the default session sends exactly 20 bytes and works against an older daemon. **Known limitation (document, don't fix):** `--session foo` against a pre-M18 daemon is silently ignored by its `decodeAttach catch return` — the client hangs. Not worth a capability dance for a flag that didn't exist when that daemon shipped.
22 4. **Name validity:** 1–32 bytes, each in `'!'..'~'` (printable ASCII, no space), excluding `'#'` (the muxweb TARGET separator — a name containing it could never be spelled) and `'/'`. Empty means default and is valid on the wire, invalid as a user-supplied name.
23 5. **Attach-or-create:** unknown name spawns a session (daemon's configured shell, at the attacher's requested size); known name joins. Session table full → the same refusal as a full client table: `exit_status 1`.
24 6. **Connection = session on every transport.** QUIC untouched. A QUIC client is promoted to a slot at handshake-complete, *before* its attach arrives, so `ClientSlot.session` is `?usize` and null until attach; a session-less slot receives nothing and its session-needing frames are ignored. Re-attach re-resolves the name (join-or-create) — that is the natural code path and what a reconnecting tile does anyway.
25 7. **Per-session epoch** (random at creation, never 0; daemon-level `epoch` field removed). A recreated same-name session has a new epoch, so a tile that outlived its session's shell resyncs by snapshot for free — the existing epoch machinery already does this.
26 8. **Lifetime:** a session's shell exit sends `exit_status` to *that session's* clients, drains, frees the session and drops its clients. The daemon exits when the last session's child dies, with that child's code. At N=1 this is byte-identical to today. SIGTERM path unchanged (130). A dropped client's outstanding await resolves the way every muxa failure does — the connection dies and muxa's exit-code contract names it; no synthetic await_reply.
27 9. **`muxd dump` gains `--session NAME`** (payload = vt byte + name tail; empty = default). Unknown name answers `dump_reply` with the text `muxd: no such session: NAME\n` — visible, greppable, no fake "connection lost". `muxd stats` output gains `sessions=N` and one line per session. `stop`/`endpoint` stay daemon-global.
28 10. **`mux` gains `--session NAME`** on every transport spelling. Default sends the bare 20-byte attach (compat, decision 3).
29 11. **muxweb TARGET grows a `#NAME` suffix** (split at the *last* `'#'`; applies to every spelling: `HOST#a`, `quic://h:p#a`, `--sock PATH#a`). The full string stays the tile label; `/tiles` becomes a JSON array of `{"label":…,"session":…}` objects; mux.js appends the name bytes to the attach payload client-side (wasm_core untouched — the name is a byte-append after `mux_attach_payload`).
30 12. **`max_sessions = 4`** — wall-sized, same spirit as `max_clients = 8`.
31 13. **Command tracking is per-session** (added 2026-08-14, forced by the M-agent merge). `cmd: cmdmod.Tracker` and `last_return` move into `Session`: marks come from one session's engine, the pgid fallback asks one session's pty, and a tracker fed by two shells would interleave their command lifecycles into nonsense. `cmd_state` pushes go only to the session's own clients; an await resolves against the awaiting client's session. `AwaitState` stays on `ClientSlot` (an await is one client's question).
32 14. **`status_req` and `await_req` gain the same name tail as attach/dump** (empty = default): `status_req`'s payload is currently empty, so the whole payload becomes the name; `await_req`'s fixed 16 bytes grow a tail exactly like attach's 20 did. This is what lets `muxa` address a named session without attaching (`muxa status`), and it keeps one pattern across every session-scoped verb. `muxa` gains `--session NAME` on every verb; empty stays wire-compatible both directions (the golden-byte tests pin the empty-tail encodings).
33 15. **Creation requires a real size.** Attach-or-create only creates when the attach carries nonzero cols×rows; a 0×0 attach (muxa `send`'s `attachZero`, `muxa.zig:1204` — a client that makes no size claim) joins-only, and an unknown name is refused with `exit_status 1`. `muxa send` to a dead session must not silently spawn a 0×0 shell.
34
35 ---
36
37 ## Context for every task (read before starting any of them)
38
39 **Toolchain.** The system `zig` CANNOT build this repo (needs pinned Zig 0.15.2 + LLD; see Makefile). Use only:
40 - `make build` — builds the binaries into `zig-out/bin/`
41 - `make test` — unit tests. Does NOT compile the executables: an error in `main.zig`/`mux_main.zig`/`muxa.zig`/`webhub_main.zig` non-test code can survive `make test`. Run `make build` too before claiming success.
42 - `make e2e` — end-to-end suite (needs `make build` first)
43 - `make soak SOAK_N=10` — e2e repeated
44 - **Pipeline exit codes lie:** never `make test | tail` — capture `$?` before piping, or run unpiped.
45
46 **Resource rules (non-negotiable).** No synthetic load. Track every spawned process by pid; kill by tracked pid, never by name (never `pkill`). Verify cleanup by OBSERVATION (`ps` for the exact comm, `kill -0`), and report the observed state, never the cleanup command's claim. `muxd stop --sock PATH` is the sanctioned daemon teardown.
47
48 **Conventions.** Error messages: say what happened, name the way out, don't guess a cause a lower layer already named. Tests must be able to fail: no asserting a constant against itself. Orientation comments explain why, in the codebase's voice. Commit prefixes: `feat:`/`test:`/`refactor:`/`fix:`. git-collab trailers on every commit (repo is collab-init'd).
49
50 **Key existing surfaces you will touch (verified against the merged tree 2026-08-14; lines shift as tasks land — re-grep, don't trust stale numbers):**
51
52 - `protocol.zig` (1116 lines) — `MsgType :11`: M-agent took `await_req = 0x09`, `status_req = 0x0a`, `cmd_state = 0x8a`, `await_reply = 0x8b`, `status_reply = 0x8c`; **M18 adds NO verbs**, only payload tails. `AttachReq :~420`, `attach_len = 20 :424`, `encodeAttach :426`, `decodeAttach :436` (currently `!= attach_len → BadPayload`; becomes `< attach_len`). `AwaitReq :305`, `encodeAwaitReq :309`, `decodeAwaitReq :317` (same `!=` → `<` change), golden-byte tests `:1046`.
53 - `server.zig` (5783 lines) — `max_clients = 8 :24`; `ClientSlot :199` (`await_state: ?AwaitState :220`); `Server :243` — fields moving into `Session`: `eng`, `pty`, `epoch`, `mode_sent`, `tracker`, **`cmd: cmdmod.Tracker :297`**, **`last_return: ?proto.CmdState :314`**. `init :350`, `deinit :445`, `pumpOnce :504` (fds layout; pty-read arm `:~576` — also where mark events originate), `quicHandler :905`, `pollPtyMode :1062`, `sendPtyModeTo :1080`, the marks_seen-gated cmd_state push `:~1102–1114`, `freeClientSlot :1125`, `handleFrame :1226` (`.status_req :1327`, `.await_req :1331`), the await-resolution walk `:~1365–1448` (reads `last_return :1387`, pgid fallback), `serviceObserver :1453` (observer `.status_req :1512`), `buildDump :1521`, `applySize :1544`, `recordSize :1559`, `claimGrid :1569`, `sendUpdate :1628` (drains `eng.markEvents() :1660` into `cmd`, sets `last_return :1667`), `cmdState(mechanism) :1688`, the status-reply build `:~1739–1770` (reads `eng.cursorPos/historyRows/onAltScreen`), `resyncSnapshot :1783`, `sendResync :1833`, `rowsNow :1850`, `colsNow :1854`, `stats_text_len = 256 :1871`, `statsText :1888`.
54 - `pty.zig` — `spawn :22` / `spawnArgv :52` (shell-integration injection rides the spawn path, so per-session spawns inherit it — the shim files are pid-keyed, N sessions = N pids, already safe); `fgPgid :150` (the await pgid mechanism — asks ONE pty; per-session for free once the tracker moves).
55 - `muxa.zig` — usage `:24`, `--sock` parse `:74`, one-transport rule `:100`; `status` sends bare `status_req :1101`; `capture` sends `debug_dump :1185`; `attachZero :1204` (`encodeAttach(0, 0, 0, 0)` — the 0×0 no-size-claim attach), `send :1213`, `await` sends `encodeAwaitReq :1304`. Exit-code contract in the module doc `:1–24` — muxa's refusals are typed; keep new ones inside it.
56 - `client.zig:140` `Target`; `:884` `pub fn attach(alloc, target)`; `:910` `fn session(...)`; the three attach-frame sites: `:986` (initial), `:1210` (reconnect/recovery), `:1628` (reattach quoting `last_seq`/`session_epoch`). (Untouched by the M-agent merge — refs verified 2026-08-13.)
57 - `mux_main.zig:30` `ParseResult` (variants `.attach`, `.host`, `.quic`, `.version`, `.conflict`, `.usage_error`); `:57` `parseArgs`; main's arms `:176` (.quic), `:190` (.host→hand), `:198–240` (.attach). (Untouched by the merge.)
58 - `main.zig:20` usage line for `dump`; `--vt` parse `:181`; `dumpCmd :530` → `oneShotQuery(..., .debug_dump, &payload, .dump_reply)` with `payload = [1]u8{vt}`; "usage names every subcommand" test `:~1042`.
59 - `webhub_main.zig:17` usage; `:32` `TileSpec`; `:66` `parseTiles`; `:155–210` TileSpec→`client.Target` build. `webhub.zig:511` `tilesJson(alloc, labels)`; `:535` `serve(..., labels, ...)`; tests `:849–880`. `web/mux.js:13` `MSG`; `:257–258` `sendAttach` (the ONE attach site — passivity contract); `:690–694` `/tiles` fetch → `new Tile(i, labels[i], wall)`. `test/wsclient.zig:10–11` script commands `attach C R` / `attachfresh C R`; `:~153–169` its one attach-build site. (All untouched by the merge.)
60 - `test/e2e.sh` (3277 lines) — scenario checkpoints are `ok "label"` calls counted into `OK_COUNT` (`:421–430`); **the suite's final line asserts the COUNT against a literal** — adding scenarios means updating it, and the friction is the feature. Helpers `wait_pid_gone`, `assert_converged`, `assert_ws_converged :394`; hermetic XDG homes near the top; cleanup trap (register new SOCKs/pids).
61 - Cross-version e2e runs M(n)↔M(n−1): the empty-name-tail compat (decision 3) is what keeps it green — do not "clean up" the bare-20-byte default attach, and the same rule now covers `status_req`/`await_req` empty tails (decision 14).
62
63 **File map (what each task creates/modifies):**
64
65 | File | Tasks | Responsibility |
66 |---|---|---|
67 | `src/protocol.zig` | 1 | name tails (attach, dump, status_req, await_req), `validSessionName`, `session_name_max`, `default_session` |
68 | `src/server.zig` | 2, 3, 4, 5 | Session extraction incl. cmd tracker (N=1) → attach-or-create + per-session routing/pushes/awaits → lifetime → instruments |
69 | `src/main.zig` | 5 | `muxd dump --session NAME` |
70 | `src/muxa.zig` | 5 | `muxa --session NAME` on every verb |
71 | `src/mux_main.zig` | 6 | `mux --session NAME` |
72 | `src/client.zig` | 6 | name threaded through `attach()`/`session()` to the three attach sites |
73 | `src/webhub_main.zig` | 7 | `#NAME` TARGET suffix, sessions handed to serve |
74 | `src/webhub.zig` | 7 | `/tiles` objects `{label, session}` |
75 | `web/mux.js` | 7 | Tile.session, name bytes appended at the ONE attach site |
76 | `test/wsclient.zig` | 7 | `attach C R [NAME]` script forms |
77 | `test/e2e.sh` | 8 | multi-session scenarios, OK_COUNT literal |
78 | `docs/roadmap.md`, `docs/decisions.md` | 8 | close-out |
79
80 ---
81
82 ### Task 1: Protocol — session name tails
83
84 **Files:**
85 - Modify: `src/protocol.zig` (attach codec `:424–440`, await codec `:305–320`; tests beside the existing round-trip and golden-byte tests)
86
87 - [ ] **Step 1: Write the failing tests**
88
89 Beside the existing attach round-trip test in `src/protocol.zig`:
90
91 ```zig
92 test "attach: a bare 20-byte payload decodes with an empty name (M17 wire compat)" {
93 const req = try decodeAttach(&encodeAttach(120, 40, 7, 9));
94 try std.testing.expectEqual(@as(u16, 120), req.cols);
95 try std.testing.expectEqual(@as(u64, 7), req.have_seq);
96 try std.testing.expectEqualStrings("", req.name);
97 }
98
99 test "attach: the name tail rides behind the fixed 20 bytes and round-trips" {
100 var buf: [attach_max_len]u8 = undefined;
101 const wire = encodeAttachNamed(&buf, 80, 24, 3, 5, "wall-b");
102 try std.testing.expectEqual(@as(usize, attach_len + 6), wire.len);
103 const req = try decodeAttach(wire);
104 try std.testing.expectEqual(@as(u16, 80), req.cols);
105 try std.testing.expectEqualStrings("wall-b", req.name);
106 // An empty name encodes to exactly the M17 wire bytes — this IS the
107 // cross-version story, so it is pinned, not assumed.
108 const bare = encodeAttachNamed(&buf, 80, 24, 3, 5, "");
109 try std.testing.expectEqualSlices(u8, &encodeAttach(80, 24, 3, 5), bare);
110 }
111
112 test "attach: shorter than the fixed part, or a name past the cap, is BadPayload" {
113 try std.testing.expectError(error.BadPayload, decodeAttach(&[_]u8{0} ** 19));
114 const long = [_]u8{0} ** (attach_len + session_name_max + 1);
115 try std.testing.expectError(error.BadPayload, decodeAttach(&long));
116 }
117
118 test "await_req: the name tail rides behind the fixed 16 bytes; bare stays bare" {
119 var buf: [await_req_max_len]u8 = undefined;
120 const wire = encodeAwaitReqNamed(&buf, .{ .since_seq = 77, .settle_ms = 500, .timeout_ms = 30_000 }, "b");
121 const req = try decodeAwaitReq(wire);
122 try std.testing.expectEqual(@as(u64, 77), req.since_seq);
123 try std.testing.expectEqualStrings("b", req.name);
124 const bare = encodeAwaitReqNamed(&buf, .{ .since_seq = 77, .settle_ms = 500, .timeout_ms = 30_000 }, "");
125 try std.testing.expectEqualSlices(u8, &encodeAwaitReq(.{ .since_seq = 77, .settle_ms = 500, .timeout_ms = 30_000 }), bare);
126 }
127
128 test "session names: 1..32 printable bytes, no space, no '#', no '/'" {
129 try std.testing.expect(validSessionName("0"));
130 try std.testing.expect(validSessionName("wall-b"));
131 try std.testing.expect(validSessionName("a" ** session_name_max));
132 try std.testing.expect(!validSessionName(""));
133 try std.testing.expect(!validSessionName("a" ** (session_name_max + 1)));
134 try std.testing.expect(!validSessionName("has space"));
135 try std.testing.expect(!validSessionName("has#hash"));
136 try std.testing.expect(!validSessionName("has/slash"));
137 try std.testing.expect(!validSessionName("ctrl\x01"));
138 }
139 ```
140
141 - [ ] **Step 2: Run tests to verify they fail**
142
143 Run: `make test` (unpiped; note the exit code)
144 Expected: FAIL — `req.name`, `encodeAttachNamed`, `encodeAwaitReqNamed`, `attach_max_len`, `await_req_max_len`, `session_name_max`, `validSessionName` don't exist.
145
146 - [ ] **Step 3: Implement**
147
148 In `src/protocol.zig`, beside the attach codec:
149
150 ```zig
151 /// Everything past a session-scoped verb's fixed bytes is the session
152 /// name; empty names the default session. Older clients send exactly the
153 /// fixed part, which is why the tail is a tail and not a versioned field.
154 /// One pattern, four verbs: attach, debug_dump, status_req, await_req.
155 pub const session_name_max = 32;
156 pub const attach_max_len = attach_len + session_name_max;
157 pub const await_req_max_len = await_req_len + session_name_max;
158 pub const default_session = "0";
159
160 /// A name a user may spell: printable ASCII, no space; '#' is muxweb's
161 /// TARGET separator (a name holding one could never be addressed) and '/'
162 /// is reserved. The empty string is valid ON THE WIRE (it means default)
163 /// but not as a user-supplied name — callers that take names from users
164 /// check here, the decoders do not.
165 pub fn validSessionName(name: []const u8) bool {
166 if (name.len == 0 or name.len > session_name_max) return false;
167 for (name) |c| {
168 if (c < '!' or c > '~' or c == '#' or c == '/') return false;
169 }
170 return true;
171 }
172
173 pub fn encodeAttachNamed(
174 buf: *[attach_max_len]u8,
175 cols: u16,
176 rows: u16,
177 have_seq: u64,
178 have_epoch: u64,
179 name: []const u8,
180 ) []const u8 {
181 @memcpy(buf[0..attach_len], &encodeAttach(cols, rows, have_seq, have_epoch));
182 @memcpy(buf[attach_len..][0..name.len], name);
183 return buf[0 .. attach_len + name.len];
184 }
185
186 pub fn encodeAwaitReqNamed(
187 buf: *[await_req_max_len]u8,
188 r: AwaitReq,
189 name: []const u8,
190 ) []const u8 {
191 @memcpy(buf[0..await_req_len], &encodeAwaitReq(r));
192 @memcpy(buf[await_req_len..][0..name.len], name);
193 return buf[0 .. await_req_len + name.len];
194 }
195 ```
196
197 `AttachReq` gains `name: []const u8` (doc comment: borrowed from the payload, valid only while the frame lives); `decodeAttach`'s length check becomes `< attach_len` / `> attach_max_len → BadPayload`, return gains `.name = payload[attach_len..]`. `AwaitReq :305` gains the same field with the same borrow note; `decodeAwaitReq :317` gets the identical `<`/`>` treatment and `.name = payload[await_req_len..]`. (`encodeAwaitReq` keeps returning the fixed array — the golden-byte test at `:1046` must not change.) `status_req` needs no codec: its whole payload IS the name; the server-side arms read it directly (Task 5).
198
199 - [ ] **Step 4: Fix the compile fallout in the same commit**
200
201 Callers constructing `AttachReq`/`AwaitReq` literals in tests need the new field; `muxa.zig:1304`'s `encodeAwaitReq(.{...})` call compiles only if `AwaitReq.name` has a default — give it `name: []const u8 = ""` so existing struct literals stand. Run `make test`, fix what it names, nothing more.
202
203 - [ ] **Step 5: Run tests to verify they pass**
204
205 Run: `make test` then `make build`
206 Expected: both exit 0.
207
208 - [ ] **Step 6: Commit**
209
210 ```bash
211 git add src/protocol.zig
212 git commit -m "feat(protocol): session-name tails on attach and await_req; empty names the default"
213 ```
214
215 ---
216
217 ### Task 2: Server — extract `Session`, hardwired to one (pure refactor)
218
219 The M15-shaped task: after it, behavior and wire bytes are IDENTICAL — the check is that **no existing test changes and all pass**. Do not mix Task 3 behavior into it. The M-agent merge grew this task: the command tracker moves too (decision 13).
220
221 **Files:**
222 - Modify: `src/server.zig`
223
224 - [ ] **Step 1: Define the struct and the accessor**
225
226 Above `Server :243`:
227
228 ```zig
229 pub const max_sessions = 4;
230
231 /// One shell and everything the daemon knows about it. What used to be
232 /// daemon-global state, one level down — because "the daemon's session"
233 /// stopped being a definite article when the wall wanted the same host
234 /// twice. The command tracker lives here and not on Server for the same
235 /// reason the engine does: marks are one shell's lifecycle, and a tracker
236 /// fed by two shells would interleave them into nonsense.
237 const Session = struct {
238 eng: *Engine,
239 pty: Pty,
240 tracker: DeltaTracker = .{},
241 /// Identifies this SESSION instance in every snapshot it sends (was
242 /// daemon-global; per-session so a recreated name resyncs by snapshot).
243 /// Never 0 — reserved for a client holding nothing.
244 epoch: u64,
245 mode_sent: ?proto.PtyModeFlags = null,
246 /// M-agent's command state machine, per shell (see cmd.zig).
247 cmd: cmdmod.Tracker = .{},
248 /// The last command return this session's marks reported — the await
249 /// path's memory (see the Server-level doc it moves from, :314).
250 last_return: ?proto.CmdState = null,
251 name_buf: [proto.session_name_max]u8 = undefined,
252 name_len: u8 = 0,
253
254 fn name(self: *const Session) []const u8 {
255 return self.name_buf[0..self.name_len];
256 }
257 };
258 ```
259
260 In `Server`: delete fields `eng`, `pty`, `epoch`, `mode_sent`, `tracker`, `cmd`, `last_return` (carry their doc comments with them — the `last_return` essay at `:298–314` explains the await ordering and must not be lost); add:
261
262 ```zig
263 sessions: [max_sessions]?Session = @splat(null),
264 /// Needed past init now: creating a session is no longer init's
265 /// monopoly. Owned by the caller (main), which outlives the daemon.
266 shell: [:0]const u8,
267 ```
268
269 `ClientSlot :199` gains (beside `await_state`, which stays — an await is one client's question):
270
271 ```zig
272 /// Which session this client is attached to; null between a QUIC
273 /// handshake-promotion and its first attach. Task 2 sets it to 0
274 /// everywhere; Task 3 makes it real.
275 session: ?usize = null,
276 ```
277
278 - [ ] **Step 2: Extract session creation from `init`**
279
280 The `Engine.init`/`Pty.spawn`/epoch-roll block of `init :350` becomes:
281
282 ```zig
283 fn createSession(alloc: std.mem.Allocator, shell: [:0]const u8, name: []const u8, cols: u16, rows: u16) !Session {
284 const eng = try Engine.init(alloc, .{ .cols = cols, .rows = rows });
285 errdefer eng.deinit();
286 var pty = try Pty.spawn(.{ .cols = cols, .rows = rows, .shell = shell });
287 errdefer pty.deinit();
288 var epoch: u64 = 0;
289 while (epoch == 0) epoch = std.crypto.random.int(u64);
290 var s = Session{ .eng = eng, .pty = pty, .epoch = epoch };
291 @memcpy(s.name_buf[0..name.len], name);
292 s.name_len = @intCast(name.len);
293 return s;
294 }
295 ```
296
297 (If `init` has grown shell-integration setup around `Pty.spawn` since `:350` — the shellint injection — it moves into `createSession` with the spawn; every session's shell gets marks. That is not a behavior change at N=1 and is required at N>1.)
298
299 `init` calls `createSession(alloc, opts.shell, proto.default_session, opts.cols, opts.rows)` into `sessions[0]`, plus `.shell = opts.shell` in the returned Server. `deinit :445` loops sessions: `tracker.deinit`, `pty.deinit`, `eng.deinit` per live slot (same order as today).
300
301 - [ ] **Step 3: The mechanical sweep**
302
303 Every `self.eng` / `self.pty` / `self.tracker` / `self.epoch` / `self.mode_sent` / `self.cmd` / `self.last_return` becomes a read through the accessor:
304
305 ```zig
306 /// Task 2: every caller passes 0. Task 3 threads the real index.
307 fn ses(self: *Server, si: usize) *Session {
308 return &self.sessions[si].?;
309 }
310 ```
311
312 Functions that touch session state gain a `si: usize` parameter NOW (passing 0 at every call site), so Task 3 is a parameter change, not a second sweep: `sendUpdate :1628` (including its markEvents drain and cmd_state push), `snapshotPayload`, `resyncSnapshot :1783`, `sendResync :1833`, `pollPtyMode :1062`, `sendPtyModeTo :1080`, the marks_seen-gated push `:~1102`, `applySize :1544`, `claimGrid :1569`, `rowsNow :1850`, `colsNow :1854`, `buildDump :1521`, `cmdState :1688`, the status-reply build `:~1739`, the await-resolution walk `:~1365–1448` (reads `ses(si).last_return`, `ses(si).cmd`, `ses(si).pty.fgPgid()`), `statsText :1888`.
313
314 Done when `grep -n "self\.eng\b\|self\.pty\b\|self\.tracker\b\|self\.epoch\b\|self\.mode_sent\b\|self\.cmd\b\|self\.last_return\b" src/server.zig` comes back empty.
315
316 `pumpOnce :504` fds layout changes to its final shape now (so Task 3 doesn't re-do it): `[sessions×max_sessions ptys, listener, clients×max_clients, observers×max_observers, quic]` — dead session slots poll fd −1, same idiom as dead client slots. The pty-read arm (`:~576`) and `checkExited` loop over sessions (only slot 0 is ever live in this task).
317
318 - [ ] **Step 4: Verify no behavior changed**
319
320 Run: `make test` — every existing test passes UNCHANGED (if a test needs editing to pass, the refactor changed behavior: stop and find out why; the only permitted edits are field-path renames in test assertions, e.g. `srv.epoch` → `srv.sessions[0].?.epoch`, `srv.cmd` → `srv.sessions[0].?.cmd` — renames, not behavior).
321 Then: `make build && make e2e`
322 Expected: all green — including every M-agent await/status/marks test, which is the proof the tracker survived the move.
323
324 - [ ] **Step 5: Rewrite the module doc**
325
326 `server.zig:1`: "one session (engine + pty)" → "up to max_sessions sessions (engine + pty + command tracker each), one listener; a connection is a session, named at attach". Keep the rest.
327
328 - [ ] **Step 6: Commit**
329
330 ```bash
331 git add src/server.zig
332 git commit -m "refactor(server): extract Session (incl. cmd tracker), hardwired to one — no behavior change"
333 ```
334
335 ---
336
337 ### Task 3: Server — attach-or-create, per-session routing
338
339 **Files:**
340 - Modify: `src/server.zig` (`handleFrame :1226`, `serviceObserver :1453`, the broadcast loops in `sendUpdate`/`resyncSnapshot`/`pollPtyMode` and the cmd_state push, the await walk, `claimGrid`)
341
342 - [ ] **Step 1: Write the failing tests**
343
344 Beside the existing server tests (use the file's own harness idioms — `TmpDir`, the `readStateFrame`/`Held` helpers, scripted `/bin/cat` or `session.sh` shells):
345
346 ```zig
347 test "Server: two named sessions hold two shells with independent content" {
348 // Attach c1 with name tail "a", c2 with "b" (proto.encodeAttachNamed).
349 // Type a distinct marker into each; pump. Assert each client's
350 // snapshot/delta stream contains its own marker and NEVER the other's
351 // (walk frames with the readStateFrame helper). Assert
352 // srv.sessions[1] != null and its name() is "b".
353 }
354
355 test "Server: a bare 20-byte attach lands in the default session" {
356 // encodeAttach (no tail) → attaches; assert slot.session points at
357 // the session whose name() is proto.default_session.
358 }
359
360 test "Server: attach to a fifth name is refused with exit_status, sessions intact" {
361 // Fill all max_sessions names, then attach name "e": expect
362 // exit_status 1 on that connection, and the four sessions' shells
363 // still alive (checkExited null on each).
364 }
365
366 test "Server: an invalid name on the wire is refused, not created" {
367 // Name "has space" → exit_status 1, sessions[1] == null.
368 }
369
370 test "Server: a session is created at the attacher's size" {
371 // encodeAttachNamed(100, 30, 0, 0, "big") → the snapshot answered
372 // carries 100x30 (SnapshotPrefix), and colsNow(si)==100.
373 }
374
375 test "Server: a 0x0 attach joins but never creates" {
376 // encodeAttachNamed(0, 0, 0, 0, "b") with no session "b": exit_status
377 // 1, sessions[1] == null (decision 15 — muxa send must not spawn a
378 // 0x0 shell). Then create "b" properly and repeat: the 0x0 attach
379 // joins it.
380 }
381
382 test "Server: cmd_state pushes stay inside their session" {
383 // Session "a" with the mark-emitting scripted shell the M-agent tests
384 // use; session "b" attached alongside. Drive a's marks; assert a's
385 // client reads a cmd_state push and b's client reads none (b's shell
386 // emitted no marks — its marks_seen gate never opened).
387 }
388
389 test "Server: an await resolves against the awaiting client's session" {
390 // a's client sends await_req; b's shell returns a command; a's await
391 // must NOT resolve on b's return. Then a's shell returns one and it
392 // does. (Reuse the M-agent await test scaffolding — same script, one
393 // more session in the room.)
394 }
395 ```
396
397 Write them as real tests following the existing attach-test and M-agent await-test shapes — copy their connect/attach/read scaffolding, don't invent a new harness.
398
399 - [ ] **Step 2: Run to verify they fail**
400
401 Run: `make test` — the new tests fail (no name resolution exists; everything lands in session 0; pushes broadcast to everyone).
402
403 - [ ] **Step 3: Implement name resolution**
404
405 In `Server`:
406
407 ```zig
408 /// Join a live session by name. "" is the default session's wire
409 /// spelling. Null means: no such session (and this call never creates).
410 fn findSession(self: *Server, wire_name: []const u8) ?usize {
411 const name = if (wire_name.len == 0) proto.default_session else wire_name;
412 for (&self.sessions, 0..) |*slot, si| {
413 if (slot.*) |*s| {
414 if (std.mem.eql(u8, s.name(), name)) return si;
415 }
416 }
417 return null;
418 }
419
420 /// Attach-or-create. Creation demands a real size: a 0x0 attach makes
421 /// no size claim (muxa send), and a client with no size must never be
422 /// the reason a shell spawns. Null is a refusal (bad name, table full,
423 /// or 0x0-create) — the caller answers exit_status 1, the same honest
424 /// no a full client table gives.
425 fn resolveSession(self: *Server, wire_name: []const u8, cols: u16, rows: u16) ?usize {
426 const name = if (wire_name.len == 0) proto.default_session else wire_name;
427 if (!proto.validSessionName(name)) return null;
428 if (self.findSession(wire_name)) |si| return si;
429 if (cols == 0 or rows == 0) return null;
430 var free: ?usize = null;
431 for (&self.sessions, 0..) |*slot, si| {
432 if (slot.* == null) {
433 free = si;
434 break;
435 }
436 }
437 const si = free orelse return null;
438 self.sessions[si] = createSession(self.alloc, self.shell, name, cols, rows) catch return null;
439 return si;
440 }
441 ```
442
443 - [ ] **Step 4: Wire it into both attach arms**
444
445 `serviceObserver`'s `.attach` arm: after `decodeAttach`, `const si = self.resolveSession(sz.name, sz.cols, sz.rows) orelse { …the existing exit_status-1 refusal… }`. The promoted slot gets `.session = si`; the `applySize`/`sendPtyModeTo`/`sendResync` calls pass `si` (they already take it since Task 2).
446
447 `handleFrame`'s `.attach` arm (the QUIC first-attach path): same resolution; set `self.clients[i].?.session = si` (this is also the re-attach path — re-resolving is the rule, decision 6).
448
449 Session-needing arms guard at the top: `.input`, `.resize`, `.fetch_scrollback`, `.debug_dump`, `.status_req`, `.await_req`:
450
451 ```zig
452 const si = self.clients[i].?.session orelse return;
453 ```
454
455 (`.stats_req`, `.stop_req`, `.endpoint_req`, `.detach` stay daemon-global. The observer `.status_req` arm is Task 5's — it has a name tail instead of an attached session.)
456
457 - [ ] **Step 5: Make the broadcasts and the awaits filter**
458
459 Every loop that walks all clients to send session state — inside `sendUpdate(si)` (deltas AND the cmd_state push), `resyncSnapshot(si)`, the pty_mode broadcast in `pollPtyMode(si)` — skips slots whose `.session != si`. The await-resolution walk keys each client's await off `self.clients[i].?.session` — an await on a session-less slot cannot exist (the arm guard above). `claimGrid(i)`/latest-wins reads the slot's session. The pty-read arm and `pollPtyMode` in `pumpOnce` iterate every live session (the loop exists since Task 2; drop the hardwired 0) — which also means each session's `markEvents` drain feeds its OWN tracker. QUIC promotion in `quicHandler :905` leaves `.session = null` — verify the filters mean a promoted-but-unattached QUIC client receives nothing.
460
461 - [ ] **Step 6: Run tests**
462
463 Run: `make test && make build && make e2e`
464 Expected: new tests pass; every existing test still passes (single-session behavior is the default-session path; the M-agent e2e scenarios all run in the default session).
465
466 - [ ] **Step 7: Commit**
467
468 ```bash
469 git add src/server.zig
470 git commit -m "feat(server): attach-or-create named sessions; connection = session; pushes and awaits stay home"
471 ```
472
473 ---
474
475 ### Task 4: Server — per-session death, last-death daemon exit
476
477 **Files:**
478 - Modify: `src/server.zig` (`pumpOnce`'s `checkExited` block, `deinit`)
479
480 - [ ] **Step 1: Write the failing tests**
481
482 ```zig
483 test "Server: one session's shell exiting drops only its clients; the daemon carries on" {
484 // Sessions "a" (scripted: exits after a marker) and "b" (/bin/cat).
485 // Pump until a's client reads exit_status; assert pumpOnce returned
486 // null throughout, b's client still attached (a typed byte still
487 // echoes), sessions slot for "a" is null.
488 }
489
490 test "Server: the last session's exit code is the daemon's" {
491 // Single session whose script exits 7: pumpOnce eventually returns 7
492 // (the existing exit tests' shape).
493 }
494
495 test "Server: a dead name re-attaches as a fresh session with a new epoch" {
496 // Session "a" exits; attach "a" again (real size): snapshot arrives
497 // with an epoch differing from the first (recreated ≠ resumed,
498 // decision 7).
499 }
500 ```
501
502 - [ ] **Step 2: Run to verify they fail**
503
504 Run: `make test` — today any pty exit returns from pumpOnce; the first test fails.
505
506 - [ ] **Step 3: Implement**
507
508 The `checkExited` block at the top of `pumpOnce` becomes a per-session loop:
509
510 ```zig
511 var live: usize = 0;
512 var last_code: ?u8 = null;
513 for (&self.sessions, 0..) |*slot, si| {
514 const s = if (slot.*) |*s| s else continue;
515 if (s.pty.checkExited()) |code| {
516 // This session's clients, only: exit_status is a fact about
517 // ONE shell now. Queued then drained under the same 250ms
518 // deadline, for the same reason as ever — a client that
519 // misses it reads EOF and mislabels the exit. A dropped
520 // client's outstanding await needs nothing here: the
521 // connection dying IS muxa's answer (decision 8).
522 for (0..max_clients) |i| {
523 const c = self.clients[i] orelse continue;
524 if (c.session != si) continue;
525 _ = self.queueFrame(i, .exit_status, &.{@intCast(code & 0xff)});
526 }
527 self.drainPending(250);
528 for (0..max_clients) |i| {
529 const c = self.clients[i] orelse continue;
530 if (c.session == si) self.dropClient(i);
531 }
532 s.tracker.deinit(self.alloc);
533 s.pty.deinit();
534 s.eng.deinit();
535 slot.* = null;
536 last_code = @intCast(code & 0xff);
537 } else live += 1;
538 }
539 if (live == 0) {
540 if (last_code) |code| return code;
541 }
542 ```
543
544 (Careful: when `live == 0` and `last_code == null`, there were never any sessions — unreachable after init, but the `if` chain must not return 0 by accident. `deinit` already loops sessions since Task 2, so a mid-life freed slot double-frees nothing.)
545
546 - [ ] **Step 4: Run tests**
547
548 Run: `make test && make build && make e2e` — all green. The existing single-session exit tests pass through the same loop (one session, live drops to 0, code returned).
549
550 - [ ] **Step 5: Commit**
551
552 ```bash
553 git add src/server.zig
554 git commit -m "feat(server): sessions die one at a time; the daemon exits with the last"
555 ```
556
557 ---
558
559 ### Task 5: Instruments — `muxd dump --session`, per-session stats, `muxa --session`
560
561 **Files:**
562 - Modify: `src/server.zig` (`buildDump :1521`, observer `.status_req :1512`, client `.status_req`/`.await_req` arms, `statsText :1888`, `stats_text_len :1871`), `src/main.zig` (usage `:20`, dump parse `:181`, `dumpCmd :530`), `src/muxa.zig` (parse `:74`, `status :1101`, `capture :1185`, `attachZero :1204`, `await :1304`)
563
564 - [ ] **Step 1: Write the failing tests**
565
566 In `server.zig`:
567
568 ```zig
569 test "Server: dump names a session; an unknown name answers in words" {
570 // Two sessions with distinct markers. debug_dump payload {1,'b'}:
571 // reply holds b's marker, not a's. Payload {1,'z'}: reply is exactly
572 // "muxd: no such session: z\n".
573 }
574
575 test "Server: an observer's status_req names a session by tail" {
576 // status_req payload "b" from an observer answers b's state (drive a
577 // command in b via the M-agent scripted shell; the reply's CmdState
578 // is b's). Payload "z": the observer is answered with status_reply
579 // for... nothing — refuse by dropping? NO: answer the same way dump
580 // does, in words? status_reply is binary. Decision: an unknown name
581 // on status_req/await_req is a dropped connection — muxa's typed
582 // exit codes already name "connection died" — PLUS a daemon-side
583 // stderr line "muxd: status_req for unknown session: z". Assert the
584 // drop (EOF on the observer fd).
585 }
586 ```
587
588 In `main.zig`, beside the dump parse tests:
589
590 ```zig
591 test "parse: dump --session rides into the payload" {
592 const d = parse(&.{ "muxd", "dump", "--session", "b", "--sock", "/tmp/x.sock" });
593 // assert the parsed session field == "b" (shape follows the existing Opts)
594 }
595 ```
596
597 In `muxa.zig`, beside its parse tests:
598
599 ```zig
600 test "muxa: --session rides every verb; a bad name is usage (exit 2), not wire bytes" {
601 // parse(&.{ "muxa", "status", "--session", "b", "--sock", "/tmp/s" })
602 // → session "b"; "--session", "has space" → the usage refusal path.
603 }
604 ```
605
606 - [ ] **Step 2: Run to verify they fail** — `make test`.
607
608 - [ ] **Step 3: Implement the server side**
609
610 `buildDump(si-less now — it takes the payload)`: byte 0 stays the vt flag; `payload[1..]` is the name (empty → default). Resolve with `findSession` (Task 3) — a dump must never spawn a shell. Unknown name:
611
612 ```zig
613 return std.fmt.allocPrint(self.alloc, "muxd: no such session: {s}\n", .{name});
614 ```
615
616 Observer `.status_req :1512`: the payload is the name; `findSession` it; unknown → log one stderr line and drop the observer (the test above pins both). Attached-client `.status_req`/`.await_req`: the slot's session wins; a non-empty tail naming a DIFFERENT session than the one attached is answered about the tail's session if it exists (muxa send attaches 0×0 to default but may ask about "b") — `findSession(tail) orelse slot.session`... NO. Keep it simple and honest: **for attached clients the tail must be empty or equal to the attached session's name; anything else is ignored** (the client asked two different questions at once). One rule, testable, no aliasing surprises. muxa's verbs pass the same `--session` to their attach AND their asks, so the case never arises from our own tools.
617
618 `statsText`: add `sessions=N` to the existing line and one ` session {s} clients={d} seq={d}` line per live session (count a session's clients by walking slots). Bump `stats_text_len` to 512 — the buffer is caller-declared via the constant everywhere (`grep stats_text_len`).
619
620 - [ ] **Step 4: Implement the CLI sides**
621
622 `main.zig`: `--session NAME` on `dump` (validate with `proto.validSessionName`, refuse in words: `muxd: bad session name: {s}\n` + usage); `dumpCmd` builds `payload = vt_byte ++ name` (a `[1 + proto.session_name_max]u8` buffer, sliced). Update the usage string — the "usage names every subcommand" test keeps you honest.
623
624 `muxa.zig`: `--session NAME` parsed beside `--sock`/`--quic` (`:74`), validated at parse (usage exit 2). Threading: `status` sends the name as the whole `status_req` payload (`:1101`); `capture` appends it to the `debug_dump` payload (`:1185`); `send`'s `attachZero` becomes `encodeAttachNamed(&buf, 0, 0, 0, 0, name)` (`:1204` — joins-only by decision 15, and its follow-up `status_req` ack carries the same name); `await` uses `encodeAwaitReqNamed` (`:1304`). Empty name everywhere = today's bytes (pinned in Task 1).
625
626 - [ ] **Step 5: Run** — `make test && make build`; green.
627
628 - [ ] **Step 6: Commit**
629
630 ```bash
631 git add src/server.zig src/main.zig src/muxa.zig
632 git commit -m "feat: dump/status/await learn session names; muxa --session; stats names every session"
633 ```
634
635 ---
636
637 ### Task 6: `mux --session NAME`
638
639 **Files:**
640 - Modify: `src/mux_main.zig` (ParseResult `:30`, parseArgs `:57`, the transport arms `:176–240`), `src/client.zig` (`attach :884`, `session :910`, attach sites `:986`, `:1210`, `:1628`)
641
642 - [ ] **Step 1: Write the failing parse tests**
643
644 Beside the existing parse tests in `mux_main.zig`:
645
646 ```zig
647 test "parseArgs: --session rides every transport spelling" {
648 const s = parse(&.{ "mux", "--session", "b", "--sock", "/tmp/x.sock" });
649 try std.testing.expectEqualStrings("b", s.attach.session);
650 const h = parse(&.{ "mux", "somehost", "--session", "b" });
651 try std.testing.expectEqualStrings("b", h.host.session);
652 const q = parse(&.{ "mux", "quic://h:1", "--session", "b" });
653 try std.testing.expectEqualStrings("b", q.quic.session);
654 }
655
656 test "parseArgs: a bad --session is a usage error, not a wire experiment" {
657 const r = parse(&.{ "mux", "--session", "has space" });
658 try std.testing.expect(r == .usage_error);
659 }
660
661 test "parseArgs: no --session means the empty wire name (older-daemon compat)" {
662 const s = parse(&.{"mux"});
663 try std.testing.expectEqualStrings("", s.attach.session);
664 }
665 ```
666
667 - [ ] **Step 2: Run to verify they fail** — `make test`.
668
669 - [ ] **Step 3: Implement the parse**
670
671 `parseArgs`: a `var session: []const u8 = "";` local; flag arm mirrors `--key`'s shape; validate `proto.validSessionName` when non-empty → `usage_error`. Add `session: []const u8 = ""` to the `.attach`, `.host`, `.quic` variant payloads and thread it into the three returns. Update `mux`'s usage text.
672
673 - [ ] **Step 4: Thread it through client.zig**
674
675 `pub fn attach(alloc, target, session_name: []const u8)` — and `fn session(...)` gains the same param (rename the parameter, not the function: the function name `session` keeps its meaning). The three attach-frame sites switch to the named encoder; e.g. `:986` becomes:
676
677 ```zig
678 var abuf: [proto.attach_max_len]u8 = undefined;
679 transport.writeFrame(
680 .attach,
681 proto.encodeAttachNamed(&abuf, size.cols, size.rows, 0, 0, session_name),
682 ) catch {
683 ```
684
685 Same shape at `:1210` (quoting (0,0)) and `:1628` (quoting `last_seq, session_epoch`). Empty name → 20 bytes on the wire, byte-identical to before (pinned by Task 1's test).
686
687 Then fix every `client.attach` caller: `grep -rn "client\.attach\|\battach(alloc" src test` — the arms in `mux_main.zig` pass their parsed session; any other caller passes `""`.
688
689 - [ ] **Step 5: Run** — `make test && make build`; green. Do NOT hand-verify with an interactive terminal; the e2e (Task 8) does it honestly.
690
691 - [ ] **Step 6: Commit**
692
693 ```bash
694 git add src/mux_main.zig src/client.zig
695 git commit -m "feat(mux): --session NAME on every transport spelling"
696 ```
697
698 ---
699
700 ### Task 7: The wall — `#NAME` targets, `/tiles` objects, browser name-append
701
702 **Files:**
703 - Modify: `src/webhub_main.zig` (`:66` parseTiles, `:155–210` target build), `src/webhub.zig` (`tilesJson :511`, `serve :535`), `web/mux.js` (`:257` sendAttach, `:690` tiles fetch), `test/wsclient.zig` (`:10`, `:~153–169`)
704
705 - [ ] **Step 1: Write the failing hub tests**
706
707 In `webhub_main.zig` beside the parse tests:
708
709 ```zig
710 test "tiles: #NAME splits off the session; the label keeps the full spelling" {
711 // "host#b" → spec host "host", session "b"; "quic://h:1#b" → session "b";
712 // "--sock /tmp/x#b" → sock "/tmp/x", session "b".
713 // "plainhost" → session "" (default).
714 // Split at the LAST '#': "a#b#c" yields spec "a#b", session "c" — and
715 // validSessionName refuses what cannot be a name.
716 }
717
718 test "tiles: a bad session name after # is a usage error" {
719 // "host#has space" → error.Usage (message names the tile).
720 }
721 ```
722
723 In `webhub.zig` beside the tilesJson tests (`:849`):
724
725 ```zig
726 test "tiles json: label/session objects, order preserved" {
727 // tilesJson(alloc, labels, sessions) → [{"label":"box1","session":"b"},...]
728 // Escaping rules identical for both strings (extract the existing
729 // escape loop rather than copying it).
730 }
731 ```
732
733 - [ ] **Step 2: Run to verify they fail** — `make test`.
734
735 - [ ] **Step 3: Implement the hub side**
736
737 `parseTiles`: for each TARGET, `std.mem.lastIndexOfScalar(u8, arg, '#')` → if some, split; validate the tail with `proto.validSessionName` (refuse in words, naming the tile). `TileSpec` gains `session: []const u8` (or a parallel list — follow whichever keeps the arena/cleanup story at `:54–57` simple). The label stays the FULL original string (the user asked for `host#b`; that is the tile's name). Targets build strips the suffix before spec parsing. `serve` and `tilesJson` take `sessions: []const []const u8` alongside labels; `/tiles` emits objects.
738
739 - [ ] **Step 4: Implement the browser side**
740
741 `web/mux.js:690`:
742
743 ```js
744 const tilesCfg = await (await fetch('/tiles')).json();
745 for (let i = 0; i < tilesCfg.length; i++) {
746 const tile = new Tile(i, tilesCfg[i].label, wall, tilesCfg[i].session);
747 tiles.push(tile);
748 }
749 ```
750
751 `Tile` constructor stores `this.session = session || ''`. `sendAttach` (`:257`) — the ONE attach site, keep it that way — appends the name bytes:
752
753 ```js
754 const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0);
755 if (n > 0) {
756 let payload = this.outBytes();
757 if (this.session) {
758 const name = new TextEncoder().encode(this.session);
759 const joined = new Uint8Array(payload.length + name.length);
760 joined.set(payload); joined.set(name, payload.length);
761 payload = joined;
762 }
763 this.gotState = false; this.sendFrame(MSG.attach, payload);
764 }
765 ```
766
767 (wasm_core untouched: the name is transport dressing, not replica state.)
768
769 - [ ] **Step 5: Teach wsclient the same spelling**
770
771 `test/wsclient.zig`: script forms become `attach C R [NAME]` / `attachfresh C R [NAME]`; the one build site appends NAME's bytes after the 20 fixed ones, mirroring mux.js — the comment there already says mux.js and wsclient hold the passivity contract structurally; keep them twins.
772
773 - [ ] **Step 6: Run** — `make test && make build`; green.
774
775 - [ ] **Step 7: Commit**
776
777 ```bash
778 git add src/webhub_main.zig src/webhub.zig web/mux.js test/wsclient.zig
779 git commit -m "feat(web): TARGET#NAME names a tile's session; browser appends the name at attach"
780 ```
781
782 ---
783
784 ### Task 8: e2e scenarios, docs, close-out
785
786 **Files:**
787 - Modify: `test/e2e.sh` (new scenarios; the OK_COUNT literal on the suite's final line; cleanup trap), `docs/roadmap.md`, `docs/decisions.md`
788
789 - [ ] **Step 1: CLI scenario — two sessions, one daemon**
790
791 Following the file's existing daemon-scenario shape (hermetic XDG home, SOCK registered in the trap, pids tracked, `ok "label"` checkpoints):
792
793 ```bash
794 # --- M18: two sessions on one daemon are two shells ---------------------
795 SOCK18="$TMP/m18.sock"
796 "$MUXD" start --sock "$SOCK18" --shell /bin/sh
797 # session a and session b get distinct markers via scripted mux clients
798 # (the ptyclient/script.zig harness the other attach scenarios use):
799 # client A: mux --sock $SOCK18 --session a → type "echo M18A_$$"
800 # client B: mux --sock $SOCK18 --session b → type "echo M18B_$$"
801 # assertions (each a real `e2e FAIL:` line + `ok`):
802 # muxd dump --sock $SOCK18 --session a | grep M18A (and ! grep M18B)
803 # muxd dump --sock $SOCK18 --session b | grep M18B (and ! grep M18A)
804 # muxd dump --sock $SOCK18 # default session: neither marker
805 # muxd stats --sock $SOCK18 | grep 'sessions=3'
806 # muxd dump --sock $SOCK18 --session zz | grep 'no such session'
807 # muxa status --sock $SOCK18 --session b # exit 0, and its state is b's
808 # (run "sleep 2 &&" in b first: status names a running command there
809 # while --session a reports at_prompt — the per-session tracker,
810 # observed from outside)
811 # lifetime:
812 # type "exit" into a → poll until stats shows sessions=2; daemon still
813 # answers (stats exit code 0); b's marker still dumps.
814 # muxd stop --sock $SOCK18; wait_pid_gone
815 ```
816
817 Write it as real script with the file's polling helpers — the sketch above is the checklist of assertions, each becomes a real `e2e FAIL:` line. Poll with deadlines, never sleep-and-hope; put this scenario BEFORE any test the same regression could hang (the legible-catch rule).
818
819 - [ ] **Step 2: Web scenario — one hub, same sock twice, two sessions**
820
821 Extend the existing web-hub scenario block: hub with two tiles `--sock $SOCK18#a --sock $SOCK18#b`; wsclient script `attach 80 24 a` on tile 0 and `attach 80 24 b` on tile 1; `assert_ws_converged` (`:394`) against `dump --session a` / `--session b` respectively — the tile-0 replica must NOT contain b's marker.
822
823 - [ ] **Step 3: Update the OK_COUNT literal**
824
825 The suite's final line asserts the checkpoint count against a literal (`:421–430` explains the pin) — bump it by the number of `ok` calls added; the friction is the feature.
826
827 - [ ] **Step 4: Run the suite honestly**
828
829 ```bash
830 make build && make test; echo "test rc=$?"
831 make e2e; echo "e2e rc=$?"
832 make soak SOAK_N=10
833 ```
834
835 Capture exit codes unpiped. Verify cleanup by observation: `ps -o pid,comm -C muxd,sh,cat` names nothing of ours after the run.
836
837 - [ ] **Step 5: Cross-version check**
838
839 Run the existing cross-version e2e (M18 client ↔ v0.0.1-5+agent daemon and inverse, default-session path only) the way M15's was run — the bare-20-byte default attach and the bare status/await payloads are the compat surface; this is the test that proves decisions 3 and 14 held.
840
841 - [ ] **Step 6: Docs**
842
843 - `docs/decisions.md`: an M18 entry — the locked decisions above, plus anything execution amended (amend HERE, in words, when reality disagrees with the plan).
844 - `docs/roadmap.md`: move "multi-session per connection" from the banked group to done, with the note that it shipped as multi-session per *daemon* — connection-per-session over one port/key — and that stream-multiplexing remains banked with certs/NAT.
845
846 - [ ] **Step 7: Commit**
847
848 ```bash
849 git add test/e2e.sh docs/roadmap.md docs/decisions.md
850 git commit -m "test(e2e): two sessions on one daemon, on the wall and off it; M18 close-out"
851 ```
852
853 ---
854
855 ## Self-review notes (plan time + 2026-08-14 amendment)
856
857 - **Spec coverage:** decisions 1–15 map to: 1→T2, 2→(cuts, T8 docs), 3→T1+T6+T8.5, 4→T1, 5→T3, 6→T3, 7→T2+T4, 8→T4, 9→T5, 10→T6, 11→T7, 12→T2, 13→T2+T3, 14→T1+T5, 15→T3 (+muxa in T5).
858 - **Type consistency:** `findSession`/`resolveSession` both defined T3 (T5 reuses `findSession`). `ses(si)` accessor introduced T2, used T3–T5. `encodeAttachNamed`/`encodeAwaitReqNamed`/`attach_max_len`/`await_req_max_len`/`session_name_max`/`default_session`/`validSessionName` defined T1, used T3, T5, T6, T7.
859 - **Merge-driven deltas:** Task 2's sweep list includes `cmd`/`last_return`; Task 3 session-scopes the cmd_state push and the await walk; Task 5 owns every out-of-band instrument (dump, status, await, muxa). The attached-client tail rule (empty or matching, else ignored) is deliberately stricter than aliasing — one rule, no surprises.
860 - **Deliberate non-placeholders:** Task 3 Step 1 and Task 8 Steps 1–2 describe tests as assertion checklists rather than full listings because they must be written in the file's own harness idioms (named with line refs); every assertion to make is enumerated. Everything else is literal code.
docs/superpowers/plans/2026-08-13-web-client.md
Old New
@@ -1,506 +0,0 @@
1 # Web Client (browser wall of devices) Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development
4 > (recommended) or superpowers:executing-plans to implement this plan task-by-task.
5 > Steps use checkbox (`- [ ]`) syntax for tracking.
6
7 **Goal:** `muxweb` — one binary serving a browser page of live terminal tiles,
8 each tile a real mux client (attach, type, resize, scrollback) against real
9 daemons, with the replica core compiled to wasm from the same ghostty-vt the
10 daemon runs.
11
12 **Architecture:** daemon-authoritative, clients are replicas — a browser tab is
13 just another replica. The hub (`muxweb`) dials each device through the CLI
14 client's `Transport` and pumps protocol frames over one WebSocket per tile,
15 parsing frame *headers* (to re-frame onto message-delimited WebSocket) but never
16 payloads. The browser runs `replica.zig` + `keymap.zig` compiled to
17 wasm32-freestanding and paints on canvas. Wall tiles attach at 1×1 (the daemon's
18 degenerate-size refusal makes them permanently passive); only the zoomed tile
19 sends input/resize.
20
21 **Tech stack:** Zig 0.15.2 pinned (`make build`/`make test`/`make e2e` ONLY —
22 system zig cannot build the ghostty dep), std.http's WebSocket server side,
23 wasm32-freestanding, vanilla JS + canvas, no external dependencies anywhere.
24
25 **Contract:** `docs/superpowers/specs/2026-08-11-web-client-design.md` INCLUDING
26 its Amendments section (2026-08-13) — the amendments override the body where
27 they conflict. Spike reference implementation:
28 `/home/xanderle/.claude/jobs/2e57befe/tmp/wasm-spike/repo/spike/wasm/` (build.zig,
29 src/wasm_spike.zig, verify.js) — copy patterns from it freely; it is throwaway
30 scaffolding, not shipping code.
31
32 **Standing rules for every task:** work on main in place; commit per task with
33 repo-style messages + `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`;
34 never invoke zig directly (Makefile only); never touch `~/.config/mux` or
35 `~/.cache/mux`; e2e processes tracked by pid, killed by tracked pid never by
36 name, cleanup verified BY OBSERVATION; e2e temp files named `$TMPDIR/mux-e2e-*`
37 or `muxd-e2e-*` and added to the cleanup trap, or soak goes red.
38
39 ---
40
41 ## Task 1: `src/replica.zig` — the replay core, extracted and finally unit-tested
42
43 **Files:**
44 - Create: `src/replica.zig`
45 - Modify: `src/client.zig` (session(), ~lines 881–1352: replay arms move out)
46 - Modify: `src/server.zig` (applyFrame test helper at :1500–1516 — replace with replica calls)
47 - Modify: `build.zig` (new module, wired into client/server/test loops)
48
49 The survey found NO Replica type exists: all replay state is local variables
50 inside `session()` (client.zig:974–1007), and NONE of client.zig's 24 unit
51 tests touch snapshot/delta replay (e2e-only coverage). This task is a
52 struct-extraction plus writing the missing tests.
53
54 - [ ] **Step 1: Write the failing tests first** — in `src/replica.zig`, against the type sketched in Step 2:
55
56 ```zig
57 test "snapshot replay: prefix consumed, state fed, epoch and seq adopted" {
58 var r = try Replica.init(std.testing.allocator, 80, 24);
59 defer r.deinit();
60 // A real snapshot payload: 24-byte prefix (seq=7, history=0, 80x24,
61 // epoch=0xABCD) + a VT dump that paints "hi" at the origin.
62 var payload: [proto.snapshot_prefix_len + 2]u8 = undefined;
63 proto.writeSnapshotPrefix(payload[0..proto.snapshot_prefix_len], .{
64 .seq = 7, .history_rows = 0, .cols = 80, .rows = 24, .epoch = 0xABCD,
65 });
66 @memcpy(payload[proto.snapshot_prefix_len..], "hi");
67 try r.apply(.snapshot, &payload);
68 try std.testing.expectEqual(@as(u64, 7), r.last_seq);
69 try std.testing.expectEqual(@as(u64, 0xABCD), r.session_epoch);
70 try std.testing.expect(std.mem.startsWith(u8, try r.dumpPlainLine0(), "hi"));
71 }
72
73 test "delta replay: composed rows land, last_seq advances, history follows" { ... }
74 test "snapshot at a new grid size resizes the replica engine first" { ... }
75 test "delta decode failure reports .resync — the caller re-attaches with have_seq=0" { ... }
76 test "attach args: first attach quotes (0,0); after a snapshot, (last_seq, epoch)" { ... }
77 ```
78
79 Each `...` body is written out in full by the implementer using the wire
80 layouts pinned in protocol.zig (snapshot prefix :229–237, delta header :258–272,
81 delta row :286–299) — the formats are golden-tested there, so building real
82 payloads in tests is mechanical. The `.resync` test feeds a delta whose
83 row_count disagrees with its rows (composeDelta returns error.BadPayload,
84 protocol.zig:350) and asserts `apply` returns `.resync` rather than an error.
85
86 - [ ] **Step 2: Implement `Replica`** — the state block from client.zig:974–1007 becomes fields:
87
88 ```zig
89 pub const Replica = struct {
90 eng: Engine,
91 grid: proto.Size, // authoritative grid, may differ from any tty
92 session_epoch: u64 = 0, // daemon instance id, learned from first snapshot
93 last_seq: u64 = 0, // newest seq held; quoted on re-attach
94 history_rows: u32 = 0,
95 state_since_attach: bool = false,
96
97 pub const Applied = enum { painted, resync };
98
99 pub fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) !Replica { ... }
100 pub fn deinit(self: *Replica) void { ... }
101 /// Consume one daemon frame. .snapshot: read prefix, adopt seq/epoch/
102 /// history, resize eng if the grid moved, reset+feed. .delta: composeDelta,
103 /// feed; on BadPayload return .resync (the caller re-attaches with
104 /// have_seq=0 — client.zig:1201-1204's semantics, now named).
105 pub fn apply(self: *Replica, t: proto.MsgType, payload: []const u8) !Applied { ... }
106 /// have_seq/have_epoch for an attach frame: (0,0) until the first
107 /// snapshot, (last_seq, session_epoch) after.
108 pub fn attachArgs(self: *const Replica) struct { seq: u64, epoch: u64 } { ... }
109 };
110 ```
111
112 The replay bodies move verbatim from client.zig:1153–1210 (snapshot arm,
113 delta arm). Scroll-mode state (`scroll_pages`, `requestScrollbackReq` math at
114 client.zig:1518–1529) moves too, as `scrollStart(pages_up)`; the overlay,
115 prediction, banner, and tty-painting code stay in client.zig.
116
117 - [ ] **Step 3: Rewire `session()`** to hold a `Replica` and delegate; rewire
118 server.zig's `applyFrame` test helper (:1500–1516) to call `Replica.apply` —
119 it is a hand-rolled duplicate of the same replay and the survey says
120 replica.zig absorbs it.
121
122 - [ ] **Step 4:** `make test` green (new tests pass, all 24 client tests and the
123 server suite unchanged), then `make e2e` — the 20-scenario suite is the real
124 referee that the cut didn't change behavior. Expected: `e2e OK (20 scenarios,
125 33 convergence points)`.
126
127 - [ ] **Step 5: Commit** — `refactor: replica.zig owns replay — extracted from session(), finally unit-tested`
128
129 ## Task 2: Transport goes public, abort fd injectable
130
131 **Files:**
132 - Modify: `src/client.zig` (Transport :165, Link :149, Incoming :44, waitReady :536/:544, readAnnounceAbortable :615/:626)
133
134 - [ ] **Step 1:** `pub` on `Transport`, its ten methods (open :243,
135 openHandoff :274, openQuicEndpoint :346, pollFd :364, writeFrame :368,
136 service :390, timeoutMs :403, flushQuic :413, readFrame :428, close :465),
137 `Link`, and `Incoming`.
138 - [ ] **Step 2:** Thread an `abort_fd: std.posix.fd_t` through `Transport.open`
139 into `waitReady` and `readAnnounceAbortable`, replacing the hardcoded
140 `STDIN_FILENO`; `-1` means "no abort channel" (skip that pollfd entirely).
141 The mux CLI passes `std.posix.STDIN_FILENO`; the hub will pass `-1`. All
142 four existing call sites updated; behavior for the CLI byte-identical.
143 - [ ] **Step 3:** Add one test: `Transport.open` with `abort_fd = -1` against a
144 refused socket fails with the connect error, not error.UserAbort — pinning
145 that no stray fd-0 read sneaks back in (dispatch-decision test at
146 client.zig:1990 is the template).
147 - [ ] **Step 4:** `make test` green. Commit —
148 `refactor: Transport public, abort fd injectable — the hub dials without a tty`
149
150 ## Task 3: `src/keymap.zig` — normalized key events → VT bytes
151
152 **Files:**
153 - Create: `src/keymap.zig`
154 - Modify: `build.zig` (module + test loop)
155
156 - [ ] **Step 1: Failing tests, table-driven, the modifier matrix included:**
157
158 ```zig
159 test "keymap: the table" {
160 const cases = [_]struct { ev: Event, want: []const u8 }{
161 .{ .ev = .{ .key = .char, .cp = 'a' }, .want = "a" },
162 .{ .ev = .{ .key = .char, .cp = 'a', .mods = .{ .ctrl = true } }, .want = "\x01" },
163 .{ .ev = .{ .key = .enter }, .want = "\r" },
164 .{ .ev = .{ .key = .backspace }, .want = "\x7f" },
165 .{ .ev = .{ .key = .up }, .want = "\x1b[A" },
166 .{ .ev = .{ .key = .up, .mods = .{ .ctrl = true } }, .want = "\x1b[1;5A" },
167 .{ .ev = .{ .key = .up, .mods = .{ .shift = true, .alt = true } }, .want = "\x1b[1;4A" },
168 .{ .ev = .{ .key = .home }, .want = "\x1b[H" },
169 .{ .ev = .{ .key = .page_up }, .want = "\x1b[5~" },
170 .{ .ev = .{ .key = .f5 }, .want = "\x1b[15~" },
171 .{ .ev = .{ .key = .char, .cp = 0x6f22 }, .want = "\xe6\xbc\xa2" }, // UTF-8 out
172 .{ .ev = .{ .key = .char, .cp = 'x', .mods = .{ .alt = true } }, .want = "\x1bx" },
173 };
174 // exhaustive loop; every arrow/nav/function key × modifier combination
175 // the v1 scope names gets a row here, written out by the implementer.
176 }
177 test "keymap: bracketed paste wraps" { ... } // pasteInto(writer, bytes) → \x1b[200~ bytes \x1b[201~
178 ```
179
180 - [ ] **Step 2: Implement** — `Event` = `{ key: Key, cp: u21 = 0, mods: Mods }`,
181 `Key` = enum (char, enter, tab, backspace, escape, up/down/left/right,
182 home/end/insert/delete/page_up/page_down, f1–f12), `Mods` = packed struct
183 (shift/alt/ctrl). One `encode(ev, buf) []const u8` with the CSI-with-modifier
184 rule (`1;{1+shift+2*alt+4*ctrl}`) computed, not tabulated per combination.
185 v1 scope exactly as the spec: printable, control chars, nav, F-keys,
186 modifier CSI variants, bracketed paste. No kitty/CSI-u (spec non-goal).
187 No platform imports — this module must be wasm-clean.
188 - [ ] **Step 3:** `make test` green. Commit — `feat: keymap.zig — normalized key events to VT bytes, tables pinned`
189
190 ## Task 4: `src/wasm_core.zig` + the wasm build step
191
192 **Files:**
193 - Create: `src/wasm_core.zig`
194 - Modify: `build.zig` (second resolved target + wasm exe + install step)
195
196 - [ ] **Step 1 (canary, before anything else):** add the wasm target + a stub
197 wasm_core.zig that imports `replica` (which imports `protocol` and `engine`)
198 and exports one function. `make build`. The survey flags this UNVERIFIED:
199 protocol.zig has six non-test posix references (writeFrame :48, readFrame
200 :75–77, writeAllFd :88–90, readExact :93–96) that lazy analysis SHOULD skip
201 when unreferenced. If the build fails on them, the fix is a
202 `posix.zig`-import split of protocol.zig's fd I/O into `protocol_io.zig`
203 (pure codecs stay) — do that as its own commit and note it as a plan
204 amendment. Build wiring, from the spike's proven recipe:
205
206 ```zig
207 // build.zig — beside the native target resolution:
208 const wasm_target = b.resolveTargetQuery(.{ .cpu_arch = .wasm32, .os_tag = .freestanding });
209 // second instantiation of engine/replica/keymap/protocol modules bound to
210 // wasm_target (modules are target-bound), second ghostty lazyDependency the same way
211 const wasm_exe = b.addExecutable(.{ .name = "mux_core", .root_module = wasm_core_mod,
212 .optimize = .ReleaseSmall });
213 wasm_exe.entry = .disabled; // reactor: exports survive dead-strip
214 wasm_exe.rdynamic = true;
215 // Do NOT set use_llvm/use_lld on the wasm exe (native-only workaround);
216 // no linkQuic, no libc.
217 ```
218
219 - [ ] **Step 2:** wasm_core.zig requirements proven load-bearing by the spike:
220 `pub const std_options: std.Options = .{ .logFn = noopLog };` (without it the
221 build fails inside std before any ghostty code — ghostty-vt logs warnings),
222 `std.heap.wasm_allocator`, optional `@trap` panic override (the hook a real
223 client uses to surface panic text to JS later).
224 - [ ] **Step 3: The ABI — frame-driven, flat readout.** The spike's per-cell
225 exports are a probe shape upstream documents as too slow; the shipping ABI
226 reads the viewport in one pass and is fed FRAMES, not raw bytes, so the core
227 tracks damage from delta row lists itself:
228
229 ```zig
230 export fn mux_init(cols: u32, rows: u32) i32; // 0 ok, -1 alloc, -2 init
231 export fn mux_deinit() void;
232 export fn mux_input_ptr() [*]u8; // staging buffer in
233 export fn mux_input_cap() u32; // 256*1024 — a snapshot must fit; see hub cap note in Task 6
234 export fn mux_apply_frame(msg_type: u32, len: u32) i32; // Replica.apply on staged bytes
235 // 0 painted, 1 RESYNC-NEEDED, -1 uninit, -2 overflow, -3 bad frame
236 export fn mux_attach_seq() u64; // Replica.attachArgs — browser builds the attach frame
237 export fn mux_attach_epoch() u64;
238 export fn mux_cols() u32; export fn mux_rows() u32; // authoritative grid (moves on snapshot)
239 export fn mux_cursor_x() u32; export fn mux_cursor_y() u32;
240 export fn mux_key_encode(key: u32, cp: u32, mods: u32) i32; // keymap → output buf, returns len
241 export fn mux_paste_encode(len: u32) i32; // wraps staged bytes in bracketed paste → output buf
242 export fn mux_viewport_ptr() [*]const u8; // cols*rows cells × 16 bytes:
243 // u32 codepoint | u32 fg (kind<<24|value) |
244 // u32 bg (kind<<24|value) | u32 flags (bit16 wide, bit17 spacer)
245 export fn mux_read_viewport() u32; // repaints DIRTY rows into the viewport buffer,
246 // returns a dirty-row COUNT and clears the set;
247 export fn mux_dirty_row(i: u32) u32; // the i-th dirty row index from that read
248 export fn mux_output_ptr() [*]const u8; export fn mux_output_len() u32;
249 ```
250
251 Damage: `.snapshot`/resize/RESYNC mark all rows dirty; `.delta` marks the
252 rows named in its row headers (the core reads them from the payload it
253 already parses). The JS gotcha goes in a doc comment ON `mux_viewport_ptr`:
254 every memory-growing call can detach cached ArrayBuffer views — JS re-reads
255 `exports.memory.buffer` after every call, no caching (the spike's verify.js
256 shows the discipline).
257 - [ ] **Step 4: Verify from node** (the spike's verify.js pattern): build, feed a
258 real snapshot frame + a real delta frame through `mux_apply_frame`, assert
259 styled cells via the flat viewport, assert the dirty-row list matches the
260 delta's rows, assert key_encode round-trips two table rows. Check in as
261 `web/verify.js` — it becomes the fast non-browser smoke for the core.
262 - [ ] **Step 5:** `make test` + node verify green. Commit —
263 `feat: wasm core — replica+keymap on wasm32-freestanding, flat damage-hinted viewport ABI`
264
265 ## Task 5: `src/webhub.zig` — HTTP, upgrade, Origin check, assets
266
267 **Files:**
268 - Create: `src/webhub.zig`, `web/index.html` (skeleton), `web/mux.js` (skeleton)
269 - Modify: `build.zig` (webhub module; muxweb binary lands in Task 7), `build.zig.zon` (.paths += "web")
270
271 - [ ] **Step 1: Failing tests** for the two decisions std.http does NOT make for us:
272
273 ```zig
274 test "upgrade: exact-origin accepted, anything else refused before upgrade" {
275 // originAllowed(origin, port) — table: "http://127.0.0.1:7681" true,
276 // "http://localhost:7681" true, "http://127.0.0.1:7682" false,
277 // "https://127.0.0.1:7681" false, "http://evil.example" false,
278 // null/absent false. Refusal = HTTP 403 response, connection closed,
279 // upgrade never performed.
280 }
281 test "routes: /, /mux.js, /mux_core.wasm served from @embedFile with correct content-types; /ws/<idx> gated on idx < tile count" { ... }
282 ```
283
284 - [ ] **Step 2: Implement** on `std.net.Stream.reader/.writer` →
285 `std.http.Server.init`, `upgradeRequested`/`respondWebSocket` (std computes
286 the accept key). The ONE buffer passed to the reader bounds both max HTTP
287 header size and max inbound WS message (readSmallMessage rejects fragmented
288 messages outright): **64 KiB**, stated in a comment as the max browser→hub
289 message — a paste bigger than ~64K is chunked by the browser side (Task 8)
290 rather than raising this bound. Origin check iterates request headers
291 (`Request.iterateHeaders`) and refuses BEFORE upgrade; the accepted origins
292 are exactly `http://127.0.0.1:{port}` and `http://localhost:{port}`.
293 Bind `127.0.0.1` only; no flag exists to change the interface (spec).
294 - [ ] **Step 3:** `make test` green. Commit —
295 `feat: webhub — http + websocket upgrade, origin-gated, assets embedded`
296
297 ## Task 6: webhub tile pump — envelope, reconnect, backpressure
298
299 **Files:**
300 - Modify: `src/webhub.zig`
301
302 - [ ] **Step 1: Failing tests:** envelope encode/decode round-trip (0x00 +
303 frame verbatim both directions, 0x01 + JSON control hub→browser only, exactly
304 `{"state":"connecting"|"up"|"reconnecting"|"gone"}`); frame re-framer against
305 a split stream (feed a snapshot frame in 3-byte slices through the
306 accumulator, assert one whole frame out — `pushInbound` server.zig:1003–1045
307 is the reference, including the copy-out-before-handling hazard and the
308 16 MiB `max_payload` drop); backoff schedule table (0, 200, 400, 800, 1600,
309 2000, 2000 — client.zig:1616's arithmetic, extracted as a pure
310 `nextBackoffMs(prev)` the CLI now also calls).
311 - [ ] **Step 2: Implement the per-tile pump thread.** One thread per tile
312 (blocking `Transport.readFrame` is correct in this shape): dial the tile's
313 `Target` with `abort_fd = -1` → send `{"state":"up"}` → loop
314 poll(transport.pollFd, ws socket): daemon frames re-serialized via
315 `proto.appendFrame` and written as `[0x00][frame]` with `writeMessageVec`
316 (three vectors, no copy); browser messages: 0x00 → parse the 5-byte header,
317 `transport.writeFrame(type, payload)` — the hub never inspects payloads.
318 Transport death → `{"state":"reconnecting"}` → re-dial on the backoff
319 schedule, no retry cap → `{"state":"up"}`; the BROWSER owns re-attach (its
320 replica quotes have_seq/have_epoch; RESYNC-NEEDED from the core triggers a
321 fresh attach with (0,0)). Hub process exit only on argv/bind errors;
322 a tile whose target is gone for good narrates `reconnecting` forever —
323 honest, and visible in the tile chrome.
324 A slow browser blocks only its own tile's thread (v1 stance, noted in the
325 file header; the daemon side is protected by its own 8 MiB pending cap and
326 the hub's upstream reads just stall). The 6-byte refusal frame
327 (`0x82 0x01 0x00 0x00 0x00 0x01` — server.zig:879–885, the 9th-client
328 refusal) passes through to the browser like any frame; the tile paints
329 "session full" when its replica sees exit_status before any snapshot
330 (`state_since_attach` false — the CLI's own discriminator).
331 - [ ] **Step 3:** `make test` green. Commit —
332 `feat: webhub tile pump — envelope, re-framer, backoff shared with the CLI`
333
334 ## Task 7: `muxweb` binary — argv, wiring
335
336 **Files:**
337 - Create: `src/webhub_main.zig`
338 - Modify: `build.zig` (muxweb exe: use_llvm/use_lld like the native others, linkQuic)
339
340 - [ ] **Step 1: Failing tests** (in webhub_main.zig, parse() pure like
341 mux_main's): `muxweb HOST --sock PATH quic://h:4433 --key K --port 8000` →
342 three tiles with the right Targets + port 8000; zero targets → usage error;
343 `--key` binds to the quic target per mux_main's flag-beats-env rule.
344 - [ ] **Step 2: Implement** — TARGET spellings delegate to the same parsing
345 mux_main.zig uses (parseArgs :51–127; reuse its helpers rather than copying:
346 export what's needed, mirroring Task 2's visibility pass). `--port N`
347 (default 7681). Tile label = the TARGET string verbatim. Then serve:
348 accept loop → routes (Task 5) → `/ws/<idx>` spawns the tile pump (Task 6).
349 - [ ] **Step 3:** `make build && ./zig-out/bin/muxweb --sock /tmp/nonexistent` by
350 hand: page serves, tile narrates `connecting`→`reconnecting`. Commit —
351 `feat: muxweb — the hub binary; tiles are argv, transports are mux's own`
352
353 ## Task 8: the web shell — canvas renderer, input, wall/zoom
354
355 **Files:**
356 - Modify: `web/index.html`, `web/mux.js`
357
358 No framework, no build step, no external fonts — the page is served from
359 `@embedFile` and must work with a strict same-origin CSP.
360
361 - [ ] **Step 1: Renderer.** One canvas per tile. Measure monospace cell metrics
362 once (`ctx.measureText` on a reference glyph + line height). Paint loop per
363 WS message: envelope 0x00 → stage bytes into `mux_input_ptr`, call
364 `mux_apply_frame`, then `mux_read_viewport()` and repaint ONLY the returned
365 dirty rows from the flat cell array (16 bytes/cell as the Task 4 ABI); wide
366 cells paint at 2× advance, spacers skipped. RE-READ `exports.memory.buffer`
367 after every wasm call — never cache a view (the detach gotcha, stated in
368 mux.js at the top). Envelope 0x01 → parse JSON, set tile chrome state
369 (connecting/up/reconnecting/gone badge). Wall tiles: same grid rendered
370 scaled-to-fit via `ctx.scale` transform; zoomed tile 1:1.
371 - [ ] **Step 2: Attach protocol.** On WS open: wall tile sends attach
372 `(cols=1, rows=1, seq, epoch)` from `mux_attach_seq/epoch` — 1×1 is the
373 passivity mechanism (spec amendment 1): the daemon refuses the size, answers
374 a unicast snapshot with the true grid, and the tile can never claim the
375 grid. Zoom: the zoomed tile computes its cols×rows from cell metrics and
376 sends a real `resize` frame ONLY if that differs from `mux_cols/rows()`;
377 unzoom sends nothing (session stays attached, grid stays where it is).
378 RESYNC-NEEDED (apply returns 1) → re-send attach with the core's current
379 quote; after `reconnecting`→`up` control messages → same re-attach.
380 - [ ] **Step 3: Input.** Keys go ONLY to the zoomed tile — no zoom, no bytes
381 (spec). `keydown` → normalized `(key, cp, mods)` → `mux_key_encode` → 0x00 +
382 input frame. Hidden input element focused while zoomed for IME/dead keys;
383 composition results and paste events go through `mux_paste_encode`, chunked
384 at 32 KiB so no browser→hub message approaches the hub's 64 KiB bound
385 (Task 5). Wheel on the zoomed tile enters scroll mode: fetch_scrollback
386 frames built from the core's scroll state; wall tiles ignore wheel.
387 - [ ] **Step 4:** Update `web/verify.js` to also drive one wall-tile attach
388 byte-sequence through the core (attach args → apply snapshot → viewport
389 readout) so the non-browser smoke covers the page's real call sequence.
390 `make build` + node verify green. Commit —
391 `feat: web shell — canvas wall, 1x1 passive tiles, zoomed input/IME/scrollback`
392
393 ## Task 9: `test/wsclient.zig` — the scripted browser stand-in
394
395 **Files:**
396 - Create: `test/wsclient.zig`
397 - Modify: `build.zig` (7th artifact arg), `test/e2e.sh` (`WSCLIENT="$7"`), `test/soak.sh` (pass-through)
398
399 Modelled on ptyclient.zig (M12) — a Zig test binary keeps the suite free of
400 new system dependencies and speaks the convergence machinery's language.
401
402 - [ ] **Step 1:** Client-side WS: plain TCP connect → hand-rolled upgrade
403 request (16 random bytes base64'd into Sec-WebSocket-Key, assert HTTP 101 +
404 correct Sec-WebSocket-Accept) → RFC 6455 client framing (4-byte XOR mask on
405 send — ~30 lines; the server side stays std's). `--origin STR` flag sets the
406 Origin header verbatim (the wrong-Origin scenario needs to forge one).
407 - [ ] **Step 2:** It links `replica` + `engine` + `protocol` and maintains a
408 real replica from the frames it receives (envelope 0x00 → Replica.apply).
409 Script verbs on stdin, ptyclient's exact conventions (decodeEscapes,
410 expect-cursor semantics, deadlines in ms, exit codes 2/3/4):
411 `attach C R` (send attach with the replica's quote), `send BYTES` (input
412 frame), `resize C R`, `expectgrid NEEDLE MS` (poll the replica's plain dump),
413 `expectstate STATE MS` (control messages), `settle QUIET MS`,
414 `dumpexit` (write the replica's grid in `muxd dump` text format to `--out`,
415 close, exit 0). `--out`/`--err` required, like ptyclient.
416 - [ ] **Step 3:** Unit tests in-file: mask framing round-trip against std's
417 server-side reader; dump format golden-matched against `Engine.dumpPlain`
418 so `diff` against `muxd dump` is honest. `make test` green. Commit —
419 `test: wsclient — scripted WebSocket replica client for hub scenarios`
420
421 ## Task 10: e2e scenarios
422
423 **Files:**
424 - Modify: `test/e2e.sh` (three new scenario blocks + trap entries + count pins), `build.zig` (muxweb + wsclient artifact args — wsclient's arg landed in Task 9, add muxweb's 8th here and shift e2e.sh's intake to `MUXWEB="$8"`)
425
426 Port bands (the suite's idiom is `BASE + ($$ % 4000)`, bases 5000 apart; free
427 bands): `WPORT=$((41000 + ($$ % 4000)))` for the hub. Daemon sockets follow the
428 existing `$TMPDIR/mux-e2e-*` naming. Every pid tracked, teardown via the
429 four-assertion `muxd stop` shape (rc 0, `^muxd: stopped`, socket gone,
430 `wait_pid_gone`), pids nulled after observation. The M14 cold-handoff block
431 (e2e.sh:2314–2474) is the template.
432
433 - [ ] **Scenario A — the 1×1 passivity pin (daemon contract, no hub needed).**
434 Daemon + a normal 80×24 client via `--sock`; a wsclient... no — this pin
435 must not depend on the hub: use `muxd dump` as the observer. Attach a raw
436 1×1 client (wsclient can't reach a unix socket; use `mux --sock` under
437 ptyclient with `--cols 1 --rows 1`), type into the NORMAL client, assert:
438 the daemon grid stayed 80×24 the whole time (`muxd dump` linecount), the
439 1×1 attacher received a snapshot (its capture non-empty), and input typed
440 AT the 1×1 client... is out of scope — wall tiles never send input; the pin
441 is only "a 1×1 attach cannot move the grid, before or after the real
442 client types". `ok "a 1x1 attach is refused the grid and can never claim it"`.
443 - [ ] **Scenario B — hub basic + wrong Origin.** Daemon (tracked pid) → muxweb
444 `--sock $SOCK --port $WPORT` (background, `WPID=$!`, readiness = poll
445 `curl -sf http://127.0.0.1:$WPORT/` — curl is NOT currently in the suite's
446 inventory, so use wsclient itself with a `probe` no-op script... simpler:
447 readiness = wsclient `expectstate up 5000` retried; on timeout FAIL).
448 wsclient script: `attach 1 1` / `expectgrid <marker> 15000` after the marker
449 is typed through a parallel `mux --sock` client / `dumpexit`; then
450 `diff` wsclient's `--out` grid against `muxd dump` (trailing-space strip,
451 the converged_quiet discipline) — plus the MUST-FAIL control: doctor the
452 dump copy and assert the diff catches it ("a check that cannot fail proves
453 nothing"). Same block: a second wsclient with `--origin http://evil.example`
454 asserts the upgrade is REFUSED (no 101) and the daemon never saw a second
455 client (`muxd stats` clients count unchanged). Kill hub by tracked pid,
456 `wait_pid_gone`. `ok "hub pumps a real session; wrong origin refused"`.
457 - [ ] **Scenario C — tear and re-attach through the hub.** Hub + daemon up,
458 wsclient attached and holding a grid; `muxd stop` the daemon (four
459 assertions) → wsclient `expectstate reconnecting 5000`; restart daemon on
460 the SAME socket → `expectstate up 15000` → wsclient re-attaches (new epoch
461 → RESYNC path) → type a fresh marker through a CLI client → wsclient
462 `expectgrid` + `dumpexit` + converge diff. Teardown: stop daemon (observed),
463 kill hub by pid (observed). `ok "hub narrates the tear; the replica re-attaches across an epoch"`.
464 - [ ] **Count pins:** scenarios 20→23, convergence points 33→35 — but COUNT
465 them at implementation (the numbers here are the plan's expectation, the
466 script's actual `ok`/`CONV_COUNT` calls are the truth) and update both
467 literal pins together (e2e.sh:2824, :2829).
468 - [ ] `make e2e` green twice, then `make soak SOAK_N=3` (tmp-leak gate).
469 Commit — `test: e2e — hub scenarios: passivity pin, origin gate, tear/re-attach`
470
471 ## Task 11: kill criterion + close-out
472
473 - [ ] **Step 1:** The manual pass, exactly as the spec's kill criterion: wall of
474 three real devices (this desktop via `--sock`, the LAN box 192.168.0.109
475 via `quic://`, the WAN box via its ssh spelling), zoom the LAN tile, run
476 vim, type, scroll back, unzoom; the desktop CLI client on the same session
477 never glitches; pull the LAN box's network (or `muxd stop` remotely) and
478 watch `reconnecting` → recover. View once over `ssh -L` from another
479 machine. Record the observations (not claims) in the close-out.
480 - [ ] **Step 2:** `docs/roadmap.md` — the milestone entry (what shipped, the
481 passivity mechanism, the std-WebSocket adoption, the wsclient instrument);
482 `docs/decisions.md` — new section recording: attach-1×1 as the passivity
483 contract and why attach-at-size broke the kill criterion; the header-parse
484 amendment to the proxy thesis; the shared 64 KiB header/message bound and
485 the paste-chunking answer; the detached-ArrayBuffer discipline; protocol.zig
486 wasm-cleanliness outcome (canary result from Task 4).
487 - [ ] **Step 3:** `make test && make e2e && make soak SOAK_N=10` all green.
488 Commit, push.
489
490 ---
491
492 ## Self-review notes (plan author)
493
494 - Task ordering is load-bearing: 1 (replica) before 4 (core imports it) and
495 9 (wsclient links it); 2 (Transport pub) before 6/7 (hub dials it); 9
496 before 10.
497 - The Task 4 canary is the plan's riskiest unknown (protocol.zig on wasm);
498 its fallback (protocol_io.zig split) is authorized in-task with a plan
499 amendment note, not a stop.
500 - Scenario A deliberately avoids the hub so the daemon contract pin survives
501 even if the hub grows; Scenario B carries the must-fail control the suite's
502 doctrine requires.
503 - Sizes: mux_input_cap 256 KiB (a snapshot of a full 10k-scrollback grid dump
504 transits as ONE frame — verify against real snapshot sizes in Task 4 and
505 raise if the canary shows bigger); hub inbound WS bound 64 KiB with browser
506 paste chunked at 32 KiB; both stated in code comments at the constant.
docs/superpowers/plans/2026-08-14-hygiene-kit.md
Old New
@@ -1,1064 +0,0 @@
1 # Hygiene Kit 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:** Convert the repo's architectural rules (import direction, format cleanliness, forced analysis, leak-free lifecycles) into build mechanisms that fail loudly when broken.
6
7 **Architecture:** Six independent gates. The riskiest is a pure refactor of build.zig that replaces 87 scattered `addImport` calls with a declared module table whose layer ordering is validated at comptime; the rest are additive: a fmt step, `refAllDecls` test blocks, a non-gating dead-code report, a DebugAllocator leak marker asserted by the e2e suites, a persistent-daemon soak phase, and a valgrind recipe.
8
9 **Tech Stack:** Zig 0.15.2 (pinned toolchain), POSIX sh test scripts.
10
11 **Spec:** `docs/superpowers/specs/2026-08-14-hygiene-kit-design.md` — read it before starting any task.
12
13 ---
14
15 ## Environment (every task)
16
17 - Work in this worktree: `/home/xanderle/code/rad/mux/.worktrees/hygiene-kit` (branch `feat/hygiene-kit`).
18 - The system `zig` CANNOT build this repo (0.17-dev breaks the ghostty dep). Every zig command uses the pinned toolchain:
19 ```sh
20 ZIG="$HOME/Downloads/zig-x86_64-linux-0.15.2/zig"
21 ```
22 - **Never read an exit code through a pipe** (`$ZIG build test | tail` reports tail's 0 over a failing build). Run the command bare, or capture `$?` on the line immediately after.
23 - Gates at every commit: `"$ZIG" build` and `"$ZIG" build test` green. Tasks that touch e2e/agent/soak surfaces also run their suite (stated per task; e2e ≈ 4 min, agent ≈ 1 min, soak ≈ 25 min — soak only where the task says so).
24 - Every commit message ends with the trailer:
25 ```
26 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
27 ```
28
29 ---
30
31 ### Task 1: Format gate — `zig build fmt` and the `check` umbrella
32
33 **Files:**
34 - Modify: `test/wsclient.zig` (mechanical `zig fmt`, no hand edits)
35 - Modify: `build.zig` (two new steps, at the end of `build()`)
36
37 - [ ] **Step 1: Confirm the tree's only fmt straggler, then format it**
38
39 ```sh
40 ZIG="$HOME/Downloads/zig-x86_64-linux-0.15.2/zig"
41 "$ZIG" fmt --check build.zig src test
42 ```
43 Expected: prints `test/wsclient.zig`, exits 1. (If it prints more files, format those too — same commit.) Then:
44 ```sh
45 "$ZIG" fmt test/wsclient.zig
46 "$ZIG" fmt --check build.zig src test
47 ```
48 Expected: no output, exit 0.
49
50 - [ ] **Step 2: Verify formatting changed nothing semantically**
51
52 ```sh
53 "$ZIG" build test
54 ```
55 Expected: PASS (all steps green).
56
57 - [ ] **Step 3: Commit the formatting alone**
58
59 ```sh
60 git add test/wsclient.zig
61 git commit -m "style: zig fmt test/wsclient.zig — the one straggler since the last sweep"
62 ```
63 (Trailer as in Environment.)
64
65 - [ ] **Step 4: Add the fmt step and check umbrella to build.zig**
66
67 At the very end of `pub fn build` (after the `bench_step` block, which is the current last block):
68
69 ```zig
70 // The format gate. `check = true` makes this a --check run: it fails
71 // naming the offending files and rewrites nothing.
72 const fmt_step = b.step("fmt", "Check formatting (zig fmt --check)");
73 const fmt = b.addFmt(.{ .paths = &.{ "build.zig", "src", "test" }, .check = true });
74 fmt_step.dependOn(&fmt.step);
75
76 // The seconds-long pre-commit gate: fmt + unit tests. e2e/agent/soak
77 // stay separate on purpose — they are minutes-long and process-spawning.
78 const check_step = b.step("check", "fmt + unit tests — the pre-commit gate");
79 check_step.dependOn(fmt_step);
80 check_step.dependOn(test_step);
81 ```
82
83 Note: `test_step` is already in scope (declared mid-function). `b.addFmt` recurses into directories and touches only `.zig` files.
84
85 - [ ] **Step 5: Verify the gate passes, then verify it can fail**
86
87 ```sh
88 "$ZIG" build fmt
89 echo "fmt exit: $?"
90 "$ZIG" build check
91 echo "check exit: $?"
92 ```
93 Expected: both exit 0. Then break it deliberately:
94 ```sh
95 printf '\n\n\n' >> src/keymap.zig
96 "$ZIG" build fmt
97 echo "fmt exit: $?"
98 git checkout src/keymap.zig
99 ```
100 Expected: the fmt run FAILS naming `src/keymap.zig`; after the checkout, `"$ZIG" build fmt` passes again.
101
102 - [ ] **Step 6: Commit**
103
104 ```sh
105 git add build.zig
106 git commit -m "build: zig build fmt gate + check umbrella (fmt + unit tests)"
107 ```
108
109 ---
110
111 ### Task 2: Force-analysis — `refAllDecls` in every test-loop module root
112
113 **Files:**
114 - Modify: all 32 module roots in build.zig's test loop (list in Step 4)
115
116 Scope honesty (from the spec, repeat it in the commit message): `refAllDecls` walks **pub** decls only — private unreferenced decls stay dark. This narrows the silent-module-loss hazard, it does not retire it.
117
118 - [ ] **Step 1: Prove the hazard exists today (the "failing test")**
119
120 Add to the END of `src/paint.zig` a broken, unreferenced pub decl:
121
122 ```zig
123 pub const dead_probe: NoSuchType = undefined;
124 ```
125
126 Run:
127 ```sh
128 ZIG="$HOME/Downloads/zig-x86_64-linux-0.15.2/zig"
129 "$ZIG" build test
130 echo "exit: $?"
131 ```
132 Expected: **PASS (exit 0)** — the broken decl is never analyzed. That is the hazard.
133
134 - [ ] **Step 2: Add the force-analysis block to paint.zig, watch it catch**
135
136 Add to the END of `src/paint.zig` (after the probe):
137
138 ```zig
139 // Forces semantic analysis of every pub decl under `zig build test`, so an
140 // unreferenced decl must at least compile (the silent-module-loss hazard,
141 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
142 test {
143 std.testing.refAllDeclsRecursive(@This());
144 }
145 ```
146
147 Run: `"$ZIG" build test` — Expected: **FAIL** with `use of undeclared identifier 'NoSuchType'` in paint.zig.
148
149 - [ ] **Step 3: Remove the probe, confirm green**
150
151 Delete the `dead_probe` line (keep the test block). Run `"$ZIG" build test` — Expected: PASS.
152
153 - [ ] **Step 4: Sweep the same block into the remaining 31 module roots**
154
155 Add the exact block from Step 2 (comment included) to the end of each of:
156
157 `src/protocol.zig`, `src/engine.zig`, `src/pty.zig`, `src/delta.zig`, `src/cmd.zig`, `src/shellint.zig`, `src/replica.zig`, `src/keymap.zig`, `src/webhub.zig`, `src/sockpath.zig`, `src/muxa.zig`, `src/server.zig`, `src/client.zig`, `src/proxy.zig`, `src/mux_main.zig`, `src/main.zig`, `src/testtmp.zig`, `src/predict.zig`, `src/xdg.zig`, `src/spawn.zig`, `src/handoff.zig`, `src/webhub_main.zig`, `test/script.zig`, `test/rawmode.zig`, `test/delaypipe.zig`, `test/render.zig`, `test/ptyclient.zig`, `test/wsclient.zig`
158
159 **EXCEPT** these three, which get the PLAIN variant (their roots reach `pub const c = @cImport(...)` of the QUIC stack — the recursive walk would force-analyze the whole wolfSSL/ngtcp2 namespace):
160
161 `src/quic.zig`, `src/quic_server.zig`, `src/quic_client.zig`:
162
163 ```zig
164 // Plain, not recursive: this module reaches the QUIC stack's @cImport, and
165 // a recursive walk would force-analyze the entire wolfSSL/ngtcp2 namespace.
166 test {
167 std.testing.refAllDecls(@This());
168 }
169 ```
170
171 `src/wasm_core.zig` is deliberately EXCLUDED — it is not in the native test loop, so a test block there would never run (decorative coverage is worse than stated absence).
172
173 If any file's recursive variant fails to build (platform-divergent decls, eval-branch-quota), drop that file to the plain variant and name it in the commit message.
174
175 - [ ] **Step 5: Run the full gates**
176
177 ```sh
178 "$ZIG" build test
179 echo "exit: $?"
180 "$ZIG" build fmt
181 ```
182 Expected: both PASS. (Some previously-dark decls may now fail to compile — that is the mechanism WORKING; fix the rot it finds, minimally, and note each fix in the commit message.)
183
184 - [ ] **Step 6: Commit**
185
186 ```sh
187 git add -A src test
188 git commit -m "test: refAllDecls(Recursive) in every test-loop module root
189
190 Pub decls only — private unreferenced decls stay dark; this narrows the
191 silent-module-loss hazard, not retires it. quic/quic_server/quic_client
192 use the plain variant (cImport namespace); wasm_core excluded (not in
193 the native test loop, a block there would never run)."
194 ```
195
196 ---
197
198 ### Task 3: The layer table — build.zig's import graph as declared data
199
200 **Files:**
201 - Modify: `build.zig` (the module-creation region, the wasm section, the test loop)
202
203 This is a **pure refactor**: the derived graph must equal the old graph. All existing steps (`test`/`e2e`/`agent`/`soak`/`bench`/`fmt`/`check`/install) must behave byte-for-byte identically. The two adjudications are already decided in the spec: `server → replica` moves to the test_imports column (the edge itself stays); `client → proxy` stays production, grandfathered.
204
205 - [ ] **Step 1: Record the pre-refactor state**
206
207 ```sh
208 ZIG="$HOME/Downloads/zig-x86_64-linux-0.15.2/zig"
209 PRE=$(git rev-parse HEAD)
210 echo "$PRE" > /tmp/claude-1000/-home-xanderle-code-rad-mux/8be6cee4-1886-42cd-a21d-d5739653c727/scratchpad/pre-refactor-sha
211 git show "$PRE":build.zig \
212 | grep -oE '[a-z_0-9]+_mod\.addImport\("[a-z_-]+"' \
213 | sed -E 's/_mod\.addImport\("/ /; s/"//' \
214 | grep -v '_wasm ' | grep -v '^wasm_core ' \
215 | grep -v 'ghostty' | grep -v 'build_options' \
216 | sort > /tmp/claude-1000/-home-xanderle-code-rad-mux/8be6cee4-1886-42cd-a21d-d5739653c727/scratchpad/old-edges.txt
217 wc -l /tmp/claude-1000/-home-xanderle-code-rad-mux/8be6cee4-1886-42cd-a21d-d5739653c727/scratchpad/old-edges.txt
218 ```
219 Expected: 76 native production+test edges (87 total addImport calls minus 3 build_options, 2 ghostty, 6 wasm-side). The exact number matters less than the file — it is the diff baseline.
220
221 - [ ] **Step 2: Add the table, validation, and test order at FILE SCOPE**
222
223 In `build.zig`, after the existing `linkQuic` function and before `pub fn build`, add:
224
225 ```zig
226 /// One row of the module table: the import graph as declared data.
227 /// The wiring loop below derives every addImport from it, so an import
228 /// the table does not declare cannot exist — violations are impossible,
229 /// not detected. Layers are the topological strata of the production
230 /// graph, computed 2026-08-14 and FROZEN: a new import that would
231 /// flatten or invert a stratum fails at comptime, and re-stratifying
232 /// requires editing this table, which is the point.
233 const ModSpec = struct {
234 name: []const u8,
235 path: []const u8,
236 layer: u8,
237 /// Production imports: must point at a strictly lower layer.
238 imports: []const []const u8 = &.{},
239 /// Test-only imports (the testtmp pattern): wired identically — lazy
240 /// compilation keeps them out of release binaries — but declared
241 /// apart, because "test scaffolding never ships" is a stated rule.
242 /// Excluded from the strata computation.
243 test_imports: []const []const u8 = &.{},
244 link_libc: bool = false,
245 /// Also instantiated against wasm32 (the muxweb core's twins).
246 wasm: bool = false,
247 /// This module's test binary needs the QUIC archives.
248 quic_tests: bool = false,
249 };
250
251 const mod_table = [_]ModSpec{
252 // ---- layer 0: imports nothing internal in production ----
253 .{ .name = "protocol", .path = "src/protocol.zig", .layer = 0, .wasm = true },
254 .{ .name = "engine", .path = "src/engine.zig", .layer = 0, .wasm = true },
255 .{ .name = "pty", .path = "src/pty.zig", .layer = 0, .link_libc = true },
256 .{ .name = "quic", .path = "src/quic.zig", .layer = 0, .link_libc = true, .quic_tests = true },
257 .{ .name = "testtmp", .path = "src/testtmp.zig", .layer = 0 },
258 .{ .name = "keymap", .path = "src/keymap.zig", .layer = 0, .wasm = true },
259 .{ .name = "script", .path = "test/script.zig", .layer = 0 },
260 .{ .name = "rawmode", .path = "test/rawmode.zig", .layer = 0 },
261 .{ .name = "delaypipe", .path = "test/delaypipe.zig", .layer = 0 },
262 .{ .name = "xdg", .path = "src/xdg.zig", .layer = 0, .link_libc = true, .test_imports = &.{"testtmp"} },
263 .{ .name = "sockpath", .path = "src/sockpath.zig", .layer = 0, .test_imports = &.{"testtmp"} },
264 .{ .name = "proxy", .path = "src/proxy.zig", .layer = 0, .link_libc = true, .test_imports = &.{"testtmp"} },
265 // ---- layer 1: single-hop over the leaves ----
266 .{ .name = "quic_server", .path = "src/quic_server.zig", .layer = 1, .link_libc = true, .imports = &.{"quic"}, .quic_tests = true },
267 .{ .name = "quic_client", .path = "src/quic_client.zig", .layer = 1, .link_libc = true, .imports = &.{"quic"}, .quic_tests = true },
268 .{ .name = "predict", .path = "src/predict.zig", .layer = 1, .imports = &.{"protocol"} },
269 .{ .name = "spawn", .path = "src/spawn.zig", .layer = 1, .link_libc = true, .imports = &.{"xdg"}, .test_imports = &.{"testtmp"} },
270 .{ .name = "handoff", .path = "src/handoff.zig", .layer = 1, .link_libc = true, .imports = &.{"xdg"}, .test_imports = &.{"testtmp"} },
271 .{ .name = "delta", .path = "src/delta.zig", .layer = 1, .imports = &.{ "engine", "protocol" } },
272 .{ .name = "cmd", .path = "src/cmd.zig", .layer = 1, .imports = &.{ "engine", "protocol" } },
273 .{ .name = "shellint", .path = "src/shellint.zig", .layer = 1, .imports = &.{"xdg"} },
274 .{ .name = "replica", .path = "src/replica.zig", .layer = 1, .wasm = true, .imports = &.{ "engine", "protocol" } },
275 .{ .name = "paint", .path = "src/paint.zig", .layer = 1, .imports = &.{ "engine", "protocol" } },
276 .{ .name = "render", .path = "test/render.zig", .layer = 1, .imports = &.{"engine"} },
277 .{ .name = "ptyclient", .path = "test/ptyclient.zig", .layer = 1, .link_libc = true, .imports = &.{ "pty", "script" } },
278 // ---- layer 2 ----
279 .{ .name = "server", .path = "src/server.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "pty", "protocol", "delta", "cmd", "shellint", "sockpath", "quic", "quic_server", "xdg" }, .test_imports = &.{ "replica", "testtmp" }, .quic_tests = true },
280 .{ .name = "client", .path = "src/client.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "protocol", "replica", "quic_client", "quic", "predict", "handoff", "proxy", "paint" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
281 .{ .name = "muxa", .path = "src/muxa.zig", .layer = 2, .link_libc = true, .imports = &.{ "protocol", "sockpath", "quic_client", "quic", "xdg" }, .quic_tests = true },
282 .{ .name = "wsclient", .path = "test/wsclient.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "replica", "protocol", "script" } },
283 // ---- layer 3 ----
284 .{ .name = "webhub", .path = "src/webhub.zig", .layer = 3, .imports = &.{ "protocol", "client" }, .quic_tests = true },
285 .{ .name = "mux", .path = "src/mux_main.zig", .layer = 3, .link_libc = true, .imports = &.{ "client", "xdg", "spawn", "handoff", "sockpath" }, .quic_tests = true },
286 .{ .name = "exe", .path = "src/main.zig", .layer = 3, .link_libc = true, .imports = &.{ "server", "protocol", "cmd", "proxy", "quic", "quic_server", "xdg", "spawn", "handoff", "sockpath" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
287 // ---- layer 4 ----
288 .{ .name = "webhub_main", .path = "src/webhub_main.zig", .layer = 4, .link_libc = true, .imports = &.{ "client", "webhub", "xdg", "handoff", "sockpath" }, .quic_tests = true },
289 };
290
291 fn layerOf(comptime name: []const u8) u8 {
292 for (mod_table) |m| {
293 if (std.mem.eql(u8, m.name, name)) return m.layer;
294 }
295 @compileError("module table: unknown module '" ++ name ++ "'");
296 }
297
298 comptime {
299 for (mod_table) |m| {
300 for (m.imports) |dep| {
301 if (layerOf(dep) >= m.layer) @compileError(std.fmt.comptimePrint(
302 "layer violation: {s} (layer {d}) imports {s} (layer {d}) — " ++
303 "production imports point strictly downward; re-stratifying " ++
304 "is a deliberate edit to mod_table, never an accident",
305 .{ m.name, m.layer, dep, layerOf(dep) },
306 ));
307 }
308 // Test-only imports skip the direction rule but must name real rows.
309 for (m.test_imports) |dep| _ = layerOf(dep);
310 }
311 }
312
313 /// Test registration order. Doctrine-laden and deliberately NOT derived
314 /// from the layers: delta, cmd, shellint and sockpath run BEFORE server
315 /// because their tests are seconds-long and socket-free, while a
316 /// regression in any of them can wedge a server test that waits on a
317 /// client forever — and a wedged step prints nothing at all. Failing
318 /// first is what makes the catch legible. script leads for the same
319 /// reason: instant, allocation-only, and both fixtures inherit its
320 /// escape pins. mux and exe are executable roots but carry the argument
321 /// parsers — a test that is never built is not a test (decisions.md).
322 const test_order = [_][]const u8{
323 "script", "protocol", "engine", "pty", "delta",
324 "cmd", "shellint", "replica", "keymap", "webhub",
325 "sockpath", "muxa", "server", "client", "proxy",
326 "mux", "quic", "quic_server", "exe", "testtmp",
327 "quic_client", "predict", "rawmode", "delaypipe", "xdg",
328 "spawn", "handoff", "paint", "render", "ptyclient",
329 "webhub_main", "wsclient",
330 };
331
332 comptime {
333 // Every table row appears in the test loop exactly once. A module in
334 // the table but not the loop is the silent-module-loss hazard with a
335 // new spelling; a duplicate runs a suite twice and skews timings.
336 if (test_order.len != mod_table.len)
337 @compileError("test_order must cover every mod_table row exactly once");
338 for (test_order, 0..) |n, i| {
339 _ = layerOf(n);
340 for (test_order[i + 1 ..]) |n2| {
341 if (std.mem.eql(u8, n, n2)) @compileError("duplicate in test_order: " ++ n);
342 }
343 }
344 }
345 ```
346
347 - [ ] **Step 3: Replace the module-creation region with the derived loop**
348
349 Delete every `b.createModule(...)` block and every `*.addImport(...)` line for the 32 table modules, wherever they sit — the big region from `const protocol_mod = b.createModule(.{` (currently line ~90) down to `exe_mod`, AND the three that live interleaved with the exe section further down: `muxa_mod` (~line 452), `wsclient_mod` (~line 541), `webhub_main_mod` (~line 556). KEEP: the `version`/`version_opts` block, `target`/`optimize`/`quic`/`ghostty_dep`, every `b.addExecutable`/`use_llvm`/`use_lld`/`linkQuic`/`installArtifact` line, the three `webhub_main_mod.addAnonymousImport(...)` lines, and the wasm/verify/e2e/agent/soak/bench step blocks (the wasm and test-loop rewrites are Steps 4 and 5). In place of the deleted region at the top:
350
351 ```zig
352 // The table is the law; this loop can only wire what it declares.
353 const idx = struct {
354 fn of(name: []const u8) usize {
355 for (&mod_table, 0..) |m, i| {
356 if (std.mem.eql(u8, m.name, name)) return i;
357 }
358 unreachable; // every lookup below is comptime-checked against the table
359 }
360 };
361 var mods: [mod_table.len]*std.Build.Module = undefined;
362 for (&mod_table, 0..) |spec, i| {
363 mods[i] = b.createModule(.{
364 .root_source_file = b.path(spec.path),
365 .target = target,
366 .optimize = optimize,
367 .link_libc = if (spec.link_libc) true else null,
368 });
369 }
370 for (&mod_table, 0..) |spec, i| {
371 for (spec.imports) |dep| mods[i].addImport(dep, mods[idx.of(dep)]);
372 for (spec.test_imports) |dep| mods[i].addImport(dep, mods[idx.of(dep)]);
373 }
374
375 // -Dgraph: dump the declared edges for the extract-and-diff proof.
376 if (b.option(bool, "graph", "print the module import graph and continue") orelse false) {
377 for (&mod_table) |spec| {
378 for (spec.imports) |dep| std.debug.print("edge {s} {s}\n", .{ spec.name, dep });
379 for (spec.test_imports) |dep| std.debug.print("edge {s} {s}\n", .{ spec.name, dep });
380 }
381 }
382
383 // Named handles for the exe/wasm/step wiring below — only the ones
384 // that wiring actually uses (an unused local is a compile error).
385 // Dep edges (ghostty) and build_options stay outside the table's
386 // jurisdiction, explicit.
387 const engine_mod = mods[idx.of("engine")];
388 const mux_mod = mods[idx.of("mux")];
389 const exe_mod = mods[idx.of("exe")];
390 const muxa_mod = mods[idx.of("muxa")];
391 const rawmode_mod = mods[idx.of("rawmode")];
392 const delaypipe_mod = mods[idx.of("delaypipe")];
393 const render_mod = mods[idx.of("render")];
394 const ptyclient_mod = mods[idx.of("ptyclient")];
395 const wsclient_mod = mods[idx.of("wsclient")];
396 const webhub_main_mod = mods[idx.of("webhub_main")];
397
398 if (ghostty_dep) |dep| {
399 engine_mod.addImport("ghostty-vt", dep.module("ghostty-vt"));
400 }
401 mux_mod.addImport("build_options", version_opts.createModule());
402 exe_mod.addImport("build_options", version_opts.createModule());
403 webhub_main_mod.addImport("build_options", version_opts.createModule());
404 ```
405
406 The exe section below uses exactly these handles (`exe` is built from `exe_mod`, `mux` from `mux_mod`, and so on) — if the compiler flags one as unused after your deletion, you deleted a keeper line; if it flags a missing identifier, you kept a deleted one. The three `webhub_main_mod.addAnonymousImport(...)` lines and everything about the exes stay exactly as they are, but the `webhub_main_mod.addImport("build_options", ...)` call from the old webhub_main block is now in the snippet above — do not duplicate it.
407
408 - [ ] **Step 4: Rewrite the wasm section's twin instantiation from the table**
409
410 Replace the four `wasmMod(...)` twin creations and their `addImport` lines (KEEP `wasm_target`, `ghostty_wasm_dep`, `wasm_core_mod`, `wasm_exe` and its flags):
411
412 ```zig
413 // Rows with .wasm get wasm32 twins, imports rewired from the SAME table
414 // rows — one source of truth for both instantiations. wasm_core itself
415 // stays explicit: it is wasm-only, never in the native test loop.
416 var wasm_mods = [_]?*std.Build.Module{null} ** mod_table.len;
417 for (&mod_table, 0..) |spec, i| {
418 if (spec.wasm) wasm_mods[i] = wasmMod(b, wasm_target, spec.path);
419 }
420 for (&mod_table, 0..) |spec, i| {
421 if (wasm_mods[i]) |wm| {
422 for (spec.imports) |dep| {
423 wm.addImport(dep, wasm_mods[idx.of(dep)] orelse
424 @panic("wasm module imports a module without the wasm flag"));
425 }
426 }
427 }
428 const engine_wasm_mod = wasm_mods[idx.of("engine")].?;
429 if (ghostty_wasm_dep) |dep| {
430 engine_wasm_mod.addImport("ghostty-vt", dep.module("ghostty-vt"));
431 }
432 const wasm_core_mod = wasmMod(b, wasm_target, "src/wasm_core.zig");
433 wasm_core_mod.addImport("engine", engine_wasm_mod);
434 wasm_core_mod.addImport("protocol", wasm_mods[idx.of("protocol")].?);
435 wasm_core_mod.addImport("replica", wasm_mods[idx.of("replica")].?);
436 wasm_core_mod.addImport("keymap", wasm_mods[idx.of("keymap")].?);
437 ```
438
439 - [ ] **Step 5: Rewrite the test loop from test_order**
440
441 Replace the current `for ([_]*std.Build.Module{ script_mod, ... }) |mod| { ... }` loop (its doctrine comment has already moved onto `test_order` at file scope — delete it here):
442
443 ```zig
444 for (test_order) |name| {
445 const i = idx.of(name);
446 const t = b.addTest(.{ .root_module = mods[i] });
447 t.use_llvm = true;
448 t.use_lld = true;
449 // quic_tests is also what makes `make test` build the QUIC deps on
450 // a clean checkout — the dependency must reach the test binaries,
451 // not only muxd (decisions.md, M8).
452 if (mod_table[i].quic_tests) linkQuic(b, t, quic);
453 test_step.dependOn(&b.addRunArtifact(t).step);
454 }
455 ```
456
457 - [ ] **Step 6: Build and prove graph equality**
458
459 ```sh
460 "$ZIG" build
461 echo "build exit: $?"
462 SCRATCH=/tmp/claude-1000/-home-xanderle-code-rad-mux/8be6cee4-1886-42cd-a21d-d5739653c727/scratchpad
463 "$ZIG" build -Dgraph 2>&1 | grep '^edge ' | sed 's/^edge //' | sort > "$SCRATCH/new-edges.txt"
464 diff "$SCRATCH/old-edges.txt" "$SCRATCH/new-edges.txt"
465 echo "diff exit: $?"
466 ```
467 Expected: build exit 0; **diff empty, exit 0**. (The server→replica column move does not change the edge PAIR, so the sets are identical. Any other delta is a wiring bug — fix the table, not the baseline.)
468
469 - [ ] **Step 7: Deliberate-violation check**
470
471 In `mod_table`, temporarily add `"server"` to protocol's imports:
472 ```zig
473 .{ .name = "protocol", .path = "src/protocol.zig", .layer = 0, .wasm = true, .imports = &.{"server"} },
474 ```
475 Run `"$ZIG" build` — Expected: **compile error** `layer violation: protocol (layer 0) imports server (layer 2)…`. Revert the line exactly, rebuild, confirm green.
476
477 - [ ] **Step 8: All gates**
478
479 ```sh
480 "$ZIG" build test
481 echo "test exit: $?"
482 "$ZIG" build fmt && "$ZIG" build e2e
483 echo "e2e exit: $?"
484 "$ZIG" build agent
485 echo "agent exit: $?"
486 ```
487 Expected: all 0. (e2e ≈ 4 min, agent ≈ 1 min. These MUST run for this task — the refactor touches every artifact.)
488
489 - [ ] **Step 9: Commit**
490
491 ```sh
492 git add build.zig
493 git commit -m "build: the import graph as a declared, layer-checked table
494
495 87 scattered addImport calls become one table; the wiring loop can only
496 grant what it declares, and a production import that does not point
497 strictly downward is a comptime error. Strata computed and frozen;
498 test-only imports are a first-class column (server->replica moved there
499 per the spec's adjudication; client->proxy grandfathered in production —
500 ignoreSigpipe runs in the live attach path). wasm twins derive from the
501 same rows. Graph equality proven by extract-and-diff (empty); deliberate
502 upward edge refused at comptime; test/e2e/agent green."
503 ```
504
505 ---
506
507 ### Task 4: Dead-code report — `tools/deadcode.sh` (non-gating)
508
509 **Files:**
510 - Create: `tools/deadcode.sh`
511
512 - [ ] **Step 1: Write the script**
513
514 ```sh
515 #!/bin/sh
516 # tools/deadcode.sh — NON-GATING dead-code report.
517 #
518 # Lists pub decls whose name appears in no .zig file other than the one
519 # that defines them. Zig has no mature dead-code tool, and lazy compilation
520 # makes the compiler silent about unreferenced container-level decls; this
521 # grep heuristic is a REVIEW PROMPT, not a failure — it always exits 0.
522 #
523 # Known false-positive classes (name-based, not semantic):
524 # - decls referenced only through @field or comptime-built names
525 # - wire-format API kept complete on purpose (protocol.zig codecs)
526 # - entrypoints the build system names (main, std_options, panic)
527 # - a name defined in two files: a reference to either hides both
528 set -u
529 cd "$(dirname "$0")/.."
530 for f in src/*.zig test/*.zig; do
531 grep -oE '^[[:space:]]*pub (inline )?(fn|const|var) [A-Za-z_][A-Za-z0-9_]*' "$f" \
532 | awk '{print $NF}' \
533 | while read -r name; do
534 case "$name" in main|panic|std_options) continue ;; esac
535 if ! grep -rlw --include='*.zig' "$name" src test build.zig \
536 | grep -qv "^$f\$"; then
537 echo "$f: pub $name is referenced nowhere outside its file"
538 fi
539 done
540 done
541 exit 0
542 ```
543
544 ```sh
545 chmod +x tools/deadcode.sh
546 ```
547
548 - [ ] **Step 2: Run it and sanity-check the output**
549
550 ```sh
551 tools/deadcode.sh
552 echo "exit: $?"
553 ```
554 Expected: exit 0 always. Skim the report: it should be short (tens of lines at most). Spot-check TWO reported names by hand (`grep -rn <name> src test`) to confirm the report tells the truth; if a whole legitimate class floods it (e.g. protocol codecs), the header already names that class — do NOT add per-name suppressions. Do not delete any code in this task; the report's findings are review material for a human, recorded nowhere else.
555
556 - [ ] **Step 3: Commit**
557
558 ```sh
559 git add tools/deadcode.sh
560 git commit -m "tools: deadcode.sh — non-gating pub-decl reference report"
561 ```
562
563 ---
564
565 ### Task 5: zlint trial (when obtainable; drop-for-now is the default)
566
567 **Files:**
568 - Modify: `docs/decisions.md` (one recorded verdict, whatever happens)
569
570 - [ ] **Step 1: Attempt to obtain and build zlint (time-boxed)**
571
572 ```sh
573 SCRATCH=/tmp/claude-1000/-home-xanderle-code-rad-mux/8be6cee4-1886-42cd-a21d-d5739653c727/scratchpad
574 git clone --depth 1 https://github.com/DonIsaac/zlint "$SCRATCH/zlint"
575 cd "$SCRATCH/zlint"
576 ZIG="$HOME/Downloads/zig-x86_64-linux-0.15.2/zig"
577 "$ZIG" build --release=safe
578 echo "zlint build exit: $?"
579 ```
580 Time-box: if the clone fails (no network) or the build fails against 0.15.2, STOP HERE — that IS the trial's result. Do not chase toolchain fixes.
581
582 - [ ] **Step 2 (only if it built): run it once over src/**
583
584 ```sh
585 cd /home/xanderle/code/rad/mux/.worktrees/hygiene-kit
586 "$SCRATCH/zlint/zig-out/bin/zlint" src/ > "$SCRATCH/zlint-report.txt" 2>&1
587 echo "zlint exit: $?"
588 wc -l "$SCRATCH/zlint-report.txt"
589 ```
590 Read the report. Judge: how many findings, how many are real (not style noise the compiler/fmt already owns), would any have caught a recorded past bug?
591
592 - [ ] **Step 3: Record the verdict in decisions.md**
593
594 Append to the hygiene section of `docs/decisions.md` (create the `## Hygiene kit (2026-08-14)` heading if this task runs before Task 9) ONE of:
595
596 If unobtainable/unbuildable:
597 ```markdown
598 - **zlint: unobtainable 2026-08-14, drop-for-now.** <one sentence: what failed —
599 clone, or build against 0.15.2 and the first error>. The compiler's native
600 strictness (unused locals/params, shadowing) already owns the highest-value
601 lint classes; re-try at the next toolchain bump if its 0.16 support lands.
602 ```
603 If it ran:
604 ```markdown
605 - **zlint trial 2026-08-14: <adopt into check | drop-for-now>.** <N> findings
606 over src/; <how many were signal, one example>. <One sentence of reasoning.
607 If drop: what would change the verdict.>
608 ```
609
610 - [ ] **Step 4: Commit**
611
612 ```sh
613 git add docs/decisions.md
614 git commit -m "docs: zlint trial verdict recorded"
615 ```
616
617 ---
618
619 ### Task 6: Leak gate 6a — the DebugAllocator verdict, checked and asserted
620
621 **Files:**
622 - Modify: `src/main.zig:282`, `src/mux_main.zig:135`, `src/webhub_main.zig:116`
623 - Modify: `test/e2e.sh` (stderr capture for every daemon + the leak sweep)
624 - Modify: `test/agent.sh` (leakcheck helper + call sites)
625
626 The marker is a print, NOT a panic and NOT an exit-code change — muxd's exit code carries the session shell's code, and e2e asserts on codes throughout. `DebugAllocator.deinit()` returns `std.heap.Check` (`.ok` or `.leak`); the DebugAllocator also prints its own stack traces for each leaked allocation, so the marker is the stable grep target on top of that detail.
627
628 - [ ] **Step 1: Check the verdict in all three binaries**
629
630 `src/main.zig` — replace line 282's `defer _ = gpa.deinit();`:
631 ```zig
632 defer if (gpa.deinit() == .leak)
633 std.debug.print("muxd: LEAK: allocations outlived deinit\n", .{});
634 ```
635 `src/mux_main.zig` — replace line 135's `defer _ = gpa.deinit();`:
636 ```zig
637 defer if (gpa.deinit() == .leak)
638 std.debug.print("mux: LEAK: allocations outlived deinit\n", .{});
639 ```
640 `src/webhub_main.zig` — replace line 116's `defer _ = gpa.deinit();`:
641 ```zig
642 defer if (gpa.deinit() == .leak)
643 std.debug.print("muxweb: LEAK: allocations outlived deinit\n", .{});
644 ```
645
646 ```sh
647 "$ZIG" build test
648 echo "exit: $?"
649 ```
650 Expected: PASS.
651
652 - [ ] **Step 2: Quick manual proof the marker path works end-to-end**
653
654 ```sh
655 SCRATCH=/tmp/claude-1000/-home-xanderle-code-rad-mux/8be6cee4-1886-42cd-a21d-d5739653c727/scratchpad
656 ./zig-out/bin/muxd run --sock "$SCRATCH/lk.sock" --shell /bin/sh 2> "$SCRATCH/lk.err" &
657 sleep 1
658 ./zig-out/bin/muxd stop --sock "$SCRATCH/lk.sock"
659 sleep 1
660 grep -c "LEAK:" "$SCRATCH/lk.err"; echo "grep exit: $? (1 = no marker, the healthy answer)"
661 ```
662 Expected: `0` markers, grep exits 1 — a clean lifecycle prints nothing.
663
664 - [ ] **Step 3: Give every e2e daemon a captured stderr**
665
666 Find every `"$MUXD" run` invocation whose full command (including `\` continuations) lacks a `2>` redirection:
667
668 ```sh
669 awk '/"\$MUXD" run/ { l=NR; s=$0; while (s ~ /\\$/) { getline; s=s $0 } if (s !~ /2>/) print l ": " s }' test/e2e.sh
670 ```
671
672 Expected hits (line numbers may drift; the awk output is authoritative): the long-lived daemon (`$SOCK`), the abort daemon (`$SOCK2`), both restart-scenario starts (`$SOCK3`), the first `--quic` daemon (`$SOCK4`, later killed -9), the `MUX_KEY_FILE` daemon (`$SOCK9`), the key-beats-env daemon (`$SOCK10`), the key-mismatch daemon (`$SOCK17`), and any refusal-path runs that print to the terminal. Give each a distinct capture file following the file's existing convention (`> "$OUT.<tag>.d" 2>&1` before the `&`), e.g.:
673
674 ```sh
675 "$MUXD" run --sock "$SOCK" --shell /bin/sh > "$OUT.d1.d" 2>&1 &
676 ```
677 Tags must not collide with existing `$OUT.*` names (grep the script for the tag before choosing). Daemons that already capture (`$OUT.p1.d`, `$OUT.q`, …) are left alone.
678
679 - [ ] **Step 4: Add the leak sweep to e2e.sh**
680
681 Immediately BEFORE the `OK_COUNT` pin block at the bottom of `test/e2e.sh`, add:
682
683 ```sh
684 # ---- whole-suite leak sweep (hygiene kit, 6a) ----
685 # The long-lived daemon has served every scenario that wanted it; stop it
686 # NOW so its allocator verdict is written before the sweep reads. SIGTERM
687 # runs the clean-shutdown path, so the defer chain (and the verdict) runs.
688 kill "$DPID" 2>/dev/null || true
689 wait "$DPID" 2>/dev/null || true
690 DPID=""
691 # Every capture this suite wrote — daemon stderr AND client output — is a
692 # lifecycle log now: any binary that leaked printed a grep-able marker.
693 if grep -q "LEAK:" "$OUT".* 2>/dev/null; then
694 echo "e2e FAIL: a binary reported leaked allocations:"
695 grep -H "LEAK:" "$OUT".*
696 exit 1
697 fi
698 # The detached (`muxd start`) daemons log via XDG_STATE_HOME; the file is
699 # truncated at every spawn, so this asserts the LAST such daemon only —
700 # stated, not hidden.
701 if [ -f "$XDG_STATE_HOME/mux/muxd.log" ] && grep -q "LEAK:" "$XDG_STATE_HOME/mux/muxd.log"; then
702 echo "e2e FAIL: a detached daemon reported leaked allocations:"
703 grep -H "LEAK:" "$XDG_STATE_HOME/mux/muxd.log"
704 exit 1
705 fi
706 ```
707
708 Check what the trap's cleanup does with `$DPID` — it already tolerates an empty value (`${DPID:-}`), so clearing it here is safe.
709
710 - [ ] **Step 5: Add leakcheck to agent.sh**
711
712 After the `wait_for` helper block in `test/agent.sh`, add:
713
714 ```sh
715 # After a clean `muxd stop`, the daemon's whole lifecycle has run and its
716 # log carries the allocator's verdict (hygiene kit, 6a). The log is
717 # truncated at every spawn, so the check must run NOW, before the next
718 # scenario's daemon comes up — and only after the process is actually
719 # gone, or the grep races the exit path it is asserting about.
720 leakcheck() {
721 _i=0
722 while kill -0 "$1" 2>/dev/null && [ "$_i" -lt 100 ]; do
723 sleep 0.05
724 _i=$((_i + 1))
725 done
726 _dl="$XDG_STATE_HOME/mux/muxd.log"
727 [ -f "$_dl" ] || return 0
728 grep -q "LEAK:" "$_dl" || return 0
729 why "daemon leaked: $(grep 'LEAK:' "$_dl" | head -1)"
730 }
731 ```
732
733 Then find the in-scenario stop sites:
734 ```sh
735 grep -n '"\$MUXD" stop' test/agent.sh
736 ```
737 For each stop that is INSIDE a scenario function (currently three: the marks daemon, the settle daemon, the quiet daemon — NOT the trap's cleanup loop), add after the stop line, using that scenario's daemon-pid variable:
738 ```sh
739 leakcheck "$D_MARKS" || return 1
740 ```
741 (Replace `$D_MARKS` with the pid variable each scenario actually holds — read the surrounding lines; `start_ready`'s PIDVAR argument names it.)
742
743 - [ ] **Step 6: Prove the grep catches a real leak**
744
745 Introduce a deliberate leak in muxd — in `src/main.zig`, immediately after `const args = try std.process.argsAlloc(alloc);`, add:
746 ```zig
747 _ = alloc.dupe(u8, "deliberate-leak-probe") catch {};
748 ```
749 Then:
750 ```sh
751 "$ZIG" build
752 ./zig-out/bin/muxd run --sock "$SCRATCH/lk2.sock" --shell /bin/sh 2> "$SCRATCH/lk2.err" &
753 sleep 1
754 ./zig-out/bin/muxd stop --sock "$SCRATCH/lk2.sock"
755 sleep 1
756 grep "LEAK:" "$SCRATCH/lk2.err"
757 ```
758 Expected: `muxd: LEAK: allocations outlived deinit` (plus the DebugAllocator's own trace above it). Then the suite-level catch:
759 ```sh
760 "$ZIG" build e2e
761 echo "e2e exit: $?"
762 ```
763 Expected: **FAIL (nonzero)** with `e2e FAIL: a binary reported leaked allocations`. Remove the probe line, rebuild, and re-run:
764 ```sh
765 "$ZIG" build e2e
766 echo "e2e exit: $?"
767 "$ZIG" build agent
768 echo "agent exit: $?"
769 ```
770 Expected: both 0.
771
772 - [ ] **Step 7: Commit**
773
774 ```sh
775 git add src/main.zig src/mux_main.zig src/webhub_main.zig test/e2e.sh test/agent.sh
776 git commit -m "feat: DebugAllocator verdicts print a grep-able LEAK marker; e2e/agent assert absence
777
778 A print, never a panic or exit-code change — muxd's exit code carries
779 the shell's. Every e2e daemon now captures stderr; the suite sweeps all
780 captures at the end (the detached-daemon log is truncated per spawn, so
781 only the last one is covered there — stated). agent.sh checks after
782 each clean stop, post process-exit to avoid racing the verdict.
783 Deliberate-leak proof: marker appears, e2e fails at the sweep."
784 ```
785
786 ---
787
788 ### Task 7: Leak gate 6b — the persistent-daemon soak phase
789
790 **Files:**
791 - Modify: `test/soak.sh` (new phase between the run loop and the summary)
792
793 Today every soak daemon is born and dies inside one e2e run — there is no process to measure RSS across. This phase is the missing shape: ONE daemon, many client lifecycles, RSS and fd-count sampled between cycles.
794
795 - [ ] **Step 1: Add the phase to soak.sh**
796
797 Insert between the run loop's closing `done` (line ~58) and the `rm -f "$LOG"` line:
798
799 ```sh
800 # ---- persistence phase (hygiene kit, 6b) ----
801 # One daemon, SOAK_CYCLES client lifecycles. Catches what the run loop
802 # structurally cannot: C-side growth, fd leaks, unbounded accumulation —
803 # the classes the Zig-side LEAK marker (6a) never sees. Baseline is taken
804 # AFTER a warmup: the first attaches pay one-time allocations (grid,
805 # history) that are capacity, not leakage.
806 PSOCK="$TMP/mux-soak-persist-$$.sock"
807 PLOG="$TMP/mux-soak-persist-$$.log"
808 CYCLES="${SOAK_CYCLES:-20}"
809 WARMUP=3
810 RSS_BOUND_KB=4096
811 "$MUXD" run --sock "$PSOCK" --shell /bin/sh > "$PLOG" 2>&1 &
812 PDPID=$!
813 _i=0
814 while [ ! -S "$PSOCK" ] && [ "$_i" -lt 100 ]; do sleep 0.05; _i=$((_i + 1)); done
815 if [ ! -S "$PSOCK" ]; then
816 echo "soak FAIL: persistence daemon never bound $PSOCK"
817 FAILED=$((FAILED + 1))
818 printf 'persistence: daemon never bound\n' >> "$SUMMARY"
819 else
820 BASE_RSS=0; BASE_FD=0; RSS=0; FD=0
821 c=1
822 while [ "$c" -le "$CYCLES" ]; do
823 { printf 'echo cycle-%s\n' "$c"; sleep 1; printf '\034'; } | \
824 timeout 30 "$MUX" --sock "$PSOCK" > /dev/null 2>&1
825 RSS=$(awk '/VmRSS/{print $2}' "/proc/$PDPID/status" 2>/dev/null || echo 0)
826 FD=$(ls "/proc/$PDPID/fd" 2>/dev/null | wc -l)
827 [ "$c" -eq "$WARMUP" ] && { BASE_RSS=$RSS; BASE_FD=$FD; }
828 c=$((c + 1))
829 done
830 echo "soak persistence: $CYCLES cycles, RSS ${BASE_RSS}->${RSS} kB, fds ${BASE_FD}->${FD}"
831 if [ "$RSS" -eq 0 ] || [ "$BASE_RSS" -eq 0 ]; then
832 echo "soak FAIL: persistence daemon died mid-phase"
833 FAILED=$((FAILED + 1))
834 printf 'persistence: daemon died mid-phase\n' >> "$SUMMARY"
835 else
836 # fds must RETURN to baseline exactly: every attach opens, every
837 # detach must close. RSS gets a bound, not equality — allocators
838 # retain pages — but growth past it over this few cycles is a leak.
839 if [ "$FD" -ne "$BASE_FD" ]; then
840 echo "soak FAIL: persistence fd count $BASE_FD -> $FD across detached cycles"
841 FAILED=$((FAILED + 1))
842 printf 'persistence: fd leak %s->%s\n' "$BASE_FD" "$FD" >> "$SUMMARY"
843 fi
844 if [ $((RSS - BASE_RSS)) -gt "$RSS_BOUND_KB" ]; then
845 echo "soak FAIL: persistence RSS grew $((RSS - BASE_RSS)) kB (bound $RSS_BOUND_KB)"
846 FAILED=$((FAILED + 1))
847 printf 'persistence: RSS grew %s kB\n' "$((RSS - BASE_RSS))" >> "$SUMMARY"
848 fi
849 fi
850 kill "$PDPID" 2>/dev/null
851 wait "$PDPID" 2>/dev/null
852 # The daemon's own Zig-side verdict rides along for free (6a).
853 if grep -q "LEAK:" "$PLOG"; then
854 echo "soak FAIL: persistence daemon reported leaked allocations:"
855 grep "LEAK:" "$PLOG"
856 FAILED=$((FAILED + 1))
857 printf 'persistence: LEAK marker\n' >> "$SUMMARY"
858 fi
859 fi
860 rm -f "$PLOG" "$PSOCK"
861 ```
862
863 Note `set -u` is active and there is no `set -e` in soak.sh — the arithmetic-in-if style above is safe. The socket/log names use the `mux-soak-` prefix so the stray-file check (which looks for `muxd-e2e-*`/`mux-e2e-*`) never counts them.
864
865 - [ ] **Step 2: Run a short soak to exercise the phase**
866
867 ```sh
868 SOAK_N=1 SOAK_CYCLES=8 "$ZIG" build soak
869 echo "soak exit: $?"
870 ```
871 Expected: exit 0, and the line `soak persistence: 8 cycles, RSS ...` with fd counts equal. (~3 min: one e2e run + 8 cycles.)
872
873 - [ ] **Step 3: Prove the fd assertion can fail**
874
875 Sanity-check the mechanism, not the daemon: temporarily set `WARMUP=999` (baseline never taken, `BASE_FD=0`), run `SOAK_N=1 SOAK_CYCLES=3 "$ZIG" build soak`, expect `soak FAIL: persistence daemon died mid-phase` (BASE_RSS stays 0 → the guard trips — proving the phase's failure path reaches the summary). Restore `WARMUP=3`, re-run Step 2 green. (A true fd-leak injection would mean patching the daemon; the guard-path proof is the honest cheap check.)
876
877 - [ ] **Step 4: Commit**
878
879 ```sh
880 git add test/soak.sh
881 git commit -m "test: soak persistence phase — one daemon, N client lifecycles, RSS/fd bounds
882
883 fds must return to baseline exactly; RSS gets a 4MB growth bound past a
884 3-cycle warmup. Catches the classes the Zig-side marker cannot: C-side
885 growth, fd leaks, unbounded accumulation."
886 ```
887
888 ---
889
890 ### Task 8: Leak gate 6c — the valgrind recipe, and its first real run
891
892 **Files:**
893 - Create: `tools/valgrind-quic.sh`
894 - Modify: `docs/decisions.md` (first-run record)
895
896 valgrind is NOT currently installed on this box. The recipe is committed regardless; the first run needs the install to succeed.
897
898 - [ ] **Step 1: Write the recipe script**
899
900 ```sh
901 #!/bin/sh
902 # tools/valgrind-quic.sh — periodic deep leak run over the QUIC/C stack.
903 #
904 # NON-GATING and wired into no build step, on purpose: valgrind runs
905 # 10-50x slow, which distorts every timing-sensitive path (handshakes,
906 # keepalives, settle windows) into flake territory. Its unique value is
907 # the C side — wolfSSL/ngtcp2 allocate with malloc, which the Zig
908 # DebugAllocator verdict (6a) never sees. Run it by hand after touching
909 # the QUIC stack or bumping deps/quic.
910 #
911 # Usage: tools/valgrind-quic.sh [path/to/muxd [path/to/muxa]]
912 # (defaults to zig-out/bin — run `zig build` first; Debug build is the
913 # point: Zig debug builds carry valgrind client requests natively)
914 set -eu
915 MUXD="${1:-zig-out/bin/muxd}"
916 MUXA="${2:-zig-out/bin/muxa}"
917 command -v valgrind >/dev/null || { echo "valgrind is not installed"; exit 1; }
918 [ -x "$MUXD" ] && [ -x "$MUXA" ] || { echo "binaries missing — run zig build"; exit 1; }
919 TMP="${TMPDIR:-/tmp}/mux-valgrind-$$"
920 mkdir -p "$TMP"
921 trap 'kill "$VGPID" 2>/dev/null; rm -rf "$TMP"' EXIT INT TERM
922 VGPID=""
923 # Hermetic key: keygen into a private XDG home, --key it explicitly.
924 XDG_CONFIG_HOME="$TMP/cfg"; export XDG_CONFIG_HOME
925 "$MUXD" keygen > /dev/null
926 KEY="$TMP/cfg/mux/key"
927 SOCK="$TMP/vg.sock"
928 PORT=$((47000 + ($$ % 900)))
929 valgrind --leak-check=full --error-exitcode=99 --log-file="$TMP/vg.log" \
930 "$MUXD" run --sock "$SOCK" --shell /bin/sh \
931 --quic "127.0.0.1:$PORT" --key "$KEY" &
932 VGPID=$!
933 # valgrind start is SLOW; give the bind a full minute.
934 i=0
935 while [ ! -S "$SOCK" ] && [ "$i" -lt 600 ]; do sleep 0.1; i=$((i + 1)); done
936 [ -S "$SOCK" ] || { echo "daemon never bound under valgrind"; exit 1; }
937 # One real command over QUIC — handshake, frames, teardown — with a
938 # timeout sized for valgrind's clock, then a clean stop so every exit
939 # path (and the allocator teardown) runs.
940 "$MUXA" run --quic "127.0.0.1:$PORT" --key "$KEY" --timeout 60000 "echo vg-probe" \
941 || echo "muxa run failed under valgrind (timing?) — the leak summary below still stands"
942 "$MUXD" stop --sock "$SOCK"
943 wait "$VGPID" || true
944 VGPID=""
945 echo "---- valgrind summary ----"
946 grep -E "definitely lost|indirectly lost|ERROR SUMMARY" "$TMP/vg.log" || cat "$TMP/vg.log"
947 ```
948
949 ```sh
950 chmod +x tools/valgrind-quic.sh
951 ```
952
953 - [ ] **Step 2: Install valgrind (non-interactive attempt) and run once**
954
955 ```sh
956 sudo -n pacman -S --noconfirm valgrind 2>&1 | tail -1
957 command -v valgrind && { "$ZIG" build; tools/valgrind-quic.sh; }
958 ```
959 - If the install worked and the run completed: read the summary. `definitely lost: 0` (or losses only inside wolfSSL init one-timers) is a clean bill.
960 - If `sudo -n` was refused: STOP; report `BLOCKED: valgrind install needs an interactive sudo — run 'sudo pacman -S valgrind', then tools/valgrind-quic.sh` in your final status. The controller will surface it to the user. Commit the recipe regardless (Step 4); the decisions.md record then says "first run pending install".
961
962 - [ ] **Step 3: Record the result in decisions.md**
963
964 Append to the hygiene section of `docs/decisions.md` (create the `## Hygiene kit (2026-08-14)` heading if it does not exist yet) ONE of:
965
966 ```markdown
967 - **valgrind first run (2026-08-14): <clean | findings | pending install>.**
968 Recipe at tools/valgrind-quic.sh, non-gating (10-50x slowdown distorts every
969 timing path). <If run: the "definitely lost" line verbatim, and either "clean
970 bill over the QUIC handshake + one muxa run/stop lifecycle" or the filed
971 finding. If pending: "valgrind not installable non-interactively; run
972 `sudo pacman -S valgrind` then the script.">
973 ```
974 (The `<...>` above are choices for YOU to resolve now with the actual result — the committed text must contain none of them.)
975
976 - [ ] **Step 4: Commit**
977
978 ```sh
979 git add tools/valgrind-quic.sh docs/decisions.md
980 git commit -m "tools: valgrind recipe for the QUIC/C stack + first-run record
981
982 Non-gating on purpose: the 10-50x slowdown turns every timing-sensitive
983 path into flake territory, and the C side only changes when deps/quic
984 does. This is the one lens the Zig-side verdict (6a) cannot provide."
985 ```
986
987 ---
988
989 ### Task 9: Close-out — the doctrine section in decisions.md
990
991 **Files:**
992 - Modify: `docs/decisions.md`
993
994 - [ ] **Step 1: Write the hygiene section**
995
996 Under the `## Hygiene kit (2026-08-14)` heading (created by Task 5 or 8 if either ran first — merge, don't duplicate; the zlint/valgrind lines they added stay), add:
997
998 ```markdown
999 - **The layer table is the law.** build.zig's module graph is declared data:
1000 a table row per module (name, root, frozen stratum, production imports,
1001 test-only imports), the wiring loop derives every grant, and a production
1002 import that does not point strictly downward is a comptime error. Layers
1003 are COMPUTED topological strata, frozen 2026-08-14 — hand-assignment was
1004 tried first and misplaced engine on the first draft. Changing the
1005 architecture now means editing the table, which is the point.
1006 - **Two adjudicated edges.** `server -> replica` is test-only (sole use is
1007 the applyFrame test helper) and lives in the test_imports column.
1008 `client -> proxy` is production (ignoreSigpipe in the live attach path)
1009 and is grandfathered with the debt comment at client.zig — relocating
1010 ignoreSigpipe to a leaf is the recorded fix, deliberately not taken here.
1011 - **Test-only imports are a declared column, not an accident.** The testtmp
1012 pattern (production modules importing test scaffolding used only inside
1013 `test` blocks, kept out of release binaries by lazy compilation) is now
1014 stated per-row. The strata computation excludes the column.
1015 - **refAllDecls in every test-loop module root, scope stated.** Pub decls
1016 only — std.meta.declarations sees nothing private — so this NARROWS the
1017 silent-module-loss hazard rather than retiring it. quic/quic_server/
1018 quic_client use the plain variant (the recursive walk would analyze the
1019 whole wolfSSL/ngtcp2 cImport namespace); wasm_core is excluded because it
1020 is not in the native test loop and a block there would never run.
1021 - **Leak verdicts print, never panic.** All three binaries check
1022 `gpa.deinit()` and print `<binary>: LEAK: allocations outlived deinit`
1023 to stderr on `.leak` — never an exit-code change, because muxd's exit
1024 code carries the session shell's. e2e captures every daemon's stderr and
1025 sweeps all captures at the end; agent.sh checks the detached-daemon log
1026 after each clean stop (the log truncates per spawn, so the check runs
1027 before the next daemon comes up). Proven by deliberate leak: marker
1028 appeared, e2e failed at the sweep. kill -9 paths print nothing — no
1029 false positive, no coverage, stated.
1030 - **The persistence soak phase owns the classes 6a cannot see.** One daemon,
1031 N client lifecycles: fd count must return to baseline exactly; RSS gets a
1032 4MB growth bound past a 3-cycle warmup (allocators retain pages; equality
1033 would flake). The per-run e2e loop structurally cannot measure this — its
1034 daemons die inside each run.
1035 - **`zig build check` is the pre-commit gate**: fmt + unit tests, seconds.
1036 e2e/agent/soak stay separate steps — minutes-long and process-spawning.
1037 ```
1038
1039 - [ ] **Step 2: Verify all gates one final time**
1040
1041 ```sh
1042 "$ZIG" build check
1043 echo "check exit: $?"
1044 "$ZIG" build e2e
1045 echo "e2e exit: $?"
1046 "$ZIG" build agent
1047 echo "agent exit: $?"
1048 tools/deadcode.sh > /dev/null
1049 echo "deadcode exit: $?"
1050 ```
1051 Expected: all 0.
1052
1053 - [ ] **Step 3: Commit**
1054
1055 ```sh
1056 git add docs/decisions.md
1057 git commit -m "docs: hygiene-kit doctrine — the table is the law, verdicts print, scopes stated"
1058 ```
1059
1060 ---
1061
1062 ## Success criteria (from the spec)
1063
1064 Every architecture rule a reviewer would state in prose now has exactly one mechanical owner that fails loudly: import direction (comptime table check), test-scaffolding scoping (declared column), format cleanliness (`zig build fmt`), forced analysis (refAllDecls, scope stated), leak-free lifecycles (marker + sweeps + persistence bounds). The layer table reads as the architecture document it replaces.
docs/superpowers/plans/2026-08-15-side-channel-passthrough.md
Old New
@@ -1,2567 +0,0 @@
1 # Side-Channel Passthrough 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:** Recover the four terminal side channels a repainting client drops — clipboard (OSC 52), bracketed paste (mode 2004), window title, and bell — so copy-out, paste-in, a correct title bar and an audible bell work in a mux session.
6
7 **Architecture:** Two mechanisms, not four features. *Sampled state* (modes, title) is read off the engine after each feed, shipped when it changes and unconditionally on every attach, and undone by the client on detach. *Events* (clipboard, bell) are intercepted in `MuxHandler` — the same comptime tag match that already catches OSC 133 — queued on the Engine beside `mark_events`, drained by the server, and replayed to the host tty by the client. Three new daemon→client frames carry them. A fifth piece, the watermark rule, decides what a reconnecting client gets: events follow `sendResync`'s own delta-vs-snapshot verdict.
8
9 **Tech Stack:** Zig 0.15.2 (`ZIG ?= $(HOME)/Downloads/zig-x86_64-linux-0.15.2/zig`), ghostty-vt 1.3.2-dev as the terminal engine, hand-rolled length-prefixed wire protocol, POSIX sockets + ngtcp2/wolfSSL QUIC, shell-driven e2e (`test/e2e.sh`, `test/ptyclient.zig`).
10
11 **Spec:** `docs/superpowers/specs/2026-08-15-side-channel-passthrough-design.md`
12
13 ---
14
15 ## Before you start
16
17 Read the spec. It carries the measurements this plan assumes and the two
18 security decisions (OSC 52 query refused; payload capped) that must not be
19 quietly "completed" by a later reader.
20
21 **Toolchain.** `make test`, `make e2e`, `make agent` all shell out to
22 `$(ZIG)`, which defaults to `~/Downloads/zig-x86_64-linux-0.15.2/zig`. The
23 system Zig is 0.17-dev and **cannot build this tree**. Do not "fix" a build
24 failure by switching compilers.
25
26 **Capture exit codes before piping.** `make test | tail` reports tail's 0
27 over a failing build. Every verification step below spells the redirect and
28 `echo "EXIT=$?"` form for this reason.
29
30 **Run suites in the foreground.** Backgrounding `make test`/`make e2e` and
31 waiting on a notification has hung sessions before. Foreground, with the
32 timeouts given.
33
34 **A wedged `zig build test` step prints nothing at all.** If a run produces
35 no output and does not return, a test is hanging — not passing quietly.
36
37 ---
38
39 ## File structure
40
41 | File | Responsibility | Change |
42 |---|---|---|
43 | `src/protocol.zig` | wire format: the three new frame types and their codecs | modify |
44 | `src/engine.zig` | interception of clipboard/bell into a queue the server drains; the mode/title getters | modify |
45 | `src/server.zig` | sampling, per-session pending-event slots, queueing frames to the right clients | modify |
46 | `src/client.zig` | applying frames to the host tty, and undoing them on teardown | modify |
47 | `test/ptyclient.zig` | a `paste` verb that behaves like a real terminal | modify |
48 | `test/e2e.sh` | scenarios asserting the bytes now reach the host capture | modify |
49 | `test/xversion.sh` | old-client-ignores-unknown-frame legs, both transports | modify |
50
51 No new modules. Every change lands in a file that already owns that
52 responsibility; `engine.zig` gains one more intercepted action beside the
53 OSC 133 one it was written for, and `protocol.zig` gains three frame types
54 beside the twelve it already carries.
55
56 ---
57
58 ## Task 1: `term_event` frame and the clipboard codec
59
60 **Files:**
61 - Modify: `src/protocol.zig` (MsgType enum ~`:13-39`; codecs near `encodePtyMode` `:441`; tests near `:1063`)
62
63 - [ ] **Step 1: Write the failing test**
64
65 Add at the end of `src/protocol.zig`, beside the other frame tests:
66
67 ```zig
68 test "term_event: clipboard round-trips and matches golden bytes" {
69 const alloc = std.testing.allocator;
70 var buf: std.ArrayList(u8) = .empty;
71 defer buf.deinit(alloc);
72
73 var payload: std.ArrayList(u8) = .empty;
74 defer payload.deinit(alloc);
75 try encodeClipboardEvent(&payload, alloc, 'c', "aGk=");
76 try appendFrame(&buf, alloc, .term_event, payload.items);
77
78 // 0x8f type, LE len=6, kind byte 0 (clipboard), target 'c', then base64.
79 try std.testing.expectEqualSlices(
80 u8,
81 &.{ 0x8f, 0x06, 0x00, 0x00, 0x00, 0x00, 'c', 'a', 'G', 'k', '=' },
82 buf.items,
83 );
84
85 const ev = try decodeTermEvent(buf.items[5..]);
86 try std.testing.expectEqual(TermEvent.Kind.clipboard, @as(TermEvent.Kind, ev));
87 try std.testing.expectEqual(@as(u8, 'c'), ev.clipboard.target);
88 try std.testing.expectEqualStrings("aGk=", ev.clipboard.base64);
89 }
90
91 test "term_event: bell is a kind byte and nothing else" {
92 const alloc = std.testing.allocator;
93 var payload: std.ArrayList(u8) = .empty;
94 defer payload.deinit(alloc);
95 try encodeBellEvent(&payload, alloc);
96 try std.testing.expectEqualSlices(u8, &.{0x01}, payload.items);
97
98 const ev = try decodeTermEvent(payload.items);
99 try std.testing.expectEqual(TermEvent.Kind.bell, @as(TermEvent.Kind, ev));
100 }
101
102 test "term_event: a truncated or unknown payload is refused, never guessed" {
103 // Empty: no kind byte at all.
104 try std.testing.expectError(error.BadPayload, decodeTermEvent(&[_]u8{}));
105 // Clipboard kind with no target byte.
106 try std.testing.expectError(error.BadPayload, decodeTermEvent(&[_]u8{0x00}));
107 // A kind this version does not know. Refused rather than defaulted:
108 // guessing a kind means acting on a payload we cannot parse.
109 try std.testing.expectError(error.BadPayload, decodeTermEvent(&[_]u8{0x7e}));
110 // A bell with a tail. Dropping the tail would mean two peers disagreeing
111 // about what a frame said while both believed they had parsed it.
112 try std.testing.expectError(error.BadPayload, decodeTermEvent(&[_]u8{ 0x01, 0xAA }));
113 }
114 ```
115
116 - [ ] **Step 2: Run the test and watch it fail**
117
118 ```bash
119 cd /home/xanderle/code/rad/mux
120 make test > /tmp/t1.log 2>&1; echo "EXIT=$?"; tail -25 /tmp/t1.log
121 ```
122
123 Expected: a compile error naming `encodeClipboardEvent`, `decodeTermEvent`,
124 `TermEvent` or `term_event` as undefined. That is the right failure — the
125 codec does not exist yet.
126
127 - [ ] **Step 3: Add the frame types to `MsgType`**
128
129 In `src/protocol.zig`, after `status_reply = 0x8c,`:
130
131 ```zig
132 term_modes = 0x8d, // payload: u32 LE bitset; bit0 bracketed paste (see TermModes)
133 term_title = 0x8e, // payload: UTF-8 title bytes, possibly empty
134 term_event = 0x8f, // payload: 1 byte kind ++ kind-specific bytes (see TermEvent)
135 ```
136
137 Leave `dump_reply = 0xff` and the `_,` where they are.
138
139 - [ ] **Step 4: Write the codec**
140
141 Add near `encodePtyMode` in `src/protocol.zig`:
142
143 ```zig
144 /// A side channel the daemon's engine consumed and the client must replay
145 /// onto the host terminal. Distinct from the sampled state in `term_modes`
146 /// and `term_title`: these happen once and leave nothing behind to read,
147 /// so they are queued rather than polled.
148 pub const TermEvent = union(Kind) {
149 clipboard: Clipboard,
150 bell: void,
151
152 pub const Kind = enum(u8) { clipboard = 0, bell = 1 };
153
154 /// `base64` is BORROWED from the frame payload and is valid only while
155 /// that payload lives — the same discipline `Delimited` uses. It stays
156 /// base64 the whole way: ghostty hands the OSC 52 payload over
157 /// undecoded, and every transform is a chance to corrupt bytes neither
158 /// end ever needs to read.
159 pub const Clipboard = struct {
160 target: u8,
161 base64: []const u8,
162 };
163 };
164
165 pub fn encodeClipboardEvent(
166 out: *std.ArrayList(u8),
167 alloc: std.mem.Allocator,
168 target: u8,
169 base64: []const u8,
170 ) !void {
171 try out.append(alloc, @intFromEnum(TermEvent.Kind.clipboard));
172 try out.append(alloc, target);
173 try out.appendSlice(alloc, base64);
174 }
175
176 pub fn encodeBellEvent(out: *std.ArrayList(u8), alloc: std.mem.Allocator) !void {
177 try out.append(alloc, @intFromEnum(TermEvent.Kind.bell));
178 }
179
180 pub fn decodeTermEvent(payload: []const u8) !TermEvent {
181 if (payload.len < 1) return error.BadPayload;
182 // Dispatch on the ENUM, not the raw byte. `enumFromByte` refuses an
183 // unmapped value the same way a raw-byte switch with an `else` would,
184 // but switching on the enum makes the compiler demand an arm per kind
185 // — so adding a third kind cannot silently decode as BadPayload
186 // forever. (Not `@enumFromInt`: on an unmapped value that is UB or a
187 // panic, reachable from any frame a future daemon sends.)
188 return switch (try enumFromByte(TermEvent.Kind, payload[0])) {
189 .clipboard => blk: {
190 if (payload.len < 2) return error.BadPayload;
191 break :blk .{ .clipboard = .{
192 .target = payload[1],
193 .base64 = payload[2..],
194 } };
195 },
196 // Fixed-size payload, so a trailing byte is refused rather than
197 // dropped — the discipline every other fixed-size decoder in this
198 // file applies (decodePtyMode, decodeCmdState, decodeAwaitReply).
199 .bell => if (payload.len != 1) error.BadPayload else .bell,
200 };
201 }
202 ```
203
204 And beside the other wire-shape constants (`session_name_max` and friends):
205
206 ```zig
207 /// The largest OSC 52 payload the wire will carry, in base64 bytes
208 /// (~48 KiB of text). Lives here because the wire module owns the shape:
209 /// the daemon caps on the way in and the client re-validates on the way
210 /// out, and two binaries knowing this number separately are two binaries
211 /// that can disagree about it — `encodeDebugDumpNamed`'s doc makes the
212 /// same argument for the same reason.
213 ///
214 /// Not enforced by the codec: the cap belongs where the bytes are
215 /// accepted, and a decoder that refused a long payload would turn a
216 /// daemon's mistake into a client's parse error.
217 pub const clipboard_base64_max: usize = 64 * 1024;
218 ```
219
220 - [ ] **Step 5: Run the test and watch it pass**
221
222 ```bash
223 cd /home/xanderle/code/rad/mux
224 make test > /tmp/t1.log 2>&1; echo "EXIT=$?"; tail -8 /tmp/t1.log
225 ```
226
227 Expected: `EXIT=0`.
228
229 - [ ] **Step 6: Commit**
230
231 ```bash
232 cd /home/xanderle/code/rad/mux
233 git add src/protocol.zig
234 git commit -m "feat(protocol): term_event frame carries clipboard and bell
235
236 The base64 rides verbatim because ghostty hands OSC 52 over undecoded,
237 and an unknown kind byte is refused rather than defaulted — guessing a
238 kind means acting on a payload we cannot parse.
239
240 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
241 ```
242
243 ---
244
245 ## Task 2: intercept OSC 52 in the engine
246
247 **Files:**
248 - Modify: `src/engine.zig` (`MuxHandler.vt` `:23-30`; `Engine` fields `:69-94`; `init` `:104`; `deinit` `:126`; accessors near `:146`)
249
250 The interception point already exists and already has a precedent: `MuxHandler`
251 was written because ghostty's stock handler swallows OSC 133, and `engine.zig:28`
252 is the entire mechanism.
253
254 **The payload must be copied.** `StreamAction.Value(.clipboard_contents)`
255 hands over a slice ghostty owns; it is not valid after the callback returns.
256 Every event stores a `dupe`.
257
258 - [ ] **Step 1: Write the failing test**
259
260 Add at the end of `src/engine.zig`:
261
262 ```zig
263 test "engine: an OSC 52 set is queued with its target and payload intact" {
264 const alloc = std.testing.allocator;
265 var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
266 defer eng.deinit();
267
268 eng.feed("\x1b]52;c;aGVsbG8=\x07");
269
270 const evs = eng.sideEvents();
271 try std.testing.expectEqual(@as(usize, 1), evs.len);
272 try std.testing.expectEqual(SideEvent.Kind.clipboard, evs[0].kind);
273 try std.testing.expectEqual(@as(u8, 'c'), evs[0].target);
274 try std.testing.expectEqualStrings("aGVsbG8=", evs[0].payload);
275 }
276
277 test "engine: an OSC 52 query is refused, never answered" {
278 const alloc = std.testing.allocator;
279 var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
280 defer eng.deinit();
281
282 // `?` asks the terminal to send the clipboard back on the INPUT stream.
283 // Honouring it would let anything in any session — a remote box, an
284 // agent-driven session — read whatever the human last copied.
285 eng.feed("\x1b]52;c;?\x07");
286
287 try std.testing.expectEqual(@as(usize, 0), eng.sideEvents().len);
288 // And nothing was written back toward the pty, which is the half that
289 // would actually leak.
290 try std.testing.expectEqual(@as(usize, 0), eng.ptyOutput().len);
291 }
292
293 test "engine: an oversized clipboard payload is dropped, not truncated" {
294 const alloc = std.testing.allocator;
295 // The cap is injected, so this test states its own premise and costs 9
296 // bytes instead of 64 KiB. A test that has to allocate the production
297 // limit to prove a refusal is a test nobody runs twice.
298 var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24, .clipboard_max = 8 });
299 defer eng.deinit();
300
301 // One byte past the cap. Truncating would put a corrupt payload in the
302 // user's clipboard, which is worse than putting nothing there.
303 eng.feed("\x1b]52;c;AAAAAAAAA\x07");
304 try std.testing.expectEqual(@as(usize, 0), eng.sideEvents().len);
305
306 // And the boundary itself is accepted, so the cap is a cap and not an
307 // off-by-one that quietly rejects the largest legal payload.
308 eng.feed("\x1b]52;c;AAAAAAAA\x07");
309 try std.testing.expectEqual(@as(usize, 1), eng.sideEvents().len);
310 }
311
312 test "engine: an escape split across two feeds still produces one event" {
313 const alloc = std.testing.allocator;
314 var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
315 defer eng.deinit();
316
317 // The parser is stateful across feeds and a pty read can land anywhere.
318 // This is the boundary a hand-rolled byte scanner would get wrong, and
319 // pinning it is what says we did not write one.
320 eng.feed("\x1b]52;c;aGVs");
321 eng.feed("bG8=\x07");
322
323 const evs = eng.sideEvents();
324 try std.testing.expectEqual(@as(usize, 1), evs.len);
325 try std.testing.expectEqualStrings("aGVsbG8=", evs[0].payload);
326 }
327
328 test "engine: clearing side events frees their payloads" {
329 // The allocator in std.testing fails the test on a leak, so this test
330 // IS the assertion: a payload duped on the way in must be freed here.
331 const alloc = std.testing.allocator;
332 var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
333 defer eng.deinit();
334
335 eng.feed("\x1b]52;c;aGVsbG8=\x07");
336 try std.testing.expectEqual(@as(usize, 1), eng.sideEvents().len);
337 eng.clearSideEvents();
338 try std.testing.expectEqual(@as(usize, 0), eng.sideEvents().len);
339 }
340 ```
341
342 - [ ] **Step 2: Run the tests and watch them fail**
343
344 ```bash
345 cd /home/xanderle/code/rad/mux
346 make test > /tmp/t2.log 2>&1; echo "EXIT=$?"; tail -25 /tmp/t2.log
347 ```
348
349 Expected: compile errors naming `SideEvent`, `sideEvents`, `clearSideEvents`,
350 `clipboard_max`.
351
352 - [ ] **Step 3: Add the event type, the cap, and the storage**
353
354 **The cap is passed in, not imported.** `protocol.zig` owns the number
355 (`clipboard_base64_max`, added in Task 1), but `engine` must not import
356 `protocol`: `build.zig:114-115` puts both at **layer 0** and builds both for
357 wasm32, and that layering is deliberate. So the engine takes the cap as an
358 option and the server — which already imports both — supplies it. One
359 owner, no new dependency edge, and tests can inject a small cap instead of
360 allocating 64 KiB to prove a refusal.
361
362 In `src/engine.zig`, add to `Options` (beside `max_scrollback`):
363
364 ```zig
365 /// The largest OSC 52 payload to queue, in base64 bytes. Supplied
366 /// by the caller rather than defined here: `protocol.zig` owns the
367 /// wire's shape, and `engine` deliberately does not import it —
368 /// both are layer 0 and both build for wasm32.
369 ///
370 /// It needs a bound at all because mux builds its stream with
371 /// `.initAlloc`, and ghostty's own note at `stream.zig:442` warns
372 /// that clipboard payloads "can be arbitrarily large". A clipboard
373 /// is a clipboard, not a file transfer.
374 clipboard_max: usize = 64 * 1024,
375 ```
376
377 `Options` is not retained past `init`, so the Engine must **store** it.
378 Add the field beside `alloc`:
379
380 ```zig
381 /// Copied from Options: the interception path reads it per event.
382 clipboard_max: usize,
383 ```
384
385 and set it in `init` beside `.alloc = alloc,`:
386
387 ```zig
388 .clipboard_max = opts.clipboard_max,
389 ```
390
391 Where the server constructs an Engine, pass `.clipboard_max =
392 proto.clipboard_base64_max` so the two numbers cannot drift.
393
394 ```bash
395 cd /home/xanderle/code/rad/mux
396 grep -n 'Engine.init(' src/server.zig
397 ```
398
399 Expected: one production call site (plus test call sites, which keep the
400 default). Change the production one.
401
402 Inside `Engine`, beside `MarkEvent`:
403
404 ```zig
405 /// A side channel consumed by this engine that the client must replay
406 /// onto the host terminal. `payload` is OWNED — ghostty's slice does
407 /// not outlive the callback — and freed by clearSideEvents/deinit.
408 pub const SideEvent = struct {
409 pub const Kind = enum(u8) { clipboard, bell };
410 kind: Kind,
411 /// The OSC 52 target byte ('c', 'p', ...). Meaningless for bell.
412 target: u8 = 0,
413 /// Base64 as it arrived, undecoded. Empty for bell.
414 payload: []const u8 = &.{},
415 };
416 ```
417
418 Add the field beside `mark_events`:
419
420 ```zig
421 /// Side-channel events observed since the last clear. Drained by the
422 /// server after each feed, exactly like pty_out and mark_events.
423 side_events: std.ArrayList(SideEvent),
424 ```
425
426 In `init`, beside `.mark_events = .empty,`:
427
428 ```zig
429 .side_events = .empty,
430 ```
431
432 In `deinit`, before `self.mark_events.deinit(self.alloc);`:
433
434 ```zig
435 for (self.side_events.items) |ev| self.alloc.free(ev.payload);
436 self.side_events.deinit(self.alloc);
437 ```
438
439 Accessors beside `markEvents`/`clearMarkEvents`:
440
441 ```zig
442 pub fn sideEvents(self: *const Engine) []const SideEvent {
443 return self.side_events.items;
444 }
445
446 pub fn clearSideEvents(self: *Engine) void {
447 for (self.side_events.items) |ev| self.alloc.free(ev.payload);
448 self.side_events.clearRetainingCapacity();
449 }
450 ```
451
452 - [ ] **Step 4: Intercept the action**
453
454 In `MuxHandler.vt`, after the existing `semantic_prompt` line:
455
456 ```zig
457 if (comptime action == .clipboard_contents) self.onClipboard(value);
458 ```
459
460 And the handler beside `onSemanticPrompt`:
461
462 ```zig
463 /// OSC 52. SET only: a `?` payload is the QUERY form, which asks the
464 /// terminal to write the clipboard back on the pty's INPUT stream.
465 /// Answering it would let any program in any session — including one
466 /// on a box reached over QUIC, including one an agent is driving —
467 /// read whatever the human last copied. xterm ships it disabled and
468 /// Alacritty defaults to OnlyCopy for this reason. Do not "complete"
469 /// this by adding a reply arm.
470 fn onClipboard(
471 self: *MuxHandler,
472 value: StreamAction.Value(.clipboard_contents),
473 ) void {
474 if (std.mem.eql(u8, value.data, "?")) return;
475 const eng = self.engineOf();
476 if (value.data.len > eng.clipboard_max) return;
477 // ghostty's slice dies with the callback, so the queue owns a copy.
478 const owned = eng.alloc.dupe(u8, value.data) catch return;
479 // Load-bearing catch, for mark_events' reason: under OOM we drop the
480 // event rather than fail the feed.
481 eng.side_events.append(eng.alloc, .{
482 .kind = .clipboard,
483 .target = value.kind,
484 .payload = owned,
485 }) catch eng.alloc.free(owned);
486 }
487 ```
488
489 - [ ] **Step 5: Run the tests and watch them pass**
490
491 ```bash
492 cd /home/xanderle/code/rad/mux
493 make test > /tmp/t2.log 2>&1; echo "EXIT=$?"; tail -8 /tmp/t2.log
494 ```
495
496 Expected: `EXIT=0`. If the oversize test fails because ghostty refused the
497 payload before mux saw it, that is a *stronger* result than the plan
498 assumed — keep the test, and record in the commit message that the cap is
499 belt-and-braces rather than the only guard.
500
501 - [ ] **Step 6: Commit**
502
503 ```bash
504 cd /home/xanderle/code/rad/mux
505 git add src/engine.zig
506 git commit -m "feat(engine): intercept OSC 52, refuse the query direction
507
508 The same comptime tag match that already catches OSC 133. Payloads are
509 duped because ghostty's slice dies with the callback, capped at 64 KiB of
510 base64 because .initAlloc means ghostty will buffer whatever arrives, and
511 a '?' query is dropped without an answer — honouring it would let a remote
512 session read the human's clipboard.
513
514 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
515 ```
516
517 ---
518
519 ## Task 3: drain side events to the session's clients
520
521 **Files:**
522 - Modify: `src/server.zig` (beside `drainMarkEvents` `:2058-2084`, and its caller in the pty-read arm)
523
524 - [ ] **Step 0: Pin the cap against drift**
525
526 `protocol.zig` owns `clipboard_base64_max`, but the layering forbids
527 `engine` importing `protocol`, so the engine's `Options.clipboard_max`
528 default spells the number a second time. Production passes
529 `proto.clipboard_base64_max` so the const wins there — but every Engine the
530 server does not build (64 `Engine.init` call sites in `src/`, plus the wasm
531 twin) silently uses the literal, and changing one number would leave the
532 other behind with no compile error and no failing test.
533
534 Dropping the default is the wrong fix: it would force 64 mostly
535 clipboard-indifferent call sites to name a cap. Pin the two numbers
536 together instead, here — `server.zig` is one of the few modules that
537 legitimately imports both (`:10` `Engine`, `:12` `proto`):
538
539 ```zig
540 // The layering forbids engine importing protocol (both are layer 0 and
541 // both build for wasm32), so the cap is written in two places. This is
542 // what stops them drifting apart in silence — the same doctrine as
543 // encodeDebugDumpNamed's "two binaries that can disagree", applied where
544 // the import graph will not allow a single owner.
545 test "the engine's default clipboard cap is the wire's" {
546 try std.testing.expectEqual(
547 proto.clipboard_base64_max,
548 (Engine.Options{ .cols = 80, .rows = 24 }).clipboard_max,
549 );
550 }
551 ```
552
553 Run `make test` and confirm it passes before continuing. Then break it
554 deliberately — change one of the two numbers, watch the test fail, change
555 it back. A drift pin nobody has seen fail is a drift pin nobody should
556 trust.
557
558 - [ ] **Step 1: Write the failing test**
559
560 Add to `src/server.zig`, beside the other Server tests:
561
562 ```zig
563 test "Server: a clipboard event reaches this session's clients and no others" {
564 const alloc = std.testing.allocator;
565
566 var tmp = try TmpDir.make();
567 defer tmp.cleanup();
568 const sock_path = try std.fmt.allocPrint(alloc, "{s}/clip.sock", .{tmp.path()});
569 defer alloc.free(sock_path);
570
571 // A shell that emits one OSC 52 and then holds the session open: the
572 // event has to survive the pump, not just the engine.
573 const script = try std.fmt.allocPrintSentinel(
574 alloc,
575 "{s}/emit.sh",
576 .{tmp.path()},
577 0,
578 );
579 defer alloc.free(script);
580 try std.fs.cwd().writeFile(.{
581 .sub_path = script,
582 .data = "#!/bin/sh\nprintf '\\033]52;c;aGk=\\007'\nexec sleep 30\n",
583 .flags = .{ .mode = 0o755 },
584 });
585
586 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script });
587 defer srv.deinit();
588
589 const c = try std.net.connectUnixSocket(sock_path);
590 defer c.close();
591 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
592
593 try std.testing.expect(try awaitFrame(alloc, &srv, c, .term_event, 10_000));
594 }
595 ```
596
597 Add the helper beside the existing frame-reading helpers near `:4107`:
598
599 ```zig
600 /// Pump the server until a frame of `want` arrives on `conn`, or the
601 /// deadline passes. Returns whether it arrived. Separate from the
602 /// pty_mode-specific helpers above because those record flags; this one
603 /// only answers "did it come".
604 fn awaitFrame(
605 alloc: std.mem.Allocator,
606 srv: *Server,
607 conn: std.net.Stream,
608 want: proto.MsgType,
609 deadline_ms: i64,
610 ) !bool {
611 const t0 = std.time.milliTimestamp();
612 while (std.time.milliTimestamp() - t0 < deadline_ms) {
613 try srv.pumpOnce(50);
614 while (true) {
615 const inc = readFrameNonBlocking(alloc, conn) catch break;
616 const frame = inc orelse break;
617 defer frame.deinit(alloc);
618 if (frame.type == want) return true;
619 }
620 }
621 return false;
622 }
623 ```
624
625 **Before writing that helper, read `src/server.zig:4107-4145`** and reuse
626 whatever the existing `pty_mode` helpers use to pump and to read — the names
627 `pumpOnce` and `readFrameNonBlocking` above are the shapes those helpers
628 need, and if the file spells them differently, use the file's spelling
629 rather than adding a second way to do the same thing.
630
631 - [ ] **Step 2: Run it and watch it fail**
632
633 ```bash
634 cd /home/xanderle/code/rad/mux
635 make test > /tmp/t3.log 2>&1; echo "EXIT=$?"; tail -25 /tmp/t3.log
636 ```
637
638 Expected: the test compiles and **fails at the assertion** — no `term_event`
639 frame arrives within 10s, because nothing drains the queue yet. If it fails
640 to compile, fix the helper spelling against the existing helpers first; a
641 compile error is not the failure this step is looking for.
642
643 - [ ] **Step 3: Write the drain**
644
645 Add beside `drainMarkEvents` in `src/server.zig`:
646
647 ```zig
648 /// Ship the engine's side-channel events to this session's clients.
649 /// Runs in the pty-read arm beside drainMarkEvents, for the same
650 /// reason: the events describe the chunk that was just fed.
651 ///
652 /// This session's clients alone. A clipboard write belongs to the shell
653 /// that produced it, and a push crossing sessions would set the user's
654 /// clipboard from a session they are not looking at.
655 fn drainSideEvents(self: *Server, si: usize) void {
656 const s = self.ses(si);
657 for (s.eng.sideEvents()) |ev| {
658 var payload: std.ArrayList(u8) = .empty;
659 defer payload.deinit(self.alloc);
660 switch (ev.kind) {
661 .clipboard => proto.encodeClipboardEvent(
662 &payload,
663 self.alloc,
664 ev.target,
665 ev.payload,
666 ) catch continue,
667 .bell => proto.encodeBellEvent(&payload, self.alloc) catch continue,
668 }
669 for (0..max_clients) |i| {
670 if (self.inSession(i, si)) _ = self.queueFrame(i, .term_event, payload.items);
671 }
672 }
673 s.eng.clearSideEvents();
674 }
675 ```
676
677 - [ ] **Step 4: Call it**
678
679 Find the pty-read arm's `drainMarkEvents(si)` call and add immediately
680 after it:
681
682 ```zig
683 self.drainSideEvents(si);
684 ```
685
686 ```bash
687 cd /home/xanderle/code/rad/mux
688 grep -n 'drainMarkEvents(' src/server.zig
689 ```
690
691 Expected: two hits — the definition and exactly one caller. Add the new
692 call beside that one caller.
693
694 - [ ] **Step 5: Run it and watch it pass**
695
696 ```bash
697 cd /home/xanderle/code/rad/mux
698 make test > /tmp/t3.log 2>&1; echo "EXIT=$?"; tail -8 /tmp/t3.log
699 ```
700
701 Expected: `EXIT=0`.
702
703 - [ ] **Step 6: Commit**
704
705 ```bash
706 cd /home/xanderle/code/rad/mux
707 git add src/server.zig
708 git commit -m "feat(server): drain side events to the session's own clients
709
710 Beside drainMarkEvents and for its reason — the events describe the chunk
711 just fed. Scoped to the session because a clipboard write belongs to the
712 shell that produced it.
713
714 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
715 ```
716
717 ---
718
719 ## Task 4: the client writes OSC 52 to the host tty
720
721 **Files:**
722 - Modify: `src/client.zig` (frame dispatch, beside the `.pty_mode` arm ending `:1243`)
723
724 - [ ] **Step 1: Write the failing test**
725
726 Add at the end of `src/client.zig`:
727
728 ```zig
729 test "client: a clipboard event becomes an OSC 52 write" {
730 const alloc = std.testing.allocator;
731 var out: std.ArrayList(u8) = .empty;
732 defer out.deinit(alloc);
733
734 var payload: std.ArrayList(u8) = .empty;
735 defer payload.deinit(alloc);
736 try proto.encodeClipboardEvent(&payload, alloc, 'c', "aGk=");
737
738 try appendTermEvent(&out, alloc, payload.items);
739 // BEL rather than ESC-backslash: it is what most emitters in the wild
740 // use, and every terminal that accepts one accepts it.
741 try std.testing.expectEqualStrings("\x1b]52;c;aGk=\x07", out.items);
742 }
743
744 test "client: a clipboard payload that is not base64 is refused" {
745 const alloc = std.testing.allocator;
746 var out: std.ArrayList(u8) = .empty;
747 defer out.deinit(alloc);
748
749 var payload: std.ArrayList(u8) = .empty;
750 defer payload.deinit(alloc);
751 // A payload carrying BEL would terminate the escape early and leave the
752 // rest of it painting on the user's screen. The daemon validates on the
753 // way out; the client validates again rather than trusting the wire,
754 // because the wire is not necessarily this version of muxd.
755 try proto.encodeClipboardEvent(&payload, alloc, 'c', "aGk=\x07rm -rf");
756
757 try appendTermEvent(&out, alloc, payload.items);
758 try std.testing.expectEqual(@as(usize, 0), out.items.len);
759 }
760
761 test "client: a bell event becomes a BEL" {
762 const alloc = std.testing.allocator;
763 var out: std.ArrayList(u8) = .empty;
764 defer out.deinit(alloc);
765
766 var payload: std.ArrayList(u8) = .empty;
767 defer payload.deinit(alloc);
768 try proto.encodeBellEvent(&payload, alloc);
769
770 try appendTermEvent(&out, alloc, payload.items);
771 try std.testing.expectEqualStrings("\x07", out.items);
772 }
773
774 test "client: an unparseable term_event writes nothing at all" {
775 const alloc = std.testing.allocator;
776 var out: std.ArrayList(u8) = .empty;
777 defer out.deinit(alloc);
778 try appendTermEvent(&out, alloc, &[_]u8{0x7e});
779 try std.testing.expectEqual(@as(usize, 0), out.items.len);
780 }
781 ```
782
783 - [ ] **Step 2: Run and watch it fail**
784
785 ```bash
786 cd /home/xanderle/code/rad/mux
787 make test > /tmp/t4.log 2>&1; echo "EXIT=$?"; tail -25 /tmp/t4.log
788 ```
789
790 Expected: compile error naming `appendTermEvent`.
791
792 - [ ] **Step 3: Write the builder**
793
794 Add to `src/client.zig`, near the other pure helpers:
795
796 ```zig
797 /// The standard base64 alphabet plus its padding. Validated on the way OUT
798 /// of the wire and not only on the way in: what makes an OSC 52 payload
799 /// safe to hand a terminal is that it cannot contain ESC or BEL, and that
800 /// is a property of the bytes, not of who sent them.
801 fn isBase64(s: []const u8) bool {
802 for (s) |ch| switch (ch) {
803 'A'...'Z', 'a'...'z', '0'...'9', '+', '/', '=' => {},
804 else => return false,
805 };
806 return true;
807 }
808
809 /// Render one term_event onto the bytes destined for the host terminal.
810 /// Writes nothing at all for anything it cannot fully understand — a
811 /// partial escape on a real tty paints garbage the user then has to clear.
812 fn appendTermEvent(
813 out: *std.ArrayList(u8),
814 alloc: std.mem.Allocator,
815 payload: []const u8,
816 ) !void {
817 const ev = proto.decodeTermEvent(payload) catch return;
818 switch (ev) {
819 .clipboard => |clip| {
820 if (!isBase64(clip.base64)) return;
821 switch (clip.target) {
822 'c', 'p', 's', '0'...'7' => {},
823 else => return,
824 }
825 try out.appendSlice(alloc, "\x1b]52;");
826 try out.append(alloc, clip.target);
827 try out.append(alloc, ';');
828 try out.appendSlice(alloc, clip.base64);
829 try out.append(alloc, 0x07);
830 },
831 .bell => try out.append(alloc, 0x07),
832 }
833 }
834 ```
835
836 - [ ] **Step 4: Wire it into the frame dispatch**
837
838 In the main loop's frame switch in `src/client.zig`, add an arm beside
839 `.scrollback_chunk` (before the closing `else => {}` at `:1276`):
840
841 ```zig
842 .term_event => {
843 var esc: std.ArrayList(u8) = .empty;
844 defer esc.deinit(alloc);
845 try appendTermEvent(&esc, alloc, frame.payload);
846 // Outside the paint's synchronized-update bracket: this
847 // is a message to the terminal, not part of the picture,
848 // and a sync bracket around it would hold it until the
849 // next frame.
850 if (esc.items.len > 0) try proto.writeAllFd(stdout_fd, esc.items);
851 },
852 ```
853
854 - [ ] **Step 5: Run and watch it pass**
855
856 ```bash
857 cd /home/xanderle/code/rad/mux
858 make test > /tmp/t4.log 2>&1; echo "EXIT=$?"; tail -8 /tmp/t4.log
859 ```
860
861 Expected: `EXIT=0`.
862
863 - [ ] **Step 6: Commit**
864
865 ```bash
866 cd /home/xanderle/code/rad/mux
867 git add src/client.zig
868 git commit -m "feat(client): replay clipboard and bell onto the host tty
869
870 The client re-validates the base64 rather than trusting the wire: what
871 makes an OSC 52 payload safe to hand a terminal is that it cannot contain
872 ESC or BEL, and that is a property of the bytes, not of the sender. An
873 event it cannot fully parse writes nothing — a partial escape paints
874 garbage the user has to clear.
875
876 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
877 ```
878
879 ---
880
881 ## Task 5: e2e — the OSC 52 bytes reach the host capture
882
883 This inverts the discovery measurement: the same grep that counted **0** on
884 2026-08-15 must now count **1**.
885
886 **Files:**
887 - Modify: `test/e2e.sh` (new scenario; the scenario-count literal at the end)
888
889 - [ ] **Step 1: Read how a ptyclient scenario is spelled**
890
891 ```bash
892 cd /home/xanderle/code/rad/mux
893 grep -n 'ptyclient' test/e2e.sh | head -20
894 ```
895
896 Use the surrounding scenario's spelling for tmp paths, `--out`/`--err` and
897 cleanup. `--err` is **required** by the fixture; omitting it exits 2 with
898 `--err is required`.
899
900 - [ ] **Step 2: Add the scenario**
901
902 Add to `test/e2e.sh` beside the other ptyclient scenarios:
903
904 ```sh
905 # --- Side channel: an OSC 52 written by the session reaches the HOST tty.
906 # Measured absent on 2026-08-15 (decisions.md, that date): the session
907 # emitted this exact escape and the host capture contained zero of it,
908 # while ?1049h and ?2026h arrived normally. That capture file is the
909 # instrument; this is the same reading with the bug fixed.
910 cat > "$TMP/clip.sh" <<'CLIPSH'
911 printf '\033]52;c;aGVsbG8gZnJvbSB0aGUgc2Vzc2lvbg==\007'
912 printf 'CLIPDONE\n'
913 CLIPSH
914 cat > "$TMP/clip.script" <<SCRIPT
915 settle 400 5000
916 send sh $TMP/clip.sh\n
917 expect CLIPDONE 8000
918 settle 400 5000
919 send \x1c
920 waitexit 5000
921 SCRIPT
922 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.clip" --err "$OUT.clip.err" \
923 -- "$MUX" --sock "$SOCK" < "$TMP/clip.script" > "$TMP/clip.log" 2>&1 || {
924 echo "e2e FAIL: clipboard scenario did not run"; cat "$TMP/clip.log"; exit 1; }
925 grep -qa 'CLIPDONE' "$OUT.clip" || {
926 echo "e2e FAIL: the session never ran (no marker on the host)"; exit 1; }
927 # The positive control and the assertion in one place: if the marker
928 # painted but the escape did not, the passthrough is what broke.
929 grep -qaP '\x1b\]52;c;aGVsbG8gZnJvbSB0aGUgc2Vzc2lvbg==' "$OUT.clip" || {
930 echo "e2e FAIL: OSC 52 never reached the host tty"; exit 1; }
931 rm_swept "$OUT.clip" "$OUT.clip.err"
932 ok "the session's OSC 52 reaches the host terminal"
933 ```
934
935 - [ ] **Step 3: Bump the scenario-count literal**
936
937 The suite's last line asserts the count of `ok` calls against a literal, and
938 the friction is deliberate — a scenario that silently stops running is what
939 the pin exists for.
940
941 ```bash
942 cd /home/xanderle/code/rad/mux
943 grep -n 'OK_COUNT' test/e2e.sh | tail -5
944 ```
945
946 Increment the literal by 1 (26 → 27 scenarios as of this branch). Leave the
947 convergence-point count alone: this scenario adds none.
948
949 - [ ] **Step 4: Run the suite**
950
951 ```bash
952 cd /home/xanderle/code/rad/mux
953 make build > /tmp/b.log 2>&1; echo "BUILD=$?"
954 make e2e > /tmp/t5.log 2>&1; echo "E2E=$?"; tail -12 /tmp/t5.log
955 ```
956
957 Expected: `E2E=0` and a final line reading 27 scenarios.
958
959 - [ ] **Step 5: Prove the pin can fail**
960
961 A test that cannot fail is worse than no test. Break the feature and watch
962 the new scenario catch it:
963
964 ```bash
965 cd /home/xanderle/code/rad/mux
966 sed -i 's/if (comptime action == .clipboard_contents) self.onClipboard(value);/\/\/ MUTANT/' src/engine.zig
967 make build > /dev/null 2>&1
968 make e2e > /tmp/t5m.log 2>&1; echo "E2E=$?"; grep -c 'OSC 52 never reached' /tmp/t5m.log
969 git checkout src/engine.zig
970 make build > /dev/null 2>&1
971 ```
972
973 Expected: `E2E=1` and the grep counts 1. If the suite passes with the
974 interception removed, the scenario is asserting something else and must be
975 fixed before moving on.
976
977 - [ ] **Step 6: Commit**
978
979 ```bash
980 cd /home/xanderle/code/rad/mux
981 git add test/e2e.sh
982 git commit -m "test(e2e): the session's OSC 52 reaches the host terminal
983
984 The discovery measurement inverted — same capture file, same grep, 0 to 1.
985 Verified falsifiable by removing the interception and watching this
986 scenario, and only this scenario, fail.
987
988 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
989 ```
990
991 ---
992
993 ## Task 6: `term_modes` frame
994
995 **Files:**
996 - Modify: `src/protocol.zig` (codecs near `encodePtyMode` `:441`; tests near `:1063`)
997
998 - [ ] **Step 1: Write the failing test**
999
1000 ```zig
1001 test "term_modes round-trips and matches golden bytes" {
1002 const alloc = std.testing.allocator;
1003 var buf: std.ArrayList(u8) = .empty;
1004 defer buf.deinit(alloc);
1005 try appendFrame(&buf, alloc, .term_modes, &encodeTermModes(.{ .bracketed_paste = true }));
1006 // 0x8d type, LE len=4, then the LE bitset with bit0 set.
1007 try std.testing.expectEqualSlices(
1008 u8,
1009 &.{ 0x8d, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00 },
1010 buf.items,
1011 );
1012
1013 const m = try decodeTermModes(buf.items[5..]);
1014 try std.testing.expect(m.bracketed_paste);
1015 }
1016
1017 test "term_modes: reserved bits go out zero and come back ignored" {
1018 // Reserved bits are what let the mouse modes land later without a new
1019 // frame type or a version check, so both halves are pinned: we never
1020 // SET one, and we never choke on one a future daemon set.
1021 try std.testing.expectEqualSlices(
1022 u8,
1023 &.{ 0x00, 0x00, 0x00, 0x00 },
1024 &encodeTermModes(.{ .bracketed_paste = false }),
1025 );
1026 const m = try decodeTermModes(&[_]u8{ 0x01, 0x00, 0x00, 0xF0 });
1027 try std.testing.expect(m.bracketed_paste);
1028 }
1029
1030 test "term_modes: a short payload is refused" {
1031 try std.testing.expectError(error.BadPayload, decodeTermModes(&[_]u8{ 0x01, 0x00 }));
1032 }
1033 ```
1034
1035 - [ ] **Step 2: Run and watch it fail**
1036
1037 ```bash
1038 cd /home/xanderle/code/rad/mux
1039 make test > /tmp/t6.log 2>&1; echo "EXIT=$?"; tail -20 /tmp/t6.log
1040 ```
1041
1042 Expected: compile error naming `encodeTermModes` / `decodeTermModes`.
1043
1044 - [ ] **Step 3: Write the codec**
1045
1046 ```zig
1047 /// Terminal modes the SESSION has set that the host terminal must be told
1048 /// about, because the client paints a grid and no mode survives a repaint.
1049 /// Sampled state, not events: read off the engine, sent when changed and
1050 /// unconditionally on attach.
1051 ///
1052 /// One bit is spoken for. The rest are reserved for the mouse tracking and
1053 /// format modes, focus reporting and cursor shape — deliberately, so that
1054 /// adding them later needs no new frame type and no version check. They go
1055 /// out zero and are ignored on receipt.
1056 pub const TermModes = packed struct(u32) {
1057 bracketed_paste: bool,
1058 _pad: u31 = 0,
1059 };
1060
1061 pub const term_modes_len = 4;
1062
1063 pub fn encodeTermModes(m: TermModes) [term_modes_len]u8 {
1064 var buf: [term_modes_len]u8 = undefined;
1065 std.mem.writeInt(u32, &buf, @as(u32, @bitCast(m)), .little);
1066 return buf;
1067 }
1068
1069 pub fn decodeTermModes(payload: []const u8) !TermModes {
1070 if (payload.len != term_modes_len) return error.BadPayload;
1071 return @bitCast(std.mem.readInt(u32, payload[0..term_modes_len], .little));
1072 }
1073 ```
1074
1075 - [ ] **Step 4: Run and watch it pass**
1076
1077 ```bash
1078 cd /home/xanderle/code/rad/mux
1079 make test > /tmp/t6.log 2>&1; echo "EXIT=$?"; tail -8 /tmp/t6.log
1080 ```
1081
1082 Expected: `EXIT=0`.
1083
1084 - [ ] **Step 5: Commit**
1085
1086 ```bash
1087 cd /home/xanderle/code/rad/mux
1088 git add src/protocol.zig
1089 git commit -m "feat(protocol): term_modes carries the session's DEC modes
1090
1091 One bit used, thirty-one reserved so the mouse modes land later without a
1092 new frame type. Both halves pinned: we never set a reserved bit, and we
1093 never choke on one a future daemon set.
1094
1095 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
1096 ```
1097
1098 ---
1099
1100 ## Task 7: sample bracketed paste and send it
1101
1102 **Files:**
1103 - Modify: `src/engine.zig` (a getter); `src/server.zig` (`Session` fields beside `mode_sent` `:~404`; the sampling site beside `drainSideEvents`; `sendResync` `:2243`)
1104
1105 `Session.mode_sent` is the exact precedent: "what clients have been told"
1106 rather than "what the pty says", with the gap closed by the send.
1107
1108 - [ ] **Step 1: Write the failing test**
1109
1110 In `src/engine.zig`:
1111
1112 ```zig
1113 test "engine: bracketed paste is readable as sampled state" {
1114 const alloc = std.testing.allocator;
1115 var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
1116 defer eng.deinit();
1117
1118 try std.testing.expect(!eng.bracketedPaste());
1119 eng.feed("\x1b[?2004h");
1120 try std.testing.expect(eng.bracketedPaste());
1121 eng.feed("\x1b[?2004l");
1122 try std.testing.expect(!eng.bracketedPaste());
1123 }
1124 ```
1125
1126 In `src/server.zig`:
1127
1128 ```zig
1129 test "Server: a session enabling bracketed paste tells its clients once" {
1130 const alloc = std.testing.allocator;
1131
1132 var tmp = try TmpDir.make();
1133 defer tmp.cleanup();
1134 const sock_path = try std.fmt.allocPrint(alloc, "{s}/modes.sock", .{tmp.path()});
1135 defer alloc.free(sock_path);
1136
1137 const script = try std.fmt.allocPrintSentinel(alloc, "{s}/modes.sh", .{tmp.path()}, 0);
1138 defer alloc.free(script);
1139 try std.fs.cwd().writeFile(.{
1140 .sub_path = script,
1141 .data = "#!/bin/sh\nprintf '\\033[?2004h'\nexec sleep 30\n",
1142 .flags = .{ .mode = 0o755 },
1143 });
1144
1145 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script });
1146 defer srv.deinit();
1147
1148 const c = try std.net.connectUnixSocket(sock_path);
1149 defer c.close();
1150 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
1151
1152 try std.testing.expect(try awaitFrame(alloc, &srv, c, .term_modes, 10_000));
1153 }
1154 ```
1155
1156 - [ ] **Step 2: Run and watch both fail**
1157
1158 ```bash
1159 cd /home/xanderle/code/rad/mux
1160 make test > /tmp/t7.log 2>&1; echo "EXIT=$?"; tail -25 /tmp/t7.log
1161 ```
1162
1163 Expected: compile error naming `bracketedPaste`, then (once that exists) an
1164 assertion failure in the server test because nothing samples or sends.
1165
1166 - [ ] **Step 3: Add the engine getter**
1167
1168 In `src/engine.zig`, beside `onAltScreen`:
1169
1170 ```zig
1171 /// Whether the session's application has asked for bracketed paste
1172 /// (DEC 2004). Sampled state: the client mirrors it onto the host
1173 /// terminal, which is the only thing that can actually bracket a paste.
1174 pub fn bracketedPaste(self: *const Engine) bool {
1175 return self.term.modes.get(.bracketed_paste);
1176 }
1177 ```
1178
1179 - [ ] **Step 4: Add the session field and the sampling**
1180
1181 In `Session`, beside `mode_sent`:
1182
1183 ```zig
1184 /// The terminal modes as last put on the wire, or null before the first
1185 /// sample. Same discipline as mode_sent: what clients have been TOLD.
1186 term_modes_sent: ?proto.TermModes = null,
1187 ```
1188
1189 Add beside `drainSideEvents` in `src/server.zig`:
1190
1191 ```zig
1192 /// Sample the session's terminal modes and tell its clients when they
1193 /// changed. Sampled rather than intercepted because a mode has no
1194 /// history worth keeping: a reattaching client needs the current value,
1195 /// which is also why sendResync sends it unconditionally.
1196 fn sampleTermModes(self: *Server, si: usize) void {
1197 const s = self.ses(si);
1198 const now: proto.TermModes = .{ .bracketed_paste = s.eng.bracketedPaste() };
1199 if (s.term_modes_sent) |sent| {
1200 if (std.meta.eql(sent, now)) return;
1201 }
1202 s.term_modes_sent = now;
1203 const payload = proto.encodeTermModes(now);
1204 for (0..max_clients) |i| {
1205 if (self.inSession(i, si)) _ = self.queueFrame(i, .term_modes, &payload);
1206 }
1207 }
1208 ```
1209
1210 Call it in the pty-read arm, immediately after `self.drainSideEvents(si);`:
1211
1212 ```zig
1213 self.sampleTermModes(si);
1214 ```
1215
1216 - [ ] **Step 5: Send it on attach too**
1217
1218 In `sendResync` (`src/server.zig:2243`), after the snapshot-or-delta send,
1219 add:
1220
1221 ```zig
1222 // Unconditional, on both branches. Modes are state, not history: a
1223 // client returning to a session must be told what is true now, and
1224 // a client that got a full snapshot needs it exactly as much as one
1225 // that got a delta.
1226 _ = self.queueFrame(i, .term_modes, &proto.encodeTermModes(
1227 .{ .bracketed_paste = s.eng.bracketedPaste() },
1228 ));
1229 ```
1230
1231 - [ ] **Step 6: Run and watch both pass**
1232
1233 ```bash
1234 cd /home/xanderle/code/rad/mux
1235 make test > /tmp/t7.log 2>&1; echo "EXIT=$?"; tail -8 /tmp/t7.log
1236 ```
1237
1238 Expected: `EXIT=0`.
1239
1240 - [ ] **Step 7: Commit**
1241
1242 ```bash
1243 cd /home/xanderle/code/rad/mux
1244 git add src/engine.zig src/server.zig
1245 git commit -m "feat(server): sample bracketed paste and tell the session's clients
1246
1247 mode_sent's discipline, applied to a second kind of state: the field holds
1248 what clients have been TOLD, and the gap between that and the truth is
1249 closed by the send. sendResync sends it on BOTH branches — a client that
1250 got a full snapshot needs the current modes exactly as much as one that
1251 got a delta.
1252
1253 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
1254 ```
1255
1256 ---
1257
1258 ## Task 7b: pin the `size_changed` resync arm
1259
1260 **Owed, confirmed by experiment.** Task 7's spec review ran the reverse
1261 mutation — modes sent on the delta and snapshot paths, the `size_changed`
1262 early return left bare — and the full suite passed, **439/439, exit 0**.
1263 Deleting exactly the line that fixes this milestone's bug ships green.
1264
1265 This is not a generic "someone might break it later". `size_changed` is the
1266 arm **this plan itself missed**: Task 7's instructions placed the send after
1267 the delta/snapshot split, which skips that return entirely, and the
1268 implementer caught it and used a `defer`. So it is the one arm with a
1269 demonstrated history of being overlooked and no habit of being exercised.
1270
1271 The `defer`'s guarantee is real but narrow. It covers returns added *after*
1272 its registration; it does not cover a guard or return inserted *above* it,
1273 nor someone "simplifying" it back into per-branch statements — which is the
1274 plan's original shape, so that refactor is the likely one.
1275
1276 Failure mode if it regresses: bracketed paste silently stops being mirrored
1277 after any window resize. The user pastes into a shell and gets literal
1278 `200~` in front of it, with nothing to connect that to having resized.
1279
1280 **Scaffolding already exists.** `server.zig:3415` ("latest attacher's size
1281 wins") attaches A at 80x24 and B at 100x30, which is exactly the
1282 `size_changed=true` trigger. Its read loop does `if (frame.type != .snapshot)
1283 continue;`, so it drives the arm and is blind to modes.
1284
1285 - [ ] Give the session shell a `printf '\033[?2004h'` as Task 7's own tests do.
1286 - [ ] Attach A at 80x24, wait until A has seen `.term_modes` decoding to
1287 **true** — not merely the first `term_modes` frame, which is the
1288 attach-time resync frame saying false.
1289 - [ ] Attach B at a different size so `size_changed` is true on B's resync.
1290 - [ ] Assert B receives `.term_modes` with `bracketed_paste = true`, in
1291 addition to (not instead of) the existing snapshot-size assertion.
1292 - [ ] Falsify it: leave the `size_changed` return bare and confirm this new
1293 test, and only it, fails.
1294
1295 ---
1296
1297 ## Task 8: the client mirrors mode 2004 onto the host tty
1298
1299 **Files:**
1300 - Modify: `src/client.zig` (frame dispatch; the alt-screen teardown `:959`)
1301
1302 - [ ] **Step 1: Write the failing test**
1303
1304 ```zig
1305 test "client: term_modes turns bracketed paste on and off on the host" {
1306 const alloc = std.testing.allocator;
1307 var out: std.ArrayList(u8) = .empty;
1308 defer out.deinit(alloc);
1309
1310 try appendTermModes(&out, alloc, &proto.encodeTermModes(.{ .bracketed_paste = true }));
1311 try std.testing.expectEqualStrings("\x1b[?2004h", out.items);
1312
1313 out.clearRetainingCapacity();
1314 try appendTermModes(&out, alloc, &proto.encodeTermModes(.{ .bracketed_paste = false }));
1315 try std.testing.expectEqualStrings("\x1b[?2004l", out.items);
1316 }
1317
1318 test "client: a malformed term_modes payload writes nothing" {
1319 const alloc = std.testing.allocator;
1320 var out: std.ArrayList(u8) = .empty;
1321 defer out.deinit(alloc);
1322 try appendTermModes(&out, alloc, &[_]u8{ 0x01, 0x00 });
1323 try std.testing.expectEqual(@as(usize, 0), out.items.len);
1324 }
1325
1326 test "client: the alt-screen teardown unsets every mode mux turned on" {
1327 // A multiplexer that leaves your terminal in a mode it enabled is worse
1328 // than one that pastes badly, so the teardown string is pinned as a
1329 // literal rather than assembled from the constants it writes.
1330 try std.testing.expectEqualStrings(
1331 "\x1b[?2004l\x1b[?7h\x1b[?25h\x1b[?1049l",
1332 terminal_teardown,
1333 );
1334 }
1335 ```
1336
1337 - [ ] **Step 2: Run and watch it fail**
1338
1339 ```bash
1340 cd /home/xanderle/code/rad/mux
1341 make test > /tmp/t8.log 2>&1; echo "EXIT=$?"; tail -20 /tmp/t8.log
1342 ```
1343
1344 Expected: compile error naming `appendTermModes` and `terminal_teardown`.
1345
1346 - [ ] **Step 3: Write the builder and name the teardown**
1347
1348 In `src/client.zig`:
1349
1350 ```zig
1351 /// Everything the client must undo on its way out, in one literal. mux
1352 /// turns these on; leaving any of them set hands the user a terminal that
1353 /// behaves oddly long after mux exited, with nothing on screen to explain
1354 /// it. `?2004l` leads because it is the one a session asked for rather
1355 /// than one the client needed for itself.
1356 const terminal_teardown = "\x1b[?2004l\x1b[?7h\x1b[?25h\x1b[?1049l";
1357
1358 /// Render a term_modes frame as the DECSET/DECRST writes it implies.
1359 /// Nothing at all for a payload we cannot parse: a half-written mode
1360 /// change is a terminal in a state nobody chose.
1361 fn appendTermModes(
1362 out: *std.ArrayList(u8),
1363 alloc: std.mem.Allocator,
1364 payload: []const u8,
1365 ) !void {
1366 const m = proto.decodeTermModes(payload) catch return;
1367 try out.appendSlice(alloc, if (m.bracketed_paste) "\x1b[?2004h" else "\x1b[?2004l");
1368 }
1369 ```
1370
1371 Replace the teardown write at `src/client.zig:959`:
1372
1373 ```zig
1374 if (alt_screen) proto.writeAllFd(stdout_fd, terminal_teardown) catch {};
1375 ```
1376
1377 - [ ] **Step 4: Wire the frame arm**
1378
1379 Beside the `.term_event` arm added in Task 4:
1380
1381 ```zig
1382 .term_modes => {
1383 var esc: std.ArrayList(u8) = .empty;
1384 defer esc.deinit(alloc);
1385 try appendTermModes(&esc, alloc, frame.payload);
1386 if (esc.items.len > 0) try proto.writeAllFd(stdout_fd, esc.items);
1387 },
1388 ```
1389
1390 - [ ] **Step 5: Run and watch it pass**
1391
1392 ```bash
1393 cd /home/xanderle/code/rad/mux
1394 make test > /tmp/t8.log 2>&1; echo "EXIT=$?"; tail -8 /tmp/t8.log
1395 ```
1396
1397 Expected: `EXIT=0`. The teardown literal test will fail if the original
1398 string at `:959` was ordered differently — read the line before replacing it
1399 and keep its existing order, changing only the `?2004l` prefix and the
1400 pinned literal to match.
1401
1402 - [ ] **Step 6: Commit**
1403
1404 ```bash
1405 cd /home/xanderle/code/rad/mux
1406 git add src/client.zig
1407 git commit -m "feat(client): mirror bracketed paste onto the host terminal
1408
1409 And unset it on the way out. The teardown is one named literal now,
1410 pinned as a literal rather than assembled from the constants it writes —
1411 a multiplexer that leaves your terminal in a mode it enabled is worse than
1412 one that pastes badly.
1413
1414 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
1415 ```
1416
1417 ---
1418
1419 ## Task 9: `ptyclient paste` and the nvim staircase pin
1420
1421 A grep for `?2004h` proves the frame arrived, not that pasting works. This
1422 task makes the fixture behave like a real terminal and then asserts on what
1423 nvim actually wrote to disk.
1424
1425 **Files:**
1426 - Modify: `test/ptyclient.zig` (`Verb` `:42-58`, `parseLine` `:65-101`, the verb loop)
1427 - Modify: `test/e2e.sh` (scenario; count literal)
1428
1429 - [ ] **Step 1: Write the failing test**
1430
1431 Add to `test/ptyclient.zig`:
1432
1433 ```zig
1434 test "parseLine: paste is a verb and its payload keeps its spaces" {
1435 const alloc = std.testing.allocator;
1436 const v = (try parseLine(alloc, "paste a = 1")).?;
1437 defer v.deinit(alloc);
1438 try std.testing.expectEqualStrings("a = 1", v.paste);
1439 }
1440 ```
1441
1442 - [ ] **Step 2: Run and watch it fail**
1443
1444 ```bash
1445 cd /home/xanderle/code/rad/mux
1446 make test > /tmp/t9.log 2>&1; echo "EXIT=$?"; tail -20 /tmp/t9.log
1447 ```
1448
1449 Expected: compile error — `Verb` has no `paste` field.
1450
1451 - [ ] **Step 3: Add the verb**
1452
1453 In `test/ptyclient.zig`, add to `Verb`:
1454
1455 ```zig
1456 /// Like `send`, but wrapped in bracketed-paste markers IF the client
1457 /// has asked this "terminal" for them. That condition is the whole
1458 /// point: the fixture behaves the way a real terminal behaves rather
1459 /// than asserting what the test wishes were true, so a scenario using
1460 /// `paste` fails when the mode mirror regresses.
1461 paste: []u8,
1462 ```
1463
1464 Add to the `deinit` switch:
1465
1466 ```zig
1467 .paste => |s| alloc.free(s),
1468 ```
1469
1470 Add to `parseLine`, beside the `send` arm:
1471
1472 ```zig
1473 } else if (std.mem.eql(u8, verb, "paste")) {
1474 return .{ .paste = try decodeEscapes(alloc, rest) };
1475 ```
1476
1477 - [ ] **Step 4: Track the mode and honour it**
1478
1479 The fixture already accumulates everything read off the master in an
1480 `Expecter`. Add a flag beside it, updated wherever master bytes are fed:
1481
1482 ```zig
1483 /// Whether the client under test has put this "terminal" into bracketed
1484 /// paste. Set by scanning the same bytes the Expecter accumulates, because
1485 /// that buffer is exactly what a real terminal would have received.
1486 var bracketed_paste = false;
1487
1488 fn noteModes(bytes: []const u8) void {
1489 if (std.mem.indexOf(u8, bytes, "\x1b[?2004h") != null) bracketed_paste = true;
1490 if (std.mem.indexOf(u8, bytes, "\x1b[?2004l") != null) bracketed_paste = false;
1491 }
1492 ```
1493
1494 Call `noteModes(chunk)` at the same place the fixture calls
1495 `Expecter.feed`. Then handle the verb in the loop, beside `.send`:
1496
1497 ```zig
1498 .paste => |text| {
1499 if (bracketed_paste) try writeAll(master, "\x1b[200~");
1500 try writeAll(master, text);
1501 if (bracketed_paste) try writeAll(master, "\x1b[201~");
1502 },
1503 ```
1504
1505 Use whatever the `.send` arm already calls to write to the master rather
1506 than a new helper — read that arm and match it.
1507
1508 - [ ] **Step 5: Run the unit test and watch it pass**
1509
1510 ```bash
1511 cd /home/xanderle/code/rad/mux
1512 make test > /tmp/t9.log 2>&1; echo "EXIT=$?"; tail -8 /tmp/t9.log
1513 ```
1514
1515 Expected: `EXIT=0`.
1516
1517 - [ ] **Step 6: Add the behavioural e2e scenario**
1518
1519 In `test/e2e.sh`:
1520
1521 ```sh
1522 # --- Side channel: a paste into a real editor keeps its indentation.
1523 # The byte-level pin (?2004h in the host capture) proves the frame arrived;
1524 # this proves pasting WORKS. Measured on 2026-08-15 before the fix: the
1525 # third line came out with 8 spaces instead of 4, the classic autoindent
1526 # staircase, because the host was never told to bracket the paste.
1527 # ptyclient's `paste` verb brackets only when it has SEEN ?2004h, so this
1528 # scenario fails if the mirror regresses in any way a grep would miss.
1529 command -v nvim >/dev/null 2>&1 && {
1530 cat > "$TMP/paste.script" <<SCRIPT
1531 settle 400 5000
1532 send nvim -u NONE -c "set autoindent" -c startinsert $TMP/pasted.txt\n
1533 settle 800 6000
1534 paste if x:\r a = 1\r b = 2\r
1535 settle 800 6000
1536 send \x1b:wq\r
1537 settle 800 6000
1538 send \x1c
1539 waitexit 5000
1540 SCRIPT
1541 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.paste" --err "$OUT.paste.err" \
1542 -- "$MUX" --sock "$SOCK" < "$TMP/paste.script" > "$TMP/paste.log" 2>&1 || {
1543 echo "e2e FAIL: paste scenario did not run"; cat "$TMP/paste.log"; exit 1; }
1544 grep -q '^ b = 2$' "$TMP/pasted.txt" || {
1545 echo "e2e FAIL: pasted block lost its indentation (bracketed paste not mirrored)"
1546 cat -A "$TMP/pasted.txt"; exit 1; }
1547 rm_swept "$OUT.paste" "$OUT.paste.err"
1548 ok "a paste into nvim keeps its indentation"
1549 }
1550 ```
1551
1552 **The `command -v nvim` guard is deliberate**: a box without nvim skips the
1553 scenario rather than failing it. If the guard is taken, the `ok` inside it
1554 does not run and the count literal will not match — so put the scenario's
1555 `ok` inside the guard and make the count literal a range check, or install
1556 nvim in the environments that run this suite. Prefer the second: a skipped
1557 scenario that nobody notices is the failure mode the count pin exists for.
1558 Decide, and write the decision into the scenario's comment.
1559
1560 - [ ] **Step 7: Bump the count and run**
1561
1562 ```bash
1563 cd /home/xanderle/code/rad/mux
1564 grep -n 'OK_COUNT' test/e2e.sh | tail -3
1565 make build > /tmp/b.log 2>&1; echo "BUILD=$?"
1566 make e2e > /tmp/t9e.log 2>&1; echo "E2E=$?"; tail -12 /tmp/t9e.log
1567 ```
1568
1569 Expected: `E2E=0`.
1570
1571 - [ ] **Step 8: Prove it fails without the mirror**
1572
1573 ```bash
1574 cd /home/xanderle/code/rad/mux
1575 sed -i 's/if (m.bracketed_paste) "\\x1b\[?2004h" else "\\x1b\[?2004l"/"" /' src/client.zig
1576 make build > /dev/null 2>&1
1577 make e2e > /tmp/t9m.log 2>&1; echo "E2E=$?"; grep -c 'lost its indentation' /tmp/t9m.log
1578 git checkout src/client.zig
1579 make build > /dev/null 2>&1
1580 ```
1581
1582 Expected: `E2E=1` and the grep counts 1.
1583
1584 - [ ] **Step 9: Commit**
1585
1586 ```bash
1587 cd /home/xanderle/code/rad/mux
1588 git add test/ptyclient.zig test/e2e.sh
1589 git commit -m "test: ptyclient pastes like a terminal, and nvim proves it
1590
1591 The fixture brackets a paste only when it has SEEN ?2004h, so it behaves
1592 the way a real terminal behaves instead of asserting what the test wishes
1593 were true. The assertion is the file nvim wrote: 4 spaces, not 8. Verified
1594 falsifiable by neutering the mirror.
1595
1596 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
1597 ```
1598
1599 ---
1600
1601 ## Task 9b: pin that the teardown FIRES, not just what it says
1602
1603 Task 8's quality review corrected an assumption I had recorded as a limit.
1604 I had written that the exit-path audit was done by reading because no test
1605 in `client.zig` exercises `session()`. That is true of *unit* tests and
1606 irrelevant: `test/ptyclient` runs the real client on the slave side of a pty
1607 it owns, and its scripts must end with `waitexit`, which reads the master
1608 until the client exits — so **the teardown bytes land in the capture by
1609 construction**.
1610
1611 **The discriminating scenario**, and why each part is needed:
1612
1613 - turn bracketed paste on in the session, `expect` `?2004h` in the capture
1614 - send the detach chord (`\x1c`)
1615 - `waitexit`
1616 - assert a `?2004l` appears **after** the last `?2004h`
1617
1618 **The detach chord is what makes it non-vacuous.** `client.zig` returns 0
1619 on detach with the session still alive, so the daemon never sends a
1620 `term_modes(off)` — the teardown is the only possible source of that byte.
1621 Any other exit could be explained by the daemon.
1622
1623 It pins the detach path only, not `exit_status` or error returns. That is
1624 enough for the stated worry: someone moving the `defer`, gating it
1625 differently, or adding a return above its registration.
1626
1627 **It also closes a silent-deletion hazard.** `client.zig`'s frame switch
1628 ends in `else => {}`, so deleting the entire `.term_modes` arm compiles
1629 clean and drops the feature with nothing going red. This scenario is the
1630 only thing that would notice.
1631
1632 Do this after Task 9, in the same fixture, as a separate scenario. Bump the
1633 scenario-count literal in all three places.
1634
1635 ---
1636
1637 ## Task 10: window title
1638
1639 > **Before writing the third arm, read this.** Task 8's quality review
1640 > flagged that this task adds a byte-identical *third* copy of the frame-arm
1641 > shape (`.term_event`, `.term_modes`, and now `.term_title`): build into a
1642 > per-call `ArrayList`, write only if non-empty, with the same "outside the
1643 > sync bracket" comment restated each time. Two was below the threshold;
1644 > three is over it. Collapse them into one helper —
1645 > `fn writeSideChannel(alloc, stdout_fd, payload, comptime build) !void` —
1646 > which makes the arms one-liners and gives that comment a single home.
1647 >
1648 > The *builders* stay separate: their validation genuinely differs
1649 > (`term_event` has four rejection points, `term_modes` has one, `term_title`
1650 > will have its own), and merging them yields a function whose body is a
1651 > switch on what it was called with.
1652
1653
1654
1655 **Files:**
1656 - Modify: `src/protocol.zig`, `src/engine.zig`, `src/server.zig`, `src/client.zig`
1657
1658 - [ ] **Step 1: Write the failing tests**
1659
1660 `src/protocol.zig`:
1661
1662 ```zig
1663 test "term_title round-trips and matches golden bytes" {
1664 const alloc = std.testing.allocator;
1665 var buf: std.ArrayList(u8) = .empty;
1666 defer buf.deinit(alloc);
1667 try appendFrame(&buf, alloc, .term_title, "vim");
1668 try std.testing.expectEqualSlices(
1669 u8,
1670 &.{ 0x8e, 0x03, 0x00, 0x00, 0x00, 'v', 'i', 'm' },
1671 buf.items,
1672 );
1673 }
1674 ```
1675
1676 `src/engine.zig`:
1677
1678 ```zig
1679 test "engine: the title is readable as sampled state" {
1680 const alloc = std.testing.allocator;
1681 var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
1682 defer eng.deinit();
1683
1684 try std.testing.expectEqualStrings("", eng.title());
1685 eng.feed("\x1b]0;hello\x07");
1686 try std.testing.expectEqualStrings("hello", eng.title());
1687 }
1688 ```
1689
1690 `src/client.zig`:
1691
1692 ```zig
1693 test "client: a title becomes an OSC 0 write, and control bytes are refused" {
1694 const alloc = std.testing.allocator;
1695 var out: std.ArrayList(u8) = .empty;
1696 defer out.deinit(alloc);
1697
1698 try appendTermTitle(&out, alloc, "vim");
1699 try std.testing.expectEqualStrings("\x1b]0;vim\x07", out.items);
1700
1701 // A title carrying BEL or ESC would end the escape early and paint the
1702 // remainder on the user's screen. Same reasoning as the base64 check.
1703 out.clearRetainingCapacity();
1704 try appendTermTitle(&out, alloc, "vim\x07rm -rf");
1705 try std.testing.expectEqual(@as(usize, 0), out.items.len);
1706 }
1707 ```
1708
1709 - [ ] **Step 2: Run and watch them fail**
1710
1711 ```bash
1712 cd /home/xanderle/code/rad/mux
1713 make test > /tmp/t10.log 2>&1; echo "EXIT=$?"; tail -25 /tmp/t10.log
1714 ```
1715
1716 Expected: compile errors naming `title`, `appendTermTitle`.
1717
1718 - [ ] **Step 3: Engine getter**
1719
1720 ```zig
1721 /// The window title the session set, or empty if it never did. Sampled
1722 /// state: the client mirrors it, which is why your title bar has been
1723 /// wrong under mux since M2 — nothing carried it.
1724 pub fn title(self: *const Engine) []const u8 {
1725 return self.term.getTitle() orelse "";
1726 }
1727 ```
1728
1729 - [ ] **Step 4: Server sampling**
1730
1731 Add the cap and the field. In `Session`:
1732
1733 ```zig
1734 /// The title as last put on the wire. Owned, because the engine's
1735 /// buffer is rewritten in place by the next OSC 0.
1736 title_sent: ?[]const u8 = null,
1737 ```
1738
1739 Free it in the session teardown beside the other owned fields, then:
1740
1741 ```zig
1742 /// A title is a window decoration. Anything longer than this is either
1743 /// a bug or an attempt to smuggle something through a channel nobody
1744 /// inspects.
1745 const title_max: usize = 1024;
1746
1747 fn sampleTitle(self: *Server, si: usize) void {
1748 const s = self.ses(si);
1749 const now = s.eng.title();
1750 if (now.len > title_max) return;
1751 if (s.title_sent) |sent| {
1752 if (std.mem.eql(u8, sent, now)) return;
1753 }
1754 const owned = self.alloc.dupe(u8, now) catch return;
1755 if (s.title_sent) |old| self.alloc.free(old);
1756 s.title_sent = owned;
1757 for (0..max_clients) |i| {
1758 if (self.inSession(i, si)) _ = self.queueFrame(i, .term_title, owned);
1759 }
1760 }
1761 ```
1762
1763 Call it after `sampleTermModes(si)`, and send it in `sendResync` beside the
1764 modes send:
1765
1766 ```zig
1767 _ = self.queueFrame(i, .term_title, s.eng.title());
1768 ```
1769
1770 - [ ] **Step 5: Client**
1771
1772 ```zig
1773 /// Render a title as an OSC 0 write. Refused outright if it carries a byte
1774 /// that would terminate the escape early — the remainder would paint on
1775 /// the user's screen as text.
1776 fn appendTermTitle(
1777 out: *std.ArrayList(u8),
1778 alloc: std.mem.Allocator,
1779 payload: []const u8,
1780 ) !void {
1781 for (payload) |ch| if (ch < 0x20 or ch == 0x7f) return;
1782 try out.appendSlice(alloc, "\x1b]0;");
1783 try out.appendSlice(alloc, payload);
1784 try out.append(alloc, 0x07);
1785 }
1786 ```
1787
1788 Frame arm beside the others:
1789
1790 ```zig
1791 .term_title => {
1792 var esc: std.ArrayList(u8) = .empty;
1793 defer esc.deinit(alloc);
1794 try appendTermTitle(&esc, alloc, frame.payload);
1795 if (esc.items.len > 0) try proto.writeAllFd(stdout_fd, esc.items);
1796 },
1797 ```
1798
1799 - [ ] **Step 6: Run and watch them pass**
1800
1801 ```bash
1802 cd /home/xanderle/code/rad/mux
1803 make test > /tmp/t10.log 2>&1; echo "EXIT=$?"; tail -8 /tmp/t10.log
1804 ```
1805
1806 Expected: `EXIT=0`.
1807
1808 - [ ] **Step 7: Decide the title-restore question by observation**
1809
1810 The spec leaves this open. The xterm title stack (`ESC[22;0t` push on
1811 attach, `ESC[23;0t` pop on detach) is the clean mechanism, and mux cannot
1812 read the current title back to restore it by hand.
1813
1814 ```bash
1815 cd /home/xanderle/code/rad/mux
1816 printf '\033[22;0t'; printf '\033]0;mux-title-probe\007'; sleep 2; printf '\033[23;0t'
1817 ```
1818
1819 Watch the terminal's title bar. If it returns to what it was, add the push
1820 to the attach path and the pop to `terminal_teardown` (renamed from
1821 `alt_screen_teardown` in Task 8's review round — `?2004l` is not an
1822 alternate-screen mode, and a title restore would have been a third
1823 non-alt-screen thing inside a constant named for the gate). If it does not,
1824 **do nothing** — leave the title mux set, which is what tmux does — and
1825 write the observed result into the code comment so nobody re-litigates it.
1826
1827 - [ ] **Step 8: Commit**
1828
1829 ```bash
1830 cd /home/xanderle/code/rad/mux
1831 git add src/protocol.zig src/engine.zig src/server.zig src/client.zig
1832 git commit -m "feat: carry the session's window title to the host terminal
1833
1834 Sampled state on the mechanism Task 7 built, which is why this is one
1835 getter, one sampler and one client arm. The title is refused if it carries
1836 a byte that would end the escape early — the remainder would paint on the
1837 user's screen as text.
1838
1839 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
1840 ```
1841
1842 ---
1843
1844 ## Task 10b: the non-tty path sets the title and never restores it
1845
1846 **Found by Task 10's quality review. A real bug on a reachable path.**
1847
1848 `is_tty` is `isatty(stdin_fd)` (`client.zig:909`), and the push half of the
1849 title stack is gated on it (`client.zig:1200`). The `.term_title` arm
1850 (`client.zig:1333`) is **not** gated. So with stdin not a tty and stdout a
1851 tty — `echo x | mux`, `mux < /dev/null` typed in a terminal — mux writes
1852 `ESC]0;…BEL` and never writes the pop, because `terminal_teardown` only
1853 fires when `alt_screen` is true.
1854
1855 **Why this one is different from the pre-existing exposure it resembles.**
1856 The `?2004h`-leaks-on-signal case is an accepted cost, documented at
1857 `client.zig:1150-1157`: everything leaks together and the obvious symptoms
1858 prompt a `reset`. The title is the **first side channel for which mux
1859 arranged an undo and then skipped it** — "mux can set your title because it
1860 can put it back" is this task's entire justification, and here it sets and
1861 does not put back. That makes it a broken promise rather than a known
1862 limit.
1863
1864 - [ ] Decide the fix. Cheapest honest options, in the reviewer's order of
1865 preference: gate the three side-channel arms on `alt_screen` (nothing
1866 without a title bar is listening anyway), or write `terminal_setup`
1867 under `is_tty` alone rather than under the first frame.
1868 - [ ] **Record the decision either way.** Right now the asymmetry reads as
1869 unnoticed rather than chosen.
1870 - [ ] Two cheap pins the reviewer identified, both in legs that already
1871 exist: `e2e.sh:1018` already runs a client that dies before any frame
1872 *and* has non-tty stdin — a `grep -qa` for `ESC[22;0t` failing there
1873 pins "no push when nothing was entered". And the title leg asserts
1874 push/set/pop *order* but not *count*; `grep -aoF | wc -l` equal to 1
1875 for each of `22;0t` and `23;0t` catches a double-push, which is the
1876 specific regression the reconnect argument in `c517446` rests on and
1877 which currently has no test behind it.
1878
1879 ---
1880
1881 ## Task 11: bell
1882
1883 **Files:**
1884 - Modify: `src/engine.zig` (one more intercepted action); nothing else — Tasks 1, 3 and 4 already carry it
1885
1886 - [ ] **Step 1: Write the failing test**
1887
1888 ```zig
1889 test "engine: a BEL from the session is queued as a side event" {
1890 const alloc = std.testing.allocator;
1891 var eng = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
1892 defer eng.deinit();
1893
1894 eng.feed("ding\x07");
1895
1896 const evs = eng.sideEvents();
1897 try std.testing.expectEqual(@as(usize, 1), evs.len);
1898 try std.testing.expectEqual(SideEvent.Kind.bell, evs[0].kind);
1899 try std.testing.expectEqual(@as(usize, 0), evs[0].payload.len);
1900 }
1901 ```
1902
1903 - [ ] **Step 2: Run and watch it fail**
1904
1905 ```bash
1906 cd /home/xanderle/code/rad/mux
1907 make test > /tmp/t11.log 2>&1; echo "EXIT=$?"; tail -20 /tmp/t11.log
1908 ```
1909
1910 Expected: the assertion fails with 0 events — the feed succeeds, nothing is
1911 queued.
1912
1913 - [ ] **Step 3: Intercept it**
1914
1915 In `MuxHandler.vt`, beside the clipboard line:
1916
1917 ```zig
1918 if (comptime action == .bell) self.onBell();
1919 ```
1920
1921 ```zig
1922 /// A bell has no payload and no state — it is the purest event in the
1923 /// set, and the reason `payload` defaults to empty.
1924 fn onBell(self: *MuxHandler) void {
1925 const eng = self.engineOf();
1926 eng.side_events.append(eng.alloc, .{ .kind = .bell }) catch {};
1927 }
1928 ```
1929
1930 - [ ] **Step 3a: Remove the "no producer yet" clause from `drainSideEvents`**
1931
1932 `src/server.zig`'s `drainSideEvents` doc comment carries a clause saying the
1933 `.bell` arm has no producer until this task, and that the comments below it
1934 describe the arm as designed rather than as a live path. It is there because
1935 a reviewer with the whole diff in front of it concluded bells already flowed
1936 and proposed a test that would have wedged for its full budget before
1937 failing with a misleading error.
1938
1939 **This task is what makes that clause false.** Delete it in the same commit
1940 that adds the interception — a warning that outlives its condition is the
1941 same defect pointed the other way.
1942
1943 - [ ] **Step 3b: Cover the bell through the DRAIN, not just the engine**
1944
1945 Task 3's quality review noticed that `drainSideEvents`' `.bell` arm has no
1946 server-level coverage — `encodeBellEvent` is unit-tested, but nothing
1947 exercises it through the drain, so a mis-wiring there (wrong list, wrong
1948 allocator) would be caught by no test. It proposed the test against Task 3
1949 and it was **deferred to here**, because until this task lands there is no
1950 producer: the engine never queues a bell, so the test would fail.
1951
1952 The fixture already exists in `Server: a clipboard event reaches this
1953 session's clients and no others`. After its clipboard assertion, write
1954 `"\x07\n"` as `.input` to the attached client and await a second
1955 `.term_event`, asserting the decoded kind is `.bell`. `cat` writes the raw
1956 `\x07` back regardless of what ECHOCTL does to the echo, so it is as robust
1957 as the clipboard half.
1958
1959 This closes the only production line in `drainSideEvents` that no test
1960 reaches.
1961
1962 - [ ] **Step 4: Run and watch it pass**
1963
1964 ```bash
1965 cd /home/xanderle/code/rad/mux
1966 make test > /tmp/t11.log 2>&1; echo "EXIT=$?"; tail -8 /tmp/t11.log
1967 ```
1968
1969 Expected: `EXIT=0`. The server drain and the client replay already handle
1970 `.bell` — if they do not, that is a gap in Task 3 or 4 and belongs there.
1971
1972 - [ ] **Step 5: Commit**
1973
1974 ```bash
1975 cd /home/xanderle/code/rad/mux
1976 git add src/engine.zig
1977 git commit -m "feat(engine): a session's bell reaches the host terminal
1978
1979 One more intercepted action. The drain and the replay were built to carry
1980 it in Tasks 3 and 4, so this is the arm and nothing else.
1981
1982 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
1983 ```
1984
1985 ---
1986
1987 ## Task 11b: the bell is the only stream with no coalescing
1988
1989 **Found by Task 11's quality review, with the arithmetic done.**
1990
1991 One frame per bell, 6 bytes on the wire (5-byte header + 1-byte payload).
1992 The feed chunk is 64 KiB. `cat` any binary: 0x07 is ~1/256 of random bytes,
1993 so **~256 `term_event` frames per chunk** — a 10 MB binary is ~40,000
1994 frames, ~240 KB of extra wire *per client*, and 40,000 separate
1995 `writeAllFd` syscalls on each client's stdout, since `writeSideChannel`
1996 allocates and writes once per event. A pure `\a` stream is 6x
1997 amplification.
1998
1999 Not a correctness bug: `pending_cap` is 8 MB, so nobody is dropped. It is
2000 bandwidth and syscalls — and `cat`ing a binary by accident is a thing
2001 people do.
2002
2003 **What makes it a defect rather than faithfulness is this file's own
2004 doctrine.** `sampleTermModes`' doc says a frame per pty chunk "would be a
2005 bandwidth regression on a protocol whose discipline is 'bytes proportional
2006 to what changed'". Modes dedup, titles are sampled, the grid coalesces into
2007 one bounded delta. **The bell is now the sole exception — and the cheapest
2008 event for a session to emit in bulk.**
2009
2010 - [ ] `var rang = false` in the drain; at most one bell frame per call.
2011 Per-drain, not global: a bell a second later still gets its own frame,
2012 and within one 64 KiB chunk N rings are one ring to a human.
2013 - [ ] **Then add the e2e leg** — see below.
2014
2015 **Interaction with Task 12:** that task is adding pending-event slots to
2016 this same function. Land 11b after it, and note the pending bell slot makes
2017 the coalescing question sharper, not moot: the slot already collapses a
2018 gap's bells to one.
2019
2020 ### The e2e leg, and why it is tied to this fix
2021
2022 Task 11's reviewer was asked whether the bell needs an e2e leg and said
2023 **no, with a reason worth keeping**: the client's `.term_event` dispatch is
2024 **kind-agnostic** — every term_event goes through one `writeSideChannel`
2025 call with no per-kind branch — so the existing clipboard leg already proves
2026 frame → decode → builder → host tty *for the whole frame type*. The only
2027 bell-specific code past the wire is one line, pinned directly.
2028
2029 The clipboard and title legs earn their keep because each has a **transform
2030 only e2e can see** (the title leg's whole instrument is that the session
2031 writes OSC **2** and the host receives OSC **0**). The bell has none: 0x07
2032 in, 0x07 out.
2033
2034 **Coalescing creates that transform.** N bells in a chunk → 1 on the host
2035 is exactly the kind of property that regresses silently, so the leg becomes
2036 worth writing the moment the fix lands — and not before.
2037
2038 ---
2039
2040 ## Task 12: the watermark rule
2041
2042 Everything so far is **live-only**: an event produced while a client is away
2043 is lost. This task adds the replay, and it is last on purpose — if the rule
2044 cannot be pinned, the spec's kill criterion says ship live-only rather than
2045 ship an untested replay path.
2046
2047 **A question Task 3 raised, answered here rather than left open.** The drain
2048 discards `queueFrame`'s return, matching `drainMarkEvents`. Task 3's
2049 implementer flagged that as riskier for a clipboard push than for a
2050 `cmd_state` push, because the next state broadcast re-conveys the latter and
2051 nothing re-conveys the former.
2052
2053 Read `queueFrame` (`server.zig:908`) before acting on that: a false return
2054 does not mean "the event was skipped on a live connection". Every false path
2055 **drops the client** — gone already, a half-appended frame that would corrupt
2056 every byte after it, or a queue past `pending_cap`, which is deliberate
2057 because the daemon is single-threaded and one stalled peer would otherwise
2058 freeze the session for everyone. So a backed-up client does not quietly lose
2059 its clipboard; it is disconnected, and reconnects.
2060
2061 Which is exactly the case this task serves. A client dropped for being slow
2062 reattaches with its old watermark, and if it lands on the delta branch it
2063 receives the pending clipboard event. The discarded return is therefore
2064 correct as written, and the pending slot is what makes it correct. Say so in
2065 a comment at the drain site so the question is not re-opened by the next
2066 reader.
2067
2068 **Files:**
2069 - Modify: `src/server.zig` (`Session` pending slots; `drainSideEvents`; `sendResync`)
2070 - Modify: `test/e2e.sh` (tear/heal scenario; count literal)
2071
2072 - [ ] **Step 1: Write the failing tests**
2073
2074 ```zig
2075 test "Server: a clipboard event in the gap is replayed to a delta reattach" {
2076 // The case that decides whether anyone trusts the feature: yank, the
2077 // link tears, reconnect two seconds later. Losing the yank because the
2078 // link blinked is how a copy feature becomes one you stop believing.
2079 //
2080 // Build a server, drive one clipboard event with NO client attached,
2081 // then attach quoting the session's real epoch and a serviceable seq —
2082 // which is what sendResync's delta branch requires — and assert a
2083 // term_event arrives.
2084 const alloc = std.testing.allocator;
2085
2086 var tmp = try TmpDir.make();
2087 defer tmp.cleanup();
2088 const sock_path = try std.fmt.allocPrint(alloc, "{s}/replay.sock", .{tmp.path()});
2089 defer alloc.free(sock_path);
2090
2091 const script = try std.fmt.allocPrintSentinel(alloc, "{s}/emit.sh", .{tmp.path()}, 0);
2092 defer alloc.free(script);
2093 try std.fs.cwd().writeFile(.{
2094 .sub_path = script,
2095 .data = "#!/bin/sh\nsleep 1\nprintf '\\033]52;c;aGk=\\007'\nexec sleep 30\n",
2096 .flags = .{ .mode = 0o755 },
2097 });
2098
2099 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script });
2100 defer srv.deinit();
2101
2102 // Pump with nobody attached so the event lands in the pending slot.
2103 const t0 = std.time.milliTimestamp();
2104 while (std.time.milliTimestamp() - t0 < 3000) try srv.pumpOnce(50);
2105
2106 const s = srv.ses(0);
2107 const c = try std.net.connectUnixSocket(sock_path);
2108 defer c.close();
2109 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, s.tracker.seq, s.epoch));
2110
2111 try std.testing.expect(try awaitFrame(alloc, &srv, c, .term_event, 5000));
2112 }
2113
2114 test "Server: a clipboard event in the gap is NOT replayed to a snapshot attach" {
2115 // A twenty-minute-old clipboard write hijacking the clipboard on return
2116 // is a bug wearing a feature's clothes. have_epoch 0 is exactly what a
2117 // fresh client sends, and it forces the snapshot branch.
2118 const alloc = std.testing.allocator;
2119
2120 var tmp = try TmpDir.make();
2121 defer tmp.cleanup();
2122 const sock_path = try std.fmt.allocPrint(alloc, "{s}/noreplay.sock", .{tmp.path()});
2123 defer alloc.free(sock_path);
2124
2125 const script = try std.fmt.allocPrintSentinel(alloc, "{s}/emit.sh", .{tmp.path()}, 0);
2126 defer alloc.free(script);
2127 try std.fs.cwd().writeFile(.{
2128 .sub_path = script,
2129 .data = "#!/bin/sh\nsleep 1\nprintf '\\033]52;c;aGk=\\007'\nexec sleep 30\n",
2130 .flags = .{ .mode = 0o755 },
2131 });
2132
2133 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = script });
2134 defer srv.deinit();
2135
2136 const t0 = std.time.milliTimestamp();
2137 while (std.time.milliTimestamp() - t0 < 3000) try srv.pumpOnce(50);
2138
2139 const c = try std.net.connectUnixSocket(sock_path);
2140 defer c.close();
2141 try proto.writeFrame(c.handle, .attach, &proto.encodeAttach(80, 24, 0, 0));
2142
2143 try std.testing.expect(!try awaitFrame(alloc, &srv, c, .term_event, 2000));
2144 }
2145 ```
2146
2147 - [ ] **Step 2: Run and watch them fail**
2148
2149 ```bash
2150 cd /home/xanderle/code/rad/mux
2151 make test > /tmp/t12.log 2>&1; echo "EXIT=$?"; tail -25 /tmp/t12.log
2152 ```
2153
2154 Expected: the first test fails (nothing is replayed yet), the second passes
2155 already (nothing is replayed to anyone). One failing is enough to proceed;
2156 the second is the guard that the fix does not over-deliver.
2157
2158 - [ ] **Step 3: Add the pending slots**
2159
2160 In `Session`:
2161
2162 ```zig
2163 /// The last side-channel event of each kind that a reconnecting client
2164 /// might still be owed, with the seq it happened at. One slot per kind
2165 /// rather than a log: two copies during a gap means the last one wins,
2166 /// which is what the user meant, and forty bells means one ding.
2167 ///
2168 /// "Per kind" is per event kind, NOT per clipboard target: a later `p`
2169 /// set replaces an earlier `c` set, because the slot holds the last
2170 /// clipboard event with its target inside it.
2171 ///
2172 /// The clipboard slot holds the USER'S COPIED TEXT in daemon memory, so
2173 /// it is dropped as soon as its seq is no longer servable — at which
2174 /// point it could never be delivered to anyone anyway.
2175 pending_clipboard: ?PendingEvent = null,
2176 pending_bell: ?PendingEvent = null,
2177
2178 const PendingEvent = struct {
2179 seq: u64,
2180 /// Owned; the wire payload exactly as drainSideEvents built it.
2181 payload: []const u8,
2182 };
2183 ```
2184
2185 Free both in the session teardown beside `title_sent`.
2186
2187 - [ ] **Step 4: Record on drain**
2188
2189 In `drainSideEvents`, after queueing to live clients, replace the pending
2190 slot for that kind:
2191
2192 ```zig
2193 const slot = switch (ev.kind) {
2194 .clipboard => &s.pending_clipboard,
2195 .bell => &s.pending_bell,
2196 };
2197 if (slot.*) |old| self.alloc.free(old.payload);
2198 slot.* = if (self.alloc.dupe(u8, payload.items)) |owned|
2199 .{ .seq = s.tracker.seq, .payload = owned }
2200 else |_|
2201 null;
2202 ```
2203
2204 - [ ] **Step 5: Replay on the delta branch only**
2205
2206 In `sendResync`, inside the branch that sends a delta (the one guarded by
2207 `have_epoch == s.epoch and s.tracker.canServe(have_seq)`), after the delta
2208 send and after the sampled-state sends:
2209
2210 ```zig
2211 // Ordering: the delta first, then the events. The grid should be
2212 // consistent before anything acts on it.
2213 for ([_]?Session.PendingEvent{ s.pending_clipboard, s.pending_bell }) |maybe| {
2214 const p = maybe orelse continue;
2215 if (p.seq > have_seq) _ = self.queueFrame(i, .term_event, p.payload);
2216 }
2217 ```
2218
2219 - [ ] **Step 6: Drop unservable pending events**
2220
2221 Wherever the tracker prunes (find it with the grep below), drop a pending
2222 event whose seq can no longer be served:
2223
2224 ```bash
2225 cd /home/xanderle/code/rad/mux
2226 grep -n 'fn canServe' src/server.zig
2227 ```
2228
2229 Add beside the sampling calls in the pty-read arm:
2230
2231 ```zig
2232 self.expirePending(si);
2233 ```
2234
2235 ```zig
2236 /// A pending event whose seq is no longer servable can never be
2237 /// delivered, so holding it buys nothing — and for the clipboard it
2238 /// costs something real: the user's copied text sitting in daemon
2239 /// memory long after any client could receive it.
2240 fn expirePending(self: *Server, si: usize) void {
2241 const s = self.ses(si);
2242 inline for (.{ "pending_clipboard", "pending_bell" }) |name| {
2243 if (@field(s, name)) |p| {
2244 if (!s.tracker.canServe(p.seq)) {
2245 self.alloc.free(p.payload);
2246 @field(s, name) = null;
2247 }
2248 }
2249 }
2250 }
2251 ```
2252
2253 - [ ] **Step 7: Run and watch both pass**
2254
2255 ```bash
2256 cd /home/xanderle/code/rad/mux
2257 make test > /tmp/t12.log 2>&1; echo "EXIT=$?"; tail -8 /tmp/t12.log
2258 ```
2259
2260 Expected: `EXIT=0`.
2261
2262 - [ ] **Step 8: Run every suite**
2263
2264 ```bash
2265 cd /home/xanderle/code/rad/mux
2266 make test > /tmp/all-t.log 2>&1; echo "TEST=$?"
2267 make build > /tmp/all-b.log 2>&1; echo "BUILD=$?"
2268 make e2e > /tmp/all-e.log 2>&1; echo "E2E=$?"; tail -3 /tmp/all-e.log
2269 make agent > /tmp/all-a.log 2>&1; echo "AGENT=$?"; tail -3 /tmp/all-a.log
2270 SOAK_N=10 make soak > /tmp/all-s.log 2>&1; echo "SOAK=$?"; tail -3 /tmp/all-s.log
2271 ```
2272
2273 Expected: all `=0`, soak 10/10.
2274
2275 - [ ] **Step 9: Commit**
2276
2277 ```bash
2278 cd /home/xanderle/code/rad/mux
2279 git add src/server.zig
2280 git commit -m "feat(server): replay a gap's side events to a delta reattach only
2281
2282 The side channel follows the grid's own resync verdict — delivered on the
2283 delta branch, dropped on the snapshot branch. So a yank that races a QUIC
2284 tear survives the heal, and a twenty-minute-old clipboard write never
2285 hijacks the clipboard on return. One slot per kind rather than a log, and
2286 the clipboard slot is dropped as soon as its seq is unservable: it holds
2287 the user's copied text.
2288
2289 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
2290 ```
2291
2292 ---
2293
2294 ## Task 12b: the pending slots' review round
2295
2296 **From Task 12's quality review. The first two defend the privacy contract
2297 the task exists to honour; the test gaps are each a plausible regression.**
2298
2299 - [ ] **The slot set is enumerated four times and only one is
2300 compile-checked.** `pendingSlot`'s exhaustive switch forces a new
2301 `Engine.SideEvent.Kind` to gain a field, but `dropUnservablePending`,
2302 `freePending` and `replayPending` all hand-write
2303 `{ &self.pending_clipboard, &self.pending_bell }`. A third kind
2304 compiles clean and is recorded, **never replayed, never expired — the
2305 privacy contract silently stops applying to it** — and leaked on
2306 teardown. Cheapest fix is one `pendingSlots()` accessor and three call
2307 sites; the enforced form builds the array by `inline for` over the
2308 enum so a new kind fails to compile at the switch.
2309
2310 - [ ] **A failed rebuild moves `reset_seq` but skips the expiry.**
2311 `s.tracker.rebuild(...) catch return false` returns before
2312 `dropUnservablePending`. In the `dumpVtRow` failure shape,
2313 `delta.zig:63-64` has already run — `reset_seq` moved — and `rows` is
2314 left 0, so `canServe` is false for every recorded seq. The payloads
2315 are permanently undeliverable **and still resident**: exactly the
2316 state the privacy contract names, on the one path that skips the
2317 drop. Fix is a `defer` before the `catch return`; the alloc-failure
2318 shape leaves seq untouched so the unconditional drop correctly
2319 retains there.
2320
2321 - [ ] **The replacement free is never exercised.** No test records twice
2322 into one slot, so deleting that line leaks one payload per repeat
2323 with every test still green. A gap script emitting **two** clipboard
2324 sets fixes it and pins the documented "last one wins" semantic at the
2325 same time — assert the replayed payload is the second, and
2326 `testing.allocator` catches the leak for free.
2327
2328 - [ ] **`>` vs `>=` is unpinned.** Test 1 reattaches strictly below the
2329 recorded seq, so it passes under either. Someone "fixing" the
2330 invisible-chunk limitation the spec records would flip it and hand a
2331 duplicate clipboard set to every reattaching client that never missed
2332 anything.
2333
2334 - [ ] **The bell slot is never replayed by any test.** A `if (kind !=
2335 .clipboard) return` in `recordPending` — an easy move for someone
2336 tightening the privacy rule, since every comment there is
2337 clipboard-flavoured — passes all three tests. A BEL in the gap script
2338 alongside the second clipboard set also covers "both slots pending at
2339 once" and the clipboard→bell order that is asserted in prose only.
2340
2341 Minors: a back-pointer on `DeltaTracker.rebuild`'s doc (the wrapper is the
2342 only production path but the inner call is the obvious thing to type, and
2343 there is an in-file precedent); extract the modes+title defer as
2344 `sendSampledStateTo`; and one clause in `resyncSnapshot` noting that a
2345 sibling drain now deliberately diverges from the no-clients doctrine it
2346 states.
2347
2348 ---
2349
2350 ## Task 12c: three privacy guards defended only by prose
2351
2352 **From Task 12b's review, which ran nine mutations in throwaway worktrees.
2353 Each guard below can be deleted with the whole suite green.**
2354
2355 - [ ] **`freePending` at session death is unpinned** (`server.zig:826`).
2356 Deleting it leaves the suite green: every gap fixture ends `exec
2357 sleep 30`, so no test ever reaps a session still holding a slot. A
2358 shell that exits between the copy and the next attach **leaks the
2359 user's clipboard payload outright**, and this is one of only two
2360 places the teardown half of the contract is honoured. Pin: a fixture
2361 that emits the OSC 52 then exits, pumped until the slot is nulled,
2362 under `testing.allocator`.
2363
2364 - [ ] **`recordPending`'s `canServe` guard is unpinned** (`server.zig:402`).
2365 Its own doc calls it the point of the design — "refusing to store it
2366 is refusing to hold the user's copied text for no possible reader" —
2367 and nothing checks it. Pin: release the gap fixture's shell **before
2368 any client has ever attached** (`tracker.rows == 0`) and assert both
2369 slots are still null.
2370
2371 - [ ] **The expiry's retain predicate is unpinned in the retain
2372 direction** (`server.zig:418`) — which is the direction the new
2373 `defer`'s safety argument rests on. Deleting `if
2374 (canServe(p.seq)) continue;` is green, because a *successful* rebuild
2375 already puts `reset_seq` above every stamp, so the predicate only
2376 ever retains in the alloc-failure shape, which has no seam.
2377
2378 **The reviewer's fix needs no allocator seam** — direct calls on a
2379 session already in the right state, inside the existing rebuild test:
2380
2381 ```zig
2382 s.dropUnservablePending(srv.alloc);
2383 try std.testing.expect(s.pending_clipboard != null); // retain
2384 s.tracker.rows = 0; // what a failed
2385 s.dropUnservablePending(srv.alloc); // dumpVtRow leaves
2386 try std.testing.expect(s.pending_clipboard == null); // drop
2387 ```
2388
2389 That pins the *substance* of the failed-rebuild hole and leaves only
2390 the wiring genuinely unfalsifiable.
2391
2392 **Minors:** the boundary test is a fifth hand-written enumeration of the
2393 slot set and the one place the new `inline for` does not reach — derive it
2394 with `for (s.pendingSlots())`; `delta.zig`'s back-pointer says "nowhere
2395 else" when one test drives `rebuild` directly; `gap-open` is called
2396 "load-bearing" but removing it is green (its mechanism is true, the claim
2397 about test outcome is not — it removes a *dependency* on ECHO, not a
2398 failure); the two-error-kinds wording in `rebuildTracker` describes one
2399 error kind at two points; `const kinds` is declared after its user; three
2400 gap tests hand-roll the same 18-line collect loop with a budget that must
2401 stay in step.
2402
2403 ---
2404
2405 ## Task 13: cross-version legs
2406
2407 The spec's §8 claim — an old client ignores an unknown frame type — must be
2408 **verified on both dispatch paths, not assumed**. M18 found socket and QUIC
2409 differ in exactly this area.
2410
2411 **Files:**
2412 - Modify: `test/xversion.sh`
2413
2414 - [ ] **Step 1: Read the existing legs**
2415
2416 ```bash
2417 cd /home/xanderle/code/rad/mux
2418 grep -n 'assert_\|^# ---\|leg' test/xversion.sh | head -30
2419 ```
2420
2421 - [ ] **Step 2: Add both legs**
2422
2423 Following the file's existing leg structure, add:
2424
2425 ```sh
2426 # --- Leg: an OLD client ignores the new side-channel frames, both doors.
2427 # The new daemon sends term_modes/term_title/term_event to every attached
2428 # client. An old client has no arm for those types. The spec ASSERTS it
2429 # lands in `else => {}` and is ignored; M18 is why that is asserted here
2430 # rather than believed — a pre-M18 daemon drops a bad attach cleanly on the
2431 # socket path and ignores it in SILENCE over QUIC, so "both dispatches
2432 # behave the same" is exactly the belief this project has been burned by.
2433 #
2434 # The session emits an OSC 52 and a title change, then a marker. The old
2435 # client must still paint the marker: ignoring an unknown frame means the
2436 # stream keeps parsing, and a client that mis-framed would lose everything
2437 # after it.
2438 xver_leg_old_client_ignores_side_channel socket
2439 xver_leg_old_client_ignores_side_channel quic
2440 ```
2441
2442 Implement the helper in the file's own idiom: start a **new-tree** daemon,
2443 attach the **old** client over the named transport, drive
2444 `printf '\033]52;c;aGk=\007\033]0;t\007XVERMARK\n'` into the session, and
2445 assert the old client's capture contains `XVERMARK`.
2446
2447 - [ ] **Step 3: Run the gate**
2448
2449 ```bash
2450 cd /home/xanderle/code/rad/mux
2451 make xversion > /tmp/t13.log 2>&1; echo "XVER=$?"; tail -20 /tmp/t13.log
2452 ```
2453
2454 Expected: `XVER=0` with the new legs listed.
2455
2456 - [ ] **Step 4: Prove the new legs can fail**
2457
2458 The rig is falsifiable on purpose. Point `XVER_OLD_BIN` at the new binaries
2459 and confirm the legs that must fail do:
2460
2461 ```bash
2462 cd /home/xanderle/code/rad/mux
2463 grep -n 'XVER_OLD_BIN' test/xversion.sh Makefile | head
2464 ```
2465
2466 Follow the file's documented falsification procedure. A leg that passes with
2467 old and new both pointed at the new tree is asserting nothing.
2468
2469 - [ ] **Step 5: Commit**
2470
2471 ```bash
2472 cd /home/xanderle/code/rad/mux
2473 git add test/xversion.sh
2474 git commit -m "test(xversion): an old client ignores the side-channel frames
2475
2476 Both doors, because M18 found socket and QUIC differ in exactly this area
2477 and 'both dispatches behave the same' is the belief this project has been
2478 burned by. The old client must still paint the marker that follows the
2479 unknown frames: ignoring one means the stream keeps parsing.
2480
2481 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
2482 ```
2483
2484 ---
2485
2486 ## Task 14: documentation
2487
2488 **Files:**
2489 - Modify: `README.md`, `docs/decisions.md`, `docs/roadmap.md`
2490
2491 - [ ] **Step 1: README**
2492
2493 Add to the section describing what mux carries, after the multi-client
2494 paragraph:
2495
2496 ```markdown
2497 Copy and paste work through the session: an application's OSC 52 write
2498 reaches your terminal's clipboard (including from a remote box over QUIC,
2499 where nothing else can), a paste arrives bracketed when the application
2500 asked for bracketed paste, the window title follows the session, and a
2501 bell rings. The clipboard READ direction (`OSC 52` query) is refused
2502 deliberately — answering it would let anything in any session read
2503 whatever you last copied.
2504 ```
2505
2506 - [ ] **Step 2: decisions.md**
2507
2508 Append a dated section recording: the two mechanisms and why the
2509 classification came first; the watermark rule and the one-slot-per-kind
2510 collapse; the query refusal; the measured before/after (0 → 1 on the host
2511 capture, and 8 spaces → 4 in nvim's file); whether the xterm title stack
2512 worked (Task 10 Step 7); and the mutation results from Tasks 5 and 9.
2513
2514 - [ ] **Step 3: roadmap.md**
2515
2516 Move the three issues from the candidates list to a completed section and
2517 note what remains banked: mouse mirroring (still coupled to copy-mode),
2518 `OSC 10/11` queries, browser clipboard.
2519
2520 - [ ] **Step 4: Close the tracker issues**
2521
2522 ```bash
2523 cd /home/xanderle/code/rad/mux
2524 git-collab issue comment ee062dd9 --body "[claude YYYY-MM-DD] Bracketed-paste half shipped; mouse bits reserved in term_modes and still banked behind copy-mode." 2>/dev/null
2525 git-collab issue comment 7c777ec6 --body "[claude YYYY-MM-DD] Shipped: set only, query refused, 64 KiB cap." 2>/dev/null
2526 ```
2527
2528 Use today's real date. Each git-collab write auto-syncs to origin and takes
2529 10–20s; give the batch a 300s timeout.
2530
2531 - [ ] **Step 5: Commit**
2532
2533 ```bash
2534 cd /home/xanderle/code/rad/mux
2535 git add README.md docs/decisions.md docs/roadmap.md
2536 git commit -m "docs: side-channel passthrough, and what it measured
2537
2538 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
2539 ```
2540
2541 ---
2542
2543 ## Self-review notes
2544
2545 **Spec coverage.** §3 group 1 → Tasks 6–8 (modes), 10 (title). §3 group 2 →
2546 Tasks 1–5 (clipboard), 11 (bell). §3 group 3 → out of scope, no task, by
2547 design. §4 watermark rule → Task 12. §5 wire format → Tasks 1, 6, 10. §6
2548 daemon → Tasks 2, 3, 7, 10, 12. §7 security → Task 2 (query refusal, cap),
2549 Task 4 (re-validation), Task 12 (pending expiry). §8 cross-version → Task
2550 13. §9 testing → every task's TDD steps plus Tasks 5, 9, 12, 13. §10
2551 assumption 1 (Alacritty OSC 52) is a **field check with no task** — it is
2552 outside mux and cannot be automated here; it belongs in the branch's
2553 close-out. §10 assumption 2 → Task 10 Step 7. §10 assumption 3 (browser
2554 `default:` arm) → **no task**; add it as a one-line check during Task 4.
2555
2556 **Naming consistency.** `SideEvent` is the engine's type; `TermEvent` is the
2557 wire type; they are deliberately different names because they are different
2558 things (one owns its payload, one borrows from a frame). `sideEvents()` /
2559 `clearSideEvents()` match `markEvents()` / `clearMarkEvents()`.
2560 `term_modes_sent` and `title_sent` follow `mode_sent`.
2561
2562 **Known soft spots in this plan.** Task 3's `awaitFrame` helper and Task 9's
2563 master-write call are written against helper shapes rather than verified
2564 signatures — both steps say to read the existing code and match it. Task
2565 12's `expirePending` assumes `tracker.canServe` is callable from that site.
2566 If any of those does not hold, fix the plan's step rather than inventing a
2567 parallel helper.
docs/superpowers/plans/2026-08-16-web-copy-paste-client-core.md
Old New
@@ -1,787 +0,0 @@
1 # Web Copy/Paste Client Core 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:** Share terminal-mode and host-effect interpretation between the native CLI and muxweb, then make web paste mode-correct and application-driven OSC 52 copy usable in browsers.
6
7 **Architecture:** Add a wasm-clean `client_core` module that converts existing `term_modes` and `term_event` frames into typed state/effect results. The CLI retains tty escape generation while the WASM/browser adapter consumes the same results for paste policy and Clipboard API behavior; no new wire message is introduced in this slice.
8
9 **Tech Stack:** Zig 0.15.2, existing mux protocol and module strata, wasm32-freestanding, vanilla JavaScript, Canvas, Clipboard API, Node-based `web/verify.js` ABI smoke.
10
11 **Design:** `docs/superpowers/specs/2026-08-16-web-copy-paste-client-core-design.md`
12
13 ---
14
15 ## File map
16
17 - Create `src/client_core.zig`: platform-neutral terminal state/effect decoder and validation policy.
18 - Modify `build.zig`: register the native/wasm module and grant it to the CLI and WASM root.
19 - Modify `src/client.zig`: adapt typed core results to host-terminal escapes; retain tty ownership and writing here.
20 - Modify `src/wasm_core.zig`: hold the shared core, expose semantic actions/getters, and gate paste markers from sampled state.
21 - Modify `web/verify.js`: drive the WASM ABI with the protocol's golden mode/event bytes.
22 - Modify `web/mux.js`: route mode/effect frames through WASM and perform browser clipboard policy.
23 - Modify `web/index.html`: add the transient clipboard action and its styling.
24 - Modify `docs/roadmap.md` and `docs/decisions.md`: record the delivered web consumer and shared-client boundary after verification.
25
26 ### Task 1: Add the portable state/effect decoder
27
28 **Files:**
29 - Create: `src/client_core.zig`
30 - Modify: `build.zig:191-220`
31 - Modify: `build.zig:391-399`
32 - Modify: `build.zig:591-596`
33
34 - [ ] **Step 1: Create failing state/effect tests**
35
36 Create `src/client_core.zig` with the public types and tests below, but leave `receive` returning `.ignored`. The valid cases must fail before implementation while malformed cases establish the atomic-state contract.
37
38 ```zig
39 const std = @import("std");
40 const proto = @import("protocol");
41
42 pub const ClipboardSet = struct {
43 target: u8,
44 base64: []const u8,
45 };
46
47 pub const State = union(enum) {
48 terminal_modes: proto.TermModes,
49 };
50
51 pub const Effect = union(enum) {
52 clipboard_set: ClipboardSet,
53 bell,
54 };
55
56 pub const Result = union(enum) {
57 ignored,
58 state: State,
59 effect: Effect,
60 };
61
62 pub const ClientCore = struct {
63 terminal_modes: proto.TermModes = .{},
64
65 pub fn receive(self: *ClientCore, msg_type: proto.MsgType, payload: []const u8) Result {
66 _ = self;
67 _ = msg_type;
68 _ = payload;
69 return .ignored;
70 }
71 };
72
73 test "client core: every valid terminal mode sample is delivered and stored" {
74 var core: ClientCore = .{};
75 const enabled = proto.encodeTermModes(.{ .bracketed_paste = true });
76
77 for (0..2) |_| switch (core.receive(.term_modes, &enabled)) {
78 .state => |state| switch (state) {
79 .terminal_modes => |m| try std.testing.expect(m.bracketed_paste),
80 },
81 else => return error.TestUnexpectedResult,
82 };
83 try std.testing.expect(core.terminal_modes.bracketed_paste);
84
85 const disabled = proto.encodeTermModes(.{ .bracketed_paste = false });
86 _ = core.receive(.term_modes, &disabled);
87 try std.testing.expect(!core.terminal_modes.bracketed_paste);
88 }
89
90 test "client core: malformed terminal modes preserve the last sample" {
91 var core: ClientCore = .{ .terminal_modes = .{ .bracketed_paste = true } };
92 try std.testing.expectEqual(Result.ignored, core.receive(.term_modes, &.{ 1, 0 }));
93 try std.testing.expect(core.terminal_modes.bracketed_paste);
94 }
95
96 test "client core: clipboard and bell become typed effects" {
97 const alloc = std.testing.allocator;
98 var core: ClientCore = .{};
99 var payload: std.ArrayList(u8) = .empty;
100 defer payload.deinit(alloc);
101
102 try proto.encodeClipboardEvent(&payload, alloc, 'c', "aGk=");
103 switch (core.receive(.term_event, payload.items)) {
104 .effect => |effect| switch (effect) {
105 .clipboard_set => |clip| {
106 try std.testing.expectEqual(@as(u8, 'c'), clip.target);
107 try std.testing.expectEqualStrings("aGk=", clip.base64);
108 },
109 else => return error.TestUnexpectedResult,
110 },
111 else => return error.TestUnexpectedResult,
112 }
113
114 payload.clearRetainingCapacity();
115 try proto.encodeBellEvent(&payload, alloc);
116 switch (core.receive(.term_event, payload.items)) {
117 .effect => |effect| try std.testing.expect(effect == .bell),
118 else => return error.TestUnexpectedResult,
119 }
120 }
121
122 test "client core: unsafe clipboard forms and unknown messages are ignored" {
123 const alloc = std.testing.allocator;
124 var core: ClientCore = .{};
125 const cases = [_]struct { target: u8, data: []const u8 }{
126 .{ .target = 'X', .data = "aGk=" },
127 .{ .target = 'c', .data = "" },
128 .{ .target = 'c', .data = "aGk=\x07" },
129 };
130 for (cases) |case| {
131 var payload: std.ArrayList(u8) = .empty;
132 defer payload.deinit(alloc);
133 try proto.encodeClipboardEvent(&payload, alloc, case.target, case.data);
134 try std.testing.expectEqual(Result.ignored, core.receive(.term_event, payload.items));
135 }
136 try std.testing.expectEqual(Result.ignored, core.receive(@enumFromInt(0x40), "future"));
137 }
138 ```
139
140 - [ ] **Step 2: Register the module and verify the tests fail**
141
142 Add this row after `replica` in `mod_table`:
143
144 ```zig
145 .{ .name = "client_core", .path = "src/client_core.zig", .layer = 1, .wasm = true, .imports = &.{"protocol"} },
146 ```
147
148 Add `client_core` to `test_order`, to the production imports of `client`, and to the explicit WASM root imports:
149
150 ```zig
151 .{ .name = "client", .path = "src/client.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "protocol", "replica", "client_core", "quic_client", "quic", "predict", "handoff", "proxy", "paint" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
152 ```
153
154 ```zig
155 wasm_core_mod.addImport("client_core", wasm_mods[comptime idxOf("client_core")].?);
156 ```
157
158 Run: `make test`
159
160 Expected: FAIL in `client_core` because valid `term_modes` and `term_event` inputs return `.ignored`.
161
162 - [ ] **Step 3: Implement strict semantic decoding**
163
164 Replace `ClientCore.receive` and add the two private validators:
165
166 ```zig
167 fn validClipboardTarget(target: u8) bool {
168 return switch (target) {
169 'c', 'p', 'q', 's', '0'...'7' => true,
170 else => false,
171 };
172 }
173
174 fn safeBase64Alphabet(bytes: []const u8) bool {
175 for (bytes) |byte| switch (byte) {
176 'A'...'Z', 'a'...'z', '0'...'9', '+', '/', '=' => {},
177 else => return false,
178 };
179 return true;
180 }
181
182 pub const ClientCore = struct {
183 terminal_modes: proto.TermModes = .{},
184
185 pub fn receive(self: *ClientCore, msg_type: proto.MsgType, payload: []const u8) Result {
186 return switch (msg_type) {
187 .term_modes => modes: {
188 const value = proto.decodeTermModes(payload) catch break :modes .ignored;
189 self.terminal_modes = value;
190 break :modes .{ .state = .{ .terminal_modes = value } };
191 },
192 .term_event => event: {
193 const value = proto.decodeTermEvent(payload) catch break :event .ignored;
194 break :event switch (value) {
195 .bell => .{ .effect = .bell },
196 .clipboard => |clip| if (
197 clip.base64.len == 0 or
198 clip.base64.len > proto.clipboard_base64_max or
199 !validClipboardTarget(clip.target) or
200 !safeBase64Alphabet(clip.base64)
201 ) .ignored else .{ .effect = .{ .clipboard_set = .{
202 .target = clip.target,
203 .base64 = clip.base64,
204 } } },
205 };
206 },
207 else => .ignored,
208 };
209 }
210 };
211 ```
212
213 - [ ] **Step 4: Add cap and malformed-event boundary tests**
214
215 Append tests that allocate exactly `clipboard_base64_max` bytes and one byte more, and feed `&.{}`, `&.{0x7e}`, and `&.{ @intFromEnum(proto.TermEvent.Kind.bell), 0 }`. Assert the exact-cap clipboard is emitted and all other cases return `.ignored`.
216
217 ```zig
218 test "client core: clipboard cap is inclusive and event shapes are exact" {
219 const alloc = std.testing.allocator;
220 var core: ClientCore = .{};
221 const at_cap = try alloc.alloc(u8, proto.clipboard_base64_max);
222 defer alloc.free(at_cap);
223 @memset(at_cap, 'A');
224
225 var payload: std.ArrayList(u8) = .empty;
226 defer payload.deinit(alloc);
227 try proto.encodeClipboardEvent(&payload, alloc, 'c', at_cap);
228 try std.testing.expect(core.receive(.term_event, payload.items) == .effect);
229
230 const over = try alloc.alloc(u8, proto.clipboard_base64_max + 1);
231 defer alloc.free(over);
232 @memset(over, 'A');
233 payload.clearRetainingCapacity();
234 try proto.encodeClipboardEvent(&payload, alloc, 'c', over);
235 try std.testing.expectEqual(Result.ignored, core.receive(.term_event, payload.items));
236
237 for ([_][]const u8{ &.{}, &.{0x7e}, &.{ @intFromEnum(proto.TermEvent.Kind.bell), 0 } }) |bad|
238 try std.testing.expectEqual(Result.ignored, core.receive(.term_event, bad));
239 }
240 ```
241
242 - [ ] **Step 5: Run the unit suite and commit**
243
244 Run: `make test`
245
246 Expected: PASS, including the new `client_core` suite and the WASM build dependency.
247
248 ```bash
249 git add build.zig src/client_core.zig
250 git commit -m "refactor: add shared client terminal semantics"
251 ```
252
253 ### Task 2: Make the CLI an adapter over `client_core`
254
255 **Files:**
256 - Modify: `src/client.zig:19-30`
257 - Modify: `src/client.zig:913-930`
258 - Modify: `src/client.zig:1307-1340`
259 - Modify: `src/client.zig:1454-1593`
260 - Modify: `src/client.zig:2865-3007`
261
262 - [ ] **Step 1: Rewrite the adapter tests against typed values**
263
264 Import the module and change the existing side-channel tests so they call `appendTermState` and `appendHostEffect` with typed values. The first valid clipboard test becomes:
265
266 ```zig
267 const client_core = @import("client_core");
268
269 test "client: a validated clipboard effect becomes an OSC 52 write" {
270 const alloc = std.testing.allocator;
271 var out: std.ArrayList(u8) = .empty;
272 defer out.deinit(alloc);
273 try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
274 .target = 'c',
275 .base64 = "aGk=",
276 } });
277 try std.testing.expectEqualStrings("\x1b]52;c;aGk=\x07", out.items);
278 }
279 ```
280
281 Change the mode test to:
282
283 ```zig
284 try appendTermState(&out, alloc, .{ .terminal_modes = .{ .bracketed_paste = true } });
285 try std.testing.expectEqualStrings("\x1b[?2004h", out.items);
286 out.clearRetainingCapacity();
287 try appendTermState(&out, alloc, .{ .terminal_modes = .{ .bracketed_paste = false } });
288 try std.testing.expectEqualStrings("\x1b[?2004l", out.items);
289 ```
290
291 Delete the CLI-local malformed/base64/target tests only after equivalent assertions exist in `client_core.zig`; retain exact escape and refusal-sentinel tests that exercise adapter atomicity.
292
293 - [ ] **Step 2: Run the client tests to verify the adapter functions are missing**
294
295 Run: `make test`
296
297 Expected: FAIL compiling `client.zig` because `appendTermState` and `appendHostEffect` are not yet defined.
298
299 - [ ] **Step 3: Replace payload parsers with typed adapters**
300
301 Delete `isBase64Alphabet`, `appendTermModes`, and `appendTermEvent`. Add:
302
303 ```zig
304 fn appendTermState(
305 out: *std.ArrayList(u8),
306 alloc: std.mem.Allocator,
307 state: client_core.State,
308 ) !void {
309 switch (state) {
310 .terminal_modes => |modes| try out.appendSlice(
311 alloc,
312 if (modes.bracketed_paste) "\x1b[?2004h" else "\x1b[?2004l",
313 ),
314 }
315 }
316
317 fn appendHostEffect(
318 out: *std.ArrayList(u8),
319 alloc: std.mem.Allocator,
320 effect: client_core.Effect,
321 ) !void {
322 switch (effect) {
323 .bell => try out.append(alloc, 0x07),
324 .clipboard_set => |clip| {
325 try out.appendSlice(alloc, "\x1b]52;");
326 try out.append(alloc, clip.target);
327 try out.append(alloc, ';');
328 try out.appendSlice(alloc, clip.base64);
329 try out.append(alloc, 0x07);
330 },
331 }
332 }
333 ```
334
335 Validation is absent here on purpose: only `client_core` can construct these semantic values from an untrusted frame.
336
337 - [ ] **Step 4: Route native frames through one core instance**
338
339 Beside the existing `Replica` initialization in `session`, add:
340
341 ```zig
342 var semantic_core: client_core.ClientCore = .{};
343 ```
344
345 Replace the `.term_event` and `.term_modes` switch arms with:
346
347 ```zig
348 .term_event, .term_modes => switch (semantic_core.receive(frame.type, frame.payload)) {
349 .ignored => {},
350 .state => |state| try writeSideChannel(
351 alloc,
352 stdout_fd,
353 alt_screen,
354 state,
355 appendTermState,
356 ),
357 .effect => |effect| try writeSideChannel(
358 alloc,
359 stdout_fd,
360 alt_screen,
361 effect,
362 appendHostEffect,
363 ),
364 },
365 ```
366
367 Generalize `writeSideChannel`'s payload argument and builder parameter to `anytype`, without changing its tty-ownership gate or one-buffer/one-write behavior:
368
369 ```zig
370 fn writeSideChannel(
371 alloc: std.mem.Allocator,
372 stdout_fd: std.posix.fd_t,
373 owns_terminal: bool,
374 value: anytype,
375 comptime append: anytype,
376 ) !void {
377 if (!owns_terminal) return;
378 var out: std.ArrayList(u8) = .empty;
379 defer out.deinit(alloc);
380 try append(&out, alloc, value);
381 if (out.items.len > 0) try proto.writeAllFd(stdout_fd, out.items);
382 }
383 ```
384
385 Keep `term_title` on its current adapter path; it is outside this minimal proof.
386
387 - [ ] **Step 5: Verify byte-for-byte CLI compatibility and commit**
388
389 Run: `make test`
390
391 Expected: PASS, including exact OSC 52, BEL, DECSET, and DECRST assertions.
392
393 Run: `make e2e`
394
395 Expected: PASS, including the existing OSC 52, bracketed-paste, bell, and no-owned-terminal side-channel scenarios.
396
397 ```bash
398 git add src/client.zig
399 git commit -m "refactor: drive cli side channels from client core"
400 ```
401
402 ### Task 3: Expose shared semantics through WASM
403
404 **Files:**
405 - Modify: `src/wasm_core.zig:19-86`
406 - Modify: `src/wasm_core.zig:117-181`
407 - Modify: `src/wasm_core.zig:356-381`
408 - Modify: `web/verify.js:40-230`
409
410 - [ ] **Step 1: Add failing ABI assertions to `web/verify.js`**
411
412 After initialization, stage the wire-golden payloads and assert the planned action codes and getters:
413
414 ```javascript
415 const CLIENT_ACTION = { ignored: 0, terminalModes: 1, clipboard: 2, bell: 3 };
416
417 const modesOn = Buffer.alloc(4); modesOn.writeUInt32LE(1);
418 check('mode action on', e.mux_client_frame(0x8d, stage(modesOn)), CLIENT_ACTION.terminalModes);
419 check('bracketed state on', e.mux_bracketed_paste(), 1);
420 check('repeated mode sample delivered', e.mux_client_frame(0x8d, stage(modesOn)), CLIENT_ACTION.terminalModes);
421
422 const modesOff = Buffer.alloc(4);
423 check('mode action off', e.mux_client_frame(0x8d, stage(modesOff)), CLIENT_ACTION.terminalModes);
424 check('bracketed state off', e.mux_bracketed_paste(), 0);
425 check('malformed mode ignored', e.mux_client_frame(0x8d, stage(Buffer.from([1, 0]))), CLIENT_ACTION.ignored);
426 check('malformed mode preserves state', e.mux_bracketed_paste(), 0);
427
428 const clip = Buffer.from([0, 'c'.charCodeAt(0), ...Buffer.from('aGk=', 'ascii')]);
429 check('clipboard action', e.mux_client_frame(0x8f, stage(clip)), CLIENT_ACTION.clipboard);
430 check('clipboard target', e.mux_clipboard_target(), 'c'.charCodeAt(0));
431 check(
432 'clipboard base64',
433 Buffer.from(mem().subarray(e.mux_clipboard_ptr(), e.mux_clipboard_ptr() + e.mux_clipboard_len())).toString('ascii'),
434 'aGk=',
435 );
436 check('bell action', e.mux_client_frame(0x8f, stage(Buffer.from([1]))), CLIENT_ACTION.bell);
437 check('unsafe clipboard ignored', e.mux_client_frame(0x8f, stage(Buffer.from([0, 88, 65]))), CLIENT_ACTION.ignored);
438 ```
439
440 Update the paste assertions so `mux_paste_begin/end` return zero before `modesOn`, and emit exactly one marker pair after a new `modesOn` sample.
441
442 - [ ] **Step 2: Run the WASM verifier and observe missing exports**
443
444 Run: `make test`
445
446 Expected: FAIL in `web/verify.js` with `mux_client_frame` or its getters missing.
447
448 - [ ] **Step 3: Hold `ClientCore` and expose semantic actions**
449
450 Import `client_core`, add it to `Core`, initialize it with `.client = .{}`, and add these exports:
451
452 ```zig
453 const client_core = @import("client_core");
454
455 const ClientAction = enum(i32) {
456 ignored = 0,
457 terminal_modes = 1,
458 clipboard = 2,
459 bell = 3,
460 };
461
462 // In Core:
463 client: client_core.ClientCore = .{},
464 clipboard: client_core.ClipboardSet = .{ .target = 0, .base64 = &.{} },
465
466 export fn mux_client_frame(msg_type: u32, len: u32) i32 {
467 const c = core orelse return -1;
468 if (len > input_buf.len) return -2;
469 if (msg_type > 0xff) return @intFromEnum(ClientAction.ignored);
470 const t: proto.MsgType = @enumFromInt(@as(u8, @intCast(msg_type)));
471 c.clipboard = .{ .target = 0, .base64 = &.{} };
472 return @intFromEnum(switch (c.client.receive(t, input_buf[0..len])) {
473 .ignored => ClientAction.ignored,
474 .state => ClientAction.terminal_modes,
475 .effect => |effect| switch (effect) {
476 .bell => ClientAction.bell,
477 .clipboard_set => |clip| action: {
478 c.clipboard = clip;
479 break :action ClientAction.clipboard;
480 },
481 },
482 });
483 }
484
485 export fn mux_bracketed_paste() u32 {
486 const c = core orelse return 0;
487 return @intFromBool(c.client.terminal_modes.bracketed_paste);
488 }
489
490 export fn mux_clipboard_target() u32 {
491 const c = core orelse return 0;
492 return c.clipboard.target;
493 }
494
495 export fn mux_clipboard_ptr() [*]const u8 {
496 const c = core orelse return &input_buf;
497 return if (c.clipboard.base64.len == 0) &input_buf else c.clipboard.base64.ptr;
498 }
499
500 export fn mux_clipboard_len() u32 {
501 const c = core orelse return 0;
502 return @intCast(c.clipboard.base64.len);
503 }
504 ```
505
506 Update `mux_paste_begin` and `mux_paste_end` to return zero and set `output_len = 0` when `mux_bracketed_paste() == 0`; otherwise retain their exact existing bytes.
507
508 - [ ] **Step 4: Run the native/WASM suite and commit**
509
510 Run: `make test`
511
512 Expected: PASS with the new mode/effect ABI assertions and updated disabled/enabled paste assertions.
513
514 ```bash
515 git add src/wasm_core.zig web/verify.js
516 git commit -m "feat: expose client semantics through wasm"
517 ```
518
519 ### Task 4: Make browser paste follow sampled mode state
520
521 **Files:**
522 - Modify: `web/mux.js:11-16`
523 - Modify: `web/mux.js:299-325`
524 - Modify: `web/mux.js:354-424`
525 - Modify: `web/verify.js:200-280`
526
527 - [ ] **Step 1: Pin the browser's semantic call list**
528
529 The existing final `web/verify.js` assertion reads every `core.mux_*` call from `mux.js`. Add `mux_client_frame` to the web path before implementing the switch so this assertion fails if the WASM export and page drift.
530
531 Run: `make test`
532
533 Expected: FAIL until the complete routing change below is present, or PASS only if Task 3 already supplied the export; in either case the next step supplies the behavioral assertion.
534
535 - [ ] **Step 2: Route existing mode/event frames through WASM**
536
537 Extend `MSG`:
538
539 ```javascript
540 term_modes: 0x8d, term_event: 0x8f,
541 ```
542
543 Add a semantic action table:
544
545 ```javascript
546 const CLIENT_ACTION = { ignored: 0, terminalModes: 1, clipboard: 2, bell: 3 };
547 ```
548
549 Add these switch arms before `pty_mode`:
550
551 ```javascript
552 case MSG.term_modes:
553 case MSG.term_event: {
554 if (!this.stage(payload)) return;
555 const action = this.core.mux_client_frame(type, payload.length);
556 if (action === CLIENT_ACTION.clipboard) this.onClipboardEffect();
557 return;
558 }
559 ```
560
561 Task 5 defines `onClipboardEffect`; temporarily add an empty method so this commit remains buildable:
562
563 ```javascript
564 onClipboardEffect() {}
565 ```
566
567 Do not parse either payload in JavaScript.
568
569 - [ ] **Step 3: Remove unconditional-paste assumptions**
570
571 Replace the `sendPaste` header comment with the sampled-state contract. Keep its `try/finally`; the existing `mux_paste_begin/end` calls now return zero when mode 2004 is off, so the body is raw without duplicating policy in JavaScript.
572
573 ```javascript
574 // ONE optional wrap around the WHOLE paste. The shared core sampled DEC
575 // mode 2004 from term_modes; begin/end return zero while it is disabled.
576 sendPaste(text) {
577 if (this.core.mux_paste_begin() > 0) this.sendFrame(MSG.input, this.outBytes());
578 try {
579 this.sendText(text);
580 } finally {
581 if (this.core.mux_paste_end() > 0) this.sendFrame(MSG.input, this.outBytes());
582 }
583 }
584 ```
585
586 - [ ] **Step 4: Verify and commit mode-correct web paste**
587
588 Run: `make test`
589
590 Expected: PASS; `web/verify.js` proves raw paste while disabled, exactly one pair while enabled, and the real page calls only existing WASM exports.
591
592 ```bash
593 git add web/mux.js web/verify.js
594 git commit -m "feat: make web paste respect terminal mode"
595 ```
596
597 ### Task 5: Add OSC 52 browser clipboard behavior
598
599 **Files:**
600 - Modify: `web/index.html:9-66`
601 - Modify: `web/mux.js:89-145`
602 - Modify: `web/mux.js:227-237`
603 - Modify: `web/mux.js:354-424`
604
605 - [ ] **Step 1: Add the hidden fallback control**
606
607 Add styling:
608
609 ```css
610 .copy-request {
611 display: none; border: 1px solid #3d6a8a; border-radius: 3px;
612 background: #173247; color: #9bd3f5; font: inherit; cursor: pointer;
613 }
614 .copy-request.on { display: inline-block; }
615 .copy-request.error { border-color: #7b3333; background: #421f1f; color: #f0a0a0; }
616 ```
617
618 Change the tile header template to include a real button without interpolating session data into HTML:
619
620 ```javascript
621 this.el.innerHTML =
622 `<header><span class="label"></span><button class="copy-request" type="button">Copy</button><span class="badge connecting">connecting</span></header>`;
623 this.copyButton = this.el.querySelector('.copy-request');
624 ```
625
626 Initialize:
627
628 ```javascript
629 this.pendingClipboard = null;
630 this.clipboardVersion = 0;
631 ```
632
633 - [ ] **Step 2: Implement strict base64-to-UTF-8 decoding**
634
635 Add this top-level helper. The core already validates the alphabet and size; the browser adds the only platform-specific constraint, valid UTF-8 text.
636
637 ```javascript
638 function clipboardText(base64Bytes) {
639 try {
640 let ascii = '';
641 for (const byte of base64Bytes) ascii += String.fromCharCode(byte);
642 const binary = atob(ascii);
643 const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
644 return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
645 } catch (_) {
646 return null;
647 }
648 }
649 ```
650
651 - [ ] **Step 3: Implement latest-wins automatic write and fallback**
652
653 Add these methods to `Tile` and bind the button in the constructor:
654
655 ```javascript
656 this.copyButton.addEventListener('click', (ev) => {
657 ev.stopPropagation();
658 this.copyPendingClipboard();
659 });
660
661 onClipboardEffect() {
662 if (!this.zoomed) return;
663 const ptr = this.core.mux_clipboard_ptr();
664 const len = this.core.mux_clipboard_len();
665 const text = clipboardText(this.mem().slice(ptr, ptr + len));
666 if (text === null) return;
667 const version = ++this.clipboardVersion;
668 this.pendingClipboard = text;
669 this.copyButton.className = 'copy-request';
670 this.copyButton.textContent = 'Copy';
671 this.tryClipboardWrite(version, true);
672 }
673
674 async tryClipboardWrite(version, automatic) {
675 const text = this.pendingClipboard;
676 if (text === null || version !== this.clipboardVersion) return;
677 try {
678 if (!navigator.clipboard?.writeText) throw new Error('clipboard unavailable');
679 await navigator.clipboard.writeText(text);
680 if (version !== this.clipboardVersion) return;
681 this.pendingClipboard = null;
682 this.copyButton.className = 'copy-request on';
683 this.copyButton.textContent = 'Copied';
684 setTimeout(() => {
685 if (version === this.clipboardVersion && this.pendingClipboard === null)
686 this.copyButton.className = 'copy-request';
687 }, 1200);
688 } catch (_) {
689 if (version !== this.clipboardVersion) return;
690 this.copyButton.className = `copy-request on${automatic ? '' : ' error'}`;
691 this.copyButton.textContent = automatic ? 'Copy' : 'Copy failed';
692 }
693 }
694
695 copyPendingClipboard() {
696 if (!this.zoomed || this.pendingClipboard === null) return;
697 this.tryClipboardWrite(this.clipboardVersion, false);
698 }
699 ```
700
701 Automatic failure retains exactly one latest request. A later event increments the version, so an older promise cannot clear newer text.
702
703 - [ ] **Step 4: Clear tile-local clipboard UI on unzoom**
704
705 In `unzoom`, before reflowing the old tile, invalidate pending work:
706
707 ```javascript
708 was.clipboardVersion++;
709 was.pendingClipboard = null;
710 was.copyButton.className = 'copy-request';
711 was.copyButton.textContent = 'Copy';
712 ```
713
714 - [ ] **Step 5: Run automated checks and commit**
715
716 Run: `make test && make build`
717
718 Expected: PASS; muxweb embeds the updated HTML/JS and the ABI smoke finds every called WASM export.
719
720 ```bash
721 git add web/index.html web/mux.js
722 git commit -m "feat: handle osc 52 in muxweb"
723 ```
724
725 ### Task 6: Verify Slice 1 and record the architecture
726
727 **Files:**
728 - Modify: `docs/roadmap.md:407-420`
729 - Modify: `docs/decisions.md` (append a dated entry)
730
731 - [ ] **Step 1: Run every automated gate**
732
733 Run: `make test`
734
735 Expected: PASS, including native `client_core`, CLI adapters, and `web/verify.js`.
736
737 Run: `make build`
738
739 Expected: PASS with `mux`, `muxd`, `muxa`, and `muxweb` linked.
740
741 Run: `make e2e`
742
743 Expected: PASS, including existing native bracketed-paste and OSC 52 scenarios.
744
745 - [ ] **Step 2: Perform the Firefox acceptance pass**
746
747 Start a disposable session and muxweb with the repository's normal launch commands. In the zoomed tile run:
748
749 ```sh
750 printf '\033]52;c;aGVsbG8=\007'
751 ```
752
753 Expected: Firefox rejects or cannot perform the unsolicited write, the tile shows `Copy`, clicking it places `hello` on the clipboard, and an unzoomed tile never shows the action.
754
755 Then run:
756
757 ```sh
758 printf '\033[?2004l'
759 ```
760
761 Paste two lines into `od -An -tx1`; expected bytes contain no `1b 5b 32 30 30 7e`/`1b 5b 32 30 31 7e` markers. Repeat after `printf '\033[?2004h'`; expected bytes contain exactly one begin and one end marker around the whole paste.
762
763 - [ ] **Step 3: Update roadmap and decisions**
764
765 Change the roadmap's banked browser clipboard/mode wording to say Slice 1 shipped. Append a decision entry recording:
766
767 ```markdown
768 ## 2026-08-16 (shared client semantics and muxweb copy/paste)
769
770 `client_core.zig` is the platform-neutral boundary for sampled terminal state
771 and one-shot host effects. The CLI and WASM/browser adapters now consume the
772 same validated `term_modes` and `term_event` results. Browser paste follows
773 DEC mode 2004; OSC 52 uses an automatic Clipboard API attempt with a
774 latest-wins explicit Copy fallback, and only the zoomed tile may act.
775
776 Mouse-drag selection remains Slice 2: it will add the correlated Reply family
777 without adding a web-only protocol parser.
778 ```
779
780 - [ ] **Step 4: Commit the verified slice**
781
782 ```bash
783 git add docs/roadmap.md docs/decisions.md
784 git commit -m "docs: record shared web clipboard semantics"
785 ```
786
787 Record the manual Firefox result in the implementation handoff. Do not close the native OSC 52 or terminal-mode tickets unless their remaining non-web scope is also complete; add a tracker comment only if the user authorizes tracker mutation.
docs/superpowers/plans/2026-08-16-web-mouse-selection.md
Old New
@@ -1,1007 +0,0 @@
1 # Web Mouse Selection 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:** Add retained mouse-drag selection across muxweb's live viewport and fetched scrollback, with daemon-authoritative text extraction and explicit browser copy.
6
7 **Architecture:** Extend the Slice 1 `ClientCore` with a correlated Reply family and add fixed-layout `selection_req`/`selection_reply` frames. The browser owns pointer interaction and canvas highlighting, while muxd resolves screen-space coordinates against its full Ghostty history and returns bounded plain UTF-8 text only to the requester.
8
9 **Tech Stack:** Zig 0.15.2, ghostty-vt `Selection`/`ScreenFormatter`, mux framed protocol, wasm32-freestanding, vanilla JavaScript Canvas and Clipboard API.
10
11 **Prerequisite:** Complete and verify `docs/superpowers/plans/2026-08-16-web-copy-paste-client-core.md` first.
12
13 **Design:** `docs/superpowers/specs/2026-08-16-web-copy-paste-client-core-design.md`
14
15 ---
16
17 ## File map
18
19 - Modify `src/protocol.zig`: own selection request/reply bytes, status vocabulary, UTF-8 validation, and 1 MiB text cap.
20 - Modify `src/engine.zig`: resolve screen-space points and perform bounded Ghostty plain-text formatting.
21 - Modify `src/server.zig`: answer selection requests only on the requesting connection.
22 - Modify `src/client_core.zig`: correlate selection IDs and expose typed replies.
23 - Modify `src/client.zig`: explicitly ignore a Reply no native UI requested, keeping its switch exhaustive.
24 - Modify `src/wasm_core.zig`: encode requests, decode replies, expose borrowed result getters, and expand staging to the specified cap.
25 - Modify `web/verify.js`: pin request/reply wire bytes, action ABI, cap, and stale-reply behavior.
26 - Modify `web/mux.js`: pointer model, screen-row mapping, highlight, auto-scroll, reply caching, and copy shortcuts.
27 - Modify `web/index.html`: selection cursor and highlight-related chrome styling.
28 - Modify `docs/roadmap.md` and `docs/decisions.md`: record the delivered Reply lane and browser interaction.
29
30 ### Task 1: Add selection request/reply codecs
31
32 **Files:**
33 - Modify: `src/protocol.zig:11-42`
34 - Modify: `src/protocol.zig:180-195`
35 - Modify: `src/protocol.zig` tests near the existing scrollback and side-channel codec tests
36
37 - [ ] **Step 1: Write failing protocol tests**
38
39 Add tests for the exact request layout and each reply status before defining the codecs:
40
41 ```zig
42 test "selection request round-trips and matches golden bytes" {
43 const req = SelectionReq{
44 .id = 0x01020304,
45 .anchor = .{ .row = 0x11121314, .col = 0x2122 },
46 .active = .{ .row = 0x31323334, .col = 0x4142 },
47 };
48 const bytes = encodeSelectionReq(req);
49 try std.testing.expectEqualSlices(u8, &.{
50 0x04, 0x03, 0x02, 0x01,
51 0x14, 0x13, 0x12, 0x11, 0x22, 0x21,
52 0x34, 0x33, 0x32, 0x31, 0x42, 0x41,
53 }, &bytes);
54 try std.testing.expectEqualDeep(req, try decodeSelectionReq(&bytes));
55 try std.testing.expectError(error.BadPayload, decodeSelectionReq(bytes[0..15]));
56 }
57
58 test "selection replies are status-strict, utf8, and bounded" {
59 const alloc = std.testing.allocator;
60 var payload: std.ArrayList(u8) = .empty;
61 defer payload.deinit(alloc);
62
63 try encodeSelectionReply(&payload, alloc, 7, .ok, "hello");
64 const ok = try decodeSelectionReply(payload.items);
65 try std.testing.expectEqual(@as(u32, 7), ok.id);
66 try std.testing.expectEqual(SelectionStatus.ok, ok.status);
67 try std.testing.expectEqualStrings("hello", ok.text);
68
69 payload.clearRetainingCapacity();
70 try encodeSelectionReply(&payload, alloc, 9, .invalid, "");
71 const invalid = try decodeSelectionReply(payload.items);
72 try std.testing.expectEqual(SelectionStatus.invalid, invalid.status);
73 try std.testing.expectEqual(@as(usize, 0), invalid.text.len);
74
75 try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0xff }));
76 try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 1, 'x' }));
77 try std.testing.expectError(error.BadPayload, decodeSelectionReply(&.{ 1, 0, 0, 0, 0, 0xff }));
78 }
79 ```
80
81 - [ ] **Step 2: Run the protocol suite and verify missing symbols**
82
83 Run: `make test`
84
85 Expected: FAIL compiling `protocol.zig` because the selection types/codecs do not exist.
86
87 - [ ] **Step 3: Define message types and fixed layouts**
88
89 Add the enum members:
90
91 ```zig
92 selection_req = 0x0b, // u32 id, u32 anchor row, u16 col, u32 active row, u16 col
93 selection_reply = 0x90, // u32 id, u8 SelectionStatus, UTF-8 text only for ok
94 ```
95
96 Add the codecs beside `ScrollbackReq`:
97
98 ```zig
99 pub const selection_text_max: usize = 1024 * 1024;
100 pub const selection_req_len: usize = 16;
101 pub const selection_reply_prefix_len: usize = 5;
102
103 pub const SelectionPoint = struct { row: u32, col: u16 };
104
105 pub const SelectionReq = struct {
106 id: u32,
107 anchor: SelectionPoint,
108 active: SelectionPoint,
109 };
110
111 pub fn encodeSelectionReq(req: SelectionReq) [selection_req_len]u8 {
112 var out: [selection_req_len]u8 = undefined;
113 std.mem.writeInt(u32, out[0..4], req.id, .little);
114 std.mem.writeInt(u32, out[4..8], req.anchor.row, .little);
115 std.mem.writeInt(u16, out[8..10], req.anchor.col, .little);
116 std.mem.writeInt(u32, out[10..14], req.active.row, .little);
117 std.mem.writeInt(u16, out[14..16], req.active.col, .little);
118 return out;
119 }
120
121 pub fn decodeSelectionReq(payload: []const u8) !SelectionReq {
122 if (payload.len != selection_req_len) return error.BadPayload;
123 return .{
124 .id = std.mem.readInt(u32, payload[0..4], .little),
125 .anchor = .{
126 .row = std.mem.readInt(u32, payload[4..8], .little),
127 .col = std.mem.readInt(u16, payload[8..10], .little),
128 },
129 .active = .{
130 .row = std.mem.readInt(u32, payload[10..14], .little),
131 .col = std.mem.readInt(u16, payload[14..16], .little),
132 },
133 };
134 }
135
136 pub const SelectionStatus = enum(u8) {
137 ok = 0,
138 invalid = 1,
139 too_large = 2,
140 unavailable = 3,
141 };
142
143 pub const SelectionReply = struct {
144 id: u32,
145 status: SelectionStatus,
146 text: []const u8,
147 };
148
149 pub fn encodeSelectionReply(
150 out: *std.ArrayList(u8),
151 alloc: std.mem.Allocator,
152 id: u32,
153 status: SelectionStatus,
154 text_value: []const u8,
155 ) !void {
156 if (status == .ok) {
157 if (text_value.len > selection_text_max or !std.unicode.utf8ValidateSlice(text_value))
158 return error.BadPayload;
159 } else if (text_value.len != 0) return error.BadPayload;
160 var prefix: [selection_reply_prefix_len]u8 = undefined;
161 std.mem.writeInt(u32, prefix[0..4], id, .little);
162 prefix[4] = @intFromEnum(status);
163 try out.appendSlice(alloc, &prefix);
164 try out.appendSlice(alloc, text_value);
165 }
166
167 pub fn decodeSelectionReply(payload: []const u8) !SelectionReply {
168 if (payload.len < selection_reply_prefix_len) return error.BadPayload;
169 const status = try enumFromByte(SelectionStatus, payload[4]);
170 const value = payload[selection_reply_prefix_len..];
171 if (status == .ok) {
172 if (value.len > selection_text_max or !std.unicode.utf8ValidateSlice(value))
173 return error.BadPayload;
174 } else if (value.len != 0) return error.BadPayload;
175 return .{
176 .id = std.mem.readInt(u32, payload[0..4], .little),
177 .status = status,
178 .text = value,
179 };
180 }
181 ```
182
183 - [ ] **Step 4: Add exact-cap and over-cap tests**
184
185 Allocate valid UTF-8 slices at `selection_text_max` and `selection_text_max + 1`. Assert the first encodes/decodes and the second returns `error.BadPayload`; assert all three failure statuses encode to exactly five bytes.
186
187 ```zig
188 for ([_]SelectionStatus{ .invalid, .too_large, .unavailable }) |status| {
189 payload.clearRetainingCapacity();
190 try encodeSelectionReply(&payload, alloc, 5, status, "");
191 try std.testing.expectEqual(selection_reply_prefix_len, payload.items.len);
192 }
193 ```
194
195 - [ ] **Step 5: Run tests and commit**
196
197 Run: `make test`
198
199 Expected: PASS with golden selection codecs and unchanged older frame bytes.
200
201 ```bash
202 git add src/protocol.zig
203 git commit -m "feat: add selection request reply protocol"
204 ```
205
206 ### Task 2: Add bounded authoritative extraction to `Engine`
207
208 **Files:**
209 - Modify: `src/engine.zig:240-430`
210 - Modify: `src/engine.zig` tests near scrollback formatting tests
211
212 - [ ] **Step 1: Write extraction tests against screen-space coordinates**
213
214 Add tests covering soft-wrap unwrapping, hard line breaks, wide cells, reverse selection, invalid points, and the cap:
215
216 ```zig
217 test "Engine: selection text follows Ghostty wraps, width and order" {
218 const alloc = std.testing.allocator;
219 var e = try Engine.init(alloc, .{ .cols = 5, .rows = 3 });
220 defer e.deinit();
221 e.feed("abcdeFG"); // row 0 soft-wraps into row 1
222
223 const forward = try e.extractSelection(alloc, 0, 3, 1, 1, 1024);
224 defer forward.deinit(alloc);
225 try std.testing.expectEqualStrings("deFG", forward.text.?);
226
227 const reverse = try e.extractSelection(alloc, 1, 1, 0, 3, 1024);
228 defer reverse.deinit(alloc);
229 try std.testing.expectEqualStrings("deFG", reverse.text.?);
230
231 e.reset();
232 e.feed("abc\r\nxyz");
233 const hard = try e.extractSelection(alloc, 0, 0, 1, 2, 1024);
234 defer hard.deinit(alloc);
235 try std.testing.expectEqualStrings("abc\nxyz", hard.text.?);
236
237 e.reset();
238 e.feed("A漢B");
239 const wide = try e.extractSelection(alloc, 0, 1, 0, 2, 1024);
240 defer wide.deinit(alloc);
241 try std.testing.expectEqualStrings("漢", wide.text.?);
242 }
243
244 test "Engine: selection refuses invalid points and over-limit output" {
245 const alloc = std.testing.allocator;
246 var e = try Engine.init(alloc, .{ .cols = 10, .rows = 2 });
247 defer e.deinit();
248 e.feed("abcdefghij");
249
250 const invalid = try e.extractSelection(alloc, 999, 0, 999, 1, 1024);
251 defer invalid.deinit(alloc);
252 try std.testing.expectEqual(Engine.SelectionExtract.Status.invalid, invalid.status);
253
254 const capped = try e.extractSelection(alloc, 0, 0, 0, 9, 3);
255 defer capped.deinit(alloc);
256 try std.testing.expectEqual(Engine.SelectionExtract.Status.too_large, capped.status);
257 }
258 ```
259
260 - [ ] **Step 2: Run tests and verify the API is absent**
261
262 Run: `make test`
263
264 Expected: FAIL because `Engine.extractSelection` and `SelectionExtract` do not exist.
265
266 - [ ] **Step 3: Implement fixed-buffer plain formatting**
267
268 Add the result type and method. The cap is passed by the server because `engine` is layer 0 and must not import `protocol`.
269
270 ```zig
271 pub const SelectionExtract = struct {
272 pub const Status = enum { ok, invalid, too_large };
273 status: Status,
274 text: ?[]u8 = null,
275
276 pub fn deinit(self: SelectionExtract, alloc: std.mem.Allocator) void {
277 if (self.text) |value| alloc.free(value);
278 }
279 };
280
281 pub fn extractSelection(
282 self: *Engine,
283 alloc: std.mem.Allocator,
284 anchor_row: u32,
285 anchor_col: u16,
286 active_row: u32,
287 active_col: u16,
288 max_bytes: usize,
289 ) !SelectionExtract {
290 const screen = self.term.screens.active;
291 if (anchor_col >= self.term.cols or active_col >= self.term.cols)
292 return .{ .status = .invalid };
293 const start = screen.pages.pin(.{ .screen = .{ .x = anchor_col, .y = anchor_row } }) orelse
294 return .{ .status = .invalid };
295 const end = screen.pages.pin(.{ .screen = .{ .x = active_col, .y = active_row } }) orelse
296 return .{ .status = .invalid };
297
298 const storage = try alloc.alloc(u8, max_bytes);
299 defer alloc.free(storage);
300 var writer = std.Io.Writer.fixed(storage);
301 var formatter = vt.formatter.ScreenFormatter.init(screen, .{
302 .emit = .plain,
303 .unwrap = true,
304 .trim = true,
305 });
306 formatter.content = .{ .selection = vt.Selection.init(start, end, false) };
307 formatter.format(&writer) catch |err| switch (err) {
308 error.WriteFailed => return .{ .status = .too_large },
309 };
310 return .{
311 .status = .ok,
312 .text = try alloc.dupe(u8, writer.buffered()),
313 };
314 }
315 ```
316
317 This uses a fixed `std.Io.Writer`; formatting stops when the cap is full instead of first allocating an unbounded selection string.
318
319 - [ ] **Step 4: Run engine and full tests, then commit**
320
321 Run: `make test`
322
323 Expected: PASS with exact wrap/newline/wide/reverse behavior and no leak under `std.testing.allocator`.
324
325 ```bash
326 git add src/engine.zig
327 git commit -m "feat: extract bounded terminal selections"
328 ```
329
330 ### Task 3: Serve selection replies only to their requester
331
332 **Files:**
333 - Modify: `src/server.zig:1024-1050`
334 - Modify: `src/server.zig:1682-1708`
335 - Modify: `src/server.zig:4000-4085` or the existing two-client scrollback test
336
337 - [ ] **Step 1: Extend the two-client scrollback test with a failing selection request**
338
339 After client A has fetched row zero and client B has proven it remains live, send:
340
341 ```zig
342 const select_req = proto.SelectionReq{
343 .id = 77,
344 .anchor = .{ .row = 0, .col = 0 },
345 .active = .{ .row = 0, .col = 8 },
346 };
347 try proto.writeFrame(a.handle, .selection_req, &proto.encodeSelectionReq(select_req));
348 ```
349
350 Poll A until `selection_reply`, decode it, and assert ID 77, status `.ok`, and text `seq 1 100`. Poll B with a short bounded timeout while continuing to accept snapshot/delta frames and assert it never receives `selection_reply`.
351
352 Run: `make test`
353
354 Expected: FAIL because the server ignores `selection_req`.
355
356 - [ ] **Step 2: Add one reply builder with explicit failure statuses**
357
358 Add:
359
360 ```zig
361 fn queueSelectionReply(
362 self: *Server,
363 i: usize,
364 id: u32,
365 status: proto.SelectionStatus,
366 value: []const u8,
367 ) void {
368 var payload: std.ArrayList(u8) = .empty;
369 defer payload.deinit(self.alloc);
370 proto.encodeSelectionReply(&payload, self.alloc, id, status, value) catch return;
371 _ = self.queueFrame(i, .selection_reply, payload.items);
372 }
373 ```
374
375 - [ ] **Step 3: Handle the request without claiming the grid or broadcasting**
376
377 Add beside `fetch_scrollback`:
378
379 ```zig
380 .selection_req => {
381 const si = self.clients[i].?.session orelse return;
382 const req = proto.decodeSelectionReq(frame.payload) catch return;
383 const result = self.ses(si).eng.extractSelection(
384 self.alloc,
385 req.anchor.row,
386 req.anchor.col,
387 req.active.row,
388 req.active.col,
389 proto.selection_text_max,
390 ) catch {
391 self.queueSelectionReply(i, req.id, .unavailable, "");
392 return;
393 };
394 defer result.deinit(self.alloc);
395 switch (result.status) {
396 .ok => self.queueSelectionReply(i, req.id, .ok, result.text.?),
397 .invalid => self.queueSelectionReply(i, req.id, .invalid, ""),
398 .too_large => self.queueSelectionReply(i, req.id, .too_large, ""),
399 }
400 },
401 ```
402
403 Do not call `claimGrid`; selection is observation like scrollback fetch. Do not use a session broadcast loop.
404
405 - [ ] **Step 4: Add malformed and over-limit server assertions**
406
407 In the same integration test, send a short request and assert no reply, then send valid coordinates whose formatted output exceeds a test-configured cap through a small helper-level engine test. The protocol cap itself remains pinned in Task 1; the server assertion must verify `.invalid` for an out-of-range screen row.
408
409 - [ ] **Step 5: Run tests and commit**
410
411 Run: `make test`
412
413 Expected: PASS; A receives its correlated text and B never receives a selection reply.
414
415 ```bash
416 git add src/server.zig
417 git commit -m "feat: serve authoritative selection text"
418 ```
419
420 ### Task 4: Extend the shared Reply lane and WASM ABI
421
422 **Files:**
423 - Modify: `src/client_core.zig`
424 - Modify: `src/client.zig:1307-1340`
425 - Modify: `src/wasm_core.zig:53-86`
426 - Modify: `src/wasm_core.zig:157-225`
427 - Modify: `web/verify.js`
428
429 - [ ] **Step 1: Add failing correlation tests to `client_core.zig`**
430
431 ```zig
432 test "client core: selection replies are correlated and borrowed" {
433 const alloc = std.testing.allocator;
434 var core: ClientCore = .{};
435 const request = core.beginSelection(.{
436 .id = 42,
437 .anchor = .{ .row = 3, .col = 4 },
438 .active = .{ .row = 8, .col = 9 },
439 });
440 try std.testing.expectEqualDeep(
441 try proto.decodeSelectionReq(&request),
442 proto.SelectionReq{
443 .id = 42,
444 .anchor = .{ .row = 3, .col = 4 },
445 .active = .{ .row = 8, .col = 9 },
446 },
447 );
448
449 var payload: std.ArrayList(u8) = .empty;
450 defer payload.deinit(alloc);
451 try proto.encodeSelectionReply(&payload, alloc, 41, .ok, "stale");
452 try std.testing.expectEqual(Result.ignored, core.receive(.selection_reply, payload.items));
453
454 payload.clearRetainingCapacity();
455 try proto.encodeSelectionReply(&payload, alloc, 42, .ok, "selected");
456 switch (core.receive(.selection_reply, payload.items)) {
457 .reply => |reply| {
458 try std.testing.expectEqual(proto.SelectionStatus.ok, reply.selection.status);
459 try std.testing.expectEqualStrings("selected", reply.selection.text);
460 },
461 else => return error.TestUnexpectedResult,
462 }
463 try std.testing.expect(core.pending_selection_id == null);
464 }
465 ```
466
467 - [ ] **Step 2: Implement `Reply` and correlation**
468
469 Extend `Result` and `ClientCore`:
470
471 ```zig
472 pub const Reply = union(enum) {
473 selection: proto.SelectionReply,
474 };
475
476 pub const Result = union(enum) {
477 ignored,
478 state: State,
479 effect: Effect,
480 reply: Reply,
481 };
482
483 // In ClientCore:
484 pending_selection_id: ?u32 = null,
485
486 pub fn beginSelection(self: *ClientCore, req: proto.SelectionReq) [proto.selection_req_len]u8 {
487 self.pending_selection_id = req.id;
488 return proto.encodeSelectionReq(req);
489 }
490 ```
491
492 Add this `receive` arm:
493
494 ```zig
495 .selection_reply => reply: {
496 const value = proto.decodeSelectionReply(payload) catch break :reply .ignored;
497 if (self.pending_selection_id == null or self.pending_selection_id.? != value.id)
498 break :reply .ignored;
499 self.pending_selection_id = null;
500 break :reply .{ .reply = .{ .selection = value } };
501 },
502 ```
503
504 Update the CLI's exhaustive switch with `.reply => {}`; native mux never sends a selection request, so a reply is ignored rather than rendered.
505
506 - [ ] **Step 3: Add failing WASM request/reply checks**
507
508 In `web/verify.js`, assert:
509
510 ```javascript
511 check('selection request len', e.mux_selection_request(42, 3, 4, 8, 9), 16);
512 const selectionReq = outBytes();
513 check('selection request id', selectionReq.readUInt32LE(0), 42);
514 check('selection anchor row', selectionReq.readUInt32LE(4), 3);
515 check('selection active col', selectionReq.readUInt16LE(14), 9);
516
517 const staleReply = Buffer.concat([Buffer.from([41, 0, 0, 0, 0]), Buffer.from('stale')]);
518 check('stale selection ignored', e.mux_client_frame(0x90, stage(staleReply)), CLIENT_ACTION.ignored);
519 const goodReply = Buffer.concat([Buffer.from([42, 0, 0, 0, 0]), Buffer.from('selected')]);
520 check('selection reply action', e.mux_client_frame(0x90, stage(goodReply)), CLIENT_ACTION.selection);
521 check('selection reply id', e.mux_selection_id(), 42);
522 check('selection reply status', e.mux_selection_status(), 0);
523 check(
524 'selection reply text',
525 Buffer.from(mem().subarray(e.mux_selection_ptr(), e.mux_selection_ptr() + e.mux_selection_len())).toString('utf8'),
526 'selected',
527 );
528 check('selection staging cap', e.mux_input_cap() >= 1024 * 1024 + 5, true);
529 ```
530
531 Delete the earlier exact `check('input cap', e.mux_input_cap(), 256 * 1024)`
532 assertion from `web/verify.js`. The selection-cap assertion replaces it; keeping
533 both would make the intended capacity increase fail the older smoke test.
534
535 Run: `make test`
536
537 Expected: FAIL because the selection ABI is absent.
538
539 - [ ] **Step 4: Implement the selection WASM ABI**
540
541 Increase the staging buffer and extend the action enum:
542
543 ```zig
544 var input_buf: [@max(256 * 1024, proto.selection_reply_prefix_len + proto.selection_text_max)]u8 = undefined;
545
546 // ClientAction:
547 selection = 4,
548 ```
549
550 Store the last borrowed reply in `Core`:
551
552 ```zig
553 selection: proto.SelectionReply = .{ .id = 0, .status = .unavailable, .text = &.{} },
554 ```
555
556 Add `.reply` handling to `mux_client_frame`, copying the struct (not its borrowed text):
557
558 ```zig
559 .reply => |reply| switch (reply) {
560 .selection => |selection| action: {
561 c.selection = selection;
562 break :action ClientAction.selection;
563 },
564 },
565 ```
566
567 Add exports:
568
569 ```zig
570 export fn mux_selection_request(id: u32, ar: u32, ac: u32, br: u32, bc: u32) i32 {
571 const c = core orelse return -1;
572 if (ac > std.math.maxInt(u16) or bc > std.math.maxInt(u16)) return -3;
573 const payload = c.client.beginSelection(.{
574 .id = id,
575 .anchor = .{ .row = ar, .col = @intCast(ac) },
576 .active = .{ .row = br, .col = @intCast(bc) },
577 });
578 @memcpy(output_buf[0..payload.len], &payload);
579 output_len = @intCast(payload.len);
580 return @intCast(payload.len);
581 }
582
583 export fn mux_selection_id() u32 {
584 const c = core orelse return 0;
585 return c.selection.id;
586 }
587
588 export fn mux_selection_status() u32 {
589 const c = core orelse return @intFromEnum(proto.SelectionStatus.unavailable);
590 return @intFromEnum(c.selection.status);
591 }
592
593 export fn mux_selection_ptr() [*]const u8 {
594 const c = core orelse return &input_buf;
595 return if (c.selection.text.len == 0) &input_buf else c.selection.text.ptr;
596 }
597
598 export fn mux_selection_len() u32 {
599 const c = core orelse return 0;
600 return @intCast(c.selection.text.len);
601 }
602 ```
603
604 - [ ] **Step 5: Run tests and commit**
605
606 Run: `make test`
607
608 Expected: PASS for native correlation, WASM golden request bytes, stale reply rejection, getters, and the 1 MiB staging contract.
609
610 ```bash
611 git add src/client_core.zig src/client.zig src/wasm_core.zig web/verify.js
612 git commit -m "feat: expose correlated selection replies to web"
613 ```
614
615 ### Task 5: Add retained canvas selection and daemon extraction requests
616
617 **Files:**
618 - Modify: `web/mux.js:11-30`
619 - Modify: `web/mux.js:89-145`
620 - Modify: `web/mux.js:354-530`
621 - Modify: `web/index.html:20-50`
622
623 - [ ] **Step 1: Add selection state and protocol/action constants**
624
625 Extend `MSG` and `CLIENT_ACTION`:
626
627 ```javascript
628 selection_req: 0x0b, selection_reply: 0x90,
629 ```
630
631 ```javascript
632 selection: 4,
633 ```
634
635 Initialize per tile:
636
637 ```javascript
638 this.viewStartRow = 0;
639 this.selection = null; // {anchor, active, requestId, text}
640 this.nextSelectionId = 0;
641 this.drag = null;
642 this.lastPointerY = null;
643 this.selectionScrollTimer = null;
644 ```
645
646 Add `.tile.zoomed canvas { cursor: text; touch-action: none; }` to `web/index.html`.
647
648 - [ ] **Step 2: Add coordinate conversion and selection normalization**
649
650 Add these methods to `Tile`:
651
652 ```javascript
653 cellAtPointer(ev, clampY = true) {
654 const rect = this.canvas.getBoundingClientRect();
655 const cols = this.core.mux_cols(), rows = this.core.mux_rows();
656 const x = Math.max(0, Math.min(cols - 1,
657 Math.floor((ev.clientX - rect.left) / this.drawScale / METRICS.w)));
658 let y = Math.floor((ev.clientY - rect.top) / this.drawScale / METRICS.h);
659 if (clampY) y = Math.max(0, Math.min(rows - 1, y));
660 return { col: x, viewRow: y, row: this.viewStartRow + y };
661 }
662
663 orderedSelection() {
664 if (!this.selection) return null;
665 const a = this.selection.anchor, b = this.selection.active;
666 if (a.row < b.row || (a.row === b.row && a.col <= b.col)) return [a, b];
667 return [b, a];
668 }
669
670 clearSelection(repaint = true) {
671 this.selection = null;
672 this.drag = null;
673 if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer);
674 this.selectionScrollTimer = null;
675 if (repaint && this.core) this.reflow();
676 }
677 ```
678
679 - [ ] **Step 3: Track the displayed screen-row range**
680
681 At the start of `paintLive`, set:
682
683 ```javascript
684 this.viewStartRow = this.core.mux_history_rows();
685 ```
686
687 When sending a scrollback fetch in the existing wheel path, set:
688
689 ```javascript
690 this.viewStartRow = start;
691 ```
692
693 When receiving `scrollback_chunk`, decode the echoed start before stripping its six-byte header:
694
695 ```javascript
696 this.viewStartRow = new DataView(payload.buffer, payload.byteOffset).getUint32(0, true);
697 ```
698
699 - [ ] **Step 4: Draw a retained overlay after terminal cells**
700
701 Call `paintSelection()` after `paintCursor()` in `paintLive` and after the row loop in `paintScroll`. Add:
702
703 ```javascript
704 paintSelection() {
705 const ordered = this.orderedSelection();
706 if (!ordered) return;
707 const [start, end] = ordered;
708 const cols = this.core.mux_cols(), rows = this.core.mux_rows();
709 this.ctx.fillStyle = '#6ab0e055';
710 for (let y = 0; y < rows; y++) {
711 const screenRow = this.viewStartRow + y;
712 if (screenRow < start.row || screenRow > end.row) continue;
713 const first = screenRow === start.row ? start.col : 0;
714 const last = screenRow === end.row ? end.col : cols - 1;
715 if (last < first) continue;
716 this.ctx.fillRect(first * METRICS.w, y * METRICS.h,
717 (last - first + 1) * METRICS.w, METRICS.h);
718 }
719 }
720 ```
721
722 - [ ] **Step 5: Bind pointer drag and send one request on release**
723
724 Bind `pointerdown`, `pointermove`, `pointerup`, and `pointercancel` on `this.canvas`. Use pointer capture so a drag continues beyond the canvas.
725
726 ```javascript
727 beginSelection(ev) {
728 if (!this.zoomed || ev.button !== 0) return;
729 ev.preventDefault();
730 const point = this.cellAtPointer(ev);
731 this.clearSelection(false);
732 this.selection = { anchor: point, active: point, requestId: 0, text: null };
733 this.drag = { pointerId: ev.pointerId, moved: false };
734 this.lastPointerY = ev.clientY;
735 this.canvas.setPointerCapture(ev.pointerId);
736 this.selectionScrollTimer = setInterval(() => this.autoScrollSelection(), 120);
737 this.reflow();
738 }
739
740 moveSelection(ev) {
741 if (!this.drag || ev.pointerId !== this.drag.pointerId) return;
742 ev.preventDefault();
743 this.lastPointerY = ev.clientY;
744 const point = this.cellAtPointer(ev);
745 if (point.row !== this.selection.active.row || point.col !== this.selection.active.col)
746 this.drag.moved = true;
747 this.selection.active = point;
748 this.reflow();
749 }
750
751 endSelection(ev) {
752 if (!this.drag || ev.pointerId !== this.drag.pointerId) return;
753 ev.preventDefault();
754 const moved = this.drag.moved;
755 if (this.canvas.hasPointerCapture(ev.pointerId)) this.canvas.releasePointerCapture(ev.pointerId);
756 if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer);
757 this.selectionScrollTimer = null;
758 this.drag = null;
759 if (!moved) { this.clearSelection(); return; }
760
761 this.nextSelectionId = (this.nextSelectionId + 1) >>> 0;
762 if (this.nextSelectionId === 0) this.nextSelectionId = 1;
763 this.selection.requestId = this.nextSelectionId;
764 const a = this.selection.anchor, b = this.selection.active;
765 const n = this.core.mux_selection_request(this.nextSelectionId, a.row, a.col, b.row, b.col);
766 if (n === 16) this.sendFrame(MSG.selection_req, this.outBytes());
767 else this.clearSelection();
768 }
769 ```
770
771 Bindings:
772
773 ```javascript
774 this.canvas.addEventListener('pointerdown', (ev) => this.beginSelection(ev));
775 this.canvas.addEventListener('pointermove', (ev) => this.moveSelection(ev));
776 this.canvas.addEventListener('pointerup', (ev) => this.endSelection(ev));
777 this.canvas.addEventListener('pointercancel', (ev) => {
778 if (this.drag && ev.pointerId === this.drag.pointerId) this.clearSelection();
779 });
780 ```
781
782 - [ ] **Step 6: Decode only matching semantic replies**
783
784 Route `MSG.selection_reply` with the other client-core frames. On `CLIENT_ACTION.selection`, call:
785
786 ```javascript
787 onSelectionReply() {
788 if (!this.selection) return;
789 if (this.core.mux_selection_id() !== this.selection.requestId) return;
790 if (this.core.mux_selection_status() !== 0) {
791 this.selection.text = null;
792 this.copyButton.className = 'copy-request on error';
793 this.copyButton.textContent = 'Selection unavailable';
794 return;
795 }
796 const ptr = this.core.mux_selection_ptr(), len = this.core.mux_selection_len();
797 try {
798 this.selection.text = new TextDecoder('utf-8', { fatal: true })
799 .decode(this.mem().slice(ptr, ptr + len));
800 } catch (_) {
801 this.selection.text = null;
802 }
803 }
804 ```
805
806 `ClientCore` has already rejected stale IDs and malformed UTF-8; the browser checks again before binding borrowed WASM memory to retained JS state.
807
808 - [ ] **Step 7: Run automated checks and commit**
809
810 Run: `make test && make build`
811
812 Expected: PASS; `web/verify.js` pins every new export and muxweb embeds the updated shell.
813
814 ```bash
815 git add web/mux.js web/index.html
816 git commit -m "feat: add retained web terminal selection"
817 ```
818
819 ### Task 6: Add drag auto-scroll and explicit copy shortcuts
820
821 **Files:**
822 - Modify: `web/mux.js:532-563`
823 - Modify: `web/mux.js:659-698`
824
825 - [ ] **Step 1: Factor page movement for wheel and drag**
826
827 Replace the page-count mutation in `onWheel` with a reusable method:
828
829 ```javascript
830 changeScrollPages(pagesUpDelta) {
831 const rows = this.core.mux_rows();
832 const maxPages = Math.ceil(this.core.mux_history_rows() / rows);
833 const next = Math.max(0, Math.min(maxPages, this.scrollPages + pagesUpDelta));
834 if (next === this.scrollPages) return false;
835 if (next === 0) { this.exitScroll(); return true; }
836 this.scrollPages = next;
837 this.renderBadge();
838 const start = this.core.mux_scroll_start(this.scrollPages, rows);
839 this.viewStartRow = start;
840 const payload = new Uint8Array(6);
841 const view = new DataView(payload.buffer);
842 view.setUint32(0, start, true);
843 view.setUint16(4, rows, true);
844 this.sendFrame(MSG.fetch_scrollback, payload);
845 return true;
846 }
847
848 onWheel(ev) {
849 this.changeScrollPages(ev.deltaY < 0 ? 1 : -1);
850 }
851 ```
852
853 - [ ] **Step 2: Implement bounded auto-scroll while the pointer remains outside**
854
855 Add:
856
857 ```javascript
858 autoScrollSelection() {
859 if (!this.drag || this.lastPointerY === null) return;
860 const rect = this.canvas.getBoundingClientRect();
861 const direction = this.lastPointerY < rect.top ? 1 :
862 (this.lastPointerY >= rect.bottom ? -1 : 0);
863 if (direction === 0 || !this.changeScrollPages(direction)) return;
864 const viewRow = direction > 0 ? 0 : this.core.mux_rows() - 1;
865 this.selection.active = {
866 row: this.viewStartRow + viewRow,
867 col: this.selection.active.col,
868 viewRow,
869 };
870 this.drag.moved = true;
871 this.reflow();
872 }
873 ```
874
875 The 120 ms interval created on pointer-down continues scrolling even when the browser stops emitting pointer-move events outside the canvas.
876
877 - [ ] **Step 3: Share the low-level clipboard writer**
878
879 Extract the actual browser call used by Slice 1:
880
881 ```javascript
882 async writeClipboardText(text) {
883 if (!navigator.clipboard?.writeText) throw new Error('clipboard unavailable');
884 await navigator.clipboard.writeText(text);
885 }
886 ```
887
888 Change `tryClipboardWrite` to call `await this.writeClipboardText(text)`. Add explicit selection copy:
889
890 ```javascript
891 async copySelection() {
892 if (!this.selection || this.selection.text === null) return;
893 try {
894 await this.writeClipboardText(this.selection.text);
895 this.copyButton.className = 'copy-request on';
896 this.copyButton.textContent = 'Copied';
897 } catch (_) {
898 this.copyButton.className = 'copy-request on error';
899 this.copyButton.textContent = 'Copy failed';
900 }
901 }
902 ```
903
904 - [ ] **Step 4: Implement selection-sensitive copy chords before terminal encoding**
905
906 At the start of the document `keydown` handler, after composition handling and before the existing browser-chord returns, add:
907
908 ```javascript
909 const lower = ev.key.toLowerCase();
910 const hasSelection = t.selection?.text !== null && t.selection?.text !== undefined;
911 const selectionCopy = lower === 'c' && (
912 ev.metaKey ||
913 (ev.ctrlKey && ev.shiftKey) ||
914 (ev.ctrlKey && !ev.altKey)
915 );
916 if (selectionCopy && hasSelection) {
917 ev.preventDefault();
918 t.copySelection();
919 return;
920 }
921 ```
922
923 Keep the existing rules after it:
924
925 ```javascript
926 if (ev.metaKey) return;
927 if (ev.ctrlKey && ev.shiftKey && (lower === 'c' || lower === 'v')) return;
928 ```
929
930 Therefore `Ctrl+Shift+C` reaches Firefox Inspector when there is no retained selection, while `Ctrl+C` falls through to the keymap and sends ETX when there is no selection.
931
932 - [ ] **Step 5: Clear retained selection on input and unzoom**
933
934 At the beginning of `sendKey` and `sendText`, call `this.clearSelection()`. In `unzoom`, call `was.clearSelection(false)` before repainting the wall tile. A plain click already clears through the no-movement pointer-up branch. Do not clear after a successful copy.
935
936 - [ ] **Step 6: Run tests and commit**
937
938 Run: `make test && make build`
939
940 Expected: PASS; no JavaScript call names a missing WASM export and all Zig correlation tests remain green.
941
942 ```bash
943 git add web/mux.js
944 git commit -m "feat: copy mouse selections in muxweb"
945 ```
946
947 ### Task 7: Verify Slice 2 end to end and document it
948
949 **Files:**
950 - Modify: `docs/roadmap.md`
951 - Modify: `docs/decisions.md` (append a dated entry)
952
953 - [ ] **Step 1: Run automated gates**
954
955 Run: `make test`
956
957 Expected: PASS for protocol golden bytes, bounded engine extraction, server unicast, shared-core correlation, and WASM ABI.
958
959 Run: `make build`
960
961 Expected: PASS for every binary and embedded web asset.
962
963 Run: `make e2e`
964
965 Expected: PASS with no regression in attach, scrollback, side-channel, or hub scenarios.
966
967 - [ ] **Step 2: Perform the Firefox interaction matrix**
968
969 Against a real session containing ordinary lines, a long soft-wrapped line, `漢字`, and more than one viewport of history:
970
971 1. Drag forward and backward within the live viewport; copied text must match the visible cells.
972 2. Drag across a soft wrap; copied text must not contain a newline at the wrap.
973 3. Drag across a hard line break; copied text must contain one newline.
974 4. Drag across wide characters; copied text must contain each codepoint once.
975 5. Drag beyond the top edge into fetched history; the highlight must continue and copy the full span.
976 6. Release without movement; the old selection must clear and no request must be sent.
977 7. While extraction is pending, copy must remain disabled.
978 8. With a retained selection, `Ctrl+Shift+C`, `Ctrl+C`, and macOS `Cmd+C` where available must copy without sending bytes to the PTY.
979 9. Without a retained selection, `Ctrl+Shift+C` must open Firefox Inspector and `Ctrl+C` must send ETX.
980 10. A new key, paste, IME composition, plain click, or unzoom must clear selection; copying alone must retain it.
981
982 - [ ] **Step 3: Record the delivered Reply lane**
983
984 Append:
985
986 ```markdown
987 ## 2026-08-16 (muxweb mouse selection)
988
989 Mouse selection is browser interaction over daemon-owned terminal truth.
990 `selection_req`/`selection_reply` use screen-row coordinates, client-local IDs,
991 explicit statuses, and a 1 MiB all-or-nothing cap. Ghostty performs soft-wrap,
992 wide-cell, and trimming semantics; `ClientCore` rejects stale replies. Canvas
993 highlighting is immediate, but explicit copy becomes available only after the
994 matching authoritative reply. Copy chords are selection-sensitive, so Firefox
995 Inspector remains available when no selection exists.
996 ```
997
998 Mark browser copy/paste and mouse selection complete in the roadmap while leaving native keyboard copy mode separately tracked.
999
1000 - [ ] **Step 4: Commit the verified slice**
1001
1002 ```bash
1003 git add docs/roadmap.md docs/decisions.md
1004 git commit -m "docs: record muxweb mouse selection"
1005 ```
1006
1007 Do not close or mutate tracker tickets without explicit user authorization; report which issue IDs now have implementation evidence in the handoff.
docs/superpowers/plans/2026-08-18-dynamic-wall.md
Old New
@@ -1,1086 +0,0 @@
1 # Dynamic Wall 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:** Add, remove, and reorder muxweb tiles at runtime from the browser, with the wall model consolidated in a new `wall.zig` for later reuse by the mux CLI.
6
7 **Architecture:** A new layer-1 module `src/wall.zig` owns the wall: an ordered list of TARGET spellings, parse/validate, and atomic persistence to `$XDG_STATE_HOME/mux/wall`. `webhub.zig` grows a `Hub` — the mutable, mutex-guarded runtime wall (tiles with stable per-run ids) — and three Origin-gated mutating verbs on `/tiles`. `webhub_main.zig` seeds the Hub from argv (or restores the file when argv is empty) and hands `*Hub` to every connection. The browser reconciles against `GET /tiles` as truth. No daemon changes.
8
9 **Tech Stack:** Zig 0.15.2 (pinned: `$HOME/Downloads/zig-x86_64-linux-0.15.2/zig`), std.http.Server, vanilla JS (`web/mux.js`), `test/e2e.sh` + `wsclient` + curl.
10
11 **Spec:** `docs/superpowers/specs/2026-08-18-dynamic-wall-design.md`
12
13 ## Global Constraints
14
15 - Toolchain: `ZIG=$HOME/Downloads/zig-x86_64-linux-0.15.2/zig`; system zig will NOT build this. `make build test` uses it already.
16 - Gate before every commit: `$ZIG build check` (fmt + unit tests + shell syntax). Capture `$?` before piping.
17 - Comments say *why*, not *how*. Existing comments are load-bearing — do not delete rationale.
18 - Transport stays dumb; prediction stays an overlay; one replay core (`replica.zig`). This feature touches none of those, keep it that way.
19 - Layering is enforced by `build.zig`'s module table. `wall` is layer 1 and may import only layer 0 (`protocol`, `xdg`, test-only `testtmp`).
20 - Commit style: `feat:`/`fix:`/`docs:` prefixes, `--fixup` during development, autosquash before delivery. The commit-msg hook stamps `Patch:` trailers.
21 - `std.ArrayList` in this codebase is the 0.15 unmanaged style: `.empty`, `list.append(alloc, x)`, `list.deinit(alloc)`.
22 - Every new e2e assertion must be watched failing once (break it deliberately, see it fail, restore) — a check that never ran passes silently.
23
24 ---
25
26 ### Task 1: `wall.zig` — spellings, model, persistence
27
28 **Files:**
29 - Create: `src/wall.zig`
30 - Modify: `build.zig` (module table, after the `cmd` entry around line 188)
31
32 **Interfaces:**
33 - Consumes: `protocol.validSessionName(name) bool`, `protocol.session_name_max`; `std.fs` only.
34 - Produces (later tasks rely on these exact names):
35 - `pub const Spec = union(enum) { sock: []const u8, host: []const u8, quic: []const u8 };`
36 - `pub const ParseError = error{ BadSession, EmptySpec };`
37 - `pub fn parseSpelling(line: []const u8) ParseError!Parsed` where `pub const Parsed = struct { spec: Spec, session: []const u8 };`
38 - `pub const Wall = struct { targets: std.ArrayList([]u8) = .empty, ... }` with `deinit(alloc)`, `add(alloc, spelling) !usize`, `remove(alloc, idx) void`, `reorder(alloc, order: []const usize) error{ BadOrder, OutOfMemory }!void`
39 - `pub fn load(alloc: std.mem.Allocator, path: []const u8) !Wall` (missing file → empty wall)
40 - `pub fn save(w: *const Wall, path: []const u8) !void` (atomic temp+rename, creates parent dirs)
41 - `pub fn statePathFrom(alloc, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8` and `pub fn statePath(alloc) ![]const u8` → `$XDG_STATE_HOME/mux/wall`, default `~/.local/state/mux/wall`
42
43 The one-string spelling grammar (this file's `//!` header must state it — it is the wall file format AND the POST body format AND, later, the CLI's):
44
45 ```
46 HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION]
47 ```
48
49 `--sock ` (with the space) as a line prefix keeps the file "literally an argv list" — the CLI spells sockets that way too. The session splits at the LAST `#` (a valid session name can never contain one), exactly `webhub_main.splitSession`'s rule, which this function replaces as the single owner.
50
51 - [ ] **Step 1: Write `src/wall.zig` with failing-first tests**
52
53 ```zig
54 //! The wall: an ordered list of TARGET spellings, shared by muxweb today
55 //! and the mux CLI later — one owner for the spelling grammar, the
56 //! session split, and the persisted file, so the wall built in a browser
57 //! is the wall the CLI sees.
58 //!
59 //! Spelling grammar (one string; also the line format of the state file
60 //! and the body of the hub's POST /tiles):
61 //! HOST[#SESSION] | quic://HOST[:PORT][#SESSION] | --sock PATH[#SESSION]
62 //! The session splits at the LAST '#' because validSessionName refuses
63 //! '#', so any earlier one belongs to the target's own spelling.
64 //!
65 //! The file is `$XDG_STATE_HOME/mux/wall`, one spelling per line, order
66 //! is wall order. Every mutation rewrites it atomically (temp + rename);
67 //! two concurrent writers resolve as last-rename-wins, acceptable for a
68 //! single user's state file.
69 const std = @import("std");
70 const proto = @import("protocol");
71
72 pub const Spec = union(enum) {
73 sock: []const u8,
74 host: []const u8,
75 quic: []const u8,
76 };
77
78 pub const Parsed = struct { spec: Spec, session: []const u8 };
79 pub const ParseError = error{ BadSession, EmptySpec };
80
81 /// Splits and classifies one spelling. Refuses here, at usage altitude,
82 /// what would otherwise surface as a rejected attach far from the typo:
83 /// a malformed session name, or a spelling whose target part is empty
84 /// (`#b`, `quic://`, `--sock #b`).
85 pub fn parseSpelling(line: []const u8) ParseError!Parsed {
86 var spec_str = line;
87 var session: []const u8 = "";
88 if (std.mem.lastIndexOfScalar(u8, line, '#')) |hash| {
89 const name = line[hash + 1 ..];
90 if (!proto.validSessionName(name)) return error.BadSession;
91 spec_str = line[0..hash];
92 session = name;
93 }
94 const sock_prefix = "--sock ";
95 const quic_prefix = "quic://";
96 if (std.mem.startsWith(u8, spec_str, sock_prefix)) {
97 const path = spec_str[sock_prefix.len..];
98 if (path.len == 0) return error.EmptySpec;
99 return .{ .spec = .{ .sock = path }, .session = session };
100 }
101 if (std.mem.startsWith(u8, spec_str, quic_prefix)) {
102 const hp = spec_str[quic_prefix.len..];
103 if (hp.len == 0) return error.EmptySpec;
104 return .{ .spec = .{ .quic = hp }, .session = session };
105 }
106 if (spec_str.len == 0) return error.EmptySpec;
107 return .{ .spec = .{ .host = spec_str }, .session = session };
108 }
109
110 pub const Wall = struct {
111 /// Owned copies, wall order. The spelling IS the label downstream.
112 targets: std.ArrayList([]u8) = .empty,
113
114 pub fn deinit(self: *Wall, alloc: std.mem.Allocator) void {
115 for (self.targets.items) |t| alloc.free(t);
116 self.targets.deinit(alloc);
117 }
118
119 /// Validates, then appends. Returns the new entry's index.
120 pub fn add(self: *Wall, alloc: std.mem.Allocator, spelling: []const u8) !usize {
121 _ = try parseSpelling(spelling);
122 const copy = try alloc.dupe(u8, spelling);
123 errdefer alloc.free(copy);
124 try self.targets.append(alloc, copy);
125 return self.targets.items.len - 1;
126 }
127
128 pub fn remove(self: *Wall, alloc: std.mem.Allocator, idx: usize) void {
129 alloc.free(self.targets.orderedRemove(idx));
130 }
131
132 /// `order` must be an exact permutation of 0..len — anything else is
133 /// the caller working from a stale view, refused so it can refetch.
134 pub fn reorder(self: *Wall, alloc: std.mem.Allocator, order: []const usize) error{ BadOrder, OutOfMemory }!void {
135 const n = self.targets.items.len;
136 if (order.len != n) return error.BadOrder;
137 var seen = try alloc.alloc(bool, n);
138 defer alloc.free(seen);
139 @memset(seen, false);
140 for (order) |i| {
141 if (i >= n or seen[i]) return error.BadOrder;
142 seen[i] = true;
143 }
144 const old = try alloc.dupe([]u8, self.targets.items);
145 defer alloc.free(old);
146 for (order, 0..) |src, dst| self.targets.items[dst] = old[src];
147 }
148 };
149
150 /// Missing file is an empty wall, not an error: first run has no state.
151 /// A line that no longer parses (edited by hand) is refused loudly —
152 /// error, not skip — because silently dropping a tile the user wrote
153 /// down is worse than making them fix the line.
154 pub fn load(alloc: std.mem.Allocator, path: []const u8) !Wall {
155 var w = Wall{};
156 errdefer w.deinit(alloc);
157 const data = std.fs.cwd().readFileAlloc(alloc, path, 1024 * 1024) catch |err| switch (err) {
158 error.FileNotFound => return w,
159 else => return err,
160 };
161 defer alloc.free(data);
162 var it = std.mem.tokenizeScalar(u8, data, '\n');
163 while (it.next()) |line| _ = try w.add(alloc, line);
164 return w;
165 }
166
167 pub fn save(w: *const Wall, path: []const u8) !void {
168 var write_buf: [4096]u8 = undefined;
169 var af = try std.fs.cwd().atomicFile(path, .{ .make_path = true, .write_buffer = &write_buf });
170 defer af.deinit();
171 for (w.targets.items) |t| {
172 try af.file_writer.interface.writeAll(t);
173 try af.file_writer.interface.writeAll("\n");
174 }
175 try af.finish();
176 }
177
178 /// `$XDG_STATE_HOME/mux/wall`, defaulting to `~/.local/state/mux/wall`.
179 /// The *From split is xdg.zig's pattern for the same reason: setenv is
180 /// unsafe in-process for Zig tests.
181 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 {
182 return statePathFrom(alloc, std.posix.getenv("XDG_STATE_HOME"), std.posix.getenv("HOME"));
183 }
184
185 pub fn statePathFrom(
186 alloc: std.mem.Allocator,
187 xdg_state_home: ?[]const u8,
188 home: ?[]const u8,
189 ) ![]const u8 {
190 if (xdg_state_home) |d| if (d.len > 0)
191 return std.fmt.allocPrint(alloc, "{s}/mux/wall", .{d});
192 const h = home orelse return error.NoHome;
193 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/wall", .{h});
194 }
195 ```
196
197 Tests, in the same file (this repo keeps unit tests in-module). Use `testtmp` for the round-trip (look at `src/xdg.zig`'s tests for the pattern of building paths inside a test tmp dir):
198
199 ```zig
200 test "parseSpelling: three spellings classify; session splits at the LAST '#'" {
201 try std.testing.expectEqualStrings("box1", (try parseSpelling("box1")).spec.host);
202 try std.testing.expectEqualStrings("", (try parseSpelling("box1")).session);
203 try std.testing.expectEqualStrings("h:4433", (try parseSpelling("quic://h:4433#b")).spec.quic);
204 try std.testing.expectEqualStrings("b", (try parseSpelling("quic://h:4433#b")).session);
205 try std.testing.expectEqualStrings("/tmp/x", (try parseSpelling("--sock /tmp/x#b")).spec.sock);
206 // The LAST '#': earlier ones belong to the target's own spelling.
207 try std.testing.expectEqualStrings("a#b", (try parseSpelling("a#b#c")).spec.host);
208 try std.testing.expectEqualStrings("c", (try parseSpelling("a#b#c")).session);
209 }
210
211 test "parseSpelling: refusals — bad session, empty spec in every spelling" {
212 try std.testing.expectError(error.BadSession, parseSpelling("host#has space"));
213 try std.testing.expectError(error.BadSession, parseSpelling("host#")); // empty name is not typeable
214 try std.testing.expectError(error.EmptySpec, parseSpelling("#b"));
215 try std.testing.expectError(error.EmptySpec, parseSpelling("quic://"));
216 try std.testing.expectError(error.EmptySpec, parseSpelling("--sock #b"));
217 try std.testing.expectError(error.EmptySpec, parseSpelling(""));
218 }
219
220 test "wall: add validates, remove frees, reorder is permutation-or-refused" {
221 const alloc = std.testing.allocator;
222 var w = Wall{};
223 defer w.deinit(alloc);
224 _ = try w.add(alloc, "a");
225 _ = try w.add(alloc, "b#s");
226 _ = try w.add(alloc, "quic://c:1");
227 try std.testing.expectError(error.BadSession, w.add(alloc, "d#bad name"));
228 try std.testing.expectEqual(@as(usize, 3), w.targets.items.len);
229
230 try w.reorder(alloc, &.{ 2, 0, 1 });
231 try std.testing.expectEqualStrings("quic://c:1", w.targets.items[0]);
232 try std.testing.expectEqualStrings("a", w.targets.items[1]);
233 // Stale views are refused, not guessed at: wrong length, dup, range.
234 try std.testing.expectError(error.BadOrder, w.reorder(alloc, &.{ 0, 1 }));
235 try std.testing.expectError(error.BadOrder, w.reorder(alloc, &.{ 0, 0, 1 }));
236 try std.testing.expectError(error.BadOrder, w.reorder(alloc, &.{ 0, 1, 3 }));
237
238 w.remove(alloc, 1);
239 try std.testing.expectEqual(@as(usize, 2), w.targets.items.len);
240 try std.testing.expectEqualStrings("b#s", w.targets.items[1]);
241 }
242
243 test "wall: save/load round-trip; missing file loads empty; bad line refuses" {
244 const alloc = std.testing.allocator;
245 var tmp = std.testing.tmpDir(.{});
246 defer tmp.cleanup();
247 const dir_path = try tmp.dir.realpathAlloc(alloc, ".");
248 defer alloc.free(dir_path);
249 const path = try std.fmt.allocPrint(alloc, "{s}/deep/wall", .{dir_path});
250 defer alloc.free(path);
251
252 {
253 var missing = try load(alloc, path);
254 defer missing.deinit(alloc);
255 try std.testing.expectEqual(@as(usize, 0), missing.targets.items.len);
256 }
257 {
258 var w = Wall{};
259 defer w.deinit(alloc);
260 _ = try w.add(alloc, "a#s");
261 _ = try w.add(alloc, "--sock /tmp/x");
262 try save(&w, path); // .make_path: the deep/ parent did not exist
263 }
264 {
265 var r = try load(alloc, path);
266 defer r.deinit(alloc);
267 try std.testing.expectEqual(@as(usize, 2), r.targets.items.len);
268 try std.testing.expectEqualStrings("a#s", r.targets.items[0]);
269 try std.testing.expectEqualStrings("--sock /tmp/x", r.targets.items[1]);
270 }
271 // A hand-edited line that no longer parses refuses the whole load.
272 try tmp.dir.writeFile(.{ .sub_path = "deep/wall", .data = "ok\nbad name#x y\n" });
273 try std.testing.expectError(error.BadSession, load(alloc, path));
274 }
275
276 test "statePathFrom: XDG wins when set and non-empty, HOME default otherwise" {
277 const alloc = std.testing.allocator;
278 {
279 const p = try statePathFrom(alloc, "/xs", "/home/u");
280 defer alloc.free(p);
281 try std.testing.expectEqualStrings("/xs/mux/wall", p);
282 }
283 {
284 const p = try statePathFrom(alloc, "", "/home/u");
285 defer alloc.free(p);
286 try std.testing.expectEqualStrings("/home/u/.local/state/mux/wall", p);
287 }
288 try std.testing.expectError(error.NoHome, statePathFrom(alloc, null, null));
289 }
290
291 test {
292 std.testing.refAllDeclsRecursive(@This());
293 }
294 ```
295
296 Note: `test "wall: save/load..."` uses `std.testing.tmpDir`; if the repo's `testtmp` module is the established way (check `src/xdg.zig`'s `test_imports`), follow that pattern instead — whichever the neighboring tests use.
297
298 - [ ] **Step 2: Register the module and run the tests — expect FAIL first**
299
300 Add to `build.zig`'s module table (near `cmd`, keep the table's layer ordering):
301
302 ```zig
303 .{ .name = "wall", .path = "src/wall.zig", .layer = 1, .imports = &.{"protocol"}, .test_imports = &.{"testtmp"} },
304 ```
305
306 Run: `$ZIG build test 2>&1 | tail -20` — before writing the implementation bodies you should have seen at least one red run (write tests first, stub `parseSpelling` to return `error.EmptySpec` unconditionally if you want a clean RED). Then with the real bodies: expect PASS.
307
308 - [ ] **Step 3: `$ZIG build check`; capture exit code**
309
310 Run: `$ZIG build check; echo "check=$?"` — expect `check=0`.
311
312 - [ ] **Step 4: Commit**
313
314 ```bash
315 git add src/wall.zig build.zig
316 git commit -m "feat: wall.zig — spelling grammar, wall model, atomic persistence"
317 ```
318
319 ---
320
321 ### Task 2: `webhub.zig` — the Hub (runtime wall with stable ids)
322
323 **Files:**
324 - Modify: `src/webhub.zig` (add Hub near the top, after `ws_buffer_len`; replace `wsTileIndex` ~line 47; extend `tilesJson` ~line 533)
325 - Modify: `build.zig` webhub entry (~line 237): imports become `&.{ "protocol", "client", "wall", "handoff", "xdg" }`
326
327 **Interfaces:**
328 - Consumes (Task 1): `wall.Wall`, `wall.parseSpelling`, `wall.Spec`, `wall.save`, `wall.ParseError`.
329 - Consumes (existing): `client.Target`, `client.QuicTarget` (`host_port`, `key_path`, `idle_ms`), `client.HandoffTarget` (`host`, `ssh_cmd`, `cache_path`, plus its deadline field — copy ALL fields), `handoff.recipeFor(alloc, host)`, `xdg.resolveKeyPath(alloc, given)`.
330 - Produces (Tasks 3–6 rely on):
331 - `pub const Hub = struct { ... }` with:
332 - `pub fn init(alloc, w: wall.Wall, state_path: ?[]const u8, key: ?[]const u8, idle_ms: u32) !Hub` — resolves every wall entry into a tile; `error.MissingKey` if a quic entry has no key.
333 - `pub fn deinit(self: *Hub) void`
334 - `pub fn addTile(self: *Hub, spelling: []const u8) AddError!u32` — `AddError = wall.ParseError || error{ MissingKey, OutOfMemory, PersistFailed }`
335 - `pub fn removeTile(self: *Hub, id: u32) bool` — false if unknown id
336 - `pub fn reorderTiles(self: *Hub, ids: []const u32) error{ Stale, OutOfMemory, PersistFailed }!void`
337 - `pub fn checkoutTarget(self: *Hub, id: u32, ws_fd: std.posix.fd_t, arena: std.mem.Allocator) ?client.Target` — deep-copies the target into the caller's arena and registers `ws_fd`; null if the id is gone.
338 - `pub fn releaseTile(self: *Hub, id: u32) void` — unregisters the fd.
339 - `pub fn json(self: *Hub, alloc) ![]u8` — `[{"id":N,"label":…,"session":…}]` in wall order.
340 - `pub fn wsTileId(path: []const u8) ?u32` — replaces `wsTileIndex` (no range check; the Hub lookup decides).
341
342 - [ ] **Step 1: Write the failing tests**
343
344 Add to `src/webhub.zig` (unit tests exercise the Hub with `state_path = null` — no filesystem — plus one persistence test through a tmp dir):
345
346 ```zig
347 test "hub: ids are stable across remove and reorder; json is wall order" {
348 const alloc = std.testing.allocator;
349 var w = wall.Wall{};
350 _ = try w.add(alloc, "--sock /tmp/a");
351 _ = try w.add(alloc, "--sock /tmp/b#s");
352 var hub = try Hub.init(alloc, w, null, null, client.quic_idle_ms_default);
353 defer hub.deinit();
354
355 const j0 = try hub.json(alloc);
356 defer alloc.free(j0);
357 try std.testing.expectEqualStrings(
358 \\[{"id":0,"label":"--sock /tmp/a","session":""},{"id":1,"label":"--sock /tmp/b#s","session":"s"}]
359 , j0);
360
361 const id2 = try hub.addTile("--sock /tmp/c");
362 try std.testing.expectEqual(@as(u32, 2), id2);
363 try std.testing.expect(hub.removeTile(1));
364 try std.testing.expect(!hub.removeTile(1)); // already gone
365 try hub.reorderTiles(&.{ 2, 0 });
366 try std.testing.expectError(error.Stale, hub.reorderTiles(&.{ 0, 1 })); // 1 is gone: stale view
367
368 const j1 = try hub.json(alloc);
369 defer alloc.free(j1);
370 try std.testing.expectEqualStrings(
371 \\[{"id":2,"label":"--sock /tmp/c","session":""},{"id":0,"label":"--sock /tmp/a","session":""}]
372 , j1);
373 }
374
375 test "hub: checkout copies the target into the caller's arena; release unregisters" {
376 const alloc = std.testing.allocator;
377 var w = wall.Wall{};
378 _ = try w.add(alloc, "--sock /tmp/a");
379 var hub = try Hub.init(alloc, w, null, null, client.quic_idle_ms_default);
380 defer hub.deinit();
381
382 var arena = std.heap.ArenaAllocator.init(alloc);
383 defer arena.deinit();
384 const t = hub.checkoutTarget(0, 7, arena.allocator()).?;
385 // The copy must survive the tile's death: remove frees the tile's own
386 // arena, and the pump's strings must not be in it.
387 try std.testing.expect(hub.removeTile(0));
388 try std.testing.expectEqualStrings("/tmp/a", t.sock);
389 hub.releaseTile(0); // gone id: a no-op, not a crash
390 try std.testing.expectEqual(@as(?client.Target, null), hub.checkoutTarget(0, 7, arena.allocator()));
391 }
392
393 test "hub: addTile persists; a reloaded wall matches" {
394 const alloc = std.testing.allocator;
395 var tmp = std.testing.tmpDir(.{});
396 defer tmp.cleanup();
397 const dir_path = try tmp.dir.realpathAlloc(alloc, ".");
398 defer alloc.free(dir_path);
399 const path = try std.fmt.allocPrint(alloc, "{s}/wall", .{dir_path});
400 defer alloc.free(path);
401
402 {
403 var hub = try Hub.init(alloc, wall.Wall{}, path, null, client.quic_idle_ms_default);
404 defer hub.deinit();
405 _ = try hub.addTile("--sock /tmp/a");
406 _ = try hub.addTile("--sock /tmp/b");
407 _ = try hub.addTile("--sock /tmp/c");
408 try std.testing.expect(hub.removeTile(1));
409 try hub.reorderTiles(&.{ 2, 0 });
410 }
411 var r = try wall.load(alloc, path);
412 defer r.deinit(alloc);
413 try std.testing.expectEqual(@as(usize, 2), r.targets.items.len);
414 try std.testing.expectEqualStrings("--sock /tmp/c", r.targets.items[0]);
415 try std.testing.expectEqualStrings("--sock /tmp/a", r.targets.items[1]);
416 }
417
418 test "ws path by id: parses, no range opinion" {
419 try std.testing.expectEqual(@as(?u32, 0), wsTileId("/ws/0"));
420 try std.testing.expectEqual(@as(?u32, 41), wsTileId("/ws/41"));
421 try std.testing.expectEqual(@as(?u32, null), wsTileId("/ws/"));
422 try std.testing.expectEqual(@as(?u32, null), wsTileId("/ws/x"));
423 try std.testing.expectEqual(@as(?u32, null), wsTileId("/ws/-1"));
424 try std.testing.expectEqual(@as(?u32, null), wsTileId("/wsx/0"));
425 }
426 ```
427
428 Delete the old `wsTileIndex` test ("ws path: /ws/<idx> in range..."). Keep the `tilesJson` escaping behavior: `Hub.json` must go through the existing `appendJsonString` for label and session.
429
430 - [ ] **Step 2: Run tests, verify they fail**
431
432 Run: `$ZIG build test 2>&1 | tail -20`
433 Expected: FAIL — `Hub` not defined.
434
435 - [ ] **Step 3: Implement the Hub**
436
437 Shape (adapt freely to compile, keep the names from Interfaces):
438
439 ```zig
440 const wall = @import("wall");
441 const handoff = @import("handoff");
442 const xdg = @import("xdg");
443
444 /// One runtime tile. Owns an arena holding its resolved target and label
445 /// strings; the arena dies with the tile, which is why pumps must
446 /// checkout a COPY rather than borrow.
447 const HubTile = struct {
448 id: u32,
449 arena: std.heap.ArenaAllocator,
450 target: client.Target,
451 label: []const u8,
452 session: []const u8,
453 /// Registered while a pump owns a WS for this tile. removeTile uses
454 /// it to shutdown(2) the socket, which unblocks the pump's reads;
455 /// the pump unregisters (under the hub mutex) BEFORE serveConn
456 /// closes the fd, so a shutdown can never hit a recycled fd.
457 ws_fd: ?std.posix.fd_t = null,
458 };
459
460 pub const Hub = struct {
461 alloc: std.mem.Allocator,
462 mutex: std.Thread.Mutex = .{},
463 tiles: std.ArrayList(HubTile) = .empty,
464 wall_state: wall.Wall,
465 next_id: u32 = 0,
466 /// null = don't persist (tests). Persist failures on a mutation are
467 /// PersistFailed, not silence: a wall the user thinks is saved and
468 /// isn't would be the worst kind of quiet.
469 state_path: ?[]const u8,
470 key: ?[]const u8,
471 idle_ms: u32,
472 // init: for each wall entry, resolveTile; addTile: wall.add +
473 // resolveTile + save; removeTile: find by id, shutdown ws_fd if set,
474 // wall.remove(same index), arena.deinit, save; reorderTiles: map ids
475 // to current indexes (error.Stale on any miss/dup/length mismatch),
476 // wall.reorder + reorder tiles, save.
477 };
478
479 /// Spelling → client.Target, moved from webhub_main's main loop so POST
480 /// can do at runtime exactly what argv does at startup. Allocates into
481 /// `arena` (the tile's own).
482 fn resolveTile(arena: std.mem.Allocator, spelling: []const u8, key: ?[]const u8, idle_ms: u32) !struct {
483 target: client.Target,
484 label: []const u8,
485 session: []const u8,
486 } {
487 const p = try wall.parseSpelling(spelling);
488 const label = try arena.dupe(u8, spelling);
489 const session = try arena.dupe(u8, p.session);
490 const target: client.Target = switch (p.spec) {
491 .sock => |path| .{ .sock = try arena.dupe(u8, path) },
492 .host => |h| blk: {
493 const hd = try arena.dupe(u8, h);
494 const r = try handoff.recipeFor(arena, hd);
495 break :blk .{ .hand = .{ .host = hd, .ssh_cmd = r.ssh_cmd, .cache_path = r.cache_path, .idle_ms = idle_ms } };
496 },
497 .quic => |hp| blk: {
498 const key_path = switch (try xdg.resolveKeyPath(arena, key)) {
499 .given => |kp| try arena.dupe(u8, kp),
500 .default => |kp| kp,
501 .missing => return error.MissingKey,
502 };
503 break :blk .{ .quic = .{ .host_port = try arena.dupe(u8, hp), .key_path = key_path, .idle_ms = idle_ms } };
504 },
505 };
506 return .{ .target = target, .label = label, .session = session };
507 }
508 ```
509
510 `checkoutTarget` deep-copy: switch on the tile's `client.Target` and `arena.dupe` every string field into the pump's arena (`sock`, `via`, `quic.host_port`/`key_path`, `hand.host`/`ssh_cmd`/`cache_path` if non-null); integer fields copy by value. Check `client.zig:92-121` for the full field lists — copy every field, including the deadline ones, or a checkout would quietly shorten a handshake budget.
511
512 Look at exactly how `webhub_main.zig` main built targets before writing `resolveTile` — the `.hand` arm's `recipeFor` call and the quic key refusal message live there today (~line 240-270); this function takes over that logic, and Task 3 deletes it from main.
513
514 - [ ] **Step 4: Run tests, verify they pass**
515
516 Run: `$ZIG build test 2>&1 | tail -20` — expect PASS. `webhub_main.zig` still compiles: `wsTileIndex` was replaced, so fix its one caller in `serveConn` minimally for now (`wsTileId(path)` + a bounds check against `targets.len` to preserve behavior until Task 3 rewires it).
517
518 - [ ] **Step 5: `$ZIG build check`; commit**
519
520 ```bash
521 $ZIG build check; echo "check=$?"
522 git add src/webhub.zig build.zig
523 git commit -m "feat: webhub Hub — runtime wall, stable tile ids, checkout/release"
524 ```
525
526 ---
527
528 ### Task 3: HTTP verbs + serveConn on the Hub + webhub_main cutover
529
530 **Files:**
531 - Modify: `src/webhub.zig` — `serveConn` (~line 557) signature and routes; `pumpTile` gains no changes (its caller copies the target first).
532 - Modify: `src/webhub_main.zig` — parseArgs allows zero targets; main builds a `Hub`; the resolve loop moves out (now `webhub.zig`'s `resolveTile`); spawn passes `*Hub`.
533
534 **Interfaces:**
535 - Consumes: everything Task 2 produced.
536 - Produces: the wire surface Tasks 4–6 depend on:
537 - `GET /tiles` → 200, `[{"id":N,"label":…,"session":…}]`
538 - `POST /tiles` (body: one spelling, ≤4096 bytes) → 200 `{"id":N}`; 400 + plain-text reason on bad spelling or missing key; 403 without a valid Origin
539 - `DELETE /tiles/<id>` → 204; 404 unknown id; 403 bad Origin
540 - `PUT /tiles` (body: CSV of ids, e.g. `3,0,2`) → 204; 409 + current tiles JSON on a stale view; 400 unparsable; 403 bad Origin
541 - `/ws/<id>` → WebSocket, 404 unknown id (after Origin check, as today)
542 - `serveConn(alloc, stream, port, hub: *Hub, assets)` — the new signature `webhub_main` spawns.
543
544 Two hard requirements, both security/correctness:
545
546 1. **Origin-gate every mutating verb.** A `POST` with `text/plain` body is a CSRF-able "simple request" — any web page can fire it at `127.0.0.1:7681` without CORS preflight. The same `originAllowed(origin, port)` check the WS upgrade does must refuse POST/DELETE/PUT before any body is read. GET stays ungated (no CORS headers ⇒ a hostile page cannot read the response).
547 2. **Read the path and method BEFORE the body.** `readerExpectNone` (which `readerExpectContinue` calls) invalidates `req.head`'s strings — copy `req.head.target` and `req.head.method` into locals first.
548
549 - [ ] **Step 1: Write the failing unit test for the id-order parser**
550
551 The verb handlers are exercised end-to-end in Task 6; unit-test the pure part — CSV id parsing:
552
553 ```zig
554 /// `3,0,2` → ids. Empty, junk, or trailing garbage refuse: the body is
555 /// machine-written by our own page, so anything malformed is a bug
556 /// worth surfacing, not input to repair.
557 pub fn parseIdList(alloc: std.mem.Allocator, body: []const u8) error{ Bad, OutOfMemory }![]u32 {
558 var out: std.ArrayList(u32) = .empty;
559 errdefer out.deinit(alloc);
560 var it = std.mem.splitScalar(u8, std.mem.trim(u8, body, " \t\r\n"), ',');
561 while (it.next()) |part| {
562 const id = std.fmt.parseInt(u32, part, 10) catch return error.Bad;
563 try out.append(alloc, id);
564 }
565 if (out.items.len == 0) return error.Bad;
566 return out.toOwnedSlice(alloc);
567 }
568
569 test "parseIdList: happy path and refusals" {
570 const alloc = std.testing.allocator;
571 const ids = try parseIdList(alloc, "3,0,2\n");
572 defer alloc.free(ids);
573 try std.testing.expectEqualSlices(u32, &.{ 3, 0, 2 }, ids);
574 try std.testing.expectError(error.Bad, parseIdList(alloc, ""));
575 try std.testing.expectError(error.Bad, parseIdList(alloc, "1,,2"));
576 try std.testing.expectError(error.Bad, parseIdList(alloc, "1,x"));
577 try std.testing.expectError(error.Bad, parseIdList(alloc, "-1"));
578 }
579 ```
580
581 Run: `$ZIG build test 2>&1 | tail -10` — expect FAIL (`parseIdList` undefined). Implement (code above), expect PASS.
582
583 - [ ] **Step 2: Rewire `serveConn`**
584
585 New signature and the verb dispatch (the existing WS, GET /tiles, and asset arms survive with small edits):
586
587 ```zig
588 pub fn serveConn(
589 alloc: std.mem.Allocator,
590 stream: std.net.Stream,
591 port: u16,
592 hub: *Hub,
593 assets: Assets,
594 ) void {
595 defer stream.close();
596 var in_buf: [ws_buffer_len]u8 = undefined;
597 var out_buf: [8 * 1024]u8 = undefined;
598 var conn_reader = stream.reader(&in_buf);
599 var conn_writer = stream.writer(&out_buf);
600 var server = std.http.Server.init(conn_reader.interface(), &conn_writer.interface);
601
602 while (true) {
603 var req = server.receiveHead() catch return;
604 // Copied out FIRST: reading a body invalidates head's strings.
605 const path = req.head.target;
606 const method = req.head.method;
607
608 var origin: ?[]const u8 = null;
609 var it = req.iterateHeaders();
610 while (it.next()) |h| {
611 if (std.ascii.eqlIgnoreCase(h.name, "origin")) origin = h.value;
612 }
613
614 if (wsTileId(path)) |id| {
615 // Origin BEFORE upgrade, as before (webhub.zig:577 comment).
616 if (!originAllowed(origin, port)) {
617 req.respond("forbidden\n", .{ .status = .forbidden }) catch {};
618 return;
619 }
620 const key = switch (req.upgradeRequested()) { ... unchanged ... };
621 var ws = req.respondWebSocket(.{ .key = key }) catch return;
622 ws.flush() catch return;
623 // The pump owns a COPY of the target: the tile (and its
624 // arena) may be removed mid-pump, and the shutdown() that
625 // kicks us out must never race a free of our own strings.
626 var pump_arena = std.heap.ArenaAllocator.init(alloc);
627 defer pump_arena.deinit();
628 const target = hub.checkoutTarget(id, stream.handle, pump_arena.allocator()) orelse {
629 return; // tile removed between GET and dial: browser refetches
630 };
631 pumpTile(alloc, &ws, stream.handle, target);
632 hub.releaseTile(id); // unregister BEFORE the deferred close
633 return;
634 }
635
636 if (std.mem.eql(u8, path, "/tiles") and method == .GET) {
637 const json = hub.json(alloc) catch return;
638 defer alloc.free(json);
639 req.respond(json, .{ .extra_headers = &.{
640 .{ .name = "content-type", .value = "application/json" },
641 .{ .name = "cache-control", .value = "no-cache" },
642 } }) catch return;
643 continue;
644 }
645
646 // Mutations: Origin-gated, always — a text/plain POST is a CSRF
647 // "simple request" any web page can fire at localhost without a
648 // preflight; the gate is what keeps this page's power this page's.
649 if (std.mem.startsWith(u8, path, "/tiles")) {
650 if (!originAllowed(origin, port)) {
651 req.respond("forbidden\n", .{ .status = .forbidden }) catch {};
652 return;
653 }
654 switch (method) {
655 .POST => { // body = one spelling
656 var body_buf: [512]u8 = undefined;
657 const rdr = req.readerExpectContinue(&body_buf) catch return;
658 const body_raw = rdr.allocRemaining(alloc, .limited(4096)) catch return;
659 defer alloc.free(body_raw);
660 const spelling = std.mem.trim(u8, body_raw, " \t\r\n");
661 const id = hub.addTile(spelling) catch |err| {
662 const msg = switch (err) {
663 error.BadSession => "bad session name after '#'\n",
664 error.EmptySpec => "empty target\n",
665 error.MissingKey => "no key for quic:// target (muxd keygen, or MUX_KEY_FILE)\n",
666 else => "add failed\n",
667 };
668 req.respond(msg, .{ .status = .bad_request }) catch {};
669 continue;
670 };
671 var buf: [32]u8 = undefined;
672 const resp = std.fmt.bufPrint(&buf, "{{\"id\":{d}}}", .{id}) catch unreachable;
673 req.respond(resp, .{ .extra_headers = &.{
674 .{ .name = "content-type", .value = "application/json" },
675 } }) catch return;
676 },
677 .DELETE => { // path = /tiles/<id>
678 const prefix = "/tiles/";
679 if (!std.mem.startsWith(u8, path, prefix)) {
680 req.respond("not found\n", .{ .status = .not_found }) catch return;
681 continue;
682 }
683 const id = std.fmt.parseInt(u32, path[prefix.len..], 10) catch {
684 req.respond("not found\n", .{ .status = .not_found }) catch return;
685 continue;
686 };
687 if (hub.removeTile(id)) {
688 req.respond("", .{ .status = .no_content }) catch return;
689 } else {
690 req.respond("not found\n", .{ .status = .not_found }) catch return;
691 }
692 },
693 .PUT => { // body = CSV of ids, the FULL new order
694 var body_buf: [512]u8 = undefined;
695 const rdr = req.readerExpectContinue(&body_buf) catch return;
696 const body_raw = rdr.allocRemaining(alloc, .limited(4096)) catch return;
697 defer alloc.free(body_raw);
698 const ids = parseIdList(alloc, body_raw) catch {
699 req.respond("bad id list\n", .{ .status = .bad_request }) catch return;
700 continue;
701 };
702 defer alloc.free(ids);
703 hub.reorderTiles(ids) catch {
704 // A stale view: hand back the truth so the page
705 // can reconcile and retry.
706 const json = hub.json(alloc) catch return;
707 defer alloc.free(json);
708 req.respond(json, .{ .status = .conflict, .extra_headers = &.{
709 .{ .name = "content-type", .value = "application/json" },
710 } }) catch return;
711 continue;
712 };
713 req.respond("", .{ .status = .no_content }) catch return;
714 },
715 else => req.respond("bad method\n", .{ .status = .method_not_allowed }) catch return,
716 }
717 continue;
718 }
719
720 // ... asset route and 404, unchanged ...
721 }
722 }
723 ```
724
725 The `.limited(4096)` spelling is `std.Io.Limit`; if the compiler wants `@enumFromInt(4096)` or similar under 0.15.2, adapt at this line only. The existing `route`/asset arm and the WS upgrade internals are unchanged — do not rewrite them.
726
727 - [ ] **Step 3: Cut over `webhub_main.zig`**
728
729 - `parseArgs`: zero targets is now LEGAL (delete the no-targets `error.Usage` arm and flip its test — "No targets is a usage error, not an empty wall" becomes "no targets is an empty argv wall: restore-from-file semantics live in main"). `Parsed.tiles` becomes `std.ArrayList([]const u8)` of one-string spellings: bare and `quic://` args are validated with `wall.parseSpelling(arg)` (refusal keeps printing the tile-naming usage line, then `error.Usage`); `--sock PATH` synthesizes `--sock {s}` via `std.fmt.allocPrint`. Note in a comment: sock tile labels now carry the `--sock ` prefix — the label is the spelling, and the spelling is now one string.
730 - `main`: after parse —
731
732 ```zig
733 const state_path = try wall.statePath(arena);
734 var w: wall.Wall = undefined;
735 if (parsed.tiles.items.len == 0) {
736 // No argv: the wall is whatever the last run persisted.
737 w = try wall.load(arena, state_path);
738 } else {
739 // Argv present: the explicit override. It becomes the persisted wall.
740 w = wall.Wall{};
741 for (parsed.tiles.items) |s| _ = try w.add(arena, s);
742 }
743 var hub = webhub.Hub.init(arena, w, state_path, parsed.key, parsed.idle_ms) catch |err| switch (err) {
744 error.MissingKey => {
745 std.debug.print("muxweb: no key for a quic:// tile: pass --key, set MUX_KEY_FILE, or run `muxd keygen`\n", .{});
746 return 2;
747 },
748 else => return err,
749 };
750 if (parsed.tiles.items.len != 0) try wall.save(&hub.wall_state, state_path);
751 ```
752
753 Delete the old resolve loop (the `.sock`/`.host`/`.quic` switch, ~lines 235-270) — `resolveTile` in webhub.zig owns it now. The startup tile listing loops over `hub` tiles (`muxweb: tile <id>: <label>`). The accept loop spawns `webhub.serveConn` with `.{ alloc, conn.stream, parsed.port, &hub, assets }`.
754 - `webhub_main` needs `wall` in its imports: add `"wall"` to the `webhub_main` entry's imports in `build.zig`.
755 - Update `parseArgs` tests: spellings instead of `TileSpec` fields (e.g. items are `"box1"`, `"--sock /tmp/a.sock"`, `"quic://h:4433"`); the `#NAME` split tests move their assertions to label-level only (the split itself is wall.zig's, already tested there — keep one test proving a bad name still refuses at parse).
756
757 - [ ] **Step 4: Run everything**
758
759 Run: `$ZIG build test 2>&1 | tail -20` — expect PASS.
760 Run: `$ZIG build check; echo "check=$?"` — expect `check=0`.
761
762 Smoke by hand (this is the first runnable increment):
763
764 ```bash
765 $ZIG build 2>&1 | tail -3
766 ./zig-out/bin/muxd run --sock /tmp/dw.sock --shell /bin/sh & sleep 0.5
767 XDG_STATE_HOME=/tmp/dwstate ./zig-out/bin/muxweb --port 7699 & sleep 0.5
768 curl -s http://127.0.0.1:7699/tiles # []
769 curl -s -X POST -H 'Origin: http://127.0.0.1:7699' --data '--sock /tmp/dw.sock' http://127.0.0.1:7699/tiles # {"id":0}
770 curl -s -X POST --data 'x' http://127.0.0.1:7699/tiles # forbidden (no Origin)
771 curl -s http://127.0.0.1:7699/tiles # one tile, id 0
772 cat /tmp/dwstate/mux/wall # --sock /tmp/dw.sock
773 kill %2 %1
774 ```
775
776 - [ ] **Step 5: Commit**
777
778 ```bash
779 git add src/webhub.zig src/webhub_main.zig build.zig
780 git commit -m "feat: muxweb runtime wall — POST/DELETE/PUT /tiles, argv seeds or file restores"
781 ```
782
783 ---
784
785 ### Task 4: browser — id-based tiles, reconcile, add, remove
786
787 **Files:**
788 - Modify: `web/mux.js` — `Tile` constructor (~line 140: `idx` → `id`), `connect()` (~line 217: `/ws/${this.id}`), `boot()` (~line 1366), new `reconcile()` + add-tile UI + remove.
789 - Modify: `web/index.html` — styles for the add tile and the close button.
790
791 **Interfaces:**
792 - Consumes: Task 3's wire surface (`GET/POST/DELETE /tiles`, `/ws/<id>`).
793 - Produces: `refetchWall()` (fetch `/tiles` + reconcile) — Task 5's reorder calls it; `tiles` becomes a `Map` keyed by id (Task 5 reads DOM order from `#wall` children).
794
795 Design points the implementer must honor:
796
797 - `GET /tiles` is truth. `reconcile(cfg)`: for each entry in order — existing tile (by id) is moved to that DOM position (`wall.appendChild` in cfg order re-places them); unknown id → `new Tile(id, label, wall, session)` + `start()`; any open tile whose id is absent from cfg → `tile.shutdown()` (close its ws with `onclose` suppressed, remove `this.el`, drop from the Map, and if it was zoomed, unzoom first).
798 - The add tile is a permanent last child of `#wall`: a `div.tile.add` holding a single `<input>` with placeholder `HOST[#SESSION] | quic://HOST:PORT | --sock PATH`. Enter → `fetch('/tiles', {method:'POST', body: value})` → on `res.ok` clear the input and `refetchWall()`; on 400 show `await res.text()` inline in the add tile (a `.err` span) and keep the value for editing. The add tile is excluded from the zoom click handler (`boot`'s tile click zooms — the add tile is not a `Tile` instance, so it never enters that path; just ensure its container click doesn't bubble into anything).
799 - Remove: a `×` button in each tile header, next to the copy button. Click → `fetch('/tiles/'+id, {method:'DELETE'})` → `refetchWall()`. `ev.stopPropagation()` so the click doesn't zoom the tile. Hidden while zoomed (CSS below) — the zoomed tile's controls are the terminal's, and a stray × would tear down the thing being typed into.
800 - **IME focus:** follow the copy button's exact pattern for not stealing focus from `#ime` — read `web/mux.js` around the `copyButton` click handler (~line 167) and the recent fix (`git log --oneline -3 -- web/mux.js`, "keep IME focus when a copy control hides itself") before wiring the new buttons. The add input is a REAL focus target (typing a spelling is intended); zoom handling must not fight it — while the add input has focus, the global `keydown` handler (~line 1277) must not swallow keys (guard: `if (document.activeElement === addInput) return;`).
801 - Labels: the header shows the label only (drop the `${idx}: ` prefix — order is now mutable, a baked-in number would lie after one drag).
802
803 - [ ] **Step 1: index.html styles**
804
805 Append to the `<style>` block:
806
807 ```css
808 /* The add tile: the wall's one standing control. A tile-shaped door,
809 not a toolbar — the wall stays a wall. */
810 .tile.add {
811 min-height: 80px; align-items: center; justify-content: center;
812 cursor: text; border-style: dashed;
813 }
814 .tile.add input {
815 width: 90%; background: transparent; border: 0; outline: none;
816 color: var(--fg); font: inherit; text-align: center;
817 }
818 .tile.add .err { color: #e07a7a; font-size: 11px; }
819 .tile header .close {
820 padding: 0 6px; border: 0; background: transparent; color: var(--dim);
821 font: inherit; cursor: pointer;
822 }
823 .tile header .close:hover { color: #e07a7a; }
824 /* Zoomed = the terminal owns the header; wall management waits. */
825 .tile.zoomed header .close { display: none; }
826 ```
827
828 - [ ] **Step 2: mux.js — Tile by id + shutdown**
829
830 - Constructor: rename the `idx` parameter to `id`, store `this.id`; header innerHTML gains `<button class="close" type="button">×</button>` before the badge; `this.el.querySelector('.label').textContent = label;` (no index prefix). Wire: `this.el.querySelector('.close').addEventListener('click', (ev) => { ev.stopPropagation(); removeTile(this.id); });`
831 - `connect()`: `/ws/${this.id}`.
832 - Add a `shutdown()` method: set `this.dead = true`, `this.ws?.close()`, clear reconnect timers, `this.el.remove()`. Guard the `onclose` reconnect path with `if (this.dead) return;`.
833
834 - [ ] **Step 3: mux.js — reconcile boot**
835
836 Replace `boot()`'s tile loop:
837
838 ```js
839 const tilesById = new Map();
840 let addTileEl = null; // created once in boot
841
842 async function refetchWall() {
843 const cfg = await (await fetch('/tiles')).json();
844 const wallEl = document.getElementById('wall');
845 const liveIds = new Set(cfg.map((t) => t.id));
846 for (const [id, tile] of tilesById) {
847 if (!liveIds.has(id)) { tilesById.delete(id); tile.shutdown(); }
848 }
849 for (const t of cfg) {
850 let tile = tilesById.get(t.id);
851 if (!tile) {
852 tile = new Tile(t.id, t.label, wallEl, t.session);
853 tilesById.set(t.id, tile);
854 tile.start().catch((err) => {
855 tile.setStatus('gone', 'gone');
856 console.error(`mux tile ${t.id} (${t.label}): start failed`, err);
857 });
858 }
859 // appendChild MOVES an attached node: walking cfg in order lays the
860 // wall out in wall order, existing sockets undisturbed.
861 wallEl.appendChild(tile.el);
862 }
863 wallEl.appendChild(addTileEl); // the door stays last
864 }
865
866 async function removeTile(id) {
867 await fetch(`/tiles/${id}`, { method: 'DELETE' });
868 await refetchWall();
869 }
870 ```
871
872 `boot()` becomes: compile wasm → build `addTileEl` (div.tile.add with input; Enter handler POSTs, `.err` span for a 400's text) → `await refetchWall()`. Keep the existing `tiles` array only if other code indexes it — grep `tiles[` and `tiles.` in mux.js and migrate those sites to `tilesById` (the resize handler at ~line 1357 and keydown at ~1277 iterate tiles; `for (const t of tilesById.values())` replaces them).
873
874 - [ ] **Step 4: Verify in a real browser**
875
876 ```bash
877 $ZIG build 2>&1 | tail -3
878 ./zig-out/bin/muxd run --sock /tmp/dw.sock --shell /bin/sh & sleep 0.5
879 XDG_STATE_HOME=/tmp/dwstate ./zig-out/bin/muxweb --port 7699 &
880 ```
881
882 Open `http://127.0.0.1:7699`. Verify each, in order: empty wall shows only the add tile → type `--sock /tmp/dw.sock`, Enter → live tile appears and reaches `up` → type a shell command in it (zoom, type, unzoom) → add `--sock /tmp/dw.sock#two` → second tile, fresh session → reload the page → both tiles return → click a `×` → tile leaves, no console errors → re-add it → same session content returns (scrollback intact = it was a detach, not a kill) → type `bad name#x y` in add → inline error shown, input kept. Then kill both processes.
883
884 - [ ] **Step 5: Commit**
885
886 ```bash
887 git add web/mux.js web/index.html
888 git commit -m "feat: web wall management — reconcile by id, add tile, remove tile"
889 ```
890
891 ---
892
893 ### Task 5: browser — drag reorder + new-session-here
894
895 **Files:**
896 - Modify: `web/mux.js` (drag handlers on tiles; a `+` header action), `web/index.html` (drag affordance styles).
897
898 **Interfaces:**
899 - Consumes: Task 4's `refetchWall()`, `tilesById`, the add tile's input; Task 3's `PUT /tiles`.
900 - Produces: nothing later tasks need.
901
902 - [ ] **Step 1: Drag to reorder**
903
904 HTML5 drag on the tile element, header as the handle:
905
906 - In the Tile constructor: `this.el.draggable = true;` plus
907 - `dragstart`: `ev.dataTransfer.setData('text/plain', String(this.id)); this.el.classList.add('dragging');` — and if zoomed, cancel the drag (`ev.preventDefault()`), the zoomed tile is a terminal.
908 - `dragend`: remove the class.
909 - `dragover` (on each tile): `ev.preventDefault();` and mark drop side (`before`/`after` by `ev.offsetX < el.clientWidth / 2`).
910 - `drop`: move the dragged tile's element before/after this one in the DOM, then `putOrder()`.
911 - `putOrder()` reads the truth from the DOM:
912
913 ```js
914 async function putOrder() {
915 const wallEl = document.getElementById('wall');
916 const ids = [...wallEl.children]
917 .filter((el) => el !== addTileEl)
918 .map((el) => [...tilesById.entries()].find(([, t]) => t.el === el)?.[0])
919 .filter((id) => id !== undefined);
920 const res = await fetch('/tiles', { method: 'PUT', body: ids.join(',') });
921 // 409 = we dragged against a stale wall (another browser mutated it):
922 // the response body is the current truth, and refetch reconciles us.
923 if (!res.ok) await refetchWall();
924 }
925 ```
926
927 (Storing `tile.el.dataset.tileId = id` at construction makes the id lookup a one-liner — do that instead of the entries() scan if you prefer; either way, the DOM is the order source.)
928
929 - Styles: `.tile.dragging { opacity: 0.5; }` and a drop-side indicator (`.tile.drop-before { border-left: 2px solid #6ab0e0; }`, `.drop-after` likewise on the right; clear the classes on `dragleave`/`drop`).
930
931 - [ ] **Step 2: New session, same host**
932
933 A `+` button in each tile header (before `×`): click → `ev.stopPropagation()`; compute the host part of this tile's label (`label` minus a trailing `#session` — split at the LAST `#` only if this tile's `session` is non-empty, else the whole label), set the add input's value to `hostPart + '#'`, `addInput.focus()`. Typing the session name and Enter does the rest through the existing add path. Hidden while zoomed, same CSS treatment as `.close`.
934
935 - [ ] **Step 3: Verify in a real browser**
936
937 Same two-process setup as Task 4. Verify: three tiles up → drag first to last → order changes and survives a reload (persisted) → drag while another terminal `curl -X DELETE`s a tile mid-drag → drop lands on 409 → wall snaps to truth, no console errors → `+` on a `--sock /tmp/dw.sock` tile → input pre-filled `--sock /tmp/dw.sock#`, type `b`, Enter → new tile on session b. Kill both processes.
938
939 - [ ] **Step 4: Commit**
940
941 ```bash
942 git add web/mux.js web/index.html
943 git commit -m "feat: web wall — drag reorder, new-session-here"
944 ```
945
946 ---
947
948 ### Task 6: e2e gate + docs
949
950 **Files:**
951 - Modify: `test/e2e.sh` — new section after the existing web-hub blocks (grep `M-web` for where they end; follow the local conventions: `$OUT.<name>` files, `set +e`/`set -e` around expected-failure runs, `ok "..."` at section end, next free `$SOCK`/PID variable names).
952 - Modify: `docs/decisions.md` — one entry; `README.md` — the muxweb usage lines.
953
954 **Interfaces:**
955 - Consumes: Tasks 1–5. `wsclient --tile N` already takes the id (it just builds `/ws/N`); no fixture changes.
956
957 curl is REQUIRED, not guarded — same doctrine as nvim (`test/e2e.sh:28`: a `command -v` skip lets a box quietly not test this).
958
959 - [ ] **Step 1: Write the e2e section**
960
961 ```bash
962 # --- M-web (dyn): the wall is runtime state — add, remove, reorder,
963 # restore. XDG_STATE_HOME is pointed into the test dir so the wall file
964 # is this run's own, and so we can read it back as an artifact.
965 WALLSTATE="$TMP/wallstate" # use the section's tmp-dir convention
966 "$MUXD" run --sock "$SOCKDW" --shell /bin/sh > "$OUT.dw.d" 2>&1 &
967 DWPID=$!
968 wait_sock "$SOCKDW" "$OUT.dw.d" "dyn wall daemon never bound"
969
970 XDG_STATE_HOME="$WALLSTATE" "$MUXWEB" --port "$WPORT2" > "$OUT.dwh" 2>&1 &
971 W2PID=$!
972 wait_for "$OUT.dwh" "serving" 10 || {
973 echo "e2e FAIL: dyn wall: hub never reported serving"; cat "$OUT.dwh"; exit 1; }
974 ORIG="http://127.0.0.1:$WPORT2"
975
976 # Empty argv, empty state: the wall starts bare.
977 [ "$(curl -s "$ORIG/tiles")" = "[]" ] || {
978 echo "e2e FAIL: dyn wall: fresh hub wall not empty"; exit 1; }
979
980 # No Origin → refused before anything mutates (the CSRF gate).
981 RC=$(curl -s -o /dev/null -w '%{http_code}' -X POST --data "--sock $SOCKDW" "$ORIG/tiles")
982 [ "$RC" = "403" ] || { echo "e2e FAIL: dyn wall: originless POST got $RC, want 403"; exit 1; }
983
984 # Add two tiles: default session and session b. Ids are birth order.
985 R=$(curl -s -H "Origin: $ORIG" -X POST --data "--sock $SOCKDW" "$ORIG/tiles")
986 [ "$R" = '{"id":0}' ] || { echo "e2e FAIL: dyn wall: first add returned $R"; exit 1; }
987 R=$(curl -s -H "Origin: $ORIG" -X POST --data "--sock $SOCKDW#b" "$ORIG/tiles")
988 [ "$R" = '{"id":1}' ] || { echo "e2e FAIL: dyn wall: second add returned $R"; exit 1; }
989
990 # A bad spelling refuses with a reason, and the wall is untouched.
991 RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $ORIG" -X POST --data 'h#bad name' "$ORIG/tiles")
992 [ "$RC" = "400" ] || { echo "e2e FAIL: dyn wall: bad spelling got $RC, want 400"; exit 1; }
993
994 # The POSTed tile pumps a real session: marker typed via the CLI door,
995 # seen through the WS door — the M18 one-name-one-session doctrine.
996 { printf 'printf "dyn-%%s\\n" w1\n'; sleep 4; printf '\034'; } | \
997 timeout 40 "$MUX" --sock "$SOCKDW" > "$OUT.dwcli" 2>&1 &
998 wait_grid "$SOCKDW" "dyn-w1" "dyn wall: CLI marker"
999 set +e
1000 timeout 40 "$WSCLIENT" --port "$WPORT2" --tile 0 --out "$OUT.dwws" --err "$OUT.dwws.err" <<'EOF'
1001 attach 1 1
1002 expectstate up 10000
1003 expectgrid dyn-w1 15000
1004 dumpexit
1005 EOF
1006 RC=$?
1007 set -e
1008 [ "$RC" -eq 0 ] || { echo "e2e FAIL: dyn wall: wsclient exited $RC"; cat -v "$OUT.dwws.err"; exit 1; }
1009
1010 # Reorder, then confirm both the live order and the persisted file.
1011 RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $ORIG" -X PUT --data '1,0' "$ORIG/tiles")
1012 [ "$RC" = "204" ] || { echo "e2e FAIL: dyn wall: reorder got $RC"; exit 1; }
1013 curl -s "$ORIG/tiles" | grep -q '^\[{"id":1' || {
1014 echo "e2e FAIL: dyn wall: reorder not reflected"; curl -s "$ORIG/tiles"; exit 1; }
1015 # A stale order (naming a dead id) is a 409, not a guess.
1016 RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $ORIG" -X PUT --data '0,7' "$ORIG/tiles")
1017 [ "$RC" = "409" ] || { echo "e2e FAIL: dyn wall: stale reorder got $RC, want 409"; exit 1; }
1018
1019 # DELETE detaches; the daemon keeps the session (sessions= is stats' word).
1020 SESS_BEFORE=$("$MUXD" stats --sock "$SOCKDW" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p')
1021 RC=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $ORIG" -X DELETE "$ORIG/tiles/0")
1022 [ "$RC" = "204" ] || { echo "e2e FAIL: dyn wall: delete got $RC"; exit 1; }
1023 SESS_AFTER=$("$MUXD" stats --sock "$SOCKDW" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p')
1024 [ "$SESS_BEFORE" = "$SESS_AFTER" ] || {
1025 echo "e2e FAIL: dyn wall: DELETE killed a session ($SESS_BEFORE -> $SESS_AFTER)"; exit 1; }
1026
1027 # Restart with no argv: the wall file is the wall. Tile ids are fresh
1028 # (per-run), the SPELLINGS and order are what persisted: [b-tile] only
1029 # after the delete above removed the default tile... plus order proof:
1030 grep -qx -- "--sock $SOCKDW#b" "$WALLSTATE/mux/wall" || {
1031 echo "e2e FAIL: dyn wall: state file missing the surviving tile"; cat "$WALLSTATE/mux/wall"; exit 1; }
1032 kill "$W2PID" 2>/dev/null; wait "$W2PID" 2>/dev/null || true
1033 XDG_STATE_HOME="$WALLSTATE" "$MUXWEB" --port "$WPORT2" > "$OUT.dwh2" 2>&1 &
1034 W2PID=$!
1035 wait_for "$OUT.dwh2" "serving" 10
1036 curl -s "$ORIG/tiles" | grep -q "$SOCKDW#b" || {
1037 echo "e2e FAIL: dyn wall: restart lost the persisted wall"; curl -s "$ORIG/tiles"; exit 1; }
1038
1039 # Restart WITH argv: the explicit override replaces the file.
1040 kill "$W2PID" 2>/dev/null; wait "$W2PID" 2>/dev/null || true
1041 XDG_STATE_HOME="$WALLSTATE" "$MUXWEB" --sock "$SOCKDW" --port "$WPORT2" > "$OUT.dwh3" 2>&1 &
1042 W2PID=$!
1043 wait_for "$OUT.dwh3" "serving" 10
1044 grep -qx -- "--sock $SOCKDW" "$WALLSTATE/mux/wall" || {
1045 echo "e2e FAIL: dyn wall: argv did not replace the wall file"; cat "$WALLSTATE/mux/wall"; exit 1; }
1046
1047 kill "$W2PID" 2>/dev/null; wait "$W2PID" 2>/dev/null || true
1048 W2PID=""
1049 assert_stopped "$SOCKDW" "$DWPID" "dyn wall" "$OUT.dwstop"
1050 DWPID=""
1051 ok "the wall is runtime state: add, remove, reorder, restore, argv overrides"
1052 ```
1053
1054 Adapt variable names to the file's conventions (`$SOCKDW`, `$WPORT2`, `$TMP` — grep how the neighboring sections allocate socks/ports/outfiles and follow exactly; ports come from wherever `$WPORT` did). Register the new PIDs in the trap-time cleanup list the file keeps (grep `W1PID` for the pattern).
1055
1056 - [ ] **Step 2: Watch it fail, then pass**
1057
1058 Every new assertion must be seen firing once. Cheapest: run the section once with a deliberate break (e.g. point the first `[ "$R" = '{"id":0}' ]` at `'{"id":9}'`), see the FAIL line print, restore. Then run clean:
1059
1060 ```bash
1061 make e2e 2>&1 | tail -15; echo "e2e=$?"
1062 ```
1063
1064 Expected: the new `ok` line and `e2e=0` (capture `$?` before the pipe if you rearrange this).
1065
1066 - [ ] **Step 3: Docs**
1067
1068 - `docs/decisions.md`: append one entry, dated, in the file's voice — the wall is runtime state; ids are per-run, spellings persist; mutating verbs Origin-gate because text/plain POST is CSRF-simple; `--sock ` prefix joins the spelling grammar; remove = detach.
1069 - `README.md`: update the muxweb usage lines — argv optional, wall persists at `$XDG_STATE_HOME/mux/wall`, add/remove/reorder from the page.
1070
1071 - [ ] **Step 4: Full gate + commit**
1072
1073 ```bash
1074 $ZIG build check; echo "check=$?"
1075 make e2e 2>&1 | tail -5; # capture $? before piping
1076 git add test/e2e.sh docs/decisions.md README.md
1077 git commit -m "test: e2e — dynamic wall add/remove/reorder/restore; docs"
1078 ```
1079
1080 ---
1081
1082 ## Final integration
1083
1084 - [ ] Run the whole gate: `make build test e2e` (foreground, capture each `$?` before any pipe — a backgrounded gate hung three M18 agents).
1085 - [ ] Autosquash any `--fixup` commits: the history should read as the feature's story (wall module → hub → wire → page → gate).
1086 - [ ] `git-collab issue list` — if an issue tracks this gap, comment + close in ONE batch (each write costs a ~15s origin sync).
docs/superpowers/plans/2026-08-20-agent-forwarding.md
Old New
@@ -1,845 +0,0 @@
1 # SSH Agent Forwarding 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:** `mux -A HOST` makes the remote session shell's `SSH_AUTH_SOCK` work, backed by the attaching client's local agent, over every transport.
6
7 **Architecture:** muxd binds a stable per-session unix socket and injects `SSH_AUTH_SOCK` at shell spawn; agent traffic is four new protocol frames on the existing attach stream (transport stays dumb); each agent connection routes to the latest-active client that sent `agent_offer`, or is refused fast. The daemon is a blind per-channel byte pump — it never parses agent-protocol bytes.
8
9 **Tech Stack:** Zig 0.15.2 (pinned toolchain), POSIX sockets, existing frame plumbing in `src/protocol.zig`.
10
11 **Spec:** `docs/superpowers/specs/2026-08-20-agent-forwarding-design.md` — read it first; this plan argues from it. Phase 2 (`muxweb -A`) is NOT in this plan; it gets its own once phase 1 lands.
12
13 ## Global Constraints
14
15 - Toolchain: `ZIG=$HOME/Downloads/zig-x86_64-linux-0.15.2/zig`; system zig will NOT build this. `make build test` already point at it.
16 - Gate before every commit: `$ZIG build check` (fmt + unit tests + shell syntax + comment-claim refs). Capture `$?` before piping (`make test | tail` reports tail's exit code).
17 - Zig 0.15 std: `ArrayList` is unmanaged here (`.empty`, `list.append(alloc, x)`). Mirror surrounding code, not upstream docs.
18 - Layering (`build.zig` module table): `protocol` stays layer 0 and must NOT be imported by `proxy.zig` or the `quic*` modules. Nothing in this plan touches those files — if you find yourself editing them, stop; the design is being violated.
19 - Comments say *why*, not *how*. `zig build check` verifies comment symbol references resolve.
20 - `test/e2e.sh` pins its scenario count at the bottom (`OK_COUNT`); every new leg must bump the literal or the suite fails.
21 - The daemon never blocks on a client: all daemon→client bytes go through `queueFrame` (drop-on-backpressure, `src/server.zig:1087`). Never add a blocking write to a client fd.
22 - Commit style: story commits, `--fixup` during development, autosquash before delivery. The commit-msg hook stamps `Patch:` trailers itself.
23
24 ---
25
26 ### Task 1: Protocol frames + client-side routing arms
27
28 Four new `MsgType`s and their payload helpers, plus the `interact.Core.frame` arms — one task because adding enum variants deliberately breaks `interact.zig`'s exhaustive switch (`src/interact.zig:1531-1560`, see the comment at `:1535-1539`); the tree must compile at the end of the task.
29
30 **Files:**
31 - Modify: `src/protocol.zig` (enum at `:12-49`, helpers near `putU32`, tests at end)
32 - Modify: `src/interact.zig:1496-1562` (`Core.frame` switch)
33
34 **Interfaces:**
35 - Consumes: `MsgType` non-exhaustive enum, `writeFrame`/`readFrame`/`appendFrame` (`src/protocol.zig:106-133`), `Routed` enum (`src/interact.zig:885-908`).
36 - Produces (later tasks rely on these exact names):
37 - `MsgType.agent_offer = 0x0d` (client→daemon, empty payload)
38 - `MsgType.agent_data = 0x0e`, `MsgType.agent_close = 0x0f` (bidirectional; payload: u32 LE channel id, `agent_data` followed by opaque bytes)
39 - `MsgType.agent_open = 0x92` (daemon→client; payload: u32 LE channel id)
40 - `pub const agent_id_len = 4;`
41 - `pub const agent_data_max = 4096;` (max opaque bytes per `agent_data` frame — the frame-size cap the spec's port-forwarding note requires)
42 - `pub const agent_sock_env = "SSH_AUTH_SOCK";`
43 - `pub fn encodeAgentId(id: u32) [agent_id_len]u8`
44 - `pub fn decodeAgentId(payload: []const u8) !u32` (error on `payload.len < agent_id_len`; reuse whatever error the sibling decoders use — grep `fn decodeEndpointReply` and mirror its error exactly)
45 - `Core.frame` returns `.not_mine` for `agent_open`/`agent_data`/`agent_close` and `.skip` for `agent_offer`.
46
47 - [ ] **Step 1: Write the failing protocol tests**
48
49 Append to `src/protocol.zig`, pattern-matched on the pinned-value test at `:1504-1507` and the round-trip at `:1460-1478`:
50
51 ```zig
52 test "agent message values are pinned" {
53 // Wire compatibility: these bytes are forever. 0x83 stays a hole
54 // (agent-surface precedent); data/close sit in the low range despite
55 // flowing both ways — direction is which end reads, not the high bit.
56 try std.testing.expectEqual(@as(u8, 0x0d), @intFromEnum(MsgType.agent_offer));
57 try std.testing.expectEqual(@as(u8, 0x0e), @intFromEnum(MsgType.agent_data));
58 try std.testing.expectEqual(@as(u8, 0x0f), @intFromEnum(MsgType.agent_close));
59 try std.testing.expectEqual(@as(u8, 0x92), @intFromEnum(MsgType.agent_open));
60 }
61
62 test "agent_open/agent_data round-trip through writeFrame/readFrame" {
63 const alloc = std.testing.allocator;
64 const p = try std.posix.pipe();
65 defer std.posix.close(p[0]);
66 defer std.posix.close(p[1]);
67
68 try writeFrame(p[1], .agent_open, &encodeAgentId(7));
69 var data: [agent_id_len + 3]u8 = undefined;
70 @memcpy(data[0..agent_id_len], &encodeAgentId(7));
71 @memcpy(data[agent_id_len..], "abc");
72 try writeFrame(p[1], .agent_data, &data);
73
74 const open = (try readFrame(alloc, p[0])).?;
75 defer open.deinit(alloc);
76 try std.testing.expectEqual(MsgType.agent_open, open.type);
77 try std.testing.expectEqual(@as(u32, 7), try decodeAgentId(open.payload));
78
79 const d = (try readFrame(alloc, p[0])).?;
80 defer d.deinit(alloc);
81 try std.testing.expectEqual(@as(u32, 7), try decodeAgentId(d.payload));
82 try std.testing.expectEqualSlices(u8, "abc", d.payload[agent_id_len..]);
83 }
84
85 test "decodeAgentId refuses a short payload" {
86 try std.testing.expectError(error.BadFrame, decodeAgentId("abc"));
87 // ^ swap error.BadFrame for the sibling decoders' actual error name.
88 }
89 ```
90
91 - [ ] **Step 2: Run to verify failure**
92
93 Run: `$ZIG build test 2>&1 | tail -20`
94 Expected: compile error — `agent_offer` not a member of `MsgType`.
95
96 - [ ] **Step 3: Implement the protocol side**
97
98 In the `MsgType` enum (`src/protocol.zig:12-49`), following each half's comment style:
99
100 ```zig
101 // client -> daemon (after sessions_req = 0x0c)
102 agent_offer = 0x0d, // payload: empty; re-sent after EVERY attach when -A (a redial re-attaches)
103 agent_data = 0x0e, // payload: u32 LE channel id ++ up to agent_data_max opaque agent bytes; BOTH directions
104 agent_close = 0x0f, // payload: u32 LE channel id; BOTH directions
105 // daemon -> client (after sessions_reply = 0x91)
106 agent_open = 0x92, // payload: u32 LE channel id; daemon allocates ids, only the daemon opens
107 ```
108
109 Near the other `pub const` payload constants:
110
111 ```zig
112 /// Agent channel frames. The daemon is a blind pump: nothing here describes
113 /// the agent protocol, only the channel id prefix. agent_data_max caps a
114 /// single frame so a future bulk channel (port forwarding) cannot
115 /// head-of-line-block a delta — the spec's out-of-scope note relies on it.
116 pub const agent_id_len = 4;
117 pub const agent_data_max = 4096;
118 /// The one env name both ends agree on; readers get it from protocol like
119 /// sock_env/session_env (src/protocol.zig:804-805).
120 pub const agent_sock_env = "SSH_AUTH_SOCK";
121
122 pub fn encodeAgentId(id: u32) [agent_id_len]u8 {
123 var b: [agent_id_len]u8 = undefined;
124 std.mem.writeInt(u32, &b, id, .little);
125 return b;
126 }
127
128 pub fn decodeAgentId(payload: []const u8) !u32 {
129 if (payload.len < agent_id_len) return error.BadFrame; // mirror sibling decoders' error
130 return std.mem.readInt(u32, payload[0..agent_id_len], .little);
131 }
132 ```
133
134 - [ ] **Step 4: Fix the now-broken exhaustive switch in `interact.Core.frame`**
135
136 At `src/interact.zig:1531-1560`: add `.agent_open, .agent_data, .agent_close` to the `.not_mine` return (alongside `.exit_status, .taken_over, .sessions_reply`) and `.agent_offer` to the long `.skip` list. The `.not_mine` placement is the contract Task 6 builds on: the driver owns local fds, the core owns the replica, and agent frames never touch the replica (prediction/replica invariants).
137
138 - [ ] **Step 5: Extend an existing routing test**
139
140 Grep `not_mine` in `src/interact.zig`'s test block; extend the test that asserts `.sessions_reply` routes `.not_mine` (or its nearest neighbour) with the three new daemon→client types, and `.agent_offer` → `.skip`. Same construction, three more `expectEqual` lines each.
141
142 - [ ] **Step 6: Run tests to verify pass**
143
144 Run: `$ZIG build test 2>&1 | tail -5; echo "rc=$?"` — capture rc before any pipe in your own variations.
145 Expected: PASS.
146
147 - [ ] **Step 7: Commit**
148
149 ```bash
150 git add src/protocol.zig src/interact.zig
151 git commit -m "feat: agent channel frames — four types, a blind id prefix, and a size cap"
152 ```
153
154 ---
155
156 ### Task 2: Daemon activity ordering + the offer flag
157
158 "Latest wins" today is only a grid-size claim (`claimGrid`, `src/server.zig:2227-2236`) — there is NO activity ordering to route by. This task adds one, plus the `agent_offer` bookkeeping.
159
160 **Files:**
161 - Modify: `src/server.zig` (`ClientSlot` at `:228-259`, `handleFrame` arms at `:1675-1932`, `Server` fields near `:479`)
162
163 **Interfaces:**
164 - Consumes: `ClientSlot`, `handleFrame`'s `.attach`/`.input`/`.resize` arms, the activity doctrine comment at `src/server.zig:1735-1746`.
165 - Produces (Task 4 relies on these):
166 - `ClientSlot.activity: u64 = 0`, `ClientSlot.agent_offer: bool = false`
167 - `Server.activity_clock: u64 = 0`
168 - `fn bumpActivity(self: *Server, i: usize) void` — bumped in exactly the three activity verbs (`.attach`, `.input`, `.resize`), matching the doctrine comment.
169
170 - [ ] **Step 1: Write the failing test**
171
172 In `src/server.zig`'s test block, using the in-file test harness (pattern-match the tests around `attachNamed` at `:7105` for how a test attaches two clients over socketpairs):
173
174 ```zig
175 test "activity orders clients: attach, then whoever typed last" {
176 // Routing keys on this ordering (agentAnswerer, Task 4); ties are
177 // impossible because the clock is a counter, not a timestamp.
178 // ... harness setup per neighbouring tests: server + two attached clients A, B ...
179 // after both attach: B attached later
180 try std.testing.expect(srv.clients[b].?.activity > srv.clients[a].?.activity);
181 // A types
182 // ... send .input frame from A per neighbouring tests ...
183 try std.testing.expect(srv.clients[a].?.activity > srv.clients[b].?.activity);
184 }
185
186 test "agent_offer sets the flag; an unknown low type is ignored alive" {
187 // ... one attached client ...
188 // send .agent_offer (empty payload)
189 try std.testing.expect(srv.clients[i].?.agent_offer);
190 // send a raw frame with unmapped type byte 0x7e, then a stats_req:
191 // the client must still be there and answer — the ignore-unknown
192 // stance is what makes agent_offer cross-version safe.
193 try std.testing.expect(srv.clients[i] != null);
194 }
195 ```
196
197 (If a raw-byte send helper doesn't exist, write the 5-byte header + empty payload with `proto.writeAllFd` directly — header layout at `src/protocol.zig:4-8`.)
198
199 - [ ] **Step 2: Run to verify failure**
200
201 Run: `$ZIG build test 2>&1 | tail -10`
202 Expected: compile error — no field `activity`.
203
204 - [ ] **Step 3: Implement**
205
206 `ClientSlot` gains `activity: u64 = 0` and `agent_offer: bool = false`. `Server` gains `activity_clock: u64 = 0`.
207
208 ```zig
209 /// Monotonic, not a timestamp: the two-client e2e leg flips the answerer
210 /// by typing, and a millisecond clock ties under test speed.
211 fn bumpActivity(self: *Server, i: usize) void {
212 self.activity_clock += 1;
213 self.clients[i].?.activity = self.activity_clock;
214 }
215 ```
216
217 Call it in the `.attach` arm (after the attach succeeds), the `.input` arm (`:1734-1760`, next to `claimGrid` — the doctrine comment there already names these the activity verbs), and the `.resize` arm. Add the offer arm before the `else`:
218
219 ```zig
220 .agent_offer => {
221 self.clients[i].?.agent_offer = true;
222 },
223 ```
224
225 - [ ] **Step 4: Run tests to verify pass**
226
227 Run: `$ZIG build test 2>&1 | tail -5`
228 Expected: PASS.
229
230 - [ ] **Step 5: Commit**
231
232 ```bash
233 git add src/server.zig
234 git commit -m "feat: clients carry an activity order and an agent offer"
235 ```
236
237 ---
238
239 ### Task 3: The daemon-owned agent socket, born with the session
240
241 Per-session listener + `SSH_AUTH_SOCK` injection. The insertion point is `createSession` (`src/server.zig:578-624`), NOT `Options.extra_env` — the spawn plan is computed once and shared by every session (`:461-471`), and `createSession` already handles the one per-session pair (`MUX_SESSION`, `:596-604`). `SSH_AUTH_SOCK` is the second.
242
243 **Files:**
244 - Modify: `src/server.zig` (`Server.init` `:540-573`, `createSession` `:578-624`, `resolveSession` `:809-828`, `Session` struct `:295-458`, `reapSessions` teardown `:879-884`, `deinit` `:733-746`)
245
246 **Interfaces:**
247 - Consumes: `xdg.makeNewPrivateDir` (`src/xdg.zig:152` — exclusive 0700 mkdir, symlink-safe), `sockpath.max_sun_path = 107` (`src/sockpath.zig:16`), `Pty.EnvPair` (`src/pty.zig:32`), `proto.agent_sock_env` (Task 1), shellint's naming rationale (`src/shellint.zig:172-212`).
248 - Produces (Task 4 relies on these):
249 - `Server.agent_dir: ?[]const u8` — the private per-daemon directory, or null when forwarding is unavailable
250 - `Session.agent_listener: std.posix.fd_t = -1` — bound+listening, or -1
251 - `Session.agent_path: ?[:0]const u8` — the socket path (also the env value)
252
253 - [ ] **Step 1: Write the failing test**
254
255 Pattern-match the shell-spawning test at `src/server.zig:4178` (PS1 override) for harness mechanics:
256
257 ```zig
258 test "a session shell gets a live SSH_AUTH_SOCK it can stat" {
259 // ... server init per neighbouring tests, shell = /bin/sh ...
260 // ... attach a client so the default session spawns ...
261 const ses = srv.ses(0);
262 try std.testing.expect(ses.agent_path != null);
263 // The socket exists on disk and is a socket.
264 const st = try std.fs.cwd().statFile(ses.agent_path.?);
265 try std.testing.expectEqual(std.fs.File.Kind.unix_domain_socket, st.kind);
266 // And the shell actually received it: drive the pty like the PS1 test
267 // does — send `test -S "$SSH_AUTH_SOCK" && echo agentok\n`, then wait
268 // for "agentok" in the grid/capture the way that test waits.
269 }
270 ```
271
272 - [ ] **Step 2: Run to verify failure**
273
274 Run: `$ZIG build test 2>&1 | tail -10`
275 Expected: compile error — no field `agent_path`.
276
277 - [ ] **Step 3: Implement**
278
279 In `Server.init`, after the control socket binds: create the directory, mirroring `shellint.install`'s guessable-name defence (`src/shellint.zig:172-212` — same parent, same pid+random-half naming shape, same "degrade, never fail" posture):
280
281 ```zig
282 // dirname(sock_path) may be a shared /tmp (sockpath.defaultSockPath), so
283 // the dir gets an exclusive 0700 create and an unguessable name — the
284 // same reasoning shellint.install wrote down. Forwarding degrades to
285 // absent rather than failing the daemon, like shellint does.
286 ```
287
288 Compose `mux-agent-<pid>-<rand12hex>/` under `std.fs.path.dirname(opts.sock_path) orelse "."`, `xdg.makeNewPrivateDir` it, store as `Server.agent_dir`. Any error → `agent_dir = null`, daemon runs without forwarding.
289
290 Add a `Server` helper used by every session-creation site:
291
292 ```zig
293 /// Bind agent-<name>.sock in the daemon's private agent dir. null (never
294 /// an error) when the dir is absent or the composed path would overflow
295 /// sun_path (107) — sessions outlive forwarding.
296 fn bindAgentSock(self: *Server, name: []const u8) ?struct { fd: std.posix.fd_t, path: [:0]const u8 } {
297 ```
298
299 Compose `{agent_dir}/agent-{name}.sock` (`allocPrintZ`), guard `len <= sockpath.max_sun_path`, then socket/bind/listen — mirror however the control socket binds (grep the `std.net.Address.initUnix`/listen calls near `Server.init`); backlog small (8), `CLOEXEC`.
300
301 `createSession` grows a parameter `agent: ?struct { fd: std.posix.fd_t, path: [:0]const u8 }` and allocates `plan.env.len + 2` when present, appending after `MUX_SESSION`:
302
303 ```zig
304 env[plan.env.len + 1] = .{ .key = proto.agent_sock_env, .value = agent.?.path };
305 ```
306
307 Store `fd`/`path` on the returned `Session`. Update BOTH call sites (grep `createSession(` — `resolveSession` at `:823` plus the init-time one), each calling `self.bindAgentSock(name)` first.
308
309 Teardown in BOTH paths — this is hazard #8 from the code survey:
310 - `reapSessions` (`:879-884`): `close(agent_listener)` if `>= 0`, `std.fs.cwd().deleteFile(agent_path)` best-effort, free the path.
311 - `deinit` (`:733-746`): same per session, then `deleteTree(agent_dir)` next to the shim-dir removal at `:746`, and free `agent_dir`.
312
313 - [ ] **Step 4: Run tests to verify pass**
314
315 Run: `$ZIG build test 2>&1 | tail -5`
316 Expected: PASS, including the existing leak-checked server tests (the new allocations must be freed on both teardown paths or the allocator tests will catch it).
317
318 - [ ] **Step 5: Commit**
319
320 ```bash
321 git add src/server.zig
322 git commit -m "feat: every session is born with a daemon-owned agent socket"
323 ```
324
325 ---
326
327 ### Task 4: Daemon routing and the blind pump
328
329 The channel table, the accept path, refuse-fast, and both pump directions.
330
331 **Files:**
332 - Modify: `src/server.zig` (`pumpOnce` fds array `:908-1050`, `handleFrame` `:1675-1932`, `dropClient` `:1070`, new fields near `:479`)
333
334 **Interfaces:**
335 - Consumes: Tasks 1–3 (`agent_*` frames, `activity`/`agent_offer`, `Session.agent_listener`), `queueFrame` (`:1087` — drop-on-backpressure, never blocks), `proto.writeAllFd`.
336 - Produces (Tasks 6–8 rely on this behavior):
337 - Connect to `agent_path` with ≥1 offerer attached → that client receives `agent_open{id}`; bytes flow both ways as `agent_data{id, bytes}`; either side's close arrives as `agent_close{id}` / EOF.
338 - Connect with NO offerer on that session → immediate close (EOF to the connector). The socket always exists; only the answer comes and goes.
339 - Routing picks max `activity` among clients where `session == si and agent_offer`.
340
341 - [ ] **Step 1: Write the failing tests**
342
343 Four tests in `src/server.zig`, same harness as Task 2's:
344
345 ```zig
346 test "agent connect with no offerer is refused fast" {
347 // attach one client WITHOUT agent_offer; connect() to ses(0).agent_path
348 // (plain std.net.connectUnixSocket); pumpOnce; read on the connection
349 // returns 0 (EOF). Fast-fail is the spec's contract: ssh sees "agent
350 // refused" instead of a hang.
351 }
352
353 test "agent bytes pump both ways through a channel" {
354 // attach + send .agent_offer; connect to agent_path; pumpOnce;
355 // expect an .agent_open frame on the client fd, extract id;
356 // client sends .agent_data{id ++ "req"}; pumpOnce; read "req" off the
357 // agent connection; write "resp" on it; pumpOnce; expect
358 // .agent_data{id ++ "resp"} on the client fd;
359 // close the agent connection; pumpOnce; expect .agent_close{id}.
360 }
361
362 test "routing follows the latest-active offerer" {
363 // A and B both attach + offer; A types (.input); connect; the
364 // .agent_open must arrive on A's fd, and nothing on B's.
365 }
366
367 test "agent_data for an unknown or foreign id is dropped" {
368 // client sends .agent_data{id=999 ++ "x"}: no crash, no channel, the
369 // client survives — the unknown-frame stance, applied to channels.
370 }
371 ```
372
373 - [ ] **Step 2: Run to verify failure**
374
375 Run: `$ZIG build test 2>&1 | tail -10`
376 Expected: refuse-fast test FAILS (connection accepted by the listener backlog but never closed → read blocks/times out) or open test fails on no `.agent_open`.
377
378 - [ ] **Step 3: Implement**
379
380 `Server` fields:
381
382 ```zig
383 pub const max_agent_chans = 8;
384 const AgentChan = struct { fd: std.posix.fd_t, id: u32, client: usize, session: usize };
385 agent_chans: [max_agent_chans]?AgentChan = ..., // init like `clients` at :479
386 next_agent_id: u32 = 1,
387 ```
388
389 Routing:
390
391 ```zig
392 /// Latest-active client among those that offered: the latest-wins
393 /// doctrine applied to keys — the person typing is the person whose
394 /// agent signs. Decided per connection; an in-flight channel stays
395 /// pinned to its client.
396 fn agentAnswerer(self: *Server, si: usize) ?usize {
397 var best: ?usize = null;
398 for (self.clients, 0..) |c, i| {
399 const slot = c orelse continue;
400 if (slot.session != si or !slot.agent_offer) continue;
401 if (best == null or slot.activity > self.clients[best.?].?.activity) best = i;
402 }
403 return best;
404 }
405 ```
406
407 `pumpOnce` fds array (`:911-947`): two new index constants after `obs_base`, sizes added to the array literal, `quic_idx` stays `fds.len - 1`:
408
409 ```zig
410 const agent_listener_base = obs_base + max_observers; // 17
411 const agent_chan_base = agent_listener_base + max_sessions; // 21
412 // array grows: max_sessions + 1 + max_clients + max_observers
413 // + max_sessions + max_agent_chans + 1
414 ```
415
416 Register each session's `agent_listener` (or -1) and each channel's fd (or -1), `POLL.IN`. After poll, before the QUIC drain:
417
418 - Listener readable → `acceptAgent(si)`: `accept` with `SOCK.CLOEXEC`; `agentAnswerer(si)` orelse `close(fd)` (refuse fast); free channel slot orelse `close(fd)`; allocate `id = next_agent_id; next_agent_id +%= 1`; store; `queueFrame(target, .agent_open, &proto.encodeAgentId(id))` — on false the client died mid-call, close the fd and null the slot.
419 - Channel fd readable/HUP → read into `[proto.agent_id_len + proto.agent_data_max]u8` with the id pre-written in the first 4 bytes; `n == 0` or error → `closeAgentChan(slot, .notify)`; else `queueFrame(ch.client, .agent_data, buf[0 .. proto.agent_id_len + n])` (a false return means the client is gone; `dropClient` cleanup below already closed the channel — do not double-close).
420
421 `handleFrame` arms (before `else`):
422
423 ```zig
424 .agent_data => {
425 const id = proto.decodeAgentId(frame.payload) catch return;
426 const s = self.findAgentChan(id, i) orelse return; // unknown/foreign id: dropped
427 proto.writeAllFd(self.agent_chans[s].?.fd, frame.payload[proto.agent_id_len..]) catch
428 self.closeAgentChan(s, .notify);
429 },
430 .agent_close => {
431 const id = proto.decodeAgentId(frame.payload) catch return;
432 if (self.findAgentChan(id, i)) |s| self.closeAgentChan(s, .silent);
433 },
434 ```
435
436 `findAgentChan(id, owner)` matches BOTH id and `client == owner` — a client can only touch its own channels. `closeAgentChan(s, notify)` closes the fd, optionally `queueFrame(.agent_close, id)` to the owner, nulls the slot.
437
438 Cleanup hooks:
439 - `dropClient` (`:1070`): close every channel whose `client == i`, silently — the peer is gone.
440 - Session teardown (both paths from Task 3): close every channel whose `session == si`, `.notify`.
441
442 Add a note-comment where `queueFrame` is called for agent data: backpressure is connection-fatal by design (`pending_cap` → `dropClient`); agent messages are 1–2 KB so the 8 MiB cap is unreachable in practice, and drop-not-block is the daemon's standing contract.
443
444 - [ ] **Step 4: Run tests to verify pass**
445
446 Run: `$ZIG build test 2>&1 | tail -5`
447 Expected: PASS, all four.
448
449 - [ ] **Step 5: Commit**
450
451 ```bash
452 git add src/server.zig
453 git commit -m "feat: the daemon routes agent connections to whoever typed last"
454 ```
455
456 ---
457
458 ### Task 5: `mux -A` — parse, thread, offer
459
460 **Files:**
461 - Modify: `src/mux_main.zig` (usage `:24-50`, `ParseResult` `:54-80`, `parseArgs` `:125-212`, `main` `:213-352`)
462 - Modify: `src/wallview.zig` (`Entry` `:2172-2205`, `runAttach` `:2126-2168`, tile record, `sendAttach` `:657-672`)
463
464 **Interfaces:**
465 - Consumes: `Transport.writeFrame` (`src/client.zig:591`), `proto.MsgType.agent_offer`.
466 - Produces (Task 6 relies on these):
467 - `agent: bool = false` on all three `ParseResult` attach payloads (`attach`, `host`, `quic`)
468 - `wallview.runAttach(alloc, target, session, key, idle_ms, agent)` / `Entry.agent: bool = false`, carried onto the tile record next to `.session`
469 - `sendAttach` sends `.agent_offer` (empty payload) immediately after every `.attach` when the tile carries the flag — all three attach sites (`:910`, `:1007`, `:1227`) go through `sendAttach`, so a redial re-offers for free.
470
471 - [ ] **Step 1: Write the failing parse tests**
472
473 Extend the pure-parse tests (`fn parse` helper at `src/mux_main.zig:583`):
474
475 ```zig
476 test "-A rides every transport spelling" {
477 try std.testing.expect(parse(&.{ "mux", "-A", "somehost" }).host.agent);
478 try std.testing.expect(parse(&.{ "mux", "-A", "--sock", "/tmp/x.sock" }).attach.agent);
479 try std.testing.expect(parse(&.{ "mux", "quic://h:1", "-A" }).quic.agent);
480 try std.testing.expect(!parse(&.{ "mux", "somehost" }).host.agent);
481 }
482 ```
483
484 - [ ] **Step 2: Run to verify failure**
485
486 Run: `$ZIG build test 2>&1 | tail -10`
487 Expected: compile error — no field `agent`.
488
489 - [ ] **Step 3: Implement**
490
491 In `parseArgs`: `var agent = false;` and an explicit arm BEFORE the bare-host fall-through at `:172-176` (anything unrecognized starting with `-` is a usage error, so `-A` must be named):
492
493 ```zig
494 } else if (std.mem.eql(u8, a, "-A")) {
495 agent = true;
496 }
497 ```
498
499 Add `agent` to the three payload structs and every construction site; add one usage line (`:24-50`) matching the house voice, e.g. `-A forward this client's ssh-agent into the session (like ssh -A)`. Thread through `main`'s four `runAttach` calls (`:263`, `:277`, `:283`, `:345`), `Entry`, and the tile record (grep how `Entry`'s `session` reaches `t.r.session` and ride the same route). In `sendAttach`:
500
501 ```zig
502 try tr.writeFrame(.attach, proto.encodeAttachNamed(&buf, size.cols, size.rows, have_seq, have_epoch, t.r.session));
503 // The offer re-arms on every attach: a redial is a fresh attach, and the
504 // daemon's slot is fresh too. Empty payload; old daemons ignore it.
505 if (t.r.agent) try tr.writeFrame(.agent_offer, "");
506 ```
507
508 `muxa` gets nothing — grep `src/muxa.zig` for `agent` afterwards and confirm zero hits; the spec forbids it.
509
510 - [ ] **Step 4: Run tests to verify pass**
511
512 Run: `$ZIG build test 2>&1 | tail -5`
513 Expected: PASS.
514
515 - [ ] **Step 5: Commit**
516
517 ```bash
518 git add src/mux_main.zig src/wallview.zig
519 git commit -m "feat: mux -A offers this client's agent, on every transport"
520 ```
521
522 ---
523
524 ### Task 6: The client end of the pump
525
526 Per-channel fds to the local agent, joined into the tile pump's poll. Everything is thread-local to `pumpTile` — no locks, no shared state.
527
528 **Files:**
529 - Modify: `src/wallview.zig` (`pumpTile` `:923-1404`: pollfd assembly `:1036-1041`, `.not_mine` dispatch `:1230-1279`)
530 - Modify: `src/client.zig` (one small helper near `Transport`)
531
532 **Interfaces:**
533 - Consumes: `Transport.pollFd`/`writeFrame`/`readFrame` (`src/client.zig:565,591,651`), Task 1's frames, Task 4's daemon behavior.
534 - Produces:
535 - `client.connectAgent(path: []const u8) ?std.posix.fd_t` — blocking unix connect, `CLOEXEC`, null on any failure. Takes the path as a parameter, NOT from getenv — the parseArgs discipline (`src/spawn.zig:283`: tests stay environment-free); the caller passes `std.posix.getenv(proto.agent_sock_env)`.
536 - `pumpTile` answers `agent_open` by connecting (or refusing with `agent_close`), pumps `agent_data` both ways, caps reads at `proto.agent_data_max`, and drops all local channels on transport loss (the daemon's `dropClient` already closed its side).
537
538 - [ ] **Step 1: Write the failing helper test**
539
540 In `src/client.zig` tests (testtmp is already a test import):
541
542 ```zig
543 test "connectAgent: a live socket connects, a dead path returns null" {
544 // bind a throwaway unix socket in a testtmp dir; connectAgent(path)
545 // returns a valid fd (close it); connectAgent(path ++ ".gone")
546 // returns null — the daemon-side agent_close fallback depends on
547 // null, never an error, so a keyless client fails soft.
548 }
549 ```
550
551 - [ ] **Step 2: Run to verify failure**
552
553 Run: `$ZIG build test 2>&1 | tail -10`
554 Expected: compile error — `connectAgent` not defined.
555
556 - [ ] **Step 3: Implement the helper**
557
558 In `src/client.zig`, mirroring however `Transport.open`'s `.sock` arm dials a unix path (grep it — same sockaddr construction, same `CLOEXEC` habit); blocking connect is fine: the agent is local and answers in microseconds.
559
560 - [ ] **Step 4: Wire the pump**
561
562 Thread-local state at the top of `pumpTile`:
563
564 ```zig
565 const AgentLocal = struct { id: u32, fd: std.posix.fd_t };
566 var agent_locals: [8]?AgentLocal = @splat(null);
567 ```
568
569 Poll assembly replaces the fixed 2-array (`:1036-1041`):
570
571 ```zig
572 var fdbuf: [2 + agent_locals.len]std.posix.pollfd = undefined;
573 fdbuf[0] = .{ .fd = transport.pollFd(), .events = std.posix.POLL.IN, .revents = 0 };
574 fdbuf[1] = .{ .fd = t.wake_r, .events = std.posix.POLL.IN, .revents = 0 };
575 var nfds: usize = 2;
576 var at: [agent_locals.len]usize = undefined; // fdbuf index-2 -> agent_locals slot
577 for (agent_locals, 0..) |c, s| if (c) |ch| {
578 at[nfds - 2] = s;
579 fdbuf[nfds] = .{ .fd = ch.fd, .events = std.posix.POLL.IN, .revents = 0 };
580 nfds += 1;
581 };
582 _ = std.posix.poll(fdbuf[0..nfds], transport.timeoutMs(100)) catch return;
583 ```
584
585 (References to `fds[0]`/`fds[1]` below the assembly rename to `fdbuf` — mechanical.)
586
587 New `.not_mine` arms (after `.sessions_reply`, `:1261-1276`, same shape):
588
589 ```zig
590 .agent_open => {
591 const id = proto.decodeAgentId(frame.payload) catch break :frames;
592 const fd = client.connectAgent(std.posix.getenv(proto.agent_sock_env) orelse "") orelse {
593 // No local agent: close the channel, git fails fast remotely.
594 transport.writeFrame(.agent_close, &proto.encodeAgentId(id)) catch {};
595 break :frames;
596 };
597 storeLocal(&agent_locals, id, fd) orelse {
598 std.posix.close(fd);
599 transport.writeFrame(.agent_close, &proto.encodeAgentId(id)) catch {};
600 };
601 },
602 .agent_data => {
603 const id = proto.decodeAgentId(frame.payload) catch break :frames;
604 if (findLocal(&agent_locals, id)) |s| {
605 proto.writeAllFd(agent_locals[s].?.fd, frame.payload[proto.agent_id_len..]) catch
606 closeLocal(&agent_locals, s, &transport);
607 }
608 },
609 .agent_close => {
610 const id = proto.decodeAgentId(frame.payload) catch break :frames;
611 if (findLocal(&agent_locals, id)) |s| {
612 std.posix.close(agent_locals[s].?.fd);
613 agent_locals[s] = null;
614 }
615 },
616 ```
617
618 (`storeLocal`/`findLocal`/`closeLocal` are three tiny file-local fns — first-free-slot store, linear find by id, close+`writeFrame(.agent_close)`+null.)
619
620 After the transport-frame drain, service agent fds: for each set `revents` at index ≥ 2, read up to `proto.agent_data_max` into a buffer with `encodeAgentId(id)` pre-written in the first 4 bytes; `n > 0` → `transport.writeFrame(.agent_data, buf[0 .. 4 + n])`; `n == 0`/error → `closeLocal`. On `.closed`/redial (`:1180` area): close ALL locals and null the table before `redial` — the old daemon connection owns those channels and `dropClient` already reaped them server-side.
621
622 - [ ] **Step 5: Run tests + build**
623
624 Run: `$ZIG build test 2>&1 | tail -5` then `$ZIG build 2>&1 | tail -3`
625 Expected: PASS / clean build. (The full loop is proven by Task 7's e2e — there is deliberately no mock-agent unit test here; mocks are assumptions.)
626
627 - [ ] **Step 6: Commit**
628
629 ```bash
630 git add src/client.zig src/wallview.zig
631 git commit -m "feat: the tile pump answers agent channels from the local agent"
632 ```
633
634 ---
635
636 ### Task 7: e2e — the real chain, and the fast refusal
637
638 Real `ssh-agent`, real key, real `ssh-add -l` through the daemon. No mocks. The suite requires `ssh-agent`/`ssh-add`/`ssh-keygen` on PATH (openssh is a given on every box this project targets; the daemon itself needs none of them).
639
640 **Files:**
641 - Modify: `test/e2e.sh` (new leg near the end before the pin; resource declarations `~:88-250`; cleanup trap `:923-1090`; counter pin `:6975-6984`; per-scenario prose block `:6950-6974`)
642
643 **Interfaces:**
644 - Consumes: everything above; harness helpers `wait_sock` (`:472`), `ok` (`:786`), `rm_swept` (`:827`); ptyclient script verbs (`test/ptyclient.zig:145-181`); the wall-clock ceiling pattern (`:3819-3872`); the M12 echo rule (`:3122-3127`).
645 - Produces: two `ok` scenarios; `OK_COUNT` pin 55 → 57.
646
647 - [ ] **Step 1: Declare resources**
648
649 New globals next to their kin: `SOCK48` (daemon), `AGENT48` (agent socket path under `$OUT`), `AGENT48PID=""`, `D42PID=""`. Add `D42PID` to the kill list (`:1000-1050`), `SOCK48` to the `muxd stop` backstop (`:1032-1057`) and `reap_briefly` (`:1063-1069`) lists, `AGENT48PID` kill (`[ -n "$AGENT48PID" ] && kill "$AGENT48PID" 2>/dev/null || true`) in the trap, and the key/capture files to the `rm -f` tail.
650
651 - [ ] **Step 2: Write the positive leg**
652
653 The fingerprint needle is echo-proof by the M12 rule: the typed line `ssh-add -l` cannot spell a SHA256 fingerprint.
654
655 ```sh
656 # --- agent forwarding: a key in the client's agent answers in the session ---
657 "$MUXD" run --sock "$SOCK48" --shell /bin/sh > "$OUT.agt.d" 2>&1 &
658 D42PID=$!
659 wait_sock "$SOCK48" "$OUT.agt.d" "agent daemon never bound"
660
661 ssh-agent -a "$AGENT48" > "$OUT.agt.env" 2>&1
662 AGENT48PID=$(sed -n 's/.*SSH_AGENT_PID=\([0-9]*\).*/\1/p' "$OUT.agt.env")
663 ssh-keygen -q -t ed25519 -N '' -f "$OUT.agt.key"
664 SSH_AUTH_SOCK="$AGENT48" ssh-add "$OUT.agt.key" 2>/dev/null
665 FP48=$(ssh-keygen -lf "$OUT.agt.key" | awk '{print $2}')
666
667 set +e
668 SSH_AUTH_SOCK="$AGENT48" timeout 40 "$PTYCLIENT" --cols 80 --rows 24 \
669 --out "$OUT.agt" --err "$OUT.agt.err" \
670 -- "$MUX" -A --sock "$SOCK48" > "$OUT.agt.log" 2>&1 <<EOF
671 expect \x1b[?1049h 15000
672 settle 400 15000
673 send ssh-add -l\n
674 expect $FP48 15000
675 send exit\n
676 waitexit 10000
677 EOF
678 RC=$?
679 set -e
680 [ "$RC" -eq 0 ] || {
681 echo "e2e FAIL: agent: the client's key never answered ssh-add -l:"
682 cat "$OUT.agt.log"; cat "$OUT.agt"; exit 1; }
683 ok "agent forwarding: ssh-add -l in the session lists the client's key"
684 ```
685
686 (Unquoted heredoc on purpose — `$FP48` must interpolate; the precedent and its warning live at `:3132-3135`.)
687
688 - [ ] **Step 3: Write the fast-refusal leg**
689
690 Same daemon, fresh session, NO `-A`, no `SSH_AUTH_SOCK` for the client. Ceiling-only wall-clock (the `:3843-3862` comment is the precedent for arguing the bound; here only the ceiling is load-bearing — refusal should be ~0 ms, the 5 s ceiling just proves it did not sit in a retry loop):
691
692 ```sh
693 AR0=$(date +%s%N)
694 set +e
695 timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.agtn" --err "$OUT.agtn.err" \
696 -- "$MUX" --sock "$SOCK48" --session noagent > "$OUT.agtn.log" 2>&1 <<'EOF'
697 expect \x1b[?1049h 15000
698 settle 400 15000
699 send ssh-add -l 2>&1; echo agtrc=$?\n
700 expect agtrc= 15000
701 send exit\n
702 waitexit 10000
703 EOF
704 RC=$?
705 set -e
706 AR1=$(date +%s%N)
707 AMS=$(( (AR1 - AR0) / 1000000 ))
708 [ "$RC" -eq 0 ] || { echo "e2e FAIL: agent-refusal: leg exited $RC"; cat "$OUT.agtn.log"; exit 1; }
709 grep -q "agtrc=0" "$OUT.agtn" && {
710 echo "e2e FAIL: agent-refusal: ssh-add succeeded with no offerer"; cat "$OUT.agtn"; exit 1; }
711 [ "$AMS" -lt 15000 ] || {
712 echo "e2e FAIL: agent-refusal: took ${AMS}ms — a refused connect must fail fast, not hang"
713 exit 1; }
714 assert_stopped "$SOCK48" "$D42PID" "agent" "$OUT.agtstop"
715 D42PID=""
716 ok "agent forwarding: no offerer means a fast refusal, not a hang"
717 ```
718
719 - [ ] **Step 4: Bump the pins**
720
721 `OK_COUNT` check `55` → `57` (`:6975-6984`), and add two lines to the per-scenario prose block (`:6950-6974`) saying why neither leg carries a convergence point (they assert the shell's output through the normal replica path, which the surrounding legs already converge on).
722
723 - [ ] **Step 5: Run the suite**
724
725 Run: `make e2e > /tmp/claude-e2e.log 2>&1; rc=$?; tail -15 /tmp/claude-e2e.log; echo "rc=$rc"`
726 Expected: `e2e OK (57 scenarios, 35 convergence points)`, rc=0. Run in the foreground and read the rc — never background-and-hope.
727
728 - [ ] **Step 6: Commit**
729
730 ```bash
731 git add test/e2e.sh
732 git commit -m "test: a real key crosses the wire; a keyless session refuses fast"
733 ```
734
735 ---
736
737 ### Task 8: e2e — the fingerprint follows whoever typed last
738
739 Two clients, two agents, two keys, one session. Both replicate the same grid, so the two ptyclient scripts can sequence each other with typed markers — no sleeps, no races.
740
741 **Files:**
742 - Modify: `test/e2e.sh` (leg after Task 7's; resources `SOCK49`, `D43PID`, `AGENT49A`, `AGENT49B`, two agent pids, two keys; all cleanup lists; pin 57 → 58)
743
744 **Interfaces:**
745 - Consumes: Task 4's routing (`activity` max among offerers), Task 7's leg shape.
746 - Produces: one `ok` scenario proving the spec's "the fingerprint flips with activity".
747
748 - [ ] **Step 1: Write the leg**
749
750 Setup mirrors Task 7 twice (two `ssh-agent -a` sockets, two ed25519 keys, `FPA`/`FPB`). Both clients attach `-A` to the same daemon and default session. Client A backgrounded, client B foreground; both scripts:
751
752 ```sh
753 # client A (backgrounded, its own out/err/log files):
754 # expect \x1b[?1049h 15000
755 # settle 400 15000
756 # send ssh-add -l\n # A typed last -> A's agent answers
757 # expect $FPA 15000
758 # send printf 'flip-%s\n' now\n # the baton; B is watching the same grid
759 # expect flip-now 15000
760 # expect $FPB 20000 # B's query, replicated back to A's screen
761 # send exit\n # session ends for both
762 # waitexit 15000
763 # client B (foreground):
764 # expect \x1b[?1049h 15000
765 # expect flip-now 20000 # only NOW does B act, so B's input is last
766 # send ssh-add -l\n
767 # expect $FPB 15000
768 # waitexit 15000
769 ```
770
771 Write it in the harness's real syntax (two `timeout 40 "$PTYCLIENT" ... <<EOF` blocks, A with `&` + pid var, unquoted heredocs for the `$FP` needles), then `wait` on A's pid and check both RCs like the tp1 tear leg does (grep `ptyclient: done` for the barrier precedent if sequencing gets racy). Assert `grep -q "$FPA" "$OUT.flip.a"` and `grep -q "$FPB" "$OUT.flip.b"`, and that `$FPA` does NOT appear after the flip marker in B's capture beyond the replicated history (the load-bearing assertion is B's `expect $FPB` succeeding — B typed last, B's agent answered).
772
773 - [ ] **Step 2: Bump the pin**
774
775 `57` → `58`, plus its why-no-convergence line.
776
777 - [ ] **Step 3: Run the suite**
778
779 Run: `make e2e > /tmp/claude-e2e.log 2>&1; rc=$?; tail -15 /tmp/claude-e2e.log; echo "rc=$rc"`
780 Expected: `e2e OK (58 scenarios, 35 convergence points)`, rc=0.
781
782 - [ ] **Step 4: Commit**
783
784 ```bash
785 git add test/e2e.sh
786 git commit -m "test: the signing agent follows whoever typed last"
787 ```
788
789 ---
790
791 ### Task 9: Docs, full gate, delivery
792
793 **Files:**
794 - Modify: `README.md` (a `-A` paragraph in the remote-over-ssh / QUIC sections)
795 - Modify: `CLAUDE.md` (one invariant line)
796 - Modify: `docs/decisions.md` (append the decision entry)
797 - Modify: `docs/superpowers/specs/2026-08-20-agent-forwarding-design.md` (status line: phase 1 shipped)
798
799 **Interfaces:** none — prose and the gate.
800
801 - [ ] **Step 1: README**
802
803 Short paragraph in the house voice: `mux -A HOST` forwards your local ssh-agent into the session — the daemon owns a stable socket, so there is no tmux-style stale `SSH_AUTH_SOCK`, ever; the signer is whoever typed last among `-A` clients; without any `-A` client attached, agent use fails fast. Same threat model as `ssh -A`, hence the same opt-in flag. `muxa` never forwards.
804
805 - [ ] **Step 2: CLAUDE.md invariant**
806
807 Add to the invariants list:
808
809 ```markdown
810 - **Agent forwarding is opt-in (`-A`) and the daemon pumps blind.** The
811 per-session agent socket always exists; the latest-active `-A` client
812 answers. `muxa` and browsers never forward; frames, never transport.
813 ```
814
815 - [ ] **Step 3: decisions.md entry**
816
817 Append per that file's format (grep a recent entry for the shape): date, decision (daemon-owned socket, frames over transport, latest-active-offerer routing, opt-in), the measurement (e2e timings from the refusal leg), and the port-forwarding generalization note.
818
819 - [ ] **Step 4: Full gate, foreground, rc captured**
820
821 ```bash
822 $ZIG build check; echo "check rc=$?"
823 make test > /tmp/claude-t.log 2>&1; rc=$?; tail -5 /tmp/claude-t.log; echo "test rc=$rc"
824 make e2e > /tmp/claude-e.log 2>&1; rc=$?; tail -5 /tmp/claude-e.log; echo "e2e rc=$rc"
825 make agent > /tmp/claude-a.log 2>&1; rc=$?; tail -5 /tmp/claude-a.log; echo "agent rc=$rc"
826 ```
827
828 All rc=0 or stop and fix. Then the cross-version gate (the spec's compat claim — offer ignored by an old daemon — is what this proves):
829
830 ```bash
831 make xversion-build xversion > /tmp/claude-x.log 2>&1; rc=$?; tail -10 /tmp/claude-x.log; echo "xversion rc=$rc"
832 ```
833
834 `XVER_OLD_WORKTREE` defaults to `..` — if no old checkout lives there, prepare one per the Makefile comment (`Makefile:40-55`) before running.
835
836 - [ ] **Step 5: Tidy history and commit docs**
837
838 ```bash
839 git add README.md CLAUDE.md docs/decisions.md docs/superpowers/specs/2026-08-20-agent-forwarding-design.md
840 git commit -m "docs: agent forwarding — opt-in, daemon-blind, latest wins"
841 # then: autosquash any fixups accumulated along the way
842 GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash <base>
843 ```
844
845 The final history should read as the feature's story: frames → daemon socket → routing → client flag → client pump → proof → docs.
docs/superpowers/plans/2026-08-22-status-bar.md
Old New
@@ -1,544 +0,0 @@
1 # Status Bar 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:** A zoomed `mux` session keeps its tile's bar on the bottom terminal row — `mux LABEL [state]` left, the latest notice right — and the session attaches one row shorter.
6
7 **Architecture:** The wall already paints a reverse-video bar per stripe (`paintLabelLocked`, `src/wallview.zig`). Zoomed mode gains the same bar at the bottom row. The bar's text composition and bytes move to `paint.zig` (pure fd-out). `Core` learns it owns `rows-1` (`bar_rows`) and tells its sink when a paint wiped the screen so the bar can be re-laid. The two ad-hoc channels — `Core.banner`/`paintBanner` and the deferred `takeNotice` — collapse into the bar's notice slot.
8
9 **Tech Stack:** Zig 0.15.2 (`ZIG=$HOME/Downloads/zig-x86_64-linux-0.15.2/zig`), ghostty-vt engine as the test oracle, `test/render.zig` + `test/e2e.sh` for end-to-end.
10
11 **Spec:** `docs/superpowers/specs/2026-08-22-status-bar-design.md`
12
13 ## Global Constraints
14
15 - `make check` before every commit; `make ci` before delivery. Capture `$?` before piping.
16 - Never read `src/wallview.zig`/`src/interact.zig` whole — `grep -n` then `sed -n 'A,Bp'`.
17 - Unit tests assert against an engine oracle (replay bytes into `Engine`, judge the grid), not byte grep, wherever a grid is the claim.
18 - Comments say why. No history codenames in `src/` comments (`zig build check` gates it).
19 - No bar without a tty (`Shared.is_tty` false → full rows, nothing painted). No flag, no env var.
20 - Bar text is ASCII only; it is byte-truncated.
21 - Any hand rig exports `XDG_STATE_HOME` to the scratchpad first.
22 - Commit per task; autosquash before delivery.
23
24 ---
25
26 ### Task 1: Bar composition and painter in `paint.zig`
27
28 **Files:**
29 - Modify: `src/paint.zig` (add near `bannerText`, ~line 190)
30 - Test: `src/paint.zig` (inline tests)
31
32 **Interfaces:**
33 - Produces:
34 - `pub fn barText(buf: []u8, cols: u16, left: []const u8, right: []const u8) []const u8` — one line, at most `cols` bytes and at most `buf.len`. `left` first, truncated to fit; `right` takes what is left of the line, right-aligned with at least one space of gap, or is dropped whole. Plain text in, plain text out.
35 - `pub fn paintBar(out_fd: std.posix.fd_t, cols: u16, row: u16, text: []const u8) void` — writes `sync_begin \x1b[{row};1H \x1b[7m {text} {spaces to cols} \x1b[0m sync_end`. `row` is 1-based. Best-effort: write errors are swallowed, as `paintBanner` does today.
36
37 - [ ] **Step 1: Write the failing tests**
38
39 Append to `src/paint.zig`:
40
41 ```zig
42 test "barText: left wins, right takes the remainder right-aligned" {
43 var buf: [64]u8 = undefined;
44 // 20 cols: "mux a#0 [up]" is 12, leaves 8; "[n]" right-aligned with a gap.
45 const t = barText(&buf, 20, "mux a#0 [up]", "[n]");
46 try std.testing.expectEqualStrings("mux a#0 [up] [n]", t);
47 // Right that cannot fit with a gap is dropped, not squeezed into the left.
48 const t2 = barText(&buf, 14, "mux a#0 [up]", "[notice]");
49 try std.testing.expectEqualStrings("mux a#0 [up]", t2);
50 // Left wider than the line is cut at cols.
51 const t3 = barText(&buf, 5, "mux a#0 [up]", "");
52 try std.testing.expectEqualStrings("mux a", t3);
53 // buf shorter than cols bounds the output too.
54 var small: [6]u8 = undefined;
55 const t4 = barText(&small, 80, "mux a#0 [up]", "[n]");
56 try std.testing.expect(t4.len <= 6);
57 }
58
59 test "paintBar lands on the named row, full width, and nowhere else" {
60 const alloc = std.testing.allocator;
61 const pipe = try std.posix.pipe();
62 defer std.posix.close(pipe[0]);
63 paintBar(pipe[1], 10, 3, "mux x [up]");
64 std.posix.close(pipe[1]);
65 var out: [512]u8 = undefined;
66 const n = try std.posix.read(pipe[0], &out);
67 // Oracle: replay into a 10x3 engine; row 3 is the bar, rows 1-2 untouched.
68 var e = try Engine.init(alloc, .{ .cols = 10, .rows = 3 });
69 defer e.deinit();
70 e.feed("r1\r\nr2\r\n");
71 e.feed(out[0..n]);
72 const plain = try e.dumpPlain(alloc);
73 defer alloc.free(plain);
74 try std.testing.expect(std.mem.indexOf(u8, plain, "mux x [up]") != null);
75 try std.testing.expect(std.mem.startsWith(u8, plain, "r1"));
76 // Padded with spaces, not erase-to-EOL: the inverse attribute must reach the edge.
77 try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[K") == null);
78 try std.testing.expect(std.mem.endsWith(u8, out[0..n], "\x1b[0m\x1b[?2026l"));
79 }
80 ```
81
82 - [ ] **Step 2: Run to verify they fail**
83
84 Run: `make test 2>&1 | tail -5` — expected: compile error `undeclared identifier 'barText'`.
85
86 - [ ] **Step 3: Implement**
87
88 Add to `src/paint.zig` after `bannerText`:
89
90 ```zig
91 /// One bar line. The left side is the tile's identity and state and wins
92 /// the space; the notice on the right is news that can be re-said, so it
93 /// takes what is left or nothing. Plain text only: byte truncation must
94 /// never split an escape.
95 pub fn barText(buf: []u8, cols: u16, left: []const u8, right: []const u8) []const u8 {
96 const width = @min(@as(usize, cols), buf.len);
97 const l = left[0..@min(left.len, width)];
98 @memcpy(buf[0..l.len], l);
99 // Gap of one: a notice glued to the state word reads as part of it.
100 if (right.len == 0 or l.len + 1 + right.len > width) return buf[0..l.len];
101 const start = width - right.len;
102 @memset(buf[l.len..start], ' ');
103 @memcpy(buf[start..width], right);
104 return buf[0..width];
105 }
106
107 /// A full-width inverse bar on one terminal row. Padded with spaces rather
108 /// than \x1b[K: erase-to-EOL fills with the background colour, not the
109 /// inverse attribute, on most terminals — the bar would end where the text
110 /// does. Best-effort, like every status paint.
111 pub fn paintBar(out_fd: std.posix.fd_t, cols: u16, row: u16, text: []const u8) void {
112 var out: [1024]u8 = undefined;
113 var fbs = std.io.fixedBufferStream(&out);
114 const w = fbs.writer();
115 w.print(sync_begin ++ "\x1b[{d};1H\x1b[7m{s}", .{ row, text }) catch return;
116 var i: usize = text.len;
117 while (i < cols) : (i += 1) w.writeByte(' ') catch break;
118 w.writeAll("\x1b[0m" ++ sync_end) catch return;
119 proto.writeAllFd(out_fd, fbs.getWritten()) catch {};
120 }
121 ```
122
123 Check `sync_begin`/`sync_end` names: `grep -n "sync_begin\|sync_end\|2026l" src/paint.zig | head`. If the close constant has another name, use it — the bracket must be the one shared definition the module header promises.
124
125 - [ ] **Step 4: Run tests**
126
127 Run: `make test 2>&1 | tail -5` — expected: all pass.
128
129 - [ ] **Step 5: Commit**
130
131 ```bash
132 git add src/paint.zig && git commit -m "feat(paint): barText/paintBar — one bar composer and painter"
133 ```
134
135 ---
136
137 ### Task 2: The view is one row shorter than the tty
138
139 **Files:**
140 - Modify: `src/interact.zig` (`Core` fields ~line 1271, `adoptSize` ~1396, `winch` ~1631)
141 - Modify: `src/wallview.zig` (`Shared` ~line 290, `sendAttach` ~1028, `initSized` call ~1499, promote ~1674–1690, winch follow-up ~2038–2044)
142 - Test: `src/wallview.zig`, `src/interact.zig` inline tests
143
144 **Interfaces:**
145 - Produces:
146 - `Core.bar_rows: u16 = 0` — rows at the bottom the Core must not paint or claim. `Core.winch` subtracts it from the measured tty size before comparing/sending.
147 - `pub fn fullSize(self: *const Core) proto.Size` — `size` with `bar_rows` added back (what the terminal measures). Named `fullSize`, not `ttySize`: a free `interact.ttySize(fd)` already exists.
148 - wallview `pub const bar_rows: u16 = 1;` — the one constant the `-1` comes from.
149 - `Shared.viewSize(self: *const Shared) proto.Size` — `size` with `rows - bar_rows` when `is_tty`, saturating at 1 row; unchanged when not a tty.
150
151 - [ ] **Step 1: Write the failing tests**
152
153 In `src/wallview.zig` near the other `Shared{...}` tests (~line 3560):
154
155 ```zig
156 test "viewSize: a tty keeps one row for the bar, a pipe keeps none" {
157 const tty = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = true };
158 try std.testing.expectEqual(proto.Size{ .cols = 80, .rows = 23 }, tty.viewSize());
159 const pipe = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
160 try std.testing.expectEqual(proto.Size{ .cols = 80, .rows = 24 }, pipe.viewSize());
161 // Never zero rows: a 1-row tty is a bar and no grid, not an invalid size.
162 const tiny = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 1 }, .is_tty = true };
163 try std.testing.expectEqual(@as(u16, 1), tiny.viewSize().rows);
164 }
165 ```
166
167 In `src/interact.zig`, next to the nearest `test "` after `adoptSize`:
168
169 ```zig
170 test "Core.fullSize adds the bar rows back" {
171 var core = try Core.initSized(std.testing.allocator, -1, -1, .{ .cols = 80, .rows = 23 });
172 defer core.deinit();
173 core.bar_rows = 1;
174 try std.testing.expectEqual(proto.Size{ .cols = 80, .rows = 24 }, core.fullSize());
175 }
176 ```
177
178 - [ ] **Step 2: Run to verify they fail**
179
180 Run: `make test 2>&1 | tail -5` — expected: `no field or member function named 'viewSize'`.
181
182 - [ ] **Step 3: Implement**
183
184 `src/wallview.zig`, file scope: `pub const bar_rows: u16 = 1;`. In `Shared` (grep `const Shared = struct`):
185
186 ```zig
187 /// What a zoomed session gets: the terminal less the bar's row. Not a
188 /// tty, no bar, so the full size — a piped `mux` still paints 80x24.
189 pub fn viewSize(self: *const Shared) proto.Size {
190 if (!self.is_tty) return self.size;
191 return .{ .cols = self.size.cols, .rows = @max(1, self.size.rows -| bar_rows) };
192 }
193 ```
194
195 Replace `t.shared.size` with `t.shared.viewSize()` at exactly these sites (verify each by grep first):
196 - `sendAttach` (~1030): `if (zoomed) t.shared.viewSize() else .{ .cols = 0, .rows = 0 }`.
197 - `Core.initSized(..., t.shared.size)` (~1503) → `t.shared.viewSize()`; right after: `core.bar_rows = if (t.shared.is_tty) bar_rows else 0;`.
198 - Promote compare + `adoptSize` (~1674): `const want = t.shared.viewSize();` compare against `want`, `core.adoptSize(want)`.
199 - Promote resize frame (~1690): `proto.encodeSize(want.cols, want.rows)`.
200 - Winch follow-up (~2043): `setWallSize(t.shared, core.fullSize());` — `shared.size` keeps the FULL size; the wall's stripes are laid out on the whole terminal.
201
202 `src/interact.zig`, `Core`:
203
204 ```zig
205 /// Rows at the bottom of the terminal that are not this session's:
206 /// the wall's bar. `size` is what the session gets; `fullSize` is what
207 /// the terminal measures. Zero for a pipe and for the plain tests.
208 bar_rows: u16 = 0,
209
210 pub fn fullSize(self: *const Core) proto.Size {
211 return .{ .cols = self.size.cols, .rows = self.size.rows + self.bar_rows };
212 }
213 ```
214
215 In `winch`, after `const new_size = ttySize(self.out_fd) orelse return .ok;`:
216
217 ```zig
218 const view: proto.Size = .{ .cols = new_size.cols, .rows = @max(1, new_size.rows -| self.bar_rows) };
219 ```
220 and use `view` for the compare, `self.size = view`, and `encodeSize(view.cols, view.rows)`.
221
222 - [ ] **Step 4: Run tests**
223
224 `make test 2>&1 | tail -5` — pass. Then `make e2e 2>&1 | tail -15; echo rc=$?` — expected: FAIL at the first sized `assert_converged` (ptyclient legs now attach one row short; the daemon grid is 29 rows, the render 30). That failure is the evidence the size flowed; Task 5 teaches the harness. If e2e PASSES here, the size did not flow — find out why before committing.
225
226 - [ ] **Step 5: Commit**
227
228 ```bash
229 git add src/wallview.zig src/interact.zig && git commit -m "feat: a zoomed session attaches one row short of the tty"
230 ```
231
232 ---
233
234 ### Task 3: Paint the bar when zoomed, and after every wipe
235
236 **Files:**
237 - Modify: `src/interact.zig` (`Sink` ~1175; the four wipe sites: `renderClipped` at ~1552, ~1616, ~1711 and `renderScrollback` ~1748)
238 - Modify: `src/wallview.zig` (`paintLabel` ~815, `paintLabelLocked` ~855, `tilePaintBegin/End` ~1011, `core.sink = ...` ~1518)
239 - Test: `src/wallview.zig` inline, same shape as the `out_fd = p[1], .is_tty = true` tests (~line 3752 on)
240
241 **Interfaces:**
242 - Consumes: `paint.barText`, `paint.paintBar` (Task 1); `Shared.viewSize`, `bar_rows` (Task 2).
243 - Produces:
244 - `Sink.wiped: ?*const fn (?*anyopaque) void = null` — "this paint cleared the whole screen; re-lay whatever sits outside the Core's rows". Called by Core inside the begin/end window, after the wiping write.
245 - wallview `fn tilePaintWiped(ctx: ?*anyopaque) void` → `paintLabelLocked(t)`.
246 - `paintLabelLocked` paints in BOTH modes: `.stripe` at `t.stripe.top + 1` with the `> `/` ` marker; `.full` at `t.shared.size.rows` with the `mux ` prefix. `.none` paints nothing.
247 - `fn noticeForLocked(t: *Tile) []const u8` — stub returning `""` until Task 4.
248
249 - [ ] **Step 1: Write the failing test**
250
251 Find a wallview test that promotes a tile and captures `p[1]` (grep `zoom.store` in the tests after line 3752) and copy its setup. Then:
252
253 ```zig
254 test "a zoomed tile's bar sits on the bottom row and survives a full repaint" {
255 // ...setup copied from the neighbouring promote test: Shared on a pipe,
256 // is_tty = true, size 40x6, one tile, zoom = its idx...
257 paintLabel(&tiles[0], .up);
258 std.posix.close(p[1]);
259 var out: [8192]u8 = undefined;
260 const n = try std.posix.read(p[0], &out);
261 var e = try Engine.init(alloc, .{ .cols = 40, .rows = 6 });
262 defer e.deinit();
263 e.feed(out[0..n]);
264 const plain = try e.dumpPlain(alloc);
265 defer alloc.free(plain);
266 // The last non-empty line is the bar; no other line is one.
267 var it = std.mem.splitScalar(u8, plain, '\n');
268 var last: []const u8 = "";
269 var bars: usize = 0;
270 while (it.next()) |line| {
271 if (line.len == 0) continue;
272 last = line;
273 if (std.mem.startsWith(u8, line, "mux ")) bars += 1;
274 }
275 try std.testing.expect(std.mem.startsWith(u8, last, "mux "));
276 try std.testing.expect(std.mem.indexOf(u8, last, "[up]") != null);
277 try std.testing.expectEqual(@as(usize, 1), bars);
278 }
279 ```
280
281 `grep -n '@import("engine")' src/wallview.zig` — if absent, add `const Engine = @import("engine").Engine;` and add `"engine"` to `wallview`'s imports in `build.zig`'s module table (it is layer 0; allowed).
282
283 - [ ] **Step 2: Run to verify it fails**
284
285 `make test 2>&1 | tail -5` — expected: the `startsWith "mux "` expect fails (nothing painted in `.full`).
286
287 - [ ] **Step 3: Implement**
288
289 `src/wallview.zig` `paintLabel`: drop the `== .stripe` guard — `paintLabelLocked(t)` decides.
290
291 `paintLabelLocked`, replace the marker/emit logic (keep the `status_buf`/`status` computation):
292
293 ```zig
294 const mode = paintModeLocked(t);
295 if (mode == .none) return;
296 // Zoomed, the bar is the one mark that says "this is mux"; on the
297 // wall the marker is the selection. Same width both ways so labels
298 // never shift.
299 const marker: []const u8 = switch (mode) {
300 .full => "mux ",
301 .stripe => if (t.shared.sel == t.idx) "> " else " ",
302 .none => unreachable,
303 };
304 var text_buf: [256]u8 = undefined;
305 const left = labelText(&text_buf, t.shared.size.cols, marker, t.r.label, status);
306 var line_buf: [512]u8 = undefined;
307 const line = paint.barText(&line_buf, t.shared.size.cols, left, noticeForLocked(t));
308 // Zoomed, the session's rows end one above the terminal's last row
309 // (`viewSize`), which is where this bar lives.
310 const row: u16 = if (mode == .full) t.shared.size.rows else t.stripe.top + 1;
311 paint.paintBar(t.shared.out_fd, t.shared.size.cols, row, line);
312 ```
313
314 `fn noticeForLocked(t: *Tile) []const u8 { _ = t; return ""; }` for now.
315
316 Rewrite `paintLabelLocked`'s doc comment: it is no longer "only called in `.stripe` mode". Rewrite `paintLabel`'s comment: the state no longer has to "survive until a bar comes back" — it is painted in both modes; keep the "keyboard repaints with no frame in hand" reason.
317
318 `src/interact.zig`:
319 - `Sink` gains `wiped: ?*const fn (?*anyopaque) void = null` with the doc above.
320 - Add `fn paintWiped(self: *Core) void { if (self.sink.wiped) |f| f(self.sink.ctx); }` and call it immediately after each of the four wipe sites (three `renderClipped`, one `renderScrollback`), inside the `beginPaint`/`endPaint` window. `grep -n "renderClipped\|renderScrollback" src/interact.zig` must show exactly four call sites; if more, each one that writes `\x1b[2J` gets the call.
321
322 `src/wallview.zig`: `fn tilePaintWiped(ctx: ?*anyopaque) void { const t: *Tile = @ptrCast(@alignCast(ctx.?)); paintLabelLocked(t); }` and set `.wiped = tilePaintWiped` where `core.sink` is assigned.
323
324 - [ ] **Step 4: Run tests and look**
325
326 `make test 2>&1 | tail -5` — pass. Hand check in a real terminal:
327
328 ```sh
329 S=/tmp/claude-1000/-home-xanderle-code-rad-mux/*/scratchpad; export XDG_STATE_HOME=$S/state
330 zig-out/bin/muxd run --sock $S/s & zig-out/bin/mux --sock $S/s
331 ```
332 Inside: `clear`, `seq 1 100`, resize the window, `Ctrl-\ w` to the wall and back, `Ctrl-\ \`. The bar must be on the bottom row every time; `tput lines` must print the terminal's rows minus one.
333
334 - [ ] **Step 5: Commit**
335
336 ```bash
337 git add src/wallview.zig src/interact.zig build.zig && git commit -m "feat: the zoomed tile keeps its bar, on the bottom row"
338 ```
339
340 ---
341
342 ### Task 4: The notice slot replaces the banners
343
344 **Files:**
345 - Modify: `src/wallview.zig` (`Shared.notice` ~296, `setNotice`/`takeNotice` ~1094–1110, callers at ~993, ~1413, ~1643, ~1702–1713, ~2786, ~3096, ~3119, `wallBanner` ~948)
346 - Modify: `src/interact.zig` (`Core.banner` ~1622 — delete)
347 - Modify: `src/paint.zig` (`paintBanner` + its two tests ~466–500 — delete; `bannerText` STAYS — `renderScrollback` uses it for `[scroll]`)
348 - Test: `src/wallview.zig` inline
349
350 **Interfaces:**
351 - Consumes: `noticeForLocked` stub (Task 3).
352 - Produces:
353 - `Shared.notice_tile: usize = no_zoom` — which tile the notice is about.
354 - `Shared.tiles: []Tile` — set once in `run` (grep `Shared{` ~2993) so a pump can reach a sibling's bar. Skip if a tiles slice is already reachable from `Shared`.
355 - `fn sayNotice(shared: *Shared, about: usize, text: []const u8) void` — stores the notice, then repaints that tile's bar under `paint_mu`. `about` is the zoomed tile when zoomed, else the selected tile.
356 - `paintLabel(t, state)`: when `state != t.state` and `t.shared.notice_tile == t.idx`, clears the notice before painting — the spec's "cleared by the next state change of the tile it was said about".
357 - `noticeForLocked(t)` returns the notice iff `notice_tile == t.idx`, else `""`.
358 - Deleted: `setNotice`, `takeNotice`, `wallBanner`, `Core.banner`, `paint.paintBanner`.
359
360 - [ ] **Step 1: Write the failing test**
361
362 Same setup as Task 3's test. Two captures are needed (before and after the state change): either drain `p[0]` with a non-blocking read between them, or build two `Shared`s on two pipes — copy whichever the neighbouring tests already do.
363
364 ```zig
365 test "a notice rides the bar and a state change clears it" {
366 // ...setup...
367 paintLabel(&tiles[0], .up);
368 sayNotice(&shared, 0, "[hello]");
369 // capture 1 → replay → last line contains "[hello]" and "[up]"
370 paintLabel(&tiles[0], .reconnecting);
371 // capture 2 → replay → last line contains "[reconnecting]" and NOT "[hello]"
372 }
373 ```
374
375 - [ ] **Step 2: Run to verify it fails**
376
377 `make test 2>&1 | tail -5` — `sayNotice` undeclared.
378
379 - [ ] **Step 3: Implement**
380
381 ```zig
382 /// Say a sentence on the bar that owns the screen now — the zoomed tile's,
383 /// or the selected stripe's. Latest wins; it stays until that tile changes
384 /// state, so a refused chord is read rather than missed.
385 fn sayNotice(shared: *Shared, about: usize, text: []const u8) void {
386 shared.paint_mu.lock();
387 defer shared.paint_mu.unlock();
388 const n = @min(text.len, shared.notice.len);
389 @memcpy(shared.notice[0..n], text[0..n]);
390 shared.notice_len = n;
391 shared.notice_tile = about;
392 paintLabelLocked(&shared.tiles[about]);
393 }
394
395 fn noticeForLocked(t: *Tile) []const u8 {
396 if (t.shared.notice_tile != t.idx) return "";
397 return t.shared.notice[0..t.shared.notice_len];
398 }
399 ```
400
401 `paintLabel`, before `t.state = state;`:
402 ```zig
403 if (state != t.state and t.shared.notice_tile == t.idx) t.shared.notice_len = 0;
404 ```
405
406 Callers:
407 - ~993 `wallBanner(t.shared, "[selection too large to copy]")` → `sayNotice(t.shared, t.idx, ...)`.
408 - ~1413 `core.banner("[reconnecting]")` → delete the call and its comment; `paintLabel(t, .reconnecting)` just above paints the word on the bar in both modes now.
409 - ~1643 `core.banner("[no session list: upgrade muxd]")` → `sayNotice(t.shared, t.idx, ...)`.
410 - ~1702–1713 the `takeNotice` + `core.banner(notice)` block and its comment → delete; `tilePaintWiped` repaints the bar after `core.repaint()` and the bar carries the notice.
411 - ~2786, ~3096, ~3119 `setNotice(shared, ...)` → `sayNotice(shared, about, ...)` with `const about = if (zoom != no_zoom) zoom else shared.sel;` (the keyboard thread reads `shared.zoom` the way the neighbouring code does).
412 - Delete `setNotice`, `takeNotice`, `wallBanner`, `Core.banner`, `paint.paintBanner` and its two tests. Update `Shared.notice`'s comment (~293: "sized for a corner banner"). Run `zig build check` — it names every comment still referencing a deleted symbol; fix each.
413
414 - [ ] **Step 4: Run tests and check**
415
416 `make check 2>&1 | tail -5; echo rc=$?` — rc 0.
417
418 - [ ] **Step 5: Commit**
419
420 ```bash
421 git add src/wallview.zig src/interact.zig src/paint.zig && git commit -m "feat: one notice slot on the bar; the corner banners are gone"
422 ```
423
424 ---
425
426 ### Task 5: e2e — the harness knows the bar, and one leg proves it
427
428 **Files:**
429 - Modify: `test/render.zig` (arg parsing ~line 51–60, output)
430 - Modify: `test/e2e.sh` (`converged_quiet` ~925; the three sized `assert_converged` sites — `grep -n 'assert_converged.*[0-9][0-9] [0-9][0-9]' test/e2e.sh`; a new leg appended at the END, before the final summary)
431
432 **Interfaces:**
433 - Produces: `render --bar` — replays at the full `--rows`, requires the LAST row to begin with `mux ` (exit 3 with a message otherwise), prints only rows `1..rows-1`, so the output is comparable to `muxd dump` of a `rows-1` grid. Refused together with `--vt`.
434 - `converged_quiet`: when `CONV_BAR=1` is in the environment, passes `--bar` to `$RENDER`.
435
436 - [ ] **Step 1: Confirm the harness fails for the right reason**
437
438 `make e2e 2>&1 | tail -20; echo rc=$?` — the failure is a sized `assert_converged` diff (client render 30 rows incl. the bar, dump 29). This is the failing test.
439
440 - [ ] **Step 2: Implement `render --bar`**
441
442 Read `test/render.zig` lines 40–120 first for the real variable names. Add `var bar = false;` and `else if (std.mem.eql(u8, a, "--bar")) bar = true;`. After the grid is rendered to plain text:
443
444 ```zig
445 // The bar is the client's, not the session's: a converged check
446 // compares the session's rows and asserts the bar is where the client
447 // says it is.
448 if (bar) {
449 if (vt) { std.debug.print("render: --bar and --vt do not combine\n", .{}); std.process.exit(2); }
450 const trimmed = std.mem.trimRight(u8, plain, "\n");
451 const last_nl = std.mem.lastIndexOfScalar(u8, trimmed, '\n') orelse 0;
452 const last = trimmed[last_nl + 1 ..];
453 if (!std.mem.startsWith(u8, last, "mux ")) {
454 std.debug.print("render: --bar but the last row is not a bar: {s}\n", .{last});
455 std.process.exit(3);
456 }
457 plain = trimmed[0..last_nl]; // then the existing print, plus a trailing newline
458 }
459 ```
460
461 - [ ] **Step 3: Wire `CONV_BAR` and the three sites**
462
463 `converged_quiet` (~928), after `_sz` is set: `if [ "${CONV_BAR:-0}" = 1 ]; then _sz="$_sz --bar"; fi`. Prefix the three sized ptyclient `assert_converged` calls with `CONV_BAR=1 `. `make e2e 2>&1 | tail -5; echo rc=$?` — rc 0. If a FOURTH leg fails it is a tty leg this plan missed: add `CONV_BAR=1` there and say so in the commit.
464
465 - [ ] **Step 4: The bar leg**
466
467 First learn the dump's row convention: on any running e2e daemon, `"$MUXD" dump --sock X | wc -l` for a known 24-row session (24 or 25 lines?). Set the two expected counts below to match. Then append before the final summary (grep the last `echo "e2e` to find the spot); `start_daemon` stands for the file's own daemon helper — grep `run --sock` to find its name:
468
469 ```sh
470 # ---- status bar: the session is one row short and the bar says mux -------
471 # A tty client at 80x24 attaches at 80x23 — the bar owns row 24 — and the
472 # bar names the tile and its state. A piped client keeps all 24 rows: no
473 # tty, no bar. Asserted off the daemon (dump row count), off the session
474 # (`tput lines` on the grid) and off the capture (engine replay) — never off
475 # the painter's bytes.
476 SOCKB="$TMP/bar.sock"
477 start_daemon "$SOCKB"
478 set +e
479 timeout 40 "$PTYCLIENT" --cols 80 --rows 24 --out "$OUT.bar" --err "$OUT.bar.err" -- \
480 "$MUX" --sock "$SOCKB" > "$OUT.bar.log" 2>&1 <<'BARSCRIPT'
481 expect \x1b[?1049h 10000
482 send PS1=barrdy@\n
483 expect barrdy@ 10000
484 expect barrdy@ 10000
485 send tput lines\n
486 expect barrdy@ 10000
487 settle 500 15000
488 send \x1c\x1c
489 waitexit 10000
490 BARSCRIPT
491 RC=$?
492 set -e
493 [ "$RC" -eq 0 ] || { echo "e2e FAIL: bar ptyclient exited $RC:"; cat "$OUT.bar.log"; exit 1; }
494 ROWS=$("$MUXD" dump --sock "$SOCKB" | wc -l)
495 [ "$ROWS" -eq 23 ] || { echo "e2e FAIL: bar: daemon grid has $ROWS rows, want 23"; exit 1; }
496 "$MUXD" dump --sock "$SOCKB" | grep -q "^23$" || { echo "e2e FAIL: bar: tput lines in the session did not print 23"; exit 1; }
497 "$RENDER" --cols 80 --rows 24 < "$OUT.bar" > "$OUT.bar.render.full"
498 tail -1 "$OUT.bar.render.full" | grep -q "^mux .*\[up\]" || {
499 echo "e2e FAIL: bar: last rendered row is not the bar:"; tail -1 "$OUT.bar.render.full"; exit 1; }
500 CONV_BAR=1 assert_converged "$OUT.bar" "$SOCKB" "bar: 80x24 tty" 80 24
501 # The control: the same attach through a pipe gets every row.
502 SOCKC="$TMP/nobar.sock"
503 start_daemon "$SOCKC"
504 printf 'exit\n' | timeout 20 "$MUX" --sock "$SOCKC" > "$OUT.nobar" 2>&1 || true
505 ROWS=$("$MUXD" dump --sock "$SOCKC" 2>/dev/null | wc -l)
506 [ "$ROWS" -eq 24 ] || { echo "e2e FAIL: nobar: piped client grid has $ROWS rows, want 24"; exit 1; }
507 ```
508
509 Note the script heredoc delimiter is `BARSCRIPT`, not `EOF`, so the leg can be pasted through a shell heredoc.
510
511 - [ ] **Step 5: Run the gate, and watch the new checks fire once**
512
513 Temporarily flip `-eq 23` to `-eq 22`, run `make e2e 2>&1 | tail -3` — it must FAIL with the bar message. Restore. Then `make ci 2>&1 | tail -10; echo rc=$?` — rc 0.
514
515 - [ ] **Step 6: Commit**
516
517 ```bash
518 git add test/render.zig test/e2e.sh && git commit -m "test(e2e): the bar row is the client's; the session converges one row short"
519 ```
520
521 ---
522
523 ### Task 6: Docs, decision record, retro
524
525 **Files:**
526 - Modify: `README.md` (the "`mux` IS the wall" section, ~line 45–90)
527 - Modify: `docs/decisions.md` (append)
528 - Modify: `RETRO.md` (one line)
529
530 - [ ] **Step 1: README** — after the key tables, one paragraph:
531
532 > **The bar.** The bottom row of a zoomed session is the tile's bar: `mux LABEL [state]` on the left, the latest notice on the right. The session itself is one row shorter than the terminal. No tty, no bar.
533
534 - [ ] **Step 2: decisions.md** — append, dated 2026-08-22: bar costs a row; one painter for stripe and zoomed; the notice slot replaces `Core.banner` and the deferred wall notice; no off switch until someone asks; follow-up: a wall redial's ssh stderr into the notice slot.
535
536 - [ ] **Step 3: RETRO.md** — one line on what slowed the work.
537
538 - [ ] **Step 4: Commit, tidy, gate**
539
540 ```bash
541 git add README.md docs/decisions.md RETRO.md && git commit -m "docs: the bar"
542 GIT_SEQUENCE_EDITOR=true git rebase -i --autosquash 62ddb5a
543 make ci 2>&1 | tail -5; echo rc=$?
544 ```
docs/superpowers/plans/2026-08-23-retire-zoom.md
Old New
@@ -1,407 +0,0 @@
1 # Retire zoom — implementation plan
2
3 Implements `docs/superpowers/specs/2026-08-23-retire-zoom-design.md` and
4 nothing more. Branch `worktree-multipane`; nothing merges until chunk 3.
5
6 Line numbers are plan-time anchors (wallview 4314 ln, interact ~3000 ln, e2e
7 ~7000 ln), not exact after edits.
8
9 ## The invariant swap (spec, "Model")
10
11 > **Every tile claims its rectangle.** Attach sends the rect, relayout
12 > resends it. Focus is client-local and sends nothing. `mux [TARGET]` is a
13 > wall of one tile whose rect is the whole terminal — ONE interaction loop,
14 > a tile pump.
15
16 This replaces CLAUDE.md's "unzoomed tile claims nothing" invariant; chunk 3
17 reconciles CLAUDE.md (the branch may contradict it until then).
18
19 ## Finding the spec defers to: the Core paint path has no row offset
20
21 Verified against the code, not assumed. `paint.renderClipped` (paint.zig:49)
22 opens `\x1b[H\x1b[2J` then writes `\x1b[{y+1};1H` from `y=0` — **row 1
23 origin, no offset**. `renderRowsClipped` (:80), `paintDeltaClipped` (:124),
24 `bannerText`/`paintBanner` (:165/:179) and `interact.paintOverlay`
25 (interact.zig:774, by signature) are the same — all absolute, row-1.
26 `Core.repaint` (1334), `paintFull` (1402), `paintDragChange` (1356), `banner`
27 (1407) and `Core.frame`'s paint arms (1494/1498) all call these with no
28 offset. `paint.renderStripe` (paint.zig:217) is the **only** function with a
29 `row_off`, called **only** by the wall's `paintStripe` (wallview.zig:794),
30 **never** by Core.
31
32 So today there are literally two painters: the wall's `paintStripe`
33 (offset-aware, for stripes) and `Core.repaint`/`renderClipped` (row-1, for
34 the promoted tile). The spec says the stripe crop goes away and "it does not
35 keep two painters" — so this chunk threads a row offset into the Core/paint
36 path (mechanism left open; see Open questions).
37
38 ## Dependency order
39
40 Tasks 1–3 are additive (zoom still works, build green, e2e green). Task 4 is
41 the one coherent model swap — the symbol graph forces it: `setZoom`'s only
42 callers are the keyboard loop (2671–2890) and `pumpTile`'s promote/demote
43 branches (1395–1470, 1764–1812), and `paintModeLocked` (693) reads
44 `Shared.zoom` (236) from every paint; removing any one without the others
45 leaves dangling refs. Tasks 5–7 are cleanup. e2e is allowed red between 4 and
46 7 (`make check` is the per-commit gate; `make ci` is the hand-off gate).
47
48 ---
49
50 ### Task 1 — paint.zig: thread `row_off` through the render functions
51
52 Goal: every paint.zig renderer can paint at a row offset; default 0 keeps all
53 callers byte-identical.
54
55 Files / symbols: `src/paint.zig` — `renderClipped` (49), `renderRowsClipped`
56 (80), `paintDeltaClipped` (124), `bannerText` (165), `paintBanner` (179). Not
57 touched yet: `renderStripe` (217), `stripeWinStart` (211) — task 5 deletes them.
58
59 Failing test FIRST (paint.zig tests): `"renderClipped paints at a row
60 offset"` — a 4-row grid, render with `row_off=2`, assert the first row
61 emitted is addressed `\x1b[3;1H` (not `\x1b[1;1H`) and the cursor close is
62 `\x1b[3;…`. Same shape for `"paintDeltaClipped offsets every delta row"` and
63 `"paintBanner parks at row_off+1"`.
64
65 Change: add `row_off: u16` to each renderer (last before `out_fd`). Shift
66 every `\x1b[{y+1};1H` to `\x1b[{y + row_off + 1};1H` and the cursor to
67 `{cur.y + row_off + 1}`; `paintDeltaClipped` likewise on `row.row`;
68 `bannerText`/`paintBanner` row `row_off + 1`. No screen clear is added (the
69 wall clears once per relayout at 2053). Existing callers (Core, plain
70 client) pass 0.
71
72 Verify: `make check; echo EXIT=$?`
73
74 ---
75
76 ### Task 2 — interact.Core: carry `row_off` and use it on every paint
77
78 Goal: a Core paints its replica/overlay/banner at an offset its driver sets.
79
80 Files / symbols: `src/interact.zig` — `Core` struct (field near `size`,
81 ~1226/1163), `repaint` (1334), `paintFull` (1402), `paintDragChange` (1356,
82 loop at 1370, `renderRowsClipped` at 1388), `banner` (1407→1410), `frame`
83 paint arms (1494 `renderClipped`, 1498 `paintDeltaClipped`, 1503
84 `paintOverlay`), `paintOverlay` (774).
85
86 Failing test FIRST (interact.zig tests): `"Core.repaint paints at its
87 row_off"` — a Core over a 3-row grid, `core.row_off = 5`, drive `repaint`,
88 assert the sink's first CSI is `\x1b[6;1H`. And `"Core.banner lands at
89 row_off+1"`.
90
91 Change: add `row_off: u16 = 0` to `Core`. Pass `self.row_off` into every
92 `renderClipped`/`renderRowsClipped`/`paintDeltaClipped`/`paintBanner` call
93 above. Thread `row_off` into `paintOverlay` (it addresses absolute rows
94 today; add the offset to every row it writes — see Open questions #2).
95 `claimTerminal` (1241)/`releaseTerminal` (1289)/`winch` (1416) unchanged.
96 Default 0 ⇒ plain client (client.zig) and `muxa` unchanged.
97
98 Verify: `make check; echo EXIT=$?`
99
100 ---
101
102 ### Task 3 — interact.PrefixFilter: add `focus` and `forget` actions
103
104 Goal: the chord layer can express "focus tile N" and "forget the focused
105 tile", without yet rewiring the wall.
106
107 Files / symbols: `src/interact.zig` — `PrefixFilter.Action` (93), the chord
108 switch (123–137). `src/wallview.zig` — `zoomChord` (1838) only to keep its
109 switch exhaustive.
110
111 Failing test FIRST (interact.zig tests): `"Ctrl-\ 1 is focus, Ctrl-\ x is
112 forget"` — feed `\x1c1` and `\x1cx`, assert `.action == .focus` (carrying 1)
113 and `.action == .forget`, forward slice empty.
114
115 Change: extend `Action` to `{ none, detach, new_session, next_session,
116 prev_session, last_session, wall, focus: u4, forget }`. In the switch
117 (123–137) bind `'1'...'9' => .{ .action = .focus, ... }` and
118 `'x' => .{ .action = .forget, ... }`. Keep `'w' => .wall` for now. Add
119 `.focus`/`.forget` arms to `zoomChord` (1838) returning `.stay` — temporary;
120 task 4 deletes `zoomChord`. Behaviour unchanged: bare `x` in the wall still
121 forgets (the loop reads bare `x`, not the chord); `Ctrl-\ x`/`Ctrl-\ 1-9` are
122 no-ops until task 4 wires them.
123
124 Verify: `make check; echo EXIT=$?`
125
126 ---
127
128 ### Task 4 — wallview: replace zoom with focus; every tile claims its rect
129
130 Goal: the one coherent swap. Largest commit; the symbol graph (Dependency
131 order) is why it cannot be split into compiling halves — `sendAttach=rect`
132 alone breaks the zoomed tile's full-screen claim, and removing `Shared.zoom`
133 alone leaves `paintModeLocked`/`setZoom` dangling.
134
135 Files / symbols (all `src/wallview.zig` unless noted):
136
137 Attach / geometry:
138 - `sendAttach` (883): size is today `shared.size` if zoomed else `0×0`. →
139 always the tile's content rect (`tty.cols × viewRows`).
140 - `relayout` (2029): today re-cuts stripes + bumps `repaint_gen` (2053–2078).
141 → also set a per-tile `resize_pending` doorbell (new `Tile` field, mirrors
142 `detach_req` at 394); each pump sends `.resize` on wake (the pump is the
143 transport's only writer — same path the promote used at 1439).
144 - `viewRows` (428): today `stripe.rows - 1`. → when `live <= 1`, the tile
145 owns every row: return `stripe.rows` (no label) and suppress the bar.
146
147 Pump (`pumpTile`, 1207):
148 - Delete `ever_promoted`/`promoted` (1260, 1321, 1400, 1764) and the promote
149 branch (1395–1470, incl. `core.claimTerminal()` at 1419 and `.resize` at
150 1439 — resize now comes from the relayout doorbell).
151 - Delete the demote branch (1764–1776, `releaseTerminal(.already_written)`).
152 - Delete the winch branch (1780–1812): the **keyboard thread** reads the
153 process-wide winch flag, calls `relayout`, every pump follows — no pump
154 consumes winch (spec's "Winch" paragraph).
155 - Delete both `paintStripe` calls (1535, 1813). The pump paints through Core
156 at its offset: set `core.row_off = t.stripe.top + label_rows` each pass
157 (label_rows = `live > 1 ? 1 : 0`); `Core.frame`/`repaint` paint.
158 - `tilePaintBegin` (Sink.begin, ~880): today true only when
159 `paintModeLocked == .full`. → true whenever `!t.gone` (every live tile
160 paints its rect).
161
162 Keyboard loop (`run`, 2532; body 2660–2895):
163 - `last_zoom` (2671) → `last_focus: ?usize`.
164 - Delete the zoomed-in branch (2771–2840: `input.prefix.feed`, the
165 `zoomChord` switch at 2796, `.out`/`.ask`/`.to` arms). The loop now
166 **always** dispatches `PrefixFilter.Action` (the wall is always "in"):
167 `.detach`→leave, `.new_session`/`.next_session`/`.prev_session`→ask the
168 focused tile's daemon, `.last_session`→focus `last_focus`, `.focus`→set
169 focus, `.forget`→`forgetTile` (2080) on the focused tile. `cmd.forward` →
170 `sendKeys` (990) to the focused tile.
171 - Delete the unzoomed branch (2842–2895: `input.mouse.feed`, `WallDrain`,
172 `wallMouse`, bare `q`/`Enter`/`x`/`n`/`p`/digits). **Nothing bare is
173 intercepted** — every non-chord byte goes to the focused tile. Mouse:
174 hit-test by rect to pick focus (reuse `wallMouse`'s hit-test, 533), set
175 focus, forward the event to that tile's Core.
176 - `entry.zoom0` (2495) → `entry.focus0`. Call sites 2474, 2599, 2631, 2645,
177 2917: 2631's `shared.zoom.store(0,…)` becomes a focus set; 2599/2917
178 follow `focus0`.
179 - `WallInput`/`WallDrain`/`MouseFilter` (505, 609, 615, 2668): the wall's own
180 mouse filter goes away (the focused tile's Core owns mouse modes). Keep
181 `WallInput.prefix`; drop `WallInput.mouse`.
182 - `wall_mouse_claim`/`wall_setup` (interact.zig:602/618): the wall no longer
183 claims the mouse. `run`'s `wall_setup` write (2598) → terminal frame setup
184 only; the focused tile's `claimTerminal` arms session mouse modes. On
185 focus change: old pump `releaseTerminal`, new pump `claimTerminal`
186 (doorbell-driven, same pattern as `resize_pending`).
187
188 End-of-tile (`EndAction`/`endAction`, 2300/2315; `endedTile`, 2358):
189 - Drop `EndAction.unzoom` (2316) and `.fall_back` (2322). A tile ending
190 (exit/refused/lost) moves focus to the next present tile and narrates on
191 its label bar; the last present tile ending → `.finish` (2337) as now.
192 `stdin_open and presentCount > 1 ⇒ .unzoom` (2329) ⇒ focus the next
193 present tile (a `.refocus: usize` variant). `refused`+`born_from`
194 (2319–2321) ⇒ focus back where the chord was typed (was `.fall_back`).
195
196 Delete outright (no remaining caller):
197 - `Shared.zoom` (236), `no_zoom` (147), `PaintMode` (683), `paintModeLocked`
198 (693), `setZoom` (1911), `ZoomMove` (1819), `zoomChord` (1838), `ZoomTo`
199 (2204), `zoomToSession` (2218), `paintDeadZoomLocked` (1989), `paintStripe`
200 (794). Keep `paintEmptyWallLocked` (2012) — see Open questions #3.
201 - `setWallSize` (924): the keyboard thread writes `shared.size` on winch;
202 its lock discipline folds into `relayout`.
203
204 Labels (spec: "drawn only when `live > 1`"): `paintLabelLocked` (739) already
205 writes `\x1b[{t.stripe.top + 1};1H` — correct offset. Gate every caller
206 (`paintLabel` 707, `moveSelection` 770, the relayout bar redraws at 2074) on
207 `live > 1`; a one-tile wall owns every row and draws no bar.
208
209 Failing tests FIRST (wallview.zig tests, existing pipe style — no daemon),
210 written before the rewrite and made to pass by it:
211 - `"every tile's attach carries its rect"` (replaces the 0×0 attach test).
212 - `"relayout resends the size of every tile whose rect changed"` (doorbell
213 set; pump emits one `.resize` with the new rect; unchanged rect ⇒ none).
214 - `"a one-tile wall draws no label bar and paints row 1"` (live==1 ⇒ no
215 `\x1b[7m` bar; first content row `\x1b[1;1H`).
216 - `"bare keys reach the focused tile; the prefix does not"` (feed `abc` ⇒
217 `sendKeys` got `abc`; feed `\x1cn` ⇒ no `n` sent, focus moved).
218 - `"focus follows a click"` (a press in tile 1's rect sets focus to 1).
219
220 Delete/rewrite the existing zoom-named tests by claim:
221 - delete `zoomChord: w unzooms…` (3075), `zoomChord: l goes back…` (3108),
222 `zoomChord: the chords come out of the client's own table` (3274),
223 `setZoom writes the outgoing zoom's release…` (3362), `paintStripe
224 publishes the window it painted` (4094);
225 - rewrite `endAction: the only tile's ending…` (3185) and `endAction: a
226 refusal a chord earned…` (3233) to the new `EndAction` variants;
227 - rewrite `a zoomed tile copies from its own drag…` (3794) and `a zoom move
228 drops what the wall's input filters are holding` (3990) to focus terms;
229 - rewrite the mouse/drag tests that assume the wall's own MouseFilter (2982,
230 3427, 3477, 3511, 3576, 3636, 3720, 3850, 3897, 3937, 3965, 4032, 4053) to
231 hit-test-by-rect + per-tile drag.
232
233 Verify: `make check; echo EXIT=$?` (e2e red until task 7 — expected.)
234
235 ---
236
237 ### Task 5 — paint.zig: delete `renderStripe` and `stripeWinStart`
238
239 Goal: remove the second painter the spec retires.
240
241 Files / symbols: `src/paint.zig` — `renderStripe` (217), `stripeWinStart`
242 (211), and their tests (277, 312). No remaining caller after task 4
243 (`stripeWinStart` was used only by `paintStripe`, wallview.zig:827, now gone).
244
245 Failing test FIRST: the deletion is the test — `make check` fails to
246 compile if any caller remains; remove the `renderStripe` tests (277, 312)
247 in the same commit.
248
249 Change: delete the two functions and their tests.
250
251 Verify: `make check; echo EXIT=$?`
252
253 ---
254
255 ### Task 6 — docs: remove the zoom remarks the spec names
256
257 Goal: only what "lies otherwise" (spec, "Docs in this chunk"): the
258 wallview/interact headers, the chord-table comments, `mux_main.zig:7`,
259 README's chord list. decisions.md and CLAUDE.md are chunk 3.
260
261 Files / symbols:
262 - `src/mux_main.zig:7` — "Every one of those is a WALL of one tile, entered
263 zoomed (`wallview.runAttach`)" → "…a wall of one tile whose rect is the
264 whole terminal (`wallview.runAttach`)".
265 - `src/client.zig:1594` — "A name the daemon could have meant still moves the
266 zoom." → "…still moves the focus."
267 - `src/wallview.zig` header (1–12) — the "ZOOM IS A LENS" paragraph (3–6)
268 rewrites to the "every tile claims its rectangle" invariant.
269 - `src/interact.zig` — chord-table comments at 87–93 and the chord switch
270 comments at 123–137 (`w`/`l`/new `x`/`1-9`).
271 - `README.md` — the two chord tables (46–84) and the wall-keys prose
272 (323–372): drop `Ctrl-\ w`; `Ctrl-\ 1-9`→focus, `Ctrl-\ x`→forget,
273 `Ctrl-\ l`→last focus; the "Zoomed out" table (67–77) goes (nothing is
274 intercepted bare); `Enter`/bare `q`/`x`/`n`/`p` are now typed through.
275 - `docscheck.budget` — wallview.zig (29), interact.zig (9), mux_main.zig
276 (13), client.zig (3) are all `0` today and stay `0`: introduce no new
277 flagged prose, do not lower a line. README is not in the budget file (not
278 prose-gated) but its chord list must match the code for `make check`'s
279 comment-claim refs.
280
281 Failing test FIRST: `zig build doc-report` (the worklist) — resolve every
282 item it lists for these four files in this commit.
283
284 Change: as above; comments say why not how, trim noise, keep rationale.
285
286 Verify: `make check; echo EXIT=$?`
287
288 ---
289
290 ### Task 7 — e2e.sh: rewrite the wall legs to focus, delete the 0×0/stripe legs
291
292 Goal: `make ci` green. Plain-client legs pass unchanged (regression gate);
293 wall legs drive the prefix chords; legs asserting 0×0 attach or "stripe
294 shows daemon grid" are deleted with the behaviour.
295
296 Files / symbols: `test/e2e.sh`. Classification (leg header → line):
297
298 **Unchanged (regression gate / different invariant):**
299 - M1 "Ctrl-\ is a prefix" (5024), M2 "Ctrl-\ c creates a session" (5076),
300 M3 "Ctrl-\ n / p step around the ring" (5203) — plain-client chord legs.
301 - M5 "self-loop refusal" (5421) — `self_attach_refusal` unchanged.
302 - "the wall is attach HISTORY" (6588) — recording unchanged (spec:
303 "Recording: unchanged").
304 - muxa 0×0 legs (4333, 4563 "0×0-slot refusal in server.zig") — `muxa`
305 attaches at 0×0 by invariant; daemon unchanged.
306 - webhub/wsclient 0×0 legs (4392–4887, 4643 "browser stand-in: a passive 0×0
307 wall tile", 4817) — muxweb untouched (spec non-goal); not CLI wall tiles.
308
309 **Rewrite to the prefix chords / focus model:**
310 - "the zoom SKIPS between tiles" (5920) → "focus skips between tiles": drive
311 `Ctrl-\ n`/`Ctrl-\ l`; "daemon never notices a second attach" still holds
312 (one attach per tile at its rect; focus moves send nothing).
313 - "the wheel inside a zoom" (6186) and "an application in a ZOOMED tile owns
314 the mouse" (6302) → "…the focused tile": no zoom/unzoom transition; the
315 focused tile's `claimTerminal` arms the modes.
316 - "a tile whose pump has died still says something" (6512, zoom-dead) →
317 dead-tile narrative on its label bar; focus moves to the next present tile.
318 - "`x` forgets a tile" (6735) → drive `Ctrl-\ x` (was bare `x`).
319 - "`mux TARGET` IS a wall, entered zoomed" (6860) → "…a wall of one tile
320 whose rect is the whole terminal": no "entered zoomed", no label bar;
321 byte-identical capture to the plain client.
322 - "the ring GROWS the wall" (6969) → `Ctrl-\ n`/`p` appending tiles; the
323 attach-count assertion changes (tiles attach at their rect, not 0×0).
324 - "a session that ends under the zoom" (7070) → last present tile ending
325 finishes with its code; otherwise focus moves.
326 - "a drag copies" (7780) and "a drag over WIDE cells" (7826) → per-tile drag
327 at the tile's rect (Open questions #5).
328 - Watcher block "did the daemon see a second attach?" (769–849) → "tiles
329 attach once each" (drop the "and the zoom never does" clause).
330
331 **Delete with the behaviour:**
332 - M4 "Ctrl-\ w unzooms to the wall, and Enter goes back in" (5290) — `w`
333 removed; `Enter` typed through.
334 - "the wall zooms IN PLACE: Enter promotes the selected tile" (5508) —
335 promote/0×0 gone.
336 - "an unzoomed wall forwards nothing" (6099) — no unzoomed state.
337 - "a tile the zoom is not on paints NOTHING" (6378) — every tile paints its
338 rect (behaviour inverts).
339 - "a mouse report at the UNZOOMED wall is discarded" (7603) — the wall's own
340 mouse filter is gone; reports route to the focused tile.
341 - CLI-wall 0×0 passivity (5013–5018, "a wall of 0×0 attaches must never have
342 claimed either grid") and `ok "mux wall: … read-only"` — CLI wall tiles
343 now claim their rect. (The rest of the CLI-wall leg, 4951–5010, rewrites:
344 two sessions still paint on one terminal via a live delta.)
345
346 Failing test FIRST: each rewrite's `expect`/`grep` moves to the new bytes
347 (no `[0x0]`, no `\x1cw`); each delete drops its leg block and `ok` line.
348 `make e2e` is the test.
349
350 Change: as classified, each rewritten leg under an isolated `XDG_STATE_HOME`
351 (CLAUDE.md session hygiene).
352
353 Verify: `make ci; echo EXIT=$?`
354
355 ---
356
357 ## Open questions
358
359 1. **Row-offset mechanism (param vs Core field).** The spec says "If `Core`
360 cannot paint at a row offset this chunk adds the offset" but not how.
361 Tasks 1–2 use a `row_off` param on the paint.zig fns + a `Core.row_off`
362 field set by the wall pump (mirrors how `size`/`out_fd` live on Core).
363 Confirm with the reviewer before task 1.
364
365 2. **`paintOverlay` offsetting.** Verified by signature only
366 (interact.zig:774 takes no `row_off`); task 2 assumes it addresses
367 absolute rows and threads the offset through. If it already paints
368 relative to a sub-rect, task 2 shrinks — read the body first.
369
370 3. **Empty wall after the last `Ctrl-\ x`.** The spec covers a tile *ending*
371 ("last tile ending ends the run") but not the last tile being *forgotten*.
372 Today `paintEmptyWallLocked` (2012) shows "the wall is empty - q to leave",
373 but bare `q` is now typed through. Plan keeps it, leave-hint → `Ctrl-\ d`;
374 confirm the empty wall persists (left via `Ctrl-\ d`) rather than ending
375 the run.
376
377 4. **Mouse-mode ownership on focus change.** Spec: "Focus is client-local
378 and sends nothing" (no attach/resize frame) and "Mouse: hit-test by
379 rect… a click focuses the tile it lands in, then routes" — but it does
380 not spell out the terminal-mode handoff. Plan: on focus move, old pump
381 `releaseTerminal`, new pump `claimTerminal` (doorbell-driven, same
382 pattern as `resize_pending`); the local `session_release` write is the
383 only terminal-side effect, matching today's demote. Confirm this is the
384 intended meaning of "client-local".
385
386 5. **Per-tile drag vs shared `drag`.** `Shared.drag` (247) is shared today
387 because the keyboard owns the mouse. With the wall's MouseFilter gone
388 (task 4), drag-select is per-tile. The spec says "drag-select per tile
389 unchanged" — confirm `Shared.drag` moves onto `Tile`/Core and the
390 copy/OSC-52 path (`copySelection`, 856) follows.
391
392 6. **`relayout` resize ordering.** Spec: "relayout resends the size of every
393 tile whose rect changed." The plan doorbells `resize_pending` per tile and
394 lets each pump send its own `.resize` (the pump is the transport's only
395 writer; the promote path chose the pump for this, 1439). Confirm this
396 beats a keyboard-thread resize write, which would need the pump's
397 mailbox/forward path.
398
399 ## Answers (reviewer, 2026-08-23)
400
401 1. `Core.row_off` field + `row_off` param on the paint.zig fns. Yes.
402 2. Read the body of `paintOverlay` first; shrink task 2 if it is already relative.
403 3. The empty wall persists; hint says `Ctrl-\ d`. Forgetting is not ending.
404 4. Yes: focus move = old pump `releaseTerminal`, new pump `claimTerminal`,
405 doorbell-driven. "Client-local" means no frame leaves the process.
406 5. Yes: `drag` moves onto the Tile; `copySelection` follows it.
407 6. Yes: the pump sends. One writer per transport is the invariant; keep it.
docs/superpowers/plans/2026-08-24-layout-persistence.md
Old New
@@ -1,471 +0,0 @@
1 # Layout Persistence Implementation Plan (multipane chunk 3)
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:** A wall client's layout tree (splits, orientations, weights) survives detach in a sidecar file and restores verbatim on reattach, healing per-leaf against wall-file drift.
6
7 **Architecture:** `layout.zig` gains pure serialize/parse/remap over its private Node/Container internals (no I/O); `wall.zig` gains the sidecar's path + atomic file I/O beside the wall file it already owns; `wallview.zig` wires save at detach/exit (hydrated walls only) and restore at hydration (spelling-matched, per-leaf healed). The wall file itself is untouched; muxweb is untouched.
8
9 **Tech Stack:** Zig 0.15.2 (vendored `deps/zig/zig`; build ONLY via make targets). Tests: `make check` per task, e2e legs in `test/e2e.sh`.
10
11 **Spec:** `docs/superpowers/specs/2026-08-24-layout-persistence-design.md` — read it whole before your task; it decides everything this plan implements.
12
13 ## Global Constraints
14
15 - Sidecar path: `$XDG_STATE_HOME/mux/layout` (fallback `~/.local/state/mux/layout`), beside the wall file.
16 - Format: first line `mux-layout 1`; node lines indented one space per depth; EVERY node line carries CELLS (root writes `0`, ignored on parse); container = `beside CELLS`/`stacked CELLS`; leaf = `leaf CELLS SPELLING` (spelling = rest of line, no quoting).
17 - Error posture: `wall.load` stays strict; every sidecar failure (version, parse, depth jump, empty container, bad cells, unreadable, > 1 MiB) degrades silently to the default cut. Never refuse startup over the sidecar.
18 - Save: on clean detach and exit-via-last-session-end, HYDRATED walls only; atomic temp+rename; a failed write warns on stderr and the exit proceeds.
19 - Restore: saved tree verbatim (root orient from the file; `rootOrient` aspect rule NOT consulted when a restore applies); leaves match wall lines by spelling, positionally, first-unmatched-wins; zero matches → sidecar ignored entirely.
20 - Not persisted: fullscreen, focus.
21 - Comments say WHY; no task numbers or plan codenames in src comments (`make check` docscheck tier 2 rejects them). docscheck.budget raises must be exact (`deps/zig/zig build doc-report` gives true figures).
22 - Capture `$?` before piping: `make check > /tmp/c3-tN-check.log 2>&1; echo EXIT=$?`.
23 - Never `git stash`; never amend/rebase a commit after announcing completion.
24
25 ## File structure
26
27 | File | Gains |
28 |---|---|
29 | `src/layout.zig` | `serialize`, `ParsedLayout`, `parse`, `remapLeaves` — pure, unit-tested here |
30 | `src/wall.zig` | `saveBytes` (extracted atomic primitive), `layoutPath`/`layoutPathFrom`, `loadLayout`, `saveLayout` |
31 | `src/wallview.zig` | `Entry.hydrated`, `layoutBytes` + save wiring, `restoreLayout` + hydration wiring |
32 | `test/e2e.sh` | three legs at END: restore, healing, degrade |
33 | `CLAUDE.md`, `README.md` | invariant reconciliation + one persistence sentence |
34
35 ---
36
37 ### Task 1: `layout.zig` — serialize
38
39 **Files:**
40 - Modify: `src/layout.zig` (Tree struct is at ~line 120; `Node = union(enum){leaf: u8, container: *Container}`, `Container = {orient, children: ArrayListUnmanaged(*Node), weights: ArrayListUnmanaged(u32)}` at ~line 48 — both private, which is why serialize lives in this file)
41
42 **Interfaces:**
43 - Produces: `pub fn serialize(self: *const Tree, spellings: []const []const u8, writer: anytype) !void` — leaf id indexes `spellings`; writes the full sidecar text including the `mux-layout 1` header. Task 2's parse must round-trip its output; Task 4 calls it with tile labels.
44
45 - [ ] **Step 1: Write the failing round-trip-shaped test** (asserting exact bytes, since parse doesn't exist yet)
46
47 ```zig
48 test "serialize: every node line carries its parent-axis cells" {
49 const alloc = std.testing.allocator;
50 var t = Tree.init(alloc);
51 defer t.deinit();
52 try t.addFirst(0);
53 try t.splitRight(0, 1); // beside: 0 | 1
54 try t.splitBelow(1, 2); // beside: 0 | stacked(1, 2)
55 // Weights after splits are the equal-cut defaults; rewrite them to
56 // known cells the way resize does, via a flatten-consistent resize:
57 // not needed — assert against whatever weights the tree holds by
58 // reading the emitted CELLS back structurally instead of literally.
59 const spellings = [_][]const u8{ "--sock /tmp/x#a", "--sock /tmp/x#b", "--sock /tmp/x#c" };
60 var buf: std.ArrayListUnmanaged(u8) = .{};
61 defer buf.deinit(alloc);
62 try t.serialize(&spellings, buf.writer(alloc));
63 const out = buf.items;
64 // Header, then root at depth 0 with cells 0, children one space deep.
65 try std.testing.expect(std.mem.startsWith(u8, out, "mux-layout 1\nbeside 0\n leaf "));
66 // The stacked child is a container line WITH cells at depth 1.
67 try std.testing.expect(std.mem.indexOf(u8, out, "\n stacked ") != null);
68 // Both nested leaves at depth 2, spellings verbatim to end of line.
69 try std.testing.expect(std.mem.indexOf(u8, out, "\n leaf ") != null);
70 try std.testing.expect(std.mem.indexOf(u8, out, " --sock /tmp/x#c\n") != null);
71 }
72
73 test "serialize: a single-leaf tree is a bare root leaf line" {
74 const alloc = std.testing.allocator;
75 var t = Tree.init(alloc);
76 defer t.deinit();
77 try t.addFirst(4);
78 const spellings = [_][]const u8{ "", "", "", "", "box1#b" };
79 var buf: std.ArrayListUnmanaged(u8) = .{};
80 defer buf.deinit(alloc);
81 try t.serialize(&spellings, buf.writer(alloc));
82 try std.testing.expectEqualStrings("mux-layout 1\nleaf 0 box1#b\n", buf.items);
83 }
84 ```
85
86 - [ ] **Step 2: Run to verify it fails**
87
88 Run: `make test > /tmp/c3-t1-red.log 2>&1; echo EXIT=$?` — expect EXIT≠0 with "no member named 'serialize'". (A compile error in a test is a valid RED here.)
89
90 - [ ] **Step 3: Implement**
91
92 Inside `Tree` (weights live in the PARENT: a node's cells are `parent.weights.items[child_index]`; the root has none and writes 0):
93
94 ```zig
95 /// Sidecar text (spec: layout-persistence design). Leaf ids index
96 /// `spellings`. Every node line carries its weight in the parent's
97 /// axis; the root writes 0 because it has no parent.
98 pub fn serialize(self: *const Tree, spellings: []const []const u8, writer: anytype) !void {
99 try writer.writeAll("mux-layout 1\n");
100 if (self.root) |r| try serializeNode(r, 0, 0, spellings, writer);
101 }
102
103 fn serializeNode(node: *const Node, depth: usize, cells: u32, spellings: []const []const u8, writer: anytype) !void {
104 try writer.writeByteNTimes(' ', depth);
105 switch (node.*) {
106 .leaf => |id| try writer.print("leaf {d} {s}\n", .{ cells, spellings[id] }),
107 .container => |c| {
108 try writer.print("{s} {d}\n", .{ @tagName(c.orient), cells });
109 for (c.children.items, c.weights.items) |child, w|
110 try serializeNode(child, depth + 1, w, spellings, writer);
111 },
112 }
113 }
114 ```
115
116 - [ ] **Step 4: Run to verify it passes**
117
118 Run: `make test > /tmp/c3-t1-green.log 2>&1; echo EXIT=$?` — expect EXIT=0. Then `make check > /tmp/c3-t1-check.log 2>&1; echo EXIT=$?` — if docscheck's budget line for layout.zig trips, run `deps/zig/zig build doc-report` and set the exact figure in `docscheck.budget` (a raise is part of this commit and gets named in the report).
119
120 - [ ] **Step 5: Commit**
121
122 ```bash
123 git add src/layout.zig docscheck.budget
124 git commit -m "feat: the tree writes itself as sidecar lines"
125 ```
126
127 ---
128
129 ### Task 2: `layout.zig` — parse and remapLeaves
130
131 **Files:**
132 - Modify: `src/layout.zig`
133
134 **Interfaces:**
135 - Consumes: `serialize` from Task 1 (round-trip tests).
136 - Produces:
137 - `pub const ParsedLayout = struct { tree: Tree, spellings: std.ArrayListUnmanaged([]u8), pub fn deinit(self: *ParsedLayout, alloc: std.mem.Allocator) void }` — leaf id = index into `spellings`; spellings are alloc-duped copies.
138 - `pub fn parse(alloc: std.mem.Allocator, bytes: []const u8) ?ParsedLayout` — null on ANY malformation (the degrade signal; no error union, the caller never distinguishes).
139 - `pub fn remapLeaves(self: *Tree, map: []const ?u8) void` — for each leaf id `i` present: `map[i] = null` removes it (existing `remove`, containers collapse as always); otherwise the leaf id becomes `map[i].?`. Removals all happen before any rewrite so old ids stay addressable; the rewrite is one pass so new ids cannot collide with not-yet-rewritten old ones.
140
141 - [ ] **Step 1: Write the failing tests**
142
143 ```zig
144 test "parse: round-trips serialize, structure and weights intact" {
145 const alloc = std.testing.allocator;
146 var t = Tree.init(alloc);
147 defer t.deinit();
148 try t.addFirst(0);
149 try t.splitRight(0, 1);
150 try t.splitBelow(1, 2);
151 const spellings = [_][]const u8{ "--sock /tmp/x#a", "--sock /tmp/x#a", "box2#c" };
152 var buf: std.ArrayListUnmanaged(u8) = .{};
153 defer buf.deinit(alloc);
154 try t.serialize(&spellings, buf.writer(alloc));
155
156 var p = parse(alloc, buf.items) orelse return error.TestUnexpectedResult;
157 defer p.deinit(alloc);
158 try std.testing.expectEqual(@as(usize, 3), p.tree.count());
159 try std.testing.expectEqual(@as(usize, 3), p.spellings.items.len);
160 // Duplicate spellings survive as distinct entries, order preserved.
161 try std.testing.expectEqualStrings("--sock /tmp/x#a", p.spellings.items[1]);
162 // The parsed tree flattens like the original: same rects.
163 const fa = try t.flatten(alloc, 24, 80, .{ .rows = 2, .cols = 3 }, null);
164 defer fa.deinit(alloc);
165 const fb = try p.tree.flatten(alloc, 24, 80, .{ .rows = 2, .cols = 3 }, null);
166 defer fb.deinit(alloc);
167 for (fa.placed, fb.placed) |a, b| {
168 try std.testing.expectEqual(a.rect, b.rect);
169 }
170 }
171
172 test "parse: every malformation degrades to null, never an error" {
173 const alloc = std.testing.allocator;
174 const bad = [_][]const u8{
175 "mux-layout 2\nleaf 0 x\n", // wrong version
176 "wall 1\nleaf 0 x\n", // wrong magic
177 "mux-layout 1\nbeside 0\n leaf 1 x\n", // depth jump (0 -> 3)
178 "mux-layout 1\nbeside 0\n", // empty container
179 "mux-layout 1\nleaf zz x\n", // bad cells
180 "mux-layout 1\nleaf 0 x\nleaf 0 y\n", // second root
181 "mux-layout 1\n", // no tree at all
182 "", // empty input
183 };
184 for (bad) |b| try std.testing.expect(parse(alloc, b) == null);
185 }
186
187 test "remapLeaves: null removes, containers collapse, ids rewrite in one pass" {
188 const alloc = std.testing.allocator;
189 var t = Tree.init(alloc);
190 defer t.deinit();
191 try t.addFirst(0);
192 try t.splitRight(0, 1);
193 try t.splitBelow(1, 2);
194 // Leaf 1 has no wall line; 0 and 2 map to tiles 2 and 0 (a swap, the
195 // collision-prone case a two-pass rewrite gets wrong).
196 t.remapLeaves(&[_]?u8{ 2, null, 0 });
197 try std.testing.expectEqual(@as(usize, 2), t.count());
198 const f = try t.flatten(alloc, 24, 80, .{ .rows = 2, .cols = 3 }, null);
199 defer f.deinit(alloc);
200 try std.testing.expect(f.rectOf(2) != null);
201 try std.testing.expect(f.rectOf(0) != null);
202 try std.testing.expect(f.rectOf(1) == null);
203 }
204 ```
205
206 - [ ] **Step 2: Run to verify RED** — `make test > /tmp/c3-t2-red.log 2>&1; echo EXIT=$?` ≠ 0.
207
208 - [ ] **Step 3: Implement**
209
210 Parse sketch (line-driven, an explicit stack of open containers; every failure is `return null`):
211
212 ```zig
213 pub const ParsedLayout = struct {
214 tree: Tree,
215 spellings: std.ArrayListUnmanaged([]u8) = .{},
216 pub fn deinit(self: *ParsedLayout, alloc: std.mem.Allocator) void {
217 self.tree.deinit();
218 for (self.spellings.items) |s| alloc.free(s);
219 self.spellings.deinit(alloc);
220 }
221 };
222
223 /// Null on any malformation: the sidecar is derived convenience, so a
224 /// bad one degrades to the default cut instead of refusing startup —
225 /// the deliberate opposite of wall.load's strictness about lines the
226 /// user authored.
227 pub fn parse(alloc: std.mem.Allocator, bytes: []const u8) ?ParsedLayout { ... }
228 ```
229
230 Rules the implementation must enforce (each is a test above): first line exactly `mux-layout 1`; depth = leading spaces, may go deeper only by exactly 1 and only into the just-opened container; `leaf CELLS SPELLING` needs a parseable u32 and a non-empty spelling; a container closed with zero children is malformed; a second depth-0 node is malformed; zero nodes is malformed; more than 255 leaves is malformed (ids are u8). Build nodes with the same internal constructors `addFirst`/`insert` CANNOT express (nested containers with given weights), so construct `Node`/`Container` values directly — that is why parse lives in this file. On success the root's cells were read and discarded.
231
232 `remapLeaves`: first walk collecting ids whose map entry is null, call the existing `remove(id)` for each; then one walk rewriting every remaining `.leaf` id through the map.
233
234 - [ ] **Step 4: GREEN + check** — `make test`, then `make check`, both `EXIT=0` (budget: exact figure via doc-report if flagged).
235
236 - [ ] **Step 5: Commit**
237
238 ```bash
239 git add src/layout.zig docscheck.budget
240 git commit -m "feat: the sidecar parses back into a tree, or into nothing"
241 ```
242
243 ---
244
245 ### Task 3: `wall.zig` — sidecar path and I/O
246
247 **Files:**
248 - Modify: `src/wall.zig` (`statePathFrom` at ~line 241 is the pattern; `saveLines` at ~line 176-ish holds the atomic idiom)
249
250 **Interfaces:**
251 - Produces:
252 - `pub fn saveBytes(path: []const u8, bytes: []const u8) !void` — the atomic temp+rename primitive, extracted so `saveLines` and the sidecar share ONE writer of state files. `saveLines` becomes a caller (join lines with `\n` into a buffer, then `saveBytes`), or keeps its streaming write and both use `atomicFile` identically — implementer's choice, but the file must end with exactly one writer idiom.
253 - `pub fn layoutPathFrom(alloc, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8` → `{s}/mux/layout` / `{s}/.local/state/mux/layout` — mirror `statePathFrom` including its *From-for-tests reason.
254 - `pub fn layoutPath(alloc) ![]const u8` — env-reading wrapper, like `statePath`.
255 - `pub fn loadLayout(alloc: std.mem.Allocator, path: []const u8) ?[]u8` — reads at most 1 MiB; null on missing, unreadable, or oversize. No error union: every failure is the same degrade.
256 - `pub fn saveLayout(path: []const u8, bytes: []const u8) !void` — `saveBytes`.
257
258 - [ ] **Step 1: Failing tests** (use `testtmp` like wall.zig's existing file tests do — grep `testtmp` in the file for the idiom):
259
260 ```zig
261 test "layout sidecar: save round-trips through load; a missing file is null" {
262 const alloc = std.testing.allocator;
263 var tmp = try testtmp.dir();
264 defer tmp.cleanup();
265 const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path});
266 defer alloc.free(path);
267 try std.testing.expect(loadLayout(alloc, path) == null);
268 try saveLayout(path, "mux-layout 1\nleaf 0 x\n");
269 const got = loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
270 defer alloc.free(got);
271 try std.testing.expectEqualStrings("mux-layout 1\nleaf 0 x\n", got);
272 }
273
274 test "layoutPathFrom: sits beside the wall file" {
275 const alloc = std.testing.allocator;
276 const p = try layoutPathFrom(alloc, "/xdg", null);
277 defer alloc.free(p);
278 try std.testing.expectEqualStrings("/xdg/mux/layout", p);
279 const q = try layoutPathFrom(alloc, null, "/home/u");
280 defer alloc.free(q);
281 try std.testing.expectEqualStrings("/home/u/.local/state/mux/layout", q);
282 }
283 ```
284
285 - [ ] **Step 2: RED** — `make test` ≠ 0.
286 - [ ] **Step 3: Implement** per the interface block. `loadLayout`'s cap: `readFileAlloc(alloc, path, 1024 * 1024)`, any error → null.
287 - [ ] **Step 4: GREEN + check** — both EXIT=0.
288 - [ ] **Step 5: Commit**
289
290 ```bash
291 git add src/wall.zig docscheck.budget
292 git commit -m "feat: wall.zig owns the layout sidecar's file"
293 ```
294
295 ---
296
297 ### Task 4: `wallview.zig` — save at detach and exit
298
299 **Files:**
300 - Modify: `src/wallview.zig` (`Entry` struct at ~line 2224; `run` at ~2265; the `.detach` action arm at ~2613; the last-tile-exit path — grep `endedTile` and the run loop's exit returns to find where the wall ends because the final session ended)
301 - Modify: `src/mux_main.zig` (set `Entry.hydrated` at the wall-file call sites)
302
303 **Interfaces:**
304 - Consumes: `Tree.serialize` (Task 1), `wall.layoutPath`/`wall.saveLayout` (Task 3). Tile spelling: `tiles[i].r.label` IS the spelling verbatim (see `Resolved.label`'s doc at wallview.zig:38-40).
305 - Produces:
306 - `Entry.hydrated: bool = false` — true ONLY when `resolved` came from the wall FILE: the `wall.load` path in `src/mux_main.zig` (~line 524, taken by `mux wall` with no targets). `mux TARGET` (runAttach) and `mux wall` WITH argv targets leave it false — an argv wall is an explicit view; it records nothing and must save nothing.
307 - In wallview, the `Ctrl-\ w` fold (grep `hydrate(` call sites in the keyboard loop) flips a local `hydrated = true`.
308 - `fn saveLayoutIfHydrated(alloc, tiles: []Tile, present: []const bool, shared: *Shared, hydrated: bool) void` — no-op unless hydrated; serializes the tree with each present tile's `r.label` as its spelling, `wall.saveLayout(wall.layoutPath(...))`; every failure prints one stderr line (`mux: wall layout not saved: ...`) and returns — persistence failure never blocks leaving.
309
310 - [ ] **Step 1: Failing test** (unit-test the helper against a temp XDG path via `wall.layoutPathFrom` — pass the path in, don't read env in the helper; the helper takes the path as a parameter so tests can aim it):
311
312 Adjust the produced signature accordingly: `fn saveLayoutTo(alloc, path: []const u8, tiles: []Tile, present: []const bool, shared: *Shared) void` plus a thin env-path caller used by run(). Test:
313
314 ```zig
315 test "detach writes the sidecar for the present tiles, spellings verbatim" {
316 const alloc = std.testing.allocator;
317 var tmp = try testtmp.dir();
318 defer tmp.cleanup();
319 const path = try std.fmt.allocPrint(alloc, "{s}/layout", .{tmp.path});
320 defer alloc.free(path);
321 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
322 shared.tree = layout.Tree.init(alloc);
323 shared.flat_alloc = alloc;
324 defer shared.tree.deinit();
325 defer if (shared.last_flat) |*f| f.deinit(alloc);
326 defer if (shared.base_flat) |*f| f.deinit(alloc);
327 try shared.tree.addFirst(0);
328 try shared.tree.splitRight(0, 1);
329 const tiles = try alloc.alloc(Tile, 2);
330 defer alloc.free(tiles);
331 // Build tiles exactly as the resize tests in this file do (grep
332 // "resize: l grows" for the fixture idiom), labels "--sock /tmp/x#a"
333 // and "--sock /tmp/x#b".
334 const present = [_]bool{ true, true };
335 saveLayoutTo(alloc, path, tiles, &present, &shared);
336 const got = wall.loadLayout(alloc, path) orelse return error.TestUnexpectedResult;
337 defer alloc.free(got);
338 try std.testing.expect(std.mem.startsWith(u8, got, "mux-layout 1\nbeside 0\n"));
339 try std.testing.expect(std.mem.indexOf(u8, got, "--sock /tmp/x#b\n") != null);
340 }
341 ```
342
343 - [ ] **Step 2: RED.** — `make test` ≠ 0.
344 - [ ] **Step 3: Implement.** Wire `saveLayoutTo`'s env-path caller at BOTH exits: the `.detach` arm (before the detach request goes out is fine — the keyboard thread owns the tree, no lock beyond what the arm already holds) and the exit-because-last-session-ended path. Gate both on `hydrated` (Entry flag OR the fold flipped it). A vanished tile is already out of the tree at these points (the vanish path removes), so present tiles are exactly the leaves.
345 - [ ] **Step 4: GREEN + check.**
346 - [ ] **Step 5: Commit**
347
348 ```bash
349 git add src/wallview.zig src/mux_main.zig docscheck.budget
350 git commit -m "feat: a hydrated wall saves its layout on the way out"
351 ```
352
353 ---
354
355 ### Task 5: `wallview.zig` — restore at hydration
356
357 **Files:**
358 - Modify: `src/wallview.zig` (initial tree build in `run` at ~line 2299 — the `addFirst`/insert loop — and `hydrate` at ~2103)
359
360 **Interfaces:**
361 - Consumes: `layout.parse`, `layout.remapLeaves` (Task 2), `wall.loadLayout`/`wall.layoutPath` (Task 3), `Resolved.label` spellings.
362 - Produces: `fn restoreLayout(alloc, resolved: []const Resolved, shared: *Shared, bytes: []const u8) bool` — pure of file I/O (bytes passed in, tests aim it). True = `shared.tree` now holds the matched saved structure; false = caller builds today's default (aspect `rootOrient` + addFirst/insert). run() feeds it `wall.loadLayout(...)` when `entry.hydrated`.
363
364 Matching algorithm (the heart — implement exactly):
365 1. `layout.parse(bytes) orelse return false`.
366 2. Walk saved leaves in order (in-order tree walk = spellings list order is NOT guaranteed — match on leaf ids 0..n-1 in id order, which IS the spellings order). For each saved leaf id `i`: find the first wall index `j` not yet taken with `std.mem.eql(u8, resolved[j].label, parsed.spellings.items[i])`; record `map[i] = j` or null.
367 3. All null → deinit parsed, return false (zero matches: sidecar ignored, spec Restore rule 4).
368 4. `parsed.tree.remapLeaves(map)`; move `parsed.tree` into `shared.tree` (deinit the old one), keep root orient as parsed (do NOT call `setRootOrient`).
369 5. Unmatched wall indices, ascending: `shared.tree.insert(first_matched_tile, @intCast(j))` — the existing beside-focus placement.
370 6. Deinit the parsed spellings; return true.
371
372 - [ ] **Step 1: Failing tests**
373
374 ```zig
375 test "restore: a saved beside pair comes back verbatim on a tall terminal" {
376 // Aspect at 40x30 would say stacked; the sidecar says beside and wins.
377 const alloc = std.testing.allocator;
378 var shared = ...; // fixture as in the resize tests, size 40x30
379 const resolved = [_]Resolved{
380 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#a", .session = "a" },
381 .{ .target = .{ .sock = "/tmp/x" }, .label = "--sock /tmp/x#b", .session = "b" },
382 };
383 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n leaf 39 --sock /tmp/x#b\n";
384 try std.testing.expect(restoreLayout(alloc, &resolved, &shared, bytes));
385 relayout(alloc, tiles, &present, &shared, 0);
386 // Beside: both tiles at top 0, tile 1 to the right.
387 try std.testing.expectEqual(@as(u16, 0), tiles[1].rect.top);
388 try std.testing.expect(tiles[1].rect.left > 0);
389 }
390
391 test "restore: heals — unknown wall line inserted, lost leaf dropped" {
392 // Sidecar knows a and GONE; wall has a and NEW. a keeps its slot,
393 // GONE collapses out, NEW inserts beside the match.
394 const bytes = "mux-layout 1\nbeside 0\n leaf 40 --sock /tmp/x#a\n leaf 39 --sock /tmp/x#gone\n";
395 const resolved = ...; // labels "--sock /tmp/x#a", "--sock /tmp/x#new"
396 try std.testing.expect(restoreLayout(alloc, &resolved, &shared, bytes));
397 try std.testing.expectEqual(@as(usize, 2), shared.tree.count());
398 }
399
400 test "restore: zero matches or garbage degrade to false" {
401 try std.testing.expect(!restoreLayout(alloc, &resolved, &shared, "not a sidecar"));
402 const nomatch = "mux-layout 1\nleaf 0 --sock /nowhere#z\n";
403 try std.testing.expect(!restoreLayout(alloc, &resolved, &shared, nomatch));
404 }
405
406 test "restore: duplicate spellings pair positionally" {
407 // Wall shows one host twice: same spelling on both lines. Saved
408 // leaves 0 and 1 share it; leaf 0 takes wall 0, leaf 1 takes wall 1.
409 ...
410 }
411 ```
412
413 - [ ] **Step 2: RED.**
414 - [ ] **Step 3: Implement**, then wire: in `run`, when `entry.hydrated`, try `wall.loadLayout` + `restoreLayout` FIRST; only on false run today's default build (including `setRootOrient(rootOrient(size))`). In `hydrate` (the `Ctrl-\ w` fold), after the fold's tiles exist, run the same match over the folded set — the entry tile participates like any other (it has a wall line; spec Restore rule 5).
415 - [ ] **Step 4: GREEN + check.**
416 - [ ] **Step 5: Commit**
417
418 ```bash
419 git add src/wallview.zig docscheck.budget
420 git commit -m "feat: reattach restores the saved layout, healed per leaf"
421 ```
422
423 ---
424
425 ### Task 6: e2e legs
426
427 **Files:**
428 - Modify: `test/e2e.sh` (END, before the pin checks; the fullscreen/resize/span-clear legs at the file's tail are the idiom for daemons, ptyclient scripts, cleanup entries)
429
430 Three legs, each gated (`make e2e > /tmp/c3-t6-legN.log 2>&1; echo EXIT=$?` after EACH — a wedged leg prints nothing). Sockets SOCK59/60/61, pids D60PID/61/62PID — declare beside SOCK56-58, kill/stop/reap/rm-sweep in cleanup() exactly as those do. Every leg exports its OWN `XDG_STATE_HOME` scratch dir (the suite already isolates one — grep `XDG_STATE_HOME` in e2e.sh and follow the existing wall-file legs' idiom) so the sidecar lands where the leg can read it.
431
432 1. **Restore leg:** daemon, sessions a+b; ptyclient wall 80x24 hydrated from the WALL FILE: write the two spellings into `$XDG_STATE_HOME/mux/wall` first (the wall-file legs show how), then run `"$MUX" wall` with NO further arguments and the leg's state home exported — the no-argv wall-load path is the hydration. Script: `\x1cr`, `lll`, Esc, `\x1cd`. Assert the sidecar file exists and its first lines are `mux-layout 1` + `beside 0`. Reattach with a second ptyclient, same state home: assert the rail's max CUP column in the SECOND capture ≥ 43 (the resize survived the round trip; the resize leg's `_rail_re` grep is the idiom), then type a marker, `muxa capture` proves it landed in the focused session, `\x1cd`.
433 2. **Healing leg:** after a detach that saved a 2-pane sidecar, append a third spelling line to the wall file (the runtime-wall legs use `mux add` — either idiom is fine) and remove one of the originals. Reattach: assert 2 tiles present (survivor + new line), the survivor's marker still reaches its session, and the wall did not refuse.
434 3. **Degrade leg:** overwrite the sidecar with `bogus 9\n`. Reattach: wall comes up (a marker typed reaches the focused session), no error output on the client's stderr capture mentioning layout. This asserts the degrade is SILENT.
435
436 Bump the scenario pin (`OK_COUNT`, currently 68) by one per leg → 71; convergence pin unchanged unless you add a `converged_quiet` (don't need to). The summary echo derives from the counters already — leave it.
437
438 - [ ] Steps: write leg 1 → gate → leg 2 → gate → leg 3 → gate → `make check`. Commit:
439
440 ```bash
441 git add test/e2e.sh
442 git commit -m "test: the layout survives the round trip, heals, and degrades"
443 ```
444
445 ---
446
447 ### Task 7: docs — invariants for the pane era
448
449 **Files:**
450 - Modify: `CLAUDE.md` (the "Invariants" section), `README.md` (the panes paragraph from the chunk-2 docs commit)
451
452 CLAUDE.md invariants block gains (and reconciles — read the existing bullets and rewrite where chunk 1/2 made them stale):
453 - The layout tree is client-local, keyboard-thread-owned under `paint_mu`; `relayout` is the single flatten point.
454 - Resize is gain-only in `layout.zig`; the shrink keys grow a neighbor at the focus's expense in `wallview.doResize`.
455 - Rails are painted from relayout and tiles cannot reach them: every clear is span-bounded ECH.
456 - The sidecar (`$XDG_STATE_HOME/mux/layout`): last detach wins, hydrated walls only, verbatim restore, per-leaf healing; `wall.load` strict / sidecar lenient — the asymmetry and its reason (authored intent vs derived convenience).
457
458 README panes paragraph gains one sentence: the layout survives detach and comes back on the next `mux`.
459
460 - [ ] `make check` EXIT=0 (README/CLAUDE.md are prose — but docscheck flags comment-claim refs; if it names symbols, they must resolve). Commit:
461
462 ```bash
463 git add CLAUDE.md README.md
464 git commit -m "docs: the invariants learn the pane era and its sidecar"
465 ```
466
467 ---
468
469 ## Final gate (the lander, not a task)
470
471 `make ci` EXIT=0 → `make xversion` against main → user hands-on → autosquash → merge to main → `make release`. Also: the browser-layout git-collab issue is already filed (fe582e4b).
docs/superpowers/plans/2026-08-24-multipane-layout-tree.md
Old New
@@ -1,487 +0,0 @@
1 # Multipane chunk 2 — layout tree 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:** Replace the wall's horizontal stripe cut with an i3-style container tree: side-by-side and stacked panes, `hjkl` directional focus, split chords, pane resize, fullscreen-as-a-layout-op.
6
7 **Architecture:** A new pure module `src/layout.zig` (layer 1) owns the tree and flattens it to rects; `wallview.zig` swaps `layoutStripes` for it in a behavior-neutral step, then turns columns on. `paint.zig` gains a column offset and span-limited clears so a pane's repaint cannot blank a side-by-side neighbour. The chord vocabulary grows in `interact.PrefixFilter` (one table, as ever). The claim model, doorbells, one-writer pumps, and client-local focus from chunk 1 are untouched.
8
9 **Tech Stack:** Zig 0.15.2 (vendored at `deps/zig/zig` — system zig will NOT build this), ghostty-vt engine, `make` targets only.
10
11 **Spec:** `docs/superpowers/specs/2026-08-24-multipane-layout-tree-design.md` — read it first; this plan argues from it.
12
13 ## Global Constraints
14
15 - Build/test ONLY via `make check`, `make test`, `make e2e` (they use `deps/zig/zig`). Capture `$?` before piping: `make e2e > /tmp/e2e.log 2>&1; echo EXIT=$?`.
16 - `make check` gates fmt, unit tests, shell syntax, and comment claims (symbol refs must resolve; flagged files' prose must meet `docscheck.budget` byte figures EXACTLY — if you change comments in a flagged file, run `deps/zig/zig build doc-report` and adjust the budget line down, never up without cause).
17 - Never `cat` `src/server.zig`, `test/e2e.sh`, `docs/decisions.md`, `src/wallview.zig`, `src/interact.zig`. Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window. Line numbers in this plan are anchors from tip a11e1b0 — re-grep the symbol if a window looks wrong.
18 - Comments say WHY, not how/what. Do not narrate mechanics.
19 - Commit per task, message in the repo's voice (see `git log --oneline -15`), trailer `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
20 - Do not touch `RETRO.md`. Do not merge to main. Work on branch `worktree-multipane`.
21 - New e2e legs go at the END of `test/e2e.sh` (a killed run must not block earlier legs).
22 - Wire floors: `protocol.min_session_cols = 2`, `min_session_rows = 2` (src/protocol.zig:175).
23
24 ---
25
26 ### Task 1: `src/layout.zig` — tree core and flatten
27
28 **Files:**
29 - Create: `src/layout.zig`
30 - Modify: `build.zig` (module table, layer 1 block near line 154)
31
32 **Interfaces (later tasks rely on these exact names):**
33
34 ```zig
35 pub const Orient = enum { beside, stacked }; // beside: children left→right
36 pub const Dir = enum { left, down, up, right };
37 pub const Rect = struct { top: u16, left: u16, rows: u16, cols: u16 };
38 pub const Rail = struct { col: u16, top: u16, rows: u16 }; // vertical separator column
39 pub const Placed = struct { tile: u8, rect: Rect };
40 pub const Flat = struct {
41 placed: []Placed,
42 rails: []Rail,
43 pub fn deinit(self: *Flat, alloc: std.mem.Allocator) void;
44 pub fn rectOf(self: Flat, tile: u8) ?Rect;
45 };
46 pub const Floors = struct { rows: u16, cols: u16 };
47
48 pub const Tree = struct {
49 pub fn init(alloc: std.mem.Allocator) Tree;
50 pub fn deinit(self: *Tree) void;
51 pub fn count(self: *const Tree) usize;
52 pub fn addFirst(self: *Tree, tile: u8) !void; // empty tree only
53 pub fn insert(self: *Tree, focus: u8, tile: u8) !void; // sibling after focus leaf
54 pub fn splitRight(self: *Tree, focus: u8, tile: u8) !void;
55 pub fn splitBelow(self: *Tree, focus: u8, tile: u8) !void;
56 pub fn remove(self: *Tree, tile: u8) void; // collapses single-child containers
57 pub fn flatten(self: *const Tree, alloc: std.mem.Allocator, rows: u16, cols: u16,
58 floors: Floors, fullscreen: ?u8) error{ TooSmall, OutOfMemory }!Flat;
59 };
60 ```
61
62 Representation: heap nodes, `Node = union(enum) { leaf: u8, container: Container }` where `Container = struct { orient: Orient, children: std.ArrayListUnmanaged(*Node), weights: std.ArrayListUnmanaged(u32) }` (weights parallel children, equal on insert). The tree stores its allocator. `max_tiles` is 32, tiles are `u8` indexes into the wall's array.
63
64 Semantics (from the spec — the tests below assert each):
65
66 - `insert`: new leaf becomes the next sibling of the focused leaf in its parent container, weight = the focus child's weight (equal shares stay equal). If the focused leaf IS the root, the root becomes a container — orientation chosen by the CALLER in later tasks, so here: a bare root insert creates a `.stacked` container (matches today's stripes; hydration orientation is wallview's business, passed by building with splits).
67 - `splitRight`/`splitBelow`: the focused LEAF is replaced by a new container (`.beside` / `.stacked`) holding `[old, new]`, weights `{1,1}`.
68 - `remove`: delete the leaf; a container left with ONE child dissolves — the child takes its place in the grandparent (or becomes root). A container never holds one child after any op.
69 - `flatten`: recursive. A `.stacked` container splits `rows` among children by weight; `.beside` splits `cols`, but first reserves `children-1` columns for rails (one rail between each adjacent pair) and reports each rail's `{col, top, rows}`. Integer division with the REMAINDER GIVEN TO THE EARLIEST CHILDREN, one cell each — this must reproduce `layoutStripes`' remainder-at-the-top rule exactly (wallview.zig:103) so Task 5's swap is behavior-neutral. Any leaf rect with `rows < floors.rows` or `cols < floors.cols` → `error.TooSmall` (rails count toward the shortage: a `.beside` split of 4 cols with floors.cols=2 is 2+rail+1 → TooSmall).
70 - `fullscreen != null`: that tile gets `{0,0,rows,cols}`, every other tile gets `{0,0,0,0}`, `rails` is empty, floors are not consulted (the full rect trivially passes; hidden tiles claim nothing). The TREE is not consulted differently — fullscreen is a rect assignment, nothing more.
71
72 - [ ] **Step 1: register the module** in `build.zig`'s layer-1 block (alphabetical-ish placement near `paint`):
73
74 ```zig
75 .{ .name = "layout", .path = "src/layout.zig", .layer = 1 },
76 ```
77
78 No imports: floors arrive as a parameter, so the module needs nothing internal (wallview passes `proto.min_session_*`).
79
80 - [ ] **Step 2: write the failing tests** (in `src/layout.zig`, Zig inline tests). Write ALL of these first; they define the module:
81
82 ```zig
83 test "a lone tile owns the whole terminal, no rails" {
84 var t = Tree.init(std.testing.allocator);
85 defer t.deinit();
86 try t.addFirst(7);
87 var f = try t.flatten(std.testing.allocator, 24, 80, .{ .rows = 2, .cols = 2 }, null);
88 defer f.deinit(std.testing.allocator);
89 try std.testing.expectEqual(@as(usize, 1), f.placed.len);
90 try std.testing.expectEqual(Rect{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, f.rectOf(7).?);
91 try std.testing.expectEqual(@as(usize, 0), f.rails.len);
92 }
93
94 test "a stacked cut reproduces layoutStripes' remainder-at-the-top rule" {
95 // 25 rows over 3: 9,8,8 with tops 0,9,17 — the exact figures
96 // wallview's stripe test pins, so Task 5's swap moves nothing.
97 var t = Tree.init(std.testing.allocator);
98 defer t.deinit();
99 try t.addFirst(0);
100 try t.insert(0, 1);
101 try t.insert(1, 2);
102 var f = try t.flatten(std.testing.allocator, 25, 80, .{ .rows = 2, .cols = 2 }, null);
103 defer f.deinit(std.testing.allocator);
104 try std.testing.expectEqual(Rect{ .top = 0, .left = 0, .rows = 9, .cols = 80 }, f.rectOf(0).?);
105 try std.testing.expectEqual(Rect{ .top = 9, .left = 0, .rows = 8, .cols = 80 }, f.rectOf(1).?);
106 try std.testing.expectEqual(Rect{ .top = 17, .left = 0, .rows = 8, .cols = 80 }, f.rectOf(2).?);
107 }
108
109 test "a beside cut spends one column per rail" {
110 var t = Tree.init(std.testing.allocator);
111 defer t.deinit();
112 try t.addFirst(0);
113 try t.splitRight(0, 1);
114 var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
115 defer f.deinit(std.testing.allocator);
116 // 81 cols − 1 rail = 80, split 40/40; the rail sits between them.
117 try std.testing.expectEqual(Rect{ .top = 0, .left = 0, .rows = 24, .cols = 40 }, f.rectOf(0).?);
118 try std.testing.expectEqual(Rect{ .top = 0, .left = 41, .rows = 24, .cols = 40 }, f.rectOf(1).?);
119 try std.testing.expectEqual(Rail{ .col = 40, .top = 0, .rows = 24 }, f.rails[0]);
120 }
121
122 test "splitBelow nests: 1 beside (2 over 3)" {
123 var t = Tree.init(std.testing.allocator);
124 defer t.deinit();
125 try t.addFirst(0);
126 try t.splitRight(0, 1);
127 try t.splitBelow(1, 2);
128 var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
129 defer f.deinit(std.testing.allocator);
130 try std.testing.expectEqual(Rect{ .top = 0, .left = 0, .rows = 24, .cols = 40 }, f.rectOf(0).?);
131 try std.testing.expectEqual(Rect{ .top = 0, .left = 41, .rows = 12, .cols = 40 }, f.rectOf(1).?);
132 try std.testing.expectEqual(Rect{ .top = 12, .left = 41, .rows = 12, .cols = 40 }, f.rectOf(2).?);
133 }
134
135 test "remove collapses a single-child container into its parent" {
136 var t = Tree.init(std.testing.allocator);
137 defer t.deinit();
138 try t.addFirst(0);
139 try t.splitRight(0, 1);
140 try t.splitBelow(1, 2);
141 t.remove(2);
142 // The beside pair is whole again: no degenerate one-child container.
143 var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
144 defer f.deinit(std.testing.allocator);
145 try std.testing.expectEqual(Rect{ .top = 0, .left = 41, .rows = 24, .cols = 40 }, f.rectOf(1).?);
146 t.remove(1);
147 try std.testing.expectEqual(@as(usize, 1), t.count());
148 }
149
150 test "a cut under the floors is refused, rails included" {
151 var t = Tree.init(std.testing.allocator);
152 defer t.deinit();
153 try t.addFirst(0);
154 try t.splitRight(0, 1);
155 // 4 cols: 2 + rail + 1 — the right pane is under the 2-col floor.
156 try std.testing.expectError(error.TooSmall,
157 t.flatten(std.testing.allocator, 24, 4, .{ .rows = 2, .cols = 2 }, null));
158 }
159
160 test "fullscreen is a rect assignment, not a tree change" {
161 var t = Tree.init(std.testing.allocator);
162 defer t.deinit();
163 try t.addFirst(0);
164 try t.splitRight(0, 1);
165 var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, 1);
166 defer f.deinit(std.testing.allocator);
167 try std.testing.expectEqual(Rect{ .top = 0, .left = 0, .rows = 24, .cols = 81 }, f.rectOf(1).?);
168 try std.testing.expectEqual(Rect{ .top = 0, .left = 0, .rows = 0, .cols = 0 }, f.rectOf(0).?);
169 try std.testing.expectEqual(@as(usize, 0), f.rails.len);
170 // The tree still cuts two panes once fullscreen lifts.
171 var g = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
172 defer g.deinit(std.testing.allocator);
173 try std.testing.expectEqual(@as(u16, 40), g.rectOf(0).?.cols);
174 }
175
176 test "insert lands beside the focus, along the container's orientation" {
177 var t = Tree.init(std.testing.allocator);
178 defer t.deinit();
179 try t.addFirst(0);
180 try t.splitRight(0, 1);
181 try t.insert(0, 2); // 0's container is beside → 2 slots between 0 and 1
182 var f = try t.flatten(std.testing.allocator, 24, 82, .{ .rows = 2, .cols = 2 }, null);
183 defer f.deinit(std.testing.allocator);
184 const r0 = f.rectOf(0).?;
185 const r2 = f.rectOf(2).?;
186 const r1 = f.rectOf(1).?;
187 try std.testing.expect(r0.left < r2.left and r2.left < r1.left);
188 }
189 ```
190
191 - [ ] **Step 3: run to verify they fail** — `make test 2>&1 | tail -20` → compile errors (module skeleton absent). Write the skeleton (types + fn stubs returning `error.TooSmall`/empty), rerun, expect test FAILURES not compile errors.
192
193 - [ ] **Step 4: implement.** The `//!` header states the contract (why-level: "rects come from here; who claims them is wallview's business; the remainder rule is layoutStripes' so the stripe era's cut survives as the degenerate tree"). Flatten's weight split, per container axis span `S` over weights `w[i]` totalling `W`: `base[i] = S * w[i] / W`, then hand the remainder `S − Σbase` one cell to each child left-to-right. Keep the recursion allocation-light: one pass building `placed`/`rails` ArrayLists.
194
195 - [ ] **Step 5: run `make test` (tail), expect PASS; run `make check`, expect exit 0.** If the docs gate flags the new file, run `deps/zig/zig build doc-report` and add the exact byte figure to `docscheck.budget`.
196
197 - [ ] **Step 6: commit** — `feat: layout.zig grows the wall's container tree` (voice: why the tree, one line on the remainder rule).
198
199 ---
200
201 ### Task 2: `layout.zig` — directional neighbor and resize
202
203 **Files:**
204 - Modify: `src/layout.zig`
205
206 **Interfaces:**
207
208 ```zig
209 /// Over a FLAT result, not the tree: adjacency is geometry.
210 pub fn neighbor(flat: Flat, focus: u8, dir: Dir) ?u8;
211
212 /// Move the focus pane's `dir` boundary by `delta_cells` (grow toward dir's
213 /// far edge for right/down, shrink for left/up — i3's grow/shrink pair).
214 /// Walks up to the nearest container of the right orientation. Returns
215 /// false when refused (no such container, or a floor would break).
216 pub fn resize(self: *Tree, alloc: std.mem.Allocator, rows: u16, cols: u16,
217 floors: Floors, focus: u8, dir: Dir, delta_cells: u16) bool;
218 ```
219
220 `neighbor` rule (spec): from the midpoint of the focused rect's `dir` edge, candidates are panes whose opposite edge abuts it (gap ≤ 1 — a rail sits between beside panes) and whose perpendicular span contains the midpoint; nearest edge wins ties. Deterministic, no focus history.
221
222 `resize` mechanics: flatten first (current cells), find the deepest ancestor container of the axis (`left/right` → `.beside`, `up/down` → `.stacked`); inside it, the child holding focus and its adjacent sibling toward `dir` trade `delta_cells`; then REWRITE the container's weights to every child's current cell count with that trade applied — weights become cells, exact and stable. Refuse (return false, tree untouched) if there is no adjacent sibling toward `dir` or a floor breaks.
223
224 - [ ] **Step 1: failing tests:**
225
226 ```zig
227 test "neighbor crosses a rail and lands on the abutting pane" {
228 var t = Tree.init(std.testing.allocator);
229 defer t.deinit();
230 try t.addFirst(0);
231 try t.splitRight(0, 1);
232 try t.splitBelow(1, 2);
233 var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
234 defer f.deinit(std.testing.allocator);
235 // Midpoint formula is `top + (rows − 1) / 2` = row 11 here, which lands
236 // in the upper right pane (rows 0–11). The formula is the module's to
237 // state in a comment beside `neighbor` — the point is determinism.
238 try std.testing.expectEqual(@as(?u8, 1), neighbor(f, 0, .right));
239 try std.testing.expectEqual(@as(?u8, 0), neighbor(f, 1, .left));
240 try std.testing.expectEqual(@as(?u8, 2), neighbor(f, 1, .down));
241 try std.testing.expectEqual(@as(?u8, null), neighbor(f, 0, .left));
242 }
243
244 test "resize trades cells between beside siblings and holds the floors" {
245 var t = Tree.init(std.testing.allocator);
246 defer t.deinit();
247 try t.addFirst(0);
248 try t.splitRight(0, 1);
249 try std.testing.expect(t.resize(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, 0, .right, 3));
250 var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
251 defer f.deinit(std.testing.allocator);
252 try std.testing.expectEqual(@as(u16, 43), f.rectOf(0).?.cols);
253 try std.testing.expectEqual(@as(u16, 37), f.rectOf(1).?.cols);
254 // Shrinking the neighbour under its floor is refused, layout stands.
255 try std.testing.expect(!t.resize(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, 0, .right, 40));
256 }
257
258 test "resize walks up to the container of the right axis" {
259 var t = Tree.init(std.testing.allocator);
260 defer t.deinit();
261 try t.addFirst(0);
262 try t.splitRight(0, 1);
263 try t.splitBelow(1, 2);
264 // Tile 2 sits in a stacked pair; growing it RIGHT resizes the beside
265 // container above — the whole right column widens.
266 try std.testing.expect(!t.resize(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, 2, .right, 2)); // no sibling to the right of the column
267 try std.testing.expect(t.resize(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, 2, .left, 2));
268 var f = try t.flatten(std.testing.allocator, 24, 81, .{ .rows = 2, .cols = 2 }, null);
269 defer f.deinit(std.testing.allocator);
270 try std.testing.expectEqual(@as(u16, 42), f.rectOf(2).?.cols);
271 }
272 ```
273
274
275 - [ ] **Step 2: run, watch them fail. Step 3: implement. Step 4: `make test` tail PASS, `make check` exit 0. Step 5: commit** — `feat: the tree answers hjkl and trades cells on resize`.
276
277 ---
278
279 ### Task 3: `PrefixFilter` — the chord vocabulary
280
281 **Files:**
282 - Modify: `src/interact.zig` (PrefixFilter, :90–160; its tests :2005–2060)
283 - Modify: `src/wallview.zig` (the action switch, :2466–2492)
284 - Modify: `README.md`, `src/mux_main.zig` (--help text) — the chord list
285
286 **Interfaces (consumed by Tasks 6–8):**
287
288 ```zig
289 pub const Dir = enum { left, down, up, right }; // interact's own; wallview maps to layout.Dir
290 // Action union: REMOVE last_session; ADD:
291 focus_dir: Dir, // h j k l
292 split_right, // '|' and '\\'
293 split_below, // '-'
294 fullscreen, // 'f'
295 resize: Dir, // in resize mode: h l shrink/grow width, k j shrink/grow height
296 // PrefixFilter state: beside `pending`:
297 resizing: bool = false,
298 ```
299
300 Semantics (spec): `Ctrl-\ r` enters resize mode (`resizing = true`, emits no action, byte consumed). While resizing: `h`→`.{.resize = .left}`, `j`→`.down`, `k`→`.up`, `l`→`.right` — each returns like a chord (ends the chunk) but the MODE PERSISTS across reads. `Esc` (0x1b) leaves the mode and is swallowed. Any other byte leaves the mode and is then processed NORMALLY in the same pass (a printable forwards, 0x1c starts a fresh prefix) — typing your way out works, the mode never eats prose. `l` as last-session is GONE: `n`/`p` cover cycling.
301
302 - [ ] **Step 1: failing tests** (beside the existing PrefixFilter tests, same style — mutate `var buf = "...".*` arrays):
303
304 ```zig
305 test "hjkl under the prefix answer directional focus" {
306 var f = PrefixFilter{};
307 var buf = "\x1chZZ".*;
308 const out = f.feed(&buf);
309 try std.testing.expectEqual(PrefixFilter.Action{ .focus_dir = .left }, out.action);
310 try std.testing.expectEqual(@as(usize, 0), out.forward.len); // chord ends the chunk
311 }
312
313 test "the split chords and fullscreen answer, backslash aliases the pipe" {
314 inline for (.{ .{ "\x1c|", PrefixFilter.Action.split_right }, .{ "\x1c\\", PrefixFilter.Action.split_right }, .{ "\x1c-", PrefixFilter.Action.split_below }, .{ "\x1cf", PrefixFilter.Action.fullscreen } }) |case| {
315 var f = PrefixFilter{};
316 var buf = case[0].*;
317 try std.testing.expectEqual(case[1], f.feed(&buf).action);
318 }
319 }
320
321 test "resize mode is sticky across reads and Esc leaves it silently" {
322 var f = PrefixFilter{};
323 var enter = "\x1cr".*;
324 try std.testing.expectEqual(PrefixFilter.Action.none, f.feed(&enter).action);
325 var grow = "l".*;
326 try std.testing.expectEqual(PrefixFilter.Action{ .resize = .right }, f.feed(&grow).action);
327 var again = "j".*; // still in the mode: no fresh prefix needed
328 try std.testing.expectEqual(PrefixFilter.Action{ .resize = .down }, f.feed(&again).action);
329 var esc = "\x1b".*;
330 const out = f.feed(&esc);
331 try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
332 try std.testing.expectEqual(@as(usize, 0), out.forward.len); // swallowed
333 var plain = "l".*; // mode is over: l is just a letter
334 try std.testing.expectEqual(@as(usize, 1), f.feed(&plain).forward.len);
335 }
336
337 test "prose ends resize mode and goes to the session" {
338 var f = PrefixFilter{};
339 var enter = "\x1cr".*;
340 _ = f.feed(&enter);
341 var typed = "vim".* ; // v exits the mode AND forwards
342 const out = f.feed(&typed);
343 try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
344 try std.testing.expectEqualStrings("vim", out.forward);
345 }
346
347 test "l is not last-session any more" {
348 var f = PrefixFilter{};
349 var buf = "\x1cl".*;
350 try std.testing.expectEqual(PrefixFilter.Action{ .focus_dir = .right }, f.feed(&buf).action);
351 }
352 ```
353
354 Delete the two `last_session` tests (:2050–2060) and the `.last_session` variant.
355
356 - [ ] **Step 2: run — compile errors expected** (wallview's switch loses `.last_session`, gains unhandled variants). **Step 3: implement** the filter; in wallview's switch (:2466), delete the `.last_session` arm and add no-op arms in the c300afe pattern (`// The wall does not act on these yet; wired later this chunk.`) for `.focus_dir`, `.split_right`, `.split_below`, `.fullscreen`, `.resize`. Update the PrefixFilter doc comment (:73–89) — it names `l` as last-visited today. **Step 4:** `make test` tail PASS, `make check` exit 0 (budget: interact.zig is flagged — rebalance its `docscheck.budget` line to the exact new figure). **Step 5:** update README's chord table and `mux_main.zig`'s `--help` (grep `Ctrl-` in both): drop last-session, add hjkl/splits/f/r. **Step 6: commit** — `feat: the prefix table learns splits, hjkl, and a resize mode`.
357
358 ---
359
360 ### Task 4: `paint.zig` — column offset and span-limited clears
361
362 **Files:**
363 - Modify: `src/paint.zig` (renderClipped :54, delta painter :92, :137, bannerText :177, paintBanner :187, renderScrollback :200; tests from :320 down)
364 - Modify: `src/interact.zig` (Core gains `col_off`, callers at :850, :1349, :1412, :1502; byte-pinned tests :3162, :3372, :3476)
365
366 **Interfaces:** every renderer that takes `row_off: u16` gains `col_off: u16` and `view_cols: u16` beside it (the pane's content width). `interact.Core` gains `col_off: u16 = 0` beside `row_off`; the wall pump sets it in Task 5. Plain client and muxa never touch it (0).
367
368 Mechanics — an in-place swap, byte for byte where possible:
369
370 - Every `\x1b[{row};1H` becomes `\x1b[{row};{col}H` with `col = col_off + 1`.
371 - Every `\x1b[2K` becomes `\x1b[{n}X` with `n = view_cols` (ECH erases exactly the pane's span and leaves the cursor where CUP put it — a repaint cannot blank a beside-neighbour). Emit in the same position 2K held so SGR adjacency is unchanged.
372 - `owns_screen` keeps its meaning: the full-screen `\x1b[H\x1b[2J` path (:63, :210) is untouched, and the `clear` selection at :70 becomes `if (owns_screen) "" else ech`.
373 - `bannerText`/`paintBanner` right-align INSIDE the pane: `col = col_off + view_cols − label_len + 1`, clamped at `col_off + 1`.
374 - `paintOverlay` (interact.zig :850 chain) takes `col_off` the same way `row_off` rides today; a predicted glyph paints at `cursor.x + col_off`.
375
376 - [ ] **Step 1: update the byte-pinned tests FIRST** so they state the new contract and fail: e.g. paint.zig :423 `"\x1b[6;1H\x1b[2K\x1b[0mold-row-1"` → `"\x1b[6;1H\x1b[80X\x1b[0mold-row-1"` (the test's view is 80 cols — read each test's size, don't guess). Add one NEW test:
377
378 ```zig
379 test "a pane off the left edge paints inside its own span" {
380 // col_off 40, 39 cols: CUP lands at column 41 and the erase covers 39
381 // cells — the bytes a beside-neighbour's survival depends on.
382 // (build a small view as the sibling tests do, render, then:)
383 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[6;41H\x1b[39X") != null);
384 try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[2K") == null);
385 }
386 ```
387
388 Also interact.zig :3162/:3476 count `\x1b[2K` occurrences — count `X`-erases instead (`\x1b[80X` for their 80-col views; read the test).
389
390 - [ ] **Step 2: run, watch them fail. Step 3: implement** (signatures, all callers — the compiler enumerates them; pass 0 and the full width everywhere outside the wall). **Step 4:** `make test` tail PASS; `make e2e > /tmp/e2e-t4.log 2>&1; echo EXIT=$?` → 0 (e2e greps markers, not clears; if a leg pins `2K`, update the leg — grep e2e.sh for `2K` first). `make check` exit 0, rebalance budgets for paint/interact if flagged prose moved. **Step 5: commit** — `feat: painters address a column offset and erase only their span`.
391
392 ---
393
394 ### Task 5: `wallview.zig` — the tree replaces the stripes, behavior-neutral
395
396 **Files:**
397 - Modify: `src/wallview.zig` throughout; `build.zig` (wallview's imports gain `"layout"`)
398
399 **Interfaces consumed:** `layout.Tree`, `layout.Flat`, `layout.Rect`, `layout.Floors` from Task 1; `Core.col_off` from Task 4.
400
401 This task ends with `make e2e` GREEN AND UNCHANGED: every wall is still a stack of full-width rows, because the tree is built stacked-only here. Columns arrive in Task 6.
402
403 Changes:
404
405 - `pub const Stripe` (:100) and `layoutStripes` (:103) and their tests (:2540–2591) are DELETED. `Tile.stripe: Stripe` (:259) becomes `rect: layout.Rect`; `viewRows` (:404) becomes `rect.rows -| shared.label_rows`, and a new `viewCols` returns `rect.cols`.
406 - `Shared` gains the tree: `tree: layout.Tree` plus the invariant comment (the keyboard owns it under the same single-writer rule as `sel`). Every site that called `layoutStripes(alloc, n, rows)` (:1580, :1787, :2009, :2140) flattens instead: `shared.tree.flatten(alloc, shared.size.rows, shared.size.cols, floors(live), null)` where `floors(live)` = `.{ .rows = proto.min_session_rows + @intFromBool(live > 1), .cols = proto.min_session_cols }` — the same bar-row arithmetic layoutStripes carried (:116).
407 - Tree maintenance: hydration (`resolveWall` path, :2140) builds the tree by `addFirst` + `insert` in file order (stacked — the aspect rule is Task 6); `addSessionTile` (:1755) calls `tree.insert(from_tile, new_tile)`; vanish/forget call `tree.remove(tile)` before their relayout. Tree ids are the tiles-array indexes already used everywhere.
408 - `relayout` (:1555) flattens, assigns `t.rect = flat.rectOf(i)`, doorbells `.resize` on change — the existing path, wider rects. `error.TooSmall` keeps today's refusal behavior at each callsite.
409 - The pump snapshot (:1064–1085) gains `left` and `cols`; `core.row_off = snap.top + snap.label_rows`, `core.col_off = snap.left`, view size = `{ snap.rows -| snap.label_rows, snap.cols }`, and `core.owns_screen = snap.top == 0 and snap.left == 0 and snap.label_rows == 0 and snap.cols == shared.size.cols and snap.rows == shared.size.rows` — a one-tile wall, exactly as today.
410 - `sendAttach` (:598) claims `{ viewCols, viewRows }` from the rect (today it claims tty cols).
411 - `paintLabelLocked` (:464) pads the bar to `rect.cols` starting at `rect.left + 1` (today it pads to the terminal width — full-width rects make this a no-op change here, live in Task 6).
412
413 - [ ] **Step 1:** port the three deleted `layoutStripes` tests as wall-level tree tests (same figures — they pin the remainder rule and the floor refusals through the new path, e.g. flatten of 3 stacked tiles over 25 rows = 9/8/8). Run `make test` — fails while the swap is half-made; drive it compile-error by compile-error.
414 - [ ] **Step 2:** implement until `make test` tail PASS.
415 - [ ] **Step 3:** `make e2e > /tmp/e2e-t5.log 2>&1; echo EXIT=$?` → 0, log ends `e2e OK (62 scenarios, 37 convergence points)` — UNCHANGED counts: this is the whole point of the neutral step. Debug any wall-leg drift here, not in Task 6.
416 - [ ] **Step 4:** `make check` exit 0 (wallview budget rebalance). **Step 5: commit** — `feat: the wall's rects come from a tree that still cuts stripes`.
417
418 ---
419
420 ### Task 6: columns on — splits, hjkl, rails, aspect hydration
421
422 **Files:**
423 - Modify: `src/wallview.zig` (action switch :2466; hydration; relayout rail painting; `addSessionTile`)
424 - Modify: `test/e2e.sh` (pin sizes on stacked-assuming legs)
425
426 **Interfaces consumed:** `layout.neighbor`, `Action.focus_dir/split_right/split_below` — plus `interact.Dir → layout.Dir` mapping (a small `fn dirOf`).
427
428 - Hydration orientation (spec): `cols ≥ 2 × rows` → build the wall-file tiles `.beside` (addFirst then splitRight-chain? No — INSERT preserves flat order: build `addFirst`, then `insert` each next tile after the previous one, with the ROOT container's orientation chosen by aspect. Give `Tree` the small hook this needs: `addFirst` + first `insert` create the root container — add `pub fn setRootOrient(self: *Tree, o: Orient)` OR give `insert` the orientation for the root-creation case only; pick the former, one line, test it in layout.zig).
429 - Chord wiring in the action switch: `.split_right`/`.split_below` → `addSessionTile` with a placement argument (`enum { beside_focus, right_of, below }`) that picks `tree.insert` / `tree.splitRight` / `tree.splitBelow`; `.new_session` (`c`) keeps `beside_focus`. All three then take the existing chord-born path (creates = true, focus moves to the new tile). `.focus_dir` → flatten-free: keep the last `Flat` beside the tree in `Shared` (relayout stores it; the keyboard reads it under the same ownership) → `layout.neighbor(flat, sel, dirOf(d))` → existing `moveFocus`/`focusAnswer` path; `null` → silently stay.
430 - Rails: relayout paints each `flat.rails` entry — one column of `│` rows `top+1..top+rows`, in the label-bar's dim SGR, gated on `shared.is_tty` like every wall write. Tiles never touch rails (Task 4 bounded their clears).
431 - e2e: legs that assert stacked bars at 80×24 now hydrate `.beside` (80 ≥ 48). Pin those legs TALL: change their ptyclient to `--cols 40 --rows 24` (40 < 48 keeps stripes) — grep e2e.sh for the wall legs' `--cols` and adjust ONLY legs asserting row-stacked geometry; leave the rest at 80×24. A comment on each pinned leg says why (`# tall: the aspect rule would cut columns at 80x24`).
432
433 - [ ] **Step 1:** layout.zig test for `setRootOrient` (beside root: 3 tiles flatten left→right). **Step 2:** wire, keeping the no-op arms' comments only where still true (fullscreen/resize stay no-op until Tasks 7/8). **Step 3:** `make test` PASS, then `make e2e` — fix pinned-size legs until EXIT=0. **Step 4:** `make check` exit 0. **Step 5: commit** — `feat: the wall cuts columns: splits, hjkl focus, rails`.
434
435 ---
436
437 ### Task 7: fullscreen
438
439 **Files:**
440 - Modify: `src/wallview.zig`
441
442 Wiring (spec): `Shared.fullscreen: bool`. The `.fullscreen` arm toggles it and relayouts. Relayout passes `if (shared.fullscreen) shared.sel else null` to flatten. A tile whose rect is 0×0: the pump SKIPS paint and skips the `.resize` claim (a 0×0 claim is the no-claim muxa already sends, and the daemon drops sub-floor resizes — wallview.zig:114's comment states that rule; verify it by the existing behavior, don't re-derive). Focus moves while fullscreened (`focus_dir`, digits) relayout — the full rect follows `sel`, honoring "1-9 = focus N in the current layout". `neighbor` while fullscreened must answer from the BASE layout: keep the last non-fullscreen `Flat` for it (flatten with `null` on every relayout regardless, store both when fullscreened). Vanish of the fullscreened tile: `remove` + fullscreen stays on the new `sel`.
443
444 - [ ] **Step 1:** e2e leg (END of e2e.sh): two sessions, `\x1cf` → survivor's marker repaints full-width (grep a marker row at a column only a full-width pane reaches), background pane's session KEEPS its size (`muxd stats` or a marker probe after `\x1cf` again restores both bars). Bump the scenario pin. **Step 2:** implement; `make test` + `make e2e` EXIT=0. **Step 3:** `make check`, commit — `feat: fullscreen is a relayout where the focus takes the terminal`.
445
446 ---
447
448 ### Task 8: resize wired
449
450 **Files:**
451 - Modify: `src/wallview.zig`
452
453 The `.resize` arm: `layout.resize(&shared.tree, alloc, shared.size.rows, shared.size.cols, floors(live), sel_tile, dirOf(d), 1)` → on true, relayout; on false, nothing (the layout stands; no bell — silence is the wall's idiom for a refused motion, same as `focus_dir` off the edge). Fullscreened: refuse without touching the tree (a hidden layout resizing invisibly is surprise, not power).
454
455 - [ ] **Step 1:** e2e leg (END): two beside panes, `\x1cr` then `lll`, assert the rail's column moved 3 right (grep the rail glyph's CUP column in the capture), then `Esc` and typed prose lands in the focused session (marker echo). Bump the scenario pin. **Step 2:** implement; gates green. **Step 3:** commit — `feat: Ctrl-\ r trades cells between panes`.
456
457 ---
458
459 ### Task 9: the span-clear oracle and the remaining e2e legs
460
461 **Files:**
462 - Modify: `test/e2e.sh` (END)
463
464 Three legs, asserted against an engine oracle where the claim is about the GRID, never by grepping paint bytes (the repo rule: a painter that emits too much passes byte greps):
465
466 1. **Span-clear survival:** two beside panes at 80×24 hydration (aspect cuts columns). Flood the LEFT pane's session (`seq 1 200`), then replay the wall client's full captured stream through `test/render.zig` (the fixture exists for exactly this) and assert the RIGHT pane's marker text still sits on its grid row. This is the leg that catches a `2K` regression forever.
467 2. **hjkl focus:** three panes (1 | 2-over-3 via `\x1c|` then `\x1c-`), `\x1ch`/`\x1cl`/`\x1cj` walk the panes — assert by typing a marker after each move and grepping which session's capture got it.
468 3. **Split births a session beside:** from one pane, `\x1c|` → `muxd stats` names two sessions; the new pane's label bar sits at the rail's right (grep the bar text at a column > the rail's).
469
470 - [ ] **Steps:** write each leg, run `make e2e` after EACH (a wedged leg prints nothing — keep them separable), bump the scenario pin per leg. Commit — `test: the wall's column era gets its legs`.
471
472 ---
473
474 ### Task 10: docs
475
476 **Files:**
477 - Modify: `README.md`, `CLAUDE.md` (layer table row for `layout`), `src/mux_main.zig` (--help, if Task 3 left anything)
478
479 README: the chord table gains `h j k l | \ - f r`, loses last-session; one paragraph on panes (splits birth sessions; fullscreen; resize mode). CLAUDE.md: add `layout` to the layer-1 module row — nothing else (invariant reconciliation is chunk 3, per the spec). `make check` exit 0 (README is flagged — budget rebalance).
480
481 - [ ] Commit — `docs: the chord list learns the pane era`.
482
483 ---
484
485 ## Final gate (run by the lander, not per-task)
486
487 `make ci > /tmp/ci.log 2>&1; echo EXIT=$?` → 0. Autosquash review fixups before delivery; the history tells the feature's story.
docs/superpowers/plans/2026-08-25-add-tile-prompt.md
Old New
@@ -1,816 +0,0 @@
1 # Add a tile by spelling (`Ctrl-\ :`) 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:** From inside a running wall, `Ctrl-\ :` opens a one-line prompt; Enter on a wall-grammar spelling births a tile for it beside the focus with `mux TARGET`'s semantics (creates, records, no `-A`).
6
7 **Architecture:** A third modal state in `interact.PrefixFilter` (a line editor) emits `.add_tile`; `wallview` gains one `birthTile` that the chord path, the fold path and the new prompt path all call with their own row of the birth table; the prompt is painted per read from the keyboard thread through the existing corner banner, and refusals are notices shown by re-claiming the focus.
8
9 **Tech Stack:** Zig 0.15.2 at `deps/zig/zig` (the Makefile already points at it); `make check` (fmt + unit + shell syntax + comment gate) before every commit; e2e via `test/ptyclient`.
10
11 **Spec:** `docs/superpowers/specs/2026-08-25-add-tile-prompt-design.md`
12
13 ## Global Constraints
14
15 - The prompt **eats every byte**: bytes typed while prompting never reach a session (spec, "Chord and prompt").
16 - Submit and cancel (`\r`, `\n`, `0x1b`) **end the chunk**: `forward` is what preceded the prefix, the rest of the read is dropped (spec).
17 - Empty Enter is a cancel, not an `.add_tile` (spec).
18 - `prompt_max = 256` bytes; bytes past it are ignored (spec).
19 - Birth table (spec): chord row = focused target / creates / records / agent inherited; fold row = wall line / joins / no record / no agent; prompt row = resolved spelling / beside focus / **creates** / **records** / **no agent**.
20 - The prompt resolves with the wall's own `entry.key` and `entry.idle_ms`; `-A` is not spellable (spec, "Out of scope").
21 - Refusal notices are exactly: `[bad target: <errorName>]`, `[that is the session this shell is inside]`, and the existing `[no room on the wall for another tile]` (spec, "Refusals"). No refusal writes the wall file.
22 - Comment gate: `zig build check` (inside `make check`) holds every file's flagged-prose figure to `docscheck.budget` EXACTLY (`interact.zig 0`, `mux_main.zig 0`, `wallview.zig 1865`). The rule (`tools/docscheck.zig heavyBlocks`): a `///` block is flagged when its prose bytes exceed the byte span of the decl it sits on; a one-line decl (a field, a const) has no span and is exempt. So `///` on a field is free, `///` on a `struct { ... }` or a short `fn` must be lighter than the body — put the long rationale on the function with the big body, or in `//` line comments, which the tier does not count. If the gate reports a file over or under, shorten or move the comment you added — **never edit `docscheck.budget`**. Comments say why, never what; every symbol a comment cites must exist.
23 - Never `cat` `src/wallview.zig`, `src/interact.zig` or `test/e2e.sh` whole; use `grep -n` then `sed -n 'A,Bp'`.
24 - Do not `git stash`; do not kill processes with `pkill -f`; any hand-run daemon exports an isolated `XDG_STATE_HOME`.
25 - Commit per task with the message given; do not amend earlier commits.
26
27 ---
28
29 ### Task 1: PrefixFilter prompt mode
30
31 **Files:**
32 - Modify: `src/interact.zig` — `PrefixFilter` (`Action` union near line 95, state fields near line 113, `feed` near line 134), tests after line ~2150.
33
34 **Interfaces:**
35 - Consumes: nothing new.
36 - Produces (Task 3 relies on these exact names):
37 - `PrefixFilter.Action.add_tile: []const u8` — the spelling; borrows the filter's buffer until the next `feed`.
38 - `PrefixFilter.prompt_max: usize = 256`
39 - `PrefixFilter.prompting: bool` (pub field)
40 - `PrefixFilter.promptLine(self: *const PrefixFilter) []const u8`
41
42 - [ ] **Step 1: Write the failing tests**
43
44 Append after the test `"interact: prose ends resize mode and goes to the session"` (find it with `grep -n "prose ends resize mode" src/interact.zig`; the test ends at the next line that is a bare `}`):
45
46 ```zig
47 test "interact: the prompt appends, backspaces, and Enter emits add_tile" {
48 var f: PrefixFilter = .{};
49 var open = "\x1c:".*;
50 try std.testing.expectEqual(PrefixFilter.Action.none, f.feed(&open).action);
51 try std.testing.expect(f.prompting);
52 var typed = "hostX\x7f#b\r".*;
53 const out = f.feed(&typed);
54 try std.testing.expectEqualStrings("host#b", out.action.add_tile);
55 try std.testing.expectEqualStrings("", out.forward);
56 try std.testing.expect(!f.prompting);
57 }
58
59 test "interact: Esc cancels the prompt and drops the rest of the read" {
60 var f: PrefixFilter = .{};
61 var open = "\x1c:ab".*;
62 _ = f.feed(&open);
63 // An arrow key is Esc [ A in one read: the Esc cancels, the [A must
64 // neither reach the session nor survive in the line.
65 var esc = "\x1b[A".*;
66 const out = f.feed(&esc);
67 try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
68 try std.testing.expectEqualStrings("", out.forward);
69 try std.testing.expect(!f.prompting);
70 var next = "z".*;
71 try std.testing.expectEqualStrings("z", f.feed(&next).forward);
72 }
73
74 test "interact: a prompt split across reads is still one line" {
75 var f: PrefixFilter = .{};
76 var a = "\x1c".*;
77 _ = f.feed(&a);
78 var b = ":--sock ".*;
79 try std.testing.expectEqual(PrefixFilter.Action.none, f.feed(&b).action);
80 var c = "/tmp/x#b\n".*;
81 try std.testing.expectEqualStrings("--sock /tmp/x#b", f.feed(&c).action.add_tile);
82 }
83
84 test "interact: the prompt line stops at prompt_max" {
85 var f: PrefixFilter = .{};
86 var open = "\x1c:".*;
87 _ = f.feed(&open);
88 var many: [PrefixFilter.prompt_max + 1]u8 = undefined;
89 @memset(&many, 'a');
90 _ = f.feed(&many);
91 try std.testing.expectEqual(PrefixFilter.prompt_max, f.promptLine().len);
92 var enter = "\r".*;
93 try std.testing.expectEqual(PrefixFilter.prompt_max, f.feed(&enter).action.add_tile.len);
94 }
95
96 test "interact: an empty Enter cancels the prompt, it is not an add_tile" {
97 var f: PrefixFilter = .{};
98 var open = "\x1c:\r".*;
99 const out = f.feed(&open);
100 try std.testing.expectEqual(PrefixFilter.Action.none, out.action);
101 try std.testing.expect(!f.prompting);
102 }
103
104 test "interact: bytes typed behind the prompt's Enter are dropped" {
105 var f: PrefixFilter = .{};
106 var open = "\x1c:".*;
107 _ = f.feed(&open);
108 var typed = "h#a\rtail".*;
109 const out = f.feed(&typed);
110 try std.testing.expectEqualStrings("h#a", out.action.add_tile);
111 try std.testing.expectEqualStrings("", out.forward);
112 var next = "q".*;
113 try std.testing.expectEqualStrings("q", f.feed(&next).forward);
114 }
115 ```
116
117 - [ ] **Step 2: Run the tests to verify they fail**
118
119 Run: `make test 2>&1 | tail -15`
120 Expected: compile error naming `add_tile` / `prompting` / `prompt_max` (no such member).
121
122 - [ ] **Step 3: Implement prompt mode**
123
124 In `PrefixFilter.Action`, after `resize: Dir,`:
125
126 ```zig
127 /// `Ctrl-\ :` then a line and Enter: a wall-grammar spelling to add
128 /// a tile for. Borrows the filter's buffer until the next feed.
129 add_tile: []const u8,
130 ```
131
132 After the `resizing: bool = false,` field:
133
134 ```zig
135 /// A spelling is a sun_path (108) or a hostname (253) with a session
136 /// behind it; 256 holds every form the grammar accepts.
137 pub const prompt_max: usize = 256;
138
139 /// `Ctrl-\ :` opens the prompt: a line editor for one spelling. Every
140 /// byte is the prompt's until Enter or Esc — the mode eats prose on
141 /// purpose, unlike resize mode.
142 prompting: bool = false,
143 line: [prompt_max]u8 = undefined,
144 line_len: usize = 0,
145
146 pub fn promptLine(self: *const PrefixFilter) []const u8 {
147 return self.line[0..self.line_len];
148 }
149 ```
150
151 In `feed`, as the FIRST thing inside `for (buf) |b| {` (before `if (self.resizing)`):
152
153 ```zig
154 if (self.prompting) {
155 switch (b) {
156 // Submit and cancel both end the read: an Esc that is
157 // the head of an arrow key or a mouse report must take
158 // its tail with it, not hand `[A` to the shell.
159 '\r', '\n' => {
160 self.prompting = false;
161 if (self.line_len == 0) return .{ .forward = buf[0..kept], .action = .none };
162 return .{ .forward = buf[0..kept], .action = .{ .add_tile = self.line[0..self.line_len] } };
163 },
164 0x1b => {
165 self.prompting = false;
166 return .{ .forward = buf[0..kept], .action = .none };
167 },
168 0x7f, 0x08 => self.line_len -|= 1,
169 0x20...0x7e => if (self.line_len < prompt_max) {
170 self.line[self.line_len] = b;
171 self.line_len += 1;
172 },
173 else => {},
174 }
175 continue;
176 }
177 ```
178
179 In the `if (self.pending)` switch, after the `'r' => { ... }` arm:
180
181 ```zig
182 ':' => {
183 self.prompting = true;
184 self.line_len = 0;
185 continue;
186 },
187 ```
188
189 Update the `feed` doc comment's last paragraph (the one beginning "`.wall` shows the saved wall") by appending one sentence: "`.add_tile` is the prompt's Enter: the line typed after `Ctrl-\ :`, which the same rule ends the chunk on."
190
191 - [ ] **Step 4: Run the tests to verify they pass**
192
193 Run: `make test 2>&1 | tail -5`
194 Expected: no `FAIL`, no compile errors. Then `make check 2>&1 | tail -5` — expected green (docscheck: `interact.zig` stays at 0; if it reports over, shorten the comments added above).
195
196 - [ ] **Step 5: Commit**
197
198 ```bash
199 git add src/interact.zig
200 git commit -m "feat: the prefix table learns a prompt
201
202 Ctrl-\\ : opens a line editor for one spelling. Every byte is the
203 prompt's until Enter or Esc, and both end the read: an Esc that is the
204 head of an arrow key or a mouse report takes its tail with it instead
205 of handing [A to the shell. Empty Enter is a cancel."
206 ```
207
208 ---
209
210 ### Task 2: one birth path — `birthTile`
211
212 **Files:**
213 - Modify: `src/wallview.zig` — `Place` enum (~line 2012), `addSessionTile` (~2017-2090), `hydrate`'s per-spelling block (~2280-2313), tests after `"resolveSpelling: --sock with a session, label verbatim"` (~line 3028).
214
215 **Interfaces:**
216 - Consumes: `initTile(t: *Tile, r: Resolved, s: layout.Rect, shared: *Shared, idx: usize) !void`, `spawnPump(t: *Tile)`, `presentCount`, `wallFits`, `wallFloors`, `max_tiles`, `layout.Tree.insert/splitRight/splitBelow/flatten/remove`.
217 - Produces (Task 3 relies on these):
218 - `const Birth = struct { r: Resolved, from: usize, place: Place, creates: bool, record: bool, born_from: ?usize };`
219 - `fn birthTile(alloc, tiles: []Tile, present: []bool, live: *usize, shared: *Shared, b: Birth) ?usize` — the new tile's index, or null when there is no room. Does NOT spawn the pump; callers do.
220
221 - [ ] **Step 1: Write the failing tests**
222
223 Append after the test `"resolveSpelling: --sock with a session, label verbatim"` (it ends at the next bare `}`):
224
225 ```zig
226 test "birthTile: a prompt-born tile creates, records, and offers no agent" {
227 const alloc = std.testing.allocator;
228 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
229 defer shared.tree.deinit();
230 try shared.tree.addFirst(0);
231 var tiles: [2]Tile = undefined;
232 tiles[0] = .{
233 .r = .{ .target = .{ .sock = "/tmp/a" }, .label = "--sock /tmp/a", .session = "" },
234 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
235 .shared = &shared,
236 .idx = 0,
237 .wake_r = -1,
238 .wake_w = -1,
239 };
240 var present = [_]bool{ true, false };
241 var live: usize = 1;
242 const r = try resolveSpelling(alloc, "--sock /tmp/b#b", null, 30_000);
243 const at = birthTile(alloc, &tiles, &present, &live, &shared, .{
244 .r = r,
245 .from = 0,
246 .place = .beside_focus,
247 .creates = true,
248 .record = true,
249 .born_from = 0,
250 }) orelse return error.TestUnexpectedResult;
251 defer std.posix.close(tiles[at].wake_r);
252 defer std.posix.close(tiles[at].wake_w);
253 try std.testing.expectEqual(@as(usize, 1), at);
254 try std.testing.expect(tiles[at].creates);
255 try std.testing.expect(tiles[at].record);
256 try std.testing.expect(!tiles[at].r.agent);
257 try std.testing.expectEqual(@as(?usize, 0), tiles[at].born_from);
258 try std.testing.expect(present[1]);
259 try std.testing.expectEqual(@as(usize, 2), live);
260 // Two tiles draw a bar, and the bar is set at birth so viewRows is
261 // right from the first attach.
262 try std.testing.expectEqual(@as(u16, 1), shared.label_rows);
263 }
264
265 test "birthTile: a fold-born tile joins, does not record, offers no agent" {
266 const alloc = std.testing.allocator;
267 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 24 }, .is_tty = false };
268 defer shared.tree.deinit();
269 try shared.tree.addFirst(0);
270 var tiles: [2]Tile = undefined;
271 tiles[0] = .{
272 .r = .{ .target = .{ .sock = "/tmp/a" }, .label = "--sock /tmp/a", .session = "" },
273 .rect = .{ .top = 0, .left = 0, .rows = 24, .cols = 80 },
274 .shared = &shared,
275 .idx = 0,
276 .wake_r = -1,
277 .wake_w = -1,
278 };
279 var present = [_]bool{ true, false };
280 var live: usize = 1;
281 const r = try resolveSpelling(alloc, "--sock /tmp/b#b", null, 30_000);
282 const at = birthTile(alloc, &tiles, &present, &live, &shared, .{
283 .r = r,
284 .from = 0,
285 .place = .beside_focus,
286 .creates = false,
287 .record = false,
288 .born_from = null,
289 }) orelse return error.TestUnexpectedResult;
290 defer std.posix.close(tiles[at].wake_r);
291 defer std.posix.close(tiles[at].wake_w);
292 try std.testing.expect(!tiles[at].creates);
293 try std.testing.expect(!tiles[at].record);
294 try std.testing.expect(!tiles[at].r.agent);
295 try std.testing.expectEqual(@as(?usize, null), tiles[at].born_from);
296 }
297
298 test "birthTile: no room is null and the tree is left as it was" {
299 const alloc = std.testing.allocator;
300 // Four rows cannot hold two tiles under a bar (wallFloors), so the
301 // second birth must refuse and must not leave a leaf behind.
302 var shared = Shared{ .out_fd = -1, .size = .{ .cols = 80, .rows = 4 }, .is_tty = false };
303 defer shared.tree.deinit();
304 try shared.tree.addFirst(0);
305 var tiles: [2]Tile = undefined;
306 tiles[0] = .{
307 .r = .{ .target = .{ .sock = "/tmp/a" }, .label = "--sock /tmp/a", .session = "" },
308 .rect = .{ .top = 0, .left = 0, .rows = 4, .cols = 80 },
309 .shared = &shared,
310 .idx = 0,
311 .wake_r = -1,
312 .wake_w = -1,
313 };
314 var present = [_]bool{ true, false };
315 var live: usize = 1;
316 const r = try resolveSpelling(alloc, "--sock /tmp/b#b", null, 30_000);
317 try std.testing.expectEqual(@as(?usize, null), birthTile(alloc, &tiles, &present, &live, &shared, .{
318 .r = r,
319 .from = 0,
320 .place = .beside_focus,
321 .creates = true,
322 .record = true,
323 .born_from = 0,
324 }));
325 try std.testing.expectEqual(@as(usize, 1), live);
326 try std.testing.expect(!present[1]);
327 }
328 ```
329
330 - [ ] **Step 2: Run the tests to verify they fail**
331
332 Run: `make test 2>&1 | tail -15`
333 Expected: compile error, `birthTile` undeclared.
334
335 - [ ] **Step 3: Implement `birthTile`, then make `addSessionTile` and `hydrate` call it**
336
337 Insert directly after the `Place` enum (`const Place = enum { beside_focus, right_of, below };`):
338
339 ```zig
340 /// One row of the birth table: what a caller owes a new tile.
341 const Birth = struct {
342 r: Resolved,
343 from: usize,
344 place: Place,
345 creates: bool,
346 record: bool,
347 // Where a REFUSED attach hands the focus back; null for a tile no
348 // tile made (the fold's).
349 born_from: ?usize,
350 };
351
352 /// Every road a tile takes onto a running wall — chord, fold, prompt —
353 /// through one body: the room check, the tree, the rect, the pipes. The
354 /// caller keeps its row of the table and the pump spawn: the chord row
355 /// inherits target and agent, the fold row joins and records nothing,
356 /// the prompt row is argv typed from inside — creates, records, offers
357 /// no agent. Null: no room.
358 fn birthTile(
359 alloc: std.mem.Allocator,
360 tiles: []Tile,
361 present: []bool,
362 live: *usize,
363 shared: *Shared,
364 b: Birth,
365 ) ?usize {
366 if (live.* >= max_tiles) return null;
367 const new_live = presentCount(present[0..live.*]) + 1;
368 if (!wallFits(shared.size.rows, new_live)) return null;
369 // The real rect, not a placeholder: a creating tile puts its rect on
370 // the first attach frame and the daemon refuses creates under
371 // min_session_rows; a joining tile doorbells a resize on its first
372 // pass, and a 2-row rect on a live session is destructive under
373 // latest-wins. `label_rows` is set here so `viewRows` is right from
374 // the first attach; relayout re-flattens every rect and sets it again.
375 shared.label_rows = if (new_live > 1) 1 else 0;
376 const at = live.*;
377 switch (b.place) {
378 .beside_focus => shared.tree.insert(@intCast(b.from), @intCast(at)) catch return null,
379 .right_of => shared.tree.splitRight(@intCast(b.from), @intCast(at)) catch return null,
380 .below => shared.tree.splitBelow(@intCast(b.from), @intCast(at)) catch return null,
381 }
382 const flat = shared.tree.flatten(
383 alloc,
384 shared.size.rows,
385 shared.size.cols,
386 wallFloors(new_live),
387 null,
388 ) catch {
389 shared.tree.remove(@intCast(at));
390 return null;
391 };
392 defer flat.deinit(alloc);
393 const new_rect = flat.rectOf(@intCast(at)) orelse {
394 shared.tree.remove(@intCast(at));
395 return null;
396 };
397 initTile(&tiles[at], b.r, new_rect, shared, at) catch {
398 shared.tree.remove(@intCast(at));
399 return null;
400 };
401 tiles[at].record = b.record;
402 tiles[at].creates = b.creates;
403 tiles[at].born_from = b.born_from;
404 present[at] = true;
405 live.* += 1;
406 return at;
407 }
408 ```
409
410 Replace the body of `addSessionTile` from the line `if (live.* >= max_tiles) return .full;` through `return .{ .moved = at };` (keep the signature, the `target`/`want` lines and the dedup `for` loop above it) with:
411
412 ```zig
413 // The tile's own copies. `run`'s allocator outlives the process (it
414 // never returns on the success path), which is what lets a pump hold
415 // these slices for as long as it lives.
416 const session = alloc.dupe(u8, want) catch return .full;
417 const label = tileLabel(alloc, target, want) catch return .full;
418 const at = birthTile(alloc, tiles, present, live, shared, .{
419 // The offer is inherited from the tile this one grew out of. Same
420 // target, so `-A` exposes nothing the user has not already exposed
421 // to that host — and a chord-made tile has no command line to spell
422 // the flag on, so not inheriting would silently end the forwarding
423 // at the first session switch.
424 .r = .{ .target = target, .label = label, .session = session, .agent = tiles[from].r.agent },
425 .from = from,
426 .place = place,
427 .creates = true,
428 .record = true,
429 .born_from = from,
430 }) orelse return .full;
431 spawnPump(&tiles[at]);
432 return .{ .moved = at };
433 ```
434
435 In `hydrate`, replace the block from `const at = live.*;` (the line after the `showsSelf` `continue`) through `added += 1;` with:
436
437 ```zig
438 const at = birthTile(alloc, tiles, present, live, shared, .{
439 .r = r,
440 .from = if (live.* > 0) live.* - 1 else 0,
441 .place = .beside_focus,
442 .creates = false,
443 .record = false,
444 .born_from = null,
445 }) orelse continue;
446 spawnPump(&tiles[at]);
447 added += 1;
448 ```
449
450 Leave `hydrate`'s own room check and its `[saved wall truncated ...]` notice above the `resolveSpelling` line exactly as they are: that notice is the fold's, said before any dial.
451
452 - [ ] **Step 4: Run the tests to verify they pass**
453
454 Run: `make test 2>&1 | tail -5` — expected no `FAIL`.
455 Run: `make check 2>&1 | tail -8` — expected green. If docscheck reports `wallview.zig` over 1865, the `Birth` doc comment is the likely cause: shorten it (do not touch `docscheck.budget`).
456
457 - [ ] **Step 5: Commit**
458
459 ```bash
460 git add src/wallview.zig
461 git commit -m "refactor: one birth path for every tile a running wall grows
462
463 addSessionTile and the fold each carried the room check, the tree
464 placement, the flatten, initTile and the bookkeeping; birthTile owns
465 that body and each caller keeps only its row of the birth table —
466 creates, record, agent, born_from. A rectOf miss now removes its leaf
467 on the fold path too."
468 ```
469
470 ---
471
472 ### Task 3: the prompt on the wall — paint, birth, refuse, document
473
474 **Files:**
475 - Modify: `src/wallview.zig` — near `wallBanner` (~line 606), near `addSessionTile` (~2017), the keyboard loop (`var input: WallInput` ~2639; the block after `const cmd = input.prefix.feed(b[0..n]);` ~2777; the `switch (cmd.action)` arms ending with `.forget` ~2918).
476 - Modify: `src/mux_main.zig` — usage text lines 40-43; comment at line ~403.
477 - Modify: `README.md` — chord table (~line 74, the `Ctrl-\ x` row).
478 - Modify: `CLAUDE.md` — Invariants, the "Every tile claims its rect" bullet.
479
480 **Interfaces:**
481 - Consumes: Task 1's `PrefixFilter.prompting`, `promptLine()`, `Action.add_tile`, `prompt_max`; Task 2's `birthTile`/`Birth`; existing `resolveSpelling`, `showsSelf`, `setNotice`, `setFocus`, `focusAnswer`, `spawnPump`, `paint.paintBanner`, `FocusTo`.
482 - Produces: `fn addSpelledTile(...) FocusTo` (internal); the `.add_tile` arm.
483
484 - [ ] **Step 1: Add the tile-bounded banner**
485
486 Insert directly after `wallBanner` (the function ending with `paint.paintBanner(shared.out_fd, shared.size.cols, text, row_off, 0);` and `}`):
487
488 ```zig
489 /// `wallBanner` bounded by ONE tile's rect: a prompt wider than a
490 /// left-hand pane must not cross the rail into its neighbour.
491 fn tileBanner(t: *Tile, text: []const u8) void {
492 if (!t.shared.is_tty) return;
493 // The tail, so the cursor end of a long spelling is what is on screen.
494 const shown = if (text.len > t.rect.cols) text[text.len - t.rect.cols ..] else text;
495 t.shared.paint_mu.lock();
496 defer t.shared.paint_mu.unlock();
497 paint.paintBanner(t.shared.out_fd, t.rect.cols, shown, t.rect.top + t.shared.label_rows, t.rect.left);
498 }
499 ```
500
501 - [ ] **Step 2: Add `addSpelledTile`**
502
503 Insert directly after `addSessionTile` (after its closing `}`):
504
505 ```zig
506 /// `Ctrl-\ :`'s answer — argv typed from inside. The spelling is resolved
507 /// the way `Ctrl-\ w` resolves a wall line, then born with `mux TARGET`'s
508 /// row of the birth table: it creates, it records, it offers no agent
509 /// (the user spelled no `-A`, and the host may not be one they have
510 /// exposed a key to). A spelling already on the wall is a focus move,
511 /// not a second tile. A refusal leaves its notice for the re-claim the
512 /// keyboard does next; nothing here touches the wall file — recording is
513 /// the first state's, and a refused tile never has one.
514 fn addSpelledTile(
515 alloc: std.mem.Allocator,
516 tiles: []Tile,
517 present: []bool,
518 live: *usize,
519 shared: *Shared,
520 from: usize,
521 spelling: []const u8,
522 key: ?[]const u8,
523 idle_ms: u32,
524 ) FocusTo {
525 for (tiles[0..live.*], present[0..live.*], 0..) |*t, p, i| {
526 if (p and std.mem.eql(u8, t.r.label, spelling)) return .{ .moved = i };
527 }
528 // The filter's buffer is the next read's; the pump keeps this copy.
529 const own = alloc.dupe(u8, spelling) catch return .stay;
530 const r = resolveSpelling(alloc, own, key, idle_ms) catch |err| {
531 var buf: [96]u8 = undefined;
532 const text = std.fmt.bufPrint(&buf, "[bad target: {s}]", .{@errorName(err)}) catch "[bad target]";
533 setNotice(shared, text);
534 return .stay;
535 };
536 if (showsSelf(r.target, r.session, std.posix.getenv(proto.sock_env), std.posix.getenv(proto.session_env))) {
537 setNotice(shared, "[that is the session this shell is inside]");
538 return .stay;
539 }
540 const at = birthTile(alloc, tiles, present, live, shared, .{
541 .r = r,
542 .from = from,
543 .place = .beside_focus,
544 .creates = true,
545 .record = true,
546 .born_from = from,
547 }) orelse return .full;
548 spawnPump(&tiles[at]);
549 return .{ .moved = at };
550 }
551 ```
552
553 - [ ] **Step 3: Paint the prompt and re-claim on exit**
554
555 Find `var input: WallInput = .{};` in `run` and add on the next line:
556
557 ```zig
558 // Whether the last read left the prompt open, so its closing read can
559 // give the row back to the tile.
560 var was_prompting = false;
561 ```
562
563 Find the line `switch (cmd.action) {` inside the keyboard loop (the one preceded by the `sendKeys(&tiles[shared.sel], cmd.forward[seg_start..]);` block). Insert BEFORE `switch (cmd.action) {`:
564
565 ```zig
566 // The prompt is the keyboard's alone — no pump knows it exists —
567 // so it is painted from here, per read. Leaving it is a re-claim
568 // of the focus: the claim path repaints the tile over the banner
569 // and shows any notice a refusal left. `.add_tile` re-claims (or
570 // moves) in its own arm, so it is the one exit skipped here.
571 if (input.prefix.prompting) {
572 var line_buf: [interact.PrefixFilter.prompt_max + 4]u8 = undefined;
573 const text = std.fmt.bufPrint(&line_buf, ": {s}_", .{input.prefix.promptLine()}) catch "";
574 if (z < live and present[z]) tileBanner(&tiles[z], text);
575 was_prompting = true;
576 } else if (was_prompting) {
577 was_prompting = false;
578 if (cmd.action != .add_tile and z < live and present[z])
579 setFocus(tiles[0..live], &shared, z);
580 }
581 ```
582
583 - [ ] **Step 4: Add the `.add_tile` arm**
584
585 In `switch (cmd.action)`, after the `.forget => { ... },` arm (the last one), add:
586
587 ```zig
588 .add_tile => |spelling| {
589 const before = live;
590 switch (addSpelledTile(alloc, tiles, present, &live, &shared, z, spelling, entry.key, entry.idle_ms)) {
591 .moved => |to| {
592 last_focus = z;
593 focusAnswer(alloc, tiles[0..live], present[0..live], &shared, live > before, to);
594 },
595 .full => {
596 setNotice(&shared, "[no room on the wall for another tile]");
597 setFocus(tiles[0..live], &shared, z);
598 },
599 // The refusal left its notice; the re-claim shows it.
600 .stay => setFocus(tiles[0..live], &shared, z),
601 }
602 },
603 ```
604
605 - [ ] **Step 5: Build and run the unit suite**
606
607 Run: `make test 2>&1 | tail -5` — expected no `FAIL` and no compile error (a switch on `cmd.action` that misses `.add_tile` is a compile error — that is the check that every consumer of `Action` was visited; `interact.zig` may have a second switch over `Action` for the plain client: if the compiler names one, add `.add_tile => {},` there with the comment `// A plain client has no wall to add a tile to.`).
608
609 - [ ] **Step 6: Hand-check on a real terminal (the artifact, not the exit code)**
610
611 ```bash
612 export XDG_STATE_HOME=/tmp/claude-1000/-home-xanderle-code-rad-mux/2181fd32-a89a-44ca-8eda-19020eac4a18/scratchpad/prompt-state
613 mkdir -p "$XDG_STATE_HOME"
614 ./zig-out/bin/muxd run --sock /tmp/prompt-a.sock --shell /bin/sh > /tmp/prompt-a.log 2>&1 &
615 echo $! > /tmp/prompt-a.pid
616 ./zig-out/bin/muxd run --sock /tmp/prompt-b.sock --shell /bin/sh > /tmp/prompt-b.log 2>&1 &
617 echo $! > /tmp/prompt-b.pid
618 ```
619
620 This step needs a human terminal, which a subagent does not have: instead, record the exact commands above plus `./zig-out/bin/mux --sock /tmp/prompt-a.sock`, then `Ctrl-\ :`, type `--sock /tmp/prompt-b.sock#b`, Enter, in the report file for the controller to run by hand. Kill the daemons by the pids in the files, never with `pkill -f`.
621
622 - [ ] **Step 7: Docs**
623
624 `src/mux_main.zig` usage, replace the sentence fragment
625 `` `Ctrl-\ f` fullscreen, `Ctrl-\ r` resize mode, `Ctrl-\ d` leaves. ``
626 with
627 `` `Ctrl-\ f` fullscreen, `Ctrl-\ r` resize mode, `Ctrl-\ :` adds a tile by spelling (Enter adds, Esc cancels), `Ctrl-\ d` leaves. ``
628 (reflow the `\\` lines so none exceeds the neighbours' width).
629
630 `src/mux_main.zig` comment near line 403, after "never come back through this switch, so focusing from session 0 to session 1 keeps working." append: "`Ctrl-\ :` is the one chord that takes a spelling, and it runs `wallview.showsSelf` itself."
631
632 `README.md`, after the `Ctrl-\ x` row of the chord table, add:
633
634 ```
635 | `Ctrl-\` `:` | add a tile by spelling: type `HOST#SESSION`, `quic://HOST#SESSION` or `--sock PATH#SESSION`, Enter adds it beside the focus (creating the session if needed) and records it; Esc cancels |
636 ```
637
638 `CLAUDE.md`, Invariants, at the end of the "Every tile claims its rect" bullet, add the sentence: "A chord-born tile inherits the focused tile's target; `Ctrl-\ :` is the one chord that takes a spelling, born with argv's row of the birth table (creates, records, no `-A`) through the same `birthTile`."
639
640 - [ ] **Step 8: Gate and commit**
641
642 Run: `make check 2>&1 | tail -8` — expected green (`wallview.zig` must stay at 1865: if over, shorten the `addSpelledTile` or `tileBanner` doc comments; `mux_main.zig` must stay at 0).
643
644 ```bash
645 git add src/wallview.zig src/mux_main.zig README.md CLAUDE.md
646 git commit -m "feat: Ctrl-\\ : adds a tile by spelling from inside the wall
647
648 A chord-born tile inherits the focused tile's daemon, so growing the
649 wall by a HOST meant leaving it: Ctrl-\\ d, mux HOST, Ctrl-\\ w. The
650 prompt is argv typed from inside — the spelling is born with mux
651 TARGET's row of the birth table (creates, records, no -A) beside the
652 focus, placed by the user like every other chord. The prompt line is
653 painted from the keyboard through a tile-bounded banner; leaving it is
654 a re-claim of the focus, which is also how a refusal's notice lands."
655 ```
656
657 ---
658
659 ### Task 4: e2e leg — born on another daemon, recorded, refused, eaten
660
661 **Files:**
662 - Modify: `test/e2e.sh` — socket/pid declarations (~lines 380-390), the cleanup kill list (~1295), the leak list (~1364), the rm sweep (~1455, the line ending `"$OUT.lpdfa" "$OUT.lpdstop" "$SOCK61"`), the leg (append before `[ "$OK_COUNT" = "72" ] || {`), the pin (that line and the message after it).
663
664 **Interfaces:**
665 - Consumes: helpers `wait_sock`, `pipe_mux`, `pipe_send`, `await_out`, `pipe_detach`, `wait_grid`, `assert_stopped`, `ok`; `$MUXD`, `$MUX`, `$MUXA`, `$PTYCLIENT`, `$OUT`.
666 - Produces: scenario 73.
667
668 - [ ] **Step 1: Declare the sockets and pids**
669
670 After `SOCK61="${TMPDIR:-/tmp}/muxd-e2e-lpdegrade-$$.sock"` add:
671
672 ```sh
673 SOCK62="${TMPDIR:-/tmp}/muxd-e2e-promptA-$$.sock"
674 SOCK63="${TMPDIR:-/tmp}/muxd-e2e-promptB-$$.sock"
675 ```
676
677 After `D62PID=""` add:
678
679 ```sh
680 D63PID=""
681 D64PID=""
682 ```
683
684 After the cleanup line `[ -n "${D62PID:-}" ] && kill "$D62PID" 2>/dev/null || true` add:
685
686 ```sh
687 [ -n "${D63PID:-}" ] && kill "$D63PID" 2>/dev/null || true
688 [ -n "${D64PID:-}" ] && kill "$D64PID" 2>/dev/null || true
689 ```
690
691 In the leak list, change `"$D60PID" "$D61PID" "$D62PID"` to `"$D60PID" "$D61PID" "$D62PID" "$D63PID" "$D64PID"`.
692
693 In the rm sweep, after `"$OUT.lpdfa" "$OUT.lpdstop" "$SOCK61"` (keep its trailing `\` if the list continues; otherwise add one) add a line:
694
695 ```sh
696 "$OUT.pra.d" "$OUT.prb.d" "$OUT.pra" "$OUT.pra.err" "$OUT.prcap" "$OUT.prcap.err" \
697 "$OUT.prpc" "$OUT.prfb" "$OUT.prastop" "$OUT.prbstop" "$SOCK62" "$SOCK63"
698 ```
699
700 Run: `sh -n test/e2e.sh && echo SYNTAX_OK` — expected `SYNTAX_OK`.
701
702 - [ ] **Step 2: Write the leg**
703
704 Insert before the line `[ "$OK_COUNT" = "72" ] || {`:
705
706 ```sh
707 # ---- Ctrl-\ : adds a tile by spelling ---------------------------------
708 #
709 # Argv typed from inside. A spelling naming a session on ANOTHER daemon
710 # is born beside the focus, creates that session there, takes the focus,
711 # and is recorded into the wall file — mux TARGET's row of the birth
712 # table, without leaving the wall. A bad spelling is a notice and
713 # nothing else (the file does not grow). Esc eats the line: the shell
714 # never sees it, and the next keys reach the session again.
715 #
716 # The prompt echoes what is typed, so the born tile's witness is not its
717 # label on the capture (the echo would match) but a marker typed AFTER
718 # the birth landing in daemon B's session b — a hit is B's shell's work.
719 PRSTATE="${TMPDIR:-/tmp}/mux-e2e-prompt-state-$$"
720 PRWALL="$PRSTATE/mux/wall"
721 "$MUXD" run --sock "$SOCK62" --shell /bin/sh > "$OUT.pra.d" 2>&1 &
722 D63PID=$!
723 wait_sock "$SOCK62" "$OUT.pra.d" "prompt daemon A never bound"
724 "$MUXD" run --sock "$SOCK63" --shell /bin/sh > "$OUT.prb.d" 2>&1 &
725 D64PID=$!
726 wait_sock "$SOCK63" "$OUT.prb.d" "prompt daemon B never bound"
727
728 pipe_mux "$OUT.pra" "$OUT.pra.err" timeout 40 "$MUX" --sock "$SOCK62" --session a
729 pipe_send 'printf "pr-%%s\\n" origin\n'
730 await_out "$OUT.pra" "pr-origin" "prompt: session a marker never reached the client"
731 pipe_detach
732 wait_grid "$SOCK62" "pr-origin" "prompt: session a marker" a
733
734 mkdir -p "$PRSTATE/mux"
735 printf -- '--sock %s#a\n' "$SOCK62" > "$PRWALL"
736
737 set +e
738 XDG_STATE_HOME="$PRSTATE" timeout 90 "$PTYCLIENT" --cols 100 --rows 30 \
739 --out "$OUT.prcap" --err "$OUT.prcap.err" -- \
740 "$MUX" wall > "$OUT.prpc" 2>&1 <<EOF
741 expect pr-origin 20000
742 settle 700 20000
743 send \x1c:--sock $SOCK63#b\r
744 settle 1000 20000
745 send printf 'pr-born-%s\n' marker\n
746 expect pr-born-marker 15000
747 send \x1c:x#bad name\r
748 expect [bad target 10000
749 settle 500 15000
750 send \x1c:zzz\x1b
751 settle 500 15000
752 send printf 'pr-after-%s\n' esc\n
753 expect pr-after-esc 10000
754 settle 500 15000
755 send \x1cd
756 waitexit 10000
757 EOF
758 RC=$?
759 set -e
760 [ "$RC" -eq 0 ] || {
761 echo "e2e FAIL: prompt: ptyclient leg exited $RC (did \\x1c: add the tile?):"
762 cat "$OUT.prpc" "$OUT.prcap.err"; exit 1; }
763 # Born on daemon B, focused: the marker typed after the birth is in B's
764 # session b, and so are the keys typed after the Esc.
765 timeout 20 "$MUXA" capture --sock "$SOCK63" --session b > "$OUT.prfb" 2>&1
766 grep -q "pr-born-marker" "$OUT.prfb" || {
767 echo "e2e FAIL: prompt: marker not in daemon B's session b (tile not born there, or not focused):"
768 cat "$OUT.prfb"; exit 1; }
769 grep -q "pr-after-esc" "$OUT.prfb" || {
770 echo "e2e FAIL: prompt: keys after Esc never reached session b (prompt did not close):"
771 cat "$OUT.prfb"; exit 1; }
772 grep -q "zzz" "$OUT.prfb" && {
773 echo "e2e FAIL: prompt: Esc leaked the line into the shell:"
774 cat "$OUT.prfb"; exit 1; }
775 # Recorded, and only the accepted spelling: two lines, the born one among them.
776 grep -qF -- "--sock $SOCK63#b" "$PRWALL" || {
777 echo "e2e FAIL: prompt: the born tile was not recorded:"
778 cat "$PRWALL"; exit 1; }
779 [ "$(grep -c . "$PRWALL")" = "2" ] || {
780 echo "e2e FAIL: prompt: wall file is not exactly two lines (a refused spelling recorded?):"
781 cat "$PRWALL"; exit 1; }
782 assert_stopped "$SOCK62" "$D63PID" "prompt A" "$OUT.prastop"
783 D63PID=""
784 assert_stopped "$SOCK63" "$D64PID" "prompt B" "$OUT.prbstop"
785 D64PID=""
786 rm -rf "$PRSTATE"
787 ok "Ctrl-\\ : adds a tile by spelling: born on another daemon, recorded, refusals narrated, Esc eats the line"
788
789 ```
790
791 Note the heredoc is UNQUOTED (`<<EOF`) so `$SOCK63` expands; `\x1c`, `\r`, `\n`, `\x1b` are not shell escapes and reach ptyclient's `decodeEscapes` intact. The `expect [bad target` needle is literal; ptyclient decodes only backslashes.
792
793 - [ ] **Step 3: Bump the pin**
794
795 Change `[ "$OK_COUNT" = "72" ] || {` to `[ "$OK_COUNT" = "73" ] || {` and `the pin says 72 —` to `the pin says 73 —`.
796
797 - [ ] **Step 4: Run the gate**
798
799 Run: `sh -n test/e2e.sh && echo SYNTAX_OK` — expected `SYNTAX_OK`.
800 Run: `make e2e > /tmp/prompt-e2e.log 2>&1; echo EXIT=$? >> /tmp/prompt-e2e.log; tail -4 /tmp/prompt-e2e.log`
801 Expected: the last lines contain `e2e OK (73 scenarios, 37 convergence points)` and `EXIT=0`. Verify by the log, not by the tail's exit code. On a FAIL, read the failing block's own `cat` output in the log (`grep -n "e2e FAIL" /tmp/prompt-e2e.log`), fix, re-run.
802
803 Run: `make check 2>&1 | tail -5` — expected green.
804
805 - [ ] **Step 5: Commit**
806
807 ```bash
808 git add test/e2e.sh
809 git commit -m "test: a tile spelled at the prompt is born on the other daemon
810
811 Two daemons, the wall on A: the prompt spells B's session b, a marker
812 typed after the birth is in B's grid (born there, focused), the wall
813 file gained exactly that line, a bad spelling narrates and records
814 nothing, and Esc eats its line — the shell never sees it and the next
815 keys reach the session."
816 ```
docs/superpowers/plans/2026-08-26-muxd-upgrade-reexec.md
Old New
@@ -1,367 +0,0 @@
1 # muxd upgrade — re-exec in place: 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:** `muxd upgrade --sock S` replaces a running daemon's binary via execve without killing a single shell.
6
7 **Architecture:** The old daemon validates the candidate, writes a manifest of daemon+session state into a memfd, clears FD_CLOEXEC on the fds it keeps (unix listener, QUIC UDP, pty masters, agent listeners, memfd), and execs the new binary as `muxd run --resume-fd N`. Same pid: children, waitpid, pid-named dirs, and the socket path all survive untouched. Rollback is another exec of the old binary, whose path the manifest carries.
8
9 **Tech Stack:** Zig 0.15.2 (`deps/zig/zig` only — system zig will NOT build this), ghostty-vt, ngtcp2/wolfSSL via `quic_server`.
10
11 **Spec:** `docs/superpowers/specs/2026-08-26-muxd-upgrade-reexec-design.md` — the plan argues from it; read it first. The state inventory behind it is `/tmp/handover-inventory.md` (regenerate by reading `src/server.zig` §Server/§Session if lost).
12
13 ## Global Constraints
14
15 - Build/test ONLY with `deps/zig/zig` (`make build test e2e check`). `make check` before every commit.
16 - `docscheck.budget` is down-only: lower prose to fit, NEVER raise a number.
17 - Comments say why, not how. Every claim a comment makes must survive `zig build check` (symbol refs resolve).
18 - Layers: `build.zig`'s module table (grep `.layer =`). New module `upgrade` goes in layer 1 (imports `protocol`, `cmd` only).
19 - e2e legs go at the END of `test/e2e.sh`; bump the scenario/convergence pins at the file tail with every added leg.
20 - Commit per task with the story-shaped message style of `git log`; `--fixup` later corrections.
21 - Wire invariant: transport stays dumb; the two new frames live in `protocol` and are carried opaquely everywhere else.
22 - The upgrade path must never run `Server.deinit` (`src/server.zig:867`) — that is the demolition list (unlinks the socket, SIGKILLs shells, deleteTrees the shim/agent dirs).
23
24 ---
25
26 ### Task 1: `upgrade.zig` — the version rule
27
28 **Files:**
29 - Create: `src/upgrade.zig`
30 - Modify: `build.zig` (module table: `upgrade`, layer 1, imports `protocol`, `cmd`)
31 - Test: in-file `test` blocks (module tests run via `zig build test`)
32
33 **Interfaces:**
34 - Produces: `pub fn strictlyNewer(candidate: []const u8, mine: []const u8) error{BadVersion}!bool`
35
36 - [ ] **Step 1: Write the failing tests** — each test name is the claim:
37
38 ```zig
39 test "strictlyNewer: the next prerelease number is newer (0.0.1-14 over 0.0.1-13)" {
40 try std.testing.expect(try strictlyNewer("0.0.1-14", "0.0.1-13"));
41 }
42 test "strictlyNewer: prerelease numbers order numerically, not lexically (0.0.1-100 over 0.0.1-99)" {
43 try std.testing.expect(try strictlyNewer("0.0.1-100", "0.0.1-99"));
44 }
45 test "strictlyNewer: the same version is not newer" {
46 try std.testing.expect(!try strictlyNewer("0.0.1-13", "0.0.1-13"));
47 }
48 test "strictlyNewer: a downgrade is not newer" {
49 try std.testing.expect(!try strictlyNewer("0.0.1-12", "0.0.1-13"));
50 }
51 test "strictlyNewer: a release outranks its own prereleases" {
52 try std.testing.expect(try strictlyNewer("0.0.1", "0.0.1-13"));
53 }
54 test "strictlyNewer: garbage is an error, not a verdict" {
55 try std.testing.expectError(error.BadVersion, strictlyNewer("not-a-version", "0.0.1-13"));
56 }
57 ```
58
59 - [ ] **Step 2: Register the module and run** — add `upgrade` to `build.zig`'s table (layer 1, deps `protocol`, `cmd`), run `./deps/zig/zig build test 2>&1 | tail -20`. Expected: FAIL, `strictlyNewer` not defined.
60
61 - [ ] **Step 3: Implement**
62
63 ```zig
64 pub fn strictlyNewer(candidate: []const u8, mine: []const u8) error{BadVersion}!bool {
65 const c = std.SemanticVersion.parse(candidate) catch return error.BadVersion;
66 const m = std.SemanticVersion.parse(mine) catch return error.BadVersion;
67 return c.order(m) == .gt;
68 }
69 ```
70
71 `std.SemanticVersion` already orders numeric prerelease identifiers numerically; the tests pin that this stays true if std changes underneath a zig bump.
72
73 - [ ] **Step 4: Run tests to green.** `./deps/zig/zig build test 2>&1 | tail -5`
74 - [ ] **Step 5: Commit** — `feat: the upgrade version rule — strictly newer or refused`
75
76 ---
77
78 ### Task 2: `upgrade.zig` — the manifest codec
79
80 **Files:**
81 - Modify: `src/upgrade.zig`
82 - Test: in-file `test` blocks
83
84 **Interfaces:**
85 - Produces (consumed by Tasks 6, 9, 10):
86
87 ```zig
88 pub const manifest_version: u16 = 1;
89 pub const SectionTag = enum(u8) { daemon = 1, session = 2, _ };
90
91 pub const EnvPair = struct { key: []const u8, value: ?[]const u8 };
92 pub const QuicArm = enum(u8) { none = 0, borrowed = 1, owned = 2 };
93 pub const QuicState = struct {
94 arm: QuicArm,
95 fd: i32 = -1, // meaningful when arm != .none
96 addr: [128]u8 = undefined, // std.net.Address raw bytes (sockaddr storage)
97 addr_len: u32 = 0,
98 idle_ms: u64 = 0,
99 key: [32]u8 = @splat(0), // bytes, never a path: the memfd is anonymous memory
100 };
101 pub const Counters = struct {
102 snapshots: u64, snapshot_bytes: u64, deltas: u64, delta_bytes: u64,
103 snapshot_equiv_bytes: u64, attaches: u64,
104 agent_refused_no_offer: u64, agent_refused_full: u64,
105 };
106 pub const Daemon = struct {
107 writer_version: []const u8, // rollback target's identity
108 writer_path: []const u8, // rollback target: the OLD binary
109 sock_path: []const u8,
110 listener_fd: i32,
111 shellint_dir: ?[]const u8,
112 agent_dir: ?[]const u8,
113 shell: []const u8,
114 shell_integration: bool,
115 extra_env: []const EnvPair,
116 quic: QuicState,
117 counters: Counters,
118 };
119 pub const CmdRec = struct {
120 phase: u8, marks_seen: bool, start_row: u32, end_row: u32, exit_code: ?u8,
121 };
122 pub const SessionRec = struct {
123 name: []const u8,
124 pty_fd: i32, child_pid: i32,
125 cols: u16, rows: u16,
126 vt: []const u8, // Engine.dumpState bytes, viewport only
127 title: ?[]const u8, // dumpState omits the title; carried apart
128 cmd: CmdRec,
129 last_return: ?proto.CmdState,
130 agent_fd: i32, // -1 = none
131 agent_path: ?[]const u8,
132 };
133
134 pub fn writeManifest(w: anytype, d: Daemon, sessions: []const SessionRec) !void;
135 pub const Parsed = struct { daemon: Daemon, sessions: []SessionRec, arena: std.heap.ArenaAllocator, pub fn deinit(...) };
136 pub fn parseManifest(alloc: std.mem.Allocator, bytes: []const u8) error{BadManifest}!Parsed;
137 ```
138
139 Encoding: magic `"MUXU"`, `u16 manifest_version`, then sections, each `u8 tag ++ u32 LE len ++ len bytes`. Strings inside a section are `u32 LE len ++ bytes`; optionals are `u8 present ++ payload`. **An unknown tag is skipped by length** — that is the whole forward-compat story and it gets its own test.
140
141 - [ ] **Step 1: Write the failing tests**
142
143 ```zig
144 test "manifest: a round-trip loses nothing a session needs" { ... } // full Daemon + 2 SessionRecs, expectEqualDeep field-by-field
145 test "manifest: an unknown section tag is skipped by its length, not fatal" { ... } // splice tag 0x7f between sections; parse still succeeds
146 test "manifest: a truncated section is BadManifest, not a partial adopt" { ... }
147 test "manifest: a wrong magic is BadManifest" { ... }
148 test "manifest: a session with no agent and no title round-trips its absences" { ... }
149 ```
150
151 - [ ] **Step 2: Run to RED.** Expected: FAIL, types not defined.
152 - [ ] **Step 3: Implement writer + parser.** Parser copies everything into its arena (the caller's byte buffer dies before adoption finishes using the strings).
153 - [ ] **Step 4: Green.** `./deps/zig/zig build test 2>&1 | tail -5`
154 - [ ] **Step 5: Commit** — `feat: the upgrade manifest — sections a stranger can skip`
155
156 ---
157
158 ### Task 3: protocol — the two wire frames
159
160 **Files:**
161 - Modify: `src/protocol.zig`
162 - Test: in-file `test` blocks beside the other frame codecs
163
164 **Interfaces:**
165 - Produces:
166
167 ```zig
168 // in FrameType (free ranges: client→daemon 0x10–0x7e, daemon→client 0x90+):
169 upgrade_req = 0x10, // payload: u8 flags (bit0 allow_same_version) ++ version NUL abs-path
170 upgrade_reply = 0x93, // payload: u8 status (0 accepted, 1 refused) ++ reason text
171
172 pub const UpgradeReq = struct { allow_same_version: bool, version: []const u8, path: []const u8 };
173 pub fn encodeUpgradeReq(buf: []u8, req: UpgradeReq) ![]const u8;
174 pub fn parseUpgradeReq(payload: []const u8) error{BadFrame}!UpgradeReq;
175 ```
176
177 - [ ] **Step 1: Failing tests** — round-trip; a payload with no NUL is `BadFrame`; a relative path is `BadFrame` (the daemon must never resolve a relative path against ITS cwd).
178 - [ ] **Step 2: RED**, **Step 3: implement**, **Step 4: green.**
179 - [ ] **Step 5: Commit** — `feat: upgrade_req/upgrade_reply on the wire`
180
181 ---
182
183 ### Task 4: `Pty.adopt`
184
185 **Files:**
186 - Modify: `src/pty.zig`
187 - Test: in-file `test` block
188
189 **Interfaces:**
190 - Produces: `pub fn adopt(master: std.posix.fd_t, child: std.posix.pid_t) Pty` — builds `{ .master, .child, .exit_status = null }`. Because the exec keeps the pid, the adopted child is STILL our child: `checkExited`'s `waitpid` works unchanged and keeps the real exit code. That sentence is the design's whole reason to exist; the doc comment carries it.
191
192 - [ ] **Step 1: Failing test**
193
194 ```zig
195 test "Pty.adopt: an adopted pair still reports the child's real exit code" {
196 var p = try Pty.spawn(.{ .shell = "/bin/sh", ... });
197 const orphan = Pty.adopt(p.master, p.child); // simulate: same process, new struct
198 // drive the shell to `exit 7` via the master, then poll:
199 ... try std.testing.expectEqual(@as(u32, 7), code);
200 }
201 ```
202
203 - [ ] **Step 2: RED**, **Step 3: implement (three lines)**, **Step 4: green**, **Step 5: Commit** — `feat: Pty.adopt — same pid, same waitpid, same exit code`
204
205 ---
206
207 ### Task 5: quic_server — `initFromFd` and `closeAll`
208
209 **Files:**
210 - Modify: `src/quic_server.zig`
211 - Test: in-file tests beside `TestClient`
212
213 **Interfaces:**
214 - Produces:
215 - `pub fn initFromFd(alloc, fd: std.posix.fd_t, key: quic.Key, handler: Handler, idle_ms: u64) !*Listener` — everything `init` does from `wolfSSL_Init` on (`src/quic_server.zig:357+`), skipping `socket`/`bind`. Refuses with `error.NotAUdpSocket` unless `getsockopt(SO_TYPE) == SOCK.DGRAM`. Sets `g_listener_live`/`g_key` exactly as `init` does.
216 - `pub fn closeAll(self: *Listener) void` — send CONNECTION_CLOSE on every live conn and drain once. Today's `closeConn` is deliberately quiet; this is the loud goodbye so WAN clients redial now instead of waiting out `default_idle_ms`.
217 - `pub fn boundAddr(self: *const Listener) std.net.Address` (getsockname) — Task 6 needs it for the manifest.
218
219 - [ ] **Step 1: Failing tests** — `initFromFd` refuses a pipe fd; `initFromFd` on a bound UDP fd serves a `TestClient` handshake; `closeAll` makes an attached `TestClient` see the close (assert on the client's read, not on bytes sent — the oracle rule).
220 - [ ] **Step 2: RED**, **Step 3: implement** (factor `init`'s tail into a shared private `finishInit(fd, ...)`), **Step 4: green**, **Step 5: Commit** — `feat: a QUIC listener can adopt a bound fd and say goodbye loudly`
221
222 ---
223
224 ### Task 6: server — gather and write the manifest
225
226 **Files:**
227 - Modify: `src/server.zig`
228 - Test: in-file test using the existing daemon-test helpers (grep `test "status_req` for the pattern of standing up a Server with a fake session)
229
230 **Interfaces:**
231 - Consumes: `upgrade.writeManifest`, `Listener.boundAddr`, `Engine.dumpState` (`src/engine.zig:448`), `Engine.title()`.
232 - Produces: `fn writeManifestTo(self: *Server, fd: std.posix.fd_t, writer_version: []const u8, writer_path: []const u8) !void` — fills `upgrade.Daemon` from live fields (`sock_path`, `listener.stream.handle`, `shellint_dir`, `agent_dir`, spawn inputs, runtime QUIC state incl. key bytes from the module — add `pub fn currentKey() ?quic.Key` to `quic_server` for this — and `stats` + `agent_refused_*`), and one `upgrade.SessionRec` per live session: `dumpState` for `vt`, `Engine.title()` bytes for `title`, `cmd` fields, `last_return`, pty/agent fds and paths.
233
234 - [ ] **Step 1: Failing test** — `test "writeManifestTo: what crosses is what a session cannot rebuild"`: stand up a Server with one named session, feed the pty a title-setting OSC and a mark, write to a memfd, `upgrade.parseManifest` it back, assert: session name, cols/rows, title bytes, `marks_seen`, the pty fd number, the sock_path.
235 - [ ] **Step 2: RED**, **Step 3: implement**, **Step 4: green**, **Step 5: Commit** — `feat: the daemon can write down what only it knows`
236
237 ---
238
239 ### Task 7: server — `upgrade_req` validation, refusing on any doubt
240
241 **Files:**
242 - Modify: `src/server.zig` (observer frame switch, beside `stop_req` at `src/server.zig:2572`)
243 - Test: in-file tests for the pure validation; e2e covers the child-run checks
244
245 **Interfaces:**
246 - Consumes: `proto.parseUpgradeReq`, `upgrade.strictlyNewer`.
247 - Produces: `fn validateUpgrade(self: *Server, req: proto.UpgradeReq) ?[]const u8` — returns a refusal reason or null (accept), checking in order:
248 1. version parses and is strictly newer (or equal + `allow_same_version`) — reason names BOTH versions;
249 2. path is absolute and `access(X_OK)` passes;
250 3. child-run `path --version` prints exactly `muxd <version>\n` for the requested version (reuse `std.process.Child`; 5s timeout);
251 4. manifest written to a fresh `memfd_create("mux-upgrade", 0)` (no CLOEXEC — children must read it), then child-run `path run --resume-fd N --check` exits 0.
252 - Any refusal → `upgrade_reply{1, reason}`, memfd closed, daemon carries on unchanged. That property gets its own e2e assertion (Task 12).
253
254 - [ ] **Step 1: Failing unit tests** for the pure parts: same version without the flag → reason contains both version strings; relative path → refused; non-executable path → refused.
255 - [ ] **Step 2: RED**, **Step 3: implement** (child runs live in a helper so the unit tests can stop at the pure checks), **Step 4: green**, **Step 5: Commit** — `feat: the daemon interrogates its replacement before trusting it`
256
257 ---
258
259 ### Task 8: server — the exec
260
261 **Files:**
262 - Modify: `src/server.zig`
263 - Test: unit for the fd-flag helper; the leap itself is e2e's (Task 12)
264
265 **Interfaces:**
266 - Produces: `fn execUpgrade(self: *Server, path: []const u8, memfd: std.posix.fd_t) noreturn`-shaped flow (returns only on exec failure), run after `validateUpgrade` passes:
267 1. `upgrade_reply{0}` to the requester, then drain;
268 2. bare-close every client sink and observer — NEVER `exit_status`: that is a dying shell's word and makes clients exit instead of redial (`src/wallview.zig` redial guard; the inventory's §4 condition);
269 3. `quic` arm: `closeAll` (Task 5);
270 4. `clearCloexec(fd)` (a 4-line `fcntl` helper, unit-tested) on: unix listener, QUIC UDP fd, every pty master, every agent listener, the memfd;
271 5. `execveZ(path, ["muxd", "run", "--resume-fd", "<n>"], environ)`.
272 On exec failure: log, restore FD_CLOEXEC, keep serving — a failed exec must leave a working daemon.
273 - NOTHING from `Server.deinit` runs on this path: no unlink, no deleteTree, no SIGTERM. State the invariant in a comment anchored on `deinit`.
274
275 - [ ] **Step 1: Failing unit test** — `clearCloexec` on a freshly `memfd_create(..., MFD_CLOEXEC)` fd flips the flag (read back via `F_GETFD`).
276 - [ ] **Step 2: RED**, **Step 3: implement**, **Step 4: green**, **Step 5: Commit** — `feat: the exec that keeps every shell`
277
278 ---
279
280 ### Task 9: main — `run --resume-fd N [--check]` and adoption
281
282 **Files:**
283 - Modify: `src/main.zig` (flag parsing, `takes_value` list at `src/main.zig:193`), `src/server.zig` (adoption constructor)
284 - Test: parse tests in `main.zig`; adoption test in `server.zig`
285
286 **Interfaces:**
287 - Consumes: `upgrade.parseManifest`, `Pty.adopt`, `quic_server.initFromFd`, `Engine.init`+`feed`.
288 - Produces:
289 - `--resume-fd N` and `--check` parsed on `run`; `--check` reads the manifest to the end, prints nothing, exits 0 — the old daemon's dry-run probe.
290 - `Server.initFromManifest(alloc, parsed: upgrade.Parsed, opts_overrides_none: void) !Server`:
291 - **no** `sockpath.claim` (the listener fd IS the claim);
292 - `listener` built from the fd, `sock_path`/`shellint_dir`/`agent_dir` from the manifest; spawn plan rebuilt from the manifest's shell/shell_integration/extra_env;
293 - per session: fresh `Engine.init` at cols×rows, `feed(vt)`; the title is restored by FEEDING an OSC 0 built from the title bytes — the engine becomes the owner again and `sampleTermTitle` re-announces it to the first client naturally, no special case downstream;
294 - `Pty.adopt(pty_fd, child_pid)`; `cmd`/`last_return` restored field-for-field; epoch minted fresh (nonzero random — forces every returning client onto the snapshot path); delta tracker fresh; `*_sent` latches null;
295 - QUIC: arm `none` → nothing; else `initFromFd` with the manifest's key bytes and idle_ms;
296 - counters restored into `stats`/`agent_refused_*`.
297
298 - [ ] **Step 1: Failing parse tests** (`main.zig` has the pattern at `src/main.zig:963`): `run --resume-fd 7 --check` parses; `--resume-fd` without a value is usage.
299 - [ ] **Step 2: Failing adoption test** in `server.zig` — `test "initFromManifest: an adopted session answers a status_req without having been created"`: write a manifest from a live rig (Task 6's test rig), tear the first Server down WITHOUT deinit (leak-list its allocations in the test), adopt, assert `status_req` for the session answers with the manifest's cols/rows and `mechanism` reflects `marks_seen`.
300 - [ ] **Step 3: RED**, **Step 4: implement**, **Step 5: green**, **Step 6: Commit** — `feat: a daemon that starts from another daemon's memory`
301
302 ---
303
304 ### Task 10: rollback — the second exec
305
306 **Files:**
307 - Modify: `src/main.zig`, `src/server.zig`
308 - Test: e2e (Task 12); unit for the lseek-rewind
309
310 **Interfaces:**
311 - Produces: adoption failure before the pump starts → log the section that failed → `lseek(memfd, 0)` → `execveZ(manifest.writer_path, ["muxd","run","--resume-fd","<n>"], environ)`. Only if THAT exec fails does the process exit (shells then get SIGHUP when the masters close — exactly today's `stop`+`run` outcome, not worse). Plus a test-only `--resume-fail-at <tag>` flag on `run` that aborts adoption after parsing the named section — the rollback leg's trigger.
312
313 - [ ] **Step 1: RED via e2e sketch** (the leg lands in Task 12; here write the flag + rollback and a unit test that `--resume-fail-at daemon` on `--check` still exits 0 — check never adopts, so the flag must not touch it).
314 - [ ] **Step 2: implement**, **Step 3: green**, **Step 4: Commit** — `feat: a failed adoption execs its way back`
315
316 ---
317
318 ### Task 11: main — `muxd upgrade` and the README
319
320 **Files:**
321 - Modify: `src/main.zig`, `README.md`
322 - Test: parse tests; behavior is e2e's
323
324 **Interfaces:**
325 - Consumes: `proto.encodeUpgradeReq`.
326 - Produces: `upgradeCmd(alloc, sock_path, allow_same: bool) !u8`, shaped like `stopCmd` (`src/main.zig:575`): resolve `/proc/self/exe` (the `startCmd` pattern, `src/main.zig:888`), connect, send `upgrade_req` with THIS binary's `build_options.version`, then poll the fd for `upgrade_reply` with a 5000 ms deadline:
327 - reply status 0 → print `muxd: upgraded to <version>` — then confirm by probing the socket answers;
328 - reply status 1 → print the daemon's reason verbatim, exit 1;
329 - timeout, no reply → `muxd upgrade: no reply: this daemon predates upgrade — stop and run` (a pre-feature daemon drops unknown frames silently), exit 1.
330 - `--allow-same-version` sets the req flag; its help text says it exists for the e2e leg.
331
332 - [ ] **Step 1: Failing parse tests**, **Step 2: RED**, **Step 3: implement + README section** (under the daemon lifecycle text: one paragraph, the command, the skew rule, the predates-upgrade message), **Step 4: green + `make check`**, **Step 5: Commit** — `feat: muxd upgrade — the new binary asks`
333
334 ---
335
336 ### Task 12: e2e legs + decisions entry
337
338 **Files:**
339 - Modify: `test/e2e.sh` (legs at the END; bump the scenario/convergence pins at the tail), `docs/decisions.md`
340
341 **Interfaces:**
342 - Consumes: everything above, as installed behavior. Assert with the existing oracles: `muxa status/await` (`mechanism` field), `muxa capture`, `muxd stats`, `ptyclient` for an attached wall.
343
344 - [ ] **Step 1: The same-binary leg** (`--allow-same-version`), the gate for v1, asserting IN ORDER:
345 1. session with shell integration on; type a marker; `echo $$` puts the shell pid on the grid; capture it;
346 2. `muxd upgrade --sock S --allow-same-version` exits 0;
347 3. an attached `ptyclient` repaints (one reattach) and the marker AND THE SAME PID are still on the grid (`muxa capture` grep — the pid string is the cross-exec witness the wire cannot carry);
348 4. a second marker typed post-upgrade lands (the pty pumps both ways);
349 5. the session's title survives (`status_reply`/capture path);
350 6. `muxa await` still answers `mechanism: marks` with a real exit code (marks_seen crossed);
351 7. `ssh-add -l` inside an `-A` session still answers (agent listener fd crossed);
352 8. `muxd stats` shows pre-upgrade counters plus the new attach, and the same daemon pid.
353 - [ ] **Step 2: Refusal legs** — same version without the flag (reason names both versions); a non-executable path; each followed by: `muxa status` still answers and the marker is intact (refusal changed nothing).
354 - [ ] **Step 3: Rollback leg** — candidate run with `--resume-fail-at session`; assert the OLD binary is back (`muxd stats` answers, same pid, marker intact).
355 - [ ] **Step 4: QUIC leg** — client attached over `quic://127.0.0.1:PORT` (ports from the e2e band pattern at the file tail); upgrade; the client reattaches within one backoff (assert elapsed < idle_ms/2, the CONNECTION_CLOSE-not-timeout witness).
356 - [ ] **Step 5: Watch each new assertion FAIL once** (revert the behavior with a targeted sed, run the leg, restore by inverse sed — never `git checkout --`).
357 - [ ] **Step 6: Pins + docs.** Bump the scenario/convergence pins; `docs/decisions.md` entry: the fd-passing → re-exec pivot, why (the inventory's three killers: waitpid ECHILD, pid-named dirs, no SCM_RIGHTS in std), what deliberately does not cross (scrollback, clients, delta trackers, agent channels), the xversion gap (a cross-version leg needs an old side that carries the feature; until then same-binary is the gate).
358 - [ ] **Step 7: `make check` then FULL `make ci`; read the status out of the log, not the pipe.**
359 - [ ] **Step 8: Commit** — `test: the upgrade keeps the shell, its pid, its title, its marks, its agent`
360
361 ---
362
363 ## Self-Review (done at write time)
364
365 - **Spec coverage:** shape steps 1-5 → Tasks 7/8 (validate, goodbye, exec), 9 (adopt), 10 (rollback); manifest table → Task 2/6; exit detection unchanged → Task 4; wire → Task 3; skew → Tasks 1/7/11; trust → Task 7 (validation is safety, not auth — comment carries it); client experience → Task 12 leg 4; non-goals → decisions entry. Testing section maps 1:1 onto Tasks 5 (initFromFd refusal unit), 12 (legs), 1/2 (unit).
366 - **Types consistent:** `upgrade.Daemon`/`SessionRec` produced in Task 2, consumed by name in Tasks 6/9/10; `initFromFd` signature identical in Tasks 5/9; frame names identical in Tasks 3/7/11.
367 - **Known risk, named:** Task 9's "tear down without deinit" test needs care with allocator ownership — the implementer should lean on the test allocator's leak check and free by hand what the adopted Server now owns. If the existing server test helpers make this impractical, the fallback is asserting adoption through the e2e leg only and shrinking the unit to manifest→Server field mapping.
docs/superpowers/plans/2026-08-27-daemon-nonblocking-frames.md
Old New
@@ -1,718 +0,0 @@
1 > Superseded in part: Task 4 places the seal in `initFromManifest`; that broke rollback. It lives in `resumeRun` after the last rollback exit — see docs/decisions.md 2026-08-27.
2
3 # Daemon Non-Blocking Frame Reads Implementation Plan
4
5 > **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.
6
7 **Goal:** A peer that delivers part of a frame and goes quiet can no longer stop `muxd` — every session, every other client, and `muxd stats` keep answering.
8
9 **Architecture:** `muxd` is one thread around one `poll(2)`. Today a readable socket client or observer is serviced by `proto.readFrame`, a *blocking* three-read walk (1-byte kind, 4-byte length, whole payload) — so a partial frame parks the whole daemon in `read()`, which is the wedge observed on 2026-08-27 (daemon in `unix_stream_read_generic`, `muxd stats` rc=124, freed the instant the stalled client was killed). The fix removes `readFrame` from the pump: on `POLLIN` the daemon does exactly **one** `read()` into a per-connection inbound buffer and peels whole frames off with `proto.delimitFrame`. Socket clients already own that buffer and that walk (`ClientSlot.inbound` + `Server.pushInbound`, built for QUIC); observers gain the same. A second, separately found defect rides along as its own task: fds adopted across `muxd upgrade` come back without `FD_CLOEXEC`, so every shell spawned after an upgrade inherits the daemon's listener sockets.
10
11 **Tech Stack:** Zig 0.15.2 (`deps/zig/zig` only — system zig cannot build this), POSIX sockets, `make check` / `make e2e` / `make ci`.
12
13 **Spec:** No separate spec — bounded change, design agreed in chat 2026-08-27 (this file carries it). Root-cause evidence: `src/server.zig:1720` (`serviceClient` → `proto.readFrame`), `src/server.zig:2277` (`serviceObserver` → `proto.readFrame`), `src/protocol.zig:127-152` (`readFrame`/`readExact` block), `src/server.zig:1111` (existing comment naming the hazard for `POLLOUT`), repro: connect to any daemon, send one byte, `muxd stats` hangs.
14
15 ## Global Constraints
16
17 - Build with `deps/zig/zig` via the Makefile: `make check` before every commit (fmt + unit + shell syntax + comment-claim refs + docscheck byte budgets); `make e2e` after Tasks 3 and 5; `make ci` before delivery. Capture `$?` before piping (`make check > log 2>&1; echo rc=$?`).
18 - `docscheck.budget` lines are **never raised**: `server.zig 0`, `server_test_attach.zig 0`, `server_test_upgrade.zig 0`. New comments must say *why* only; a comment that restates code fails review. Symbol names cited in comments must resolve (`zig build check` gates them).
19 - Never `cat` `src/server.zig` (3.5k lines) or `test/e2e.sh`; use `grep -n` then `sed -n 'A,Bp'`.
20 - Never `git add -A` (an untracked `RETRO.md` lives in the tree). Stage files by name.
21 - Every hand-run daemon exports an isolated `XDG_STATE_HOME` first, e.g. `export XDG_STATE_HOME=$SCRATCH/state`.
22 - No test writes to stdout (it wedges the `zig build test` runner IPC).
23 - A unit test that can hang on RED must not hang: use the watchdog pattern in Task 1 so the failure prints instead of stalling `make check` silently.
24 - Work on a branch: `git switch -c daemon-nonblocking-frames main` and record the base once, `BASE=$(git rev-parse main)` — the e2e RED steps rebuild the pre-fix daemon from it.
25 - Commit per task with `git commit` (no amend/rebase after a task is announced done); message shapes follow `git log --oneline -20`: `fix: …`, `test: …`, `refactor: …`.
26 - Transport stays dumb: nothing in `proxy.zig` or the QUIC modules changes.
27
28 ---
29
30 ## File Map
31
32 | File | Change |
33 |---|---|
34 | `src/server.zig` | `Observer` struct (fd + inbound buffer) replaces bare observer fds; `serviceClient` and `serviceObserver` do one `read()` and delimit; `drainObserver` handles buffered observer frames; `acceptConn`/`dropObserver`/`deinit`/`execUpgrade` follow the type change; `initFromManifest` re-sets `FD_CLOEXEC` on adopted fds. |
35 | `src/server_test_attach.zig` | Two new tests: a half-frame from a seated client does not stall `pumpOnce`; a one-byte observer does not stall `pumpOnce`, and `stats_req` then `attach`+trailing frame in one write both work. |
36 | `src/server_test_upgrade.zig` | One new test: every fd `initFromManifest` adopts carries `FD_CLOEXEC`. |
37 | `test/e2e_01_boot.sh` | One new scenario: one raw byte on the socket, `muxd stats` still answers within 5 s. |
38 | `test/e2e_14_upgrade.sh` | One assertion added to an existing leg: a shell spawned after the exec holds no socket fds (asked of `/proc`). |
39 | `test/e2e.sh` | `python3` required, guarded like `nvim`/`curl`. |
40 | `RETRO.md` | One line on delivery (untracked; do not commit). |
41
42 Not touched: `src/protocol.zig` (`readFrame` stays for the client side, where one thread owns one transport and a blocking read is correct), `src/server_agent.zig:290` (already one `read()` per readiness).
43
44 ---
45
46 ### Task 1: A half frame from a seated client does not stall the pump
47
48 **Files:**
49 - Modify: `src/server.zig:1710-1728` (`serviceClient`)
50 - Test: `src/server_test_attach.zig` (append after the test ending at line ~1300, "injected bytes reach frame handling, split anywhere")
51
52 **Interfaces:**
53 - Consumes: `Server.pushInbound(self, i: usize, bytes: []const u8) void` (`src/server.zig`, existing), `proto.appendFrame`, `connectedPair` (`src/server_test_harness.zig:39`), `Server.colsNow(si) u16`.
54 - Produces: `serviceClient` never blocks; a client whose `read()` returns 0 or errors is dropped as before.
55
56 - [ ] **Step 1: Write the failing test**
57
58 Append to `src/server_test_attach.zig`. The watchdog is what keeps RED legible: without it a blocking `pumpOnce` hangs the runner and prints nothing.
59
60 ```zig
61 /// The rescue thread completes the frame after a deadline, so a pump that
62 /// blocked on the half frame is UNBLOCKED and the test fails with a message
63 /// instead of hanging the runner (which prints nothing for a wedged step).
64 const HalfFrameRescue = struct {
65 peer: std.posix.fd_t,
66 rest: []const u8,
67 fired: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
68 stop: std.atomic.Value(bool) = std.atomic.Value(bool).init(false),
69
70 fn run(self: *HalfFrameRescue) void {
71 var waited: usize = 0;
72 while (!self.stop.load(.acquire) and waited < 2000) : (waited += 10) {
73 std.Thread.sleep(10 * std.time.ns_per_ms);
74 }
75 if (self.stop.load(.acquire)) return;
76 self.fired.store(true, .release);
77 proto.writeAllFd(self.peer, self.rest) catch {};
78 }
79 };
80
81 test "Server: a client that sends half a frame does not stall the pump" {
82 const alloc = std.testing.allocator;
83
84 var tmp = try TmpDir.make();
85 defer tmp.cleanup();
86 const dir_path = tmp.path();
87 const sock_path = try std.fmt.allocPrint(alloc, "{s}/half.sock", .{dir_path});
88 defer alloc.free(sock_path);
89
90 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
91 defer srv.deinit();
92
93 const c = try connectedPair(dir_path, "half-c");
94 defer std.posix.close(c.peer);
95 srv.clients[0] = .{ .sink = .{ .socket = c.daemon }, .session = 0 };
96
97 var frame: std.ArrayList(u8) = .empty;
98 defer frame.deinit(alloc);
99 try proto.appendFrame(&frame, alloc, .resize, &proto.encodeSize(100, 30));
100
101 // Three of the five header bytes: the kind and half the length. This
102 // is the shape a peer that stalled mid-write leaves on the wire.
103 try proto.writeAllFd(c.peer, frame.items[0..3]);
104
105 var rescue: HalfFrameRescue = .{ .peer = c.peer, .rest = frame.items[3..] };
106 const th = try std.Thread.spawn(.{}, HalfFrameRescue.run, .{&rescue});
107 defer th.join();
108
109 // One pump with a 20ms poll must come back on its own — the readable
110 // socket is serviced, the partial frame is held, and the loop returns.
111 _ = try srv.pumpOnce(20);
112 rescue.stop.store(true, .release);
113 if (rescue.fired.load(.acquire)) {
114 std.debug.print("pumpOnce blocked on a half frame; the rescue thread had to complete it\n", .{});
115 return error.PumpBlockedOnHalfFrame;
116 }
117 try std.testing.expectEqual(@as(u16, 80), srv.colsNow(0)); // nothing applied yet
118 try std.testing.expect(srv.clients[0] != null); // and nobody was dropped
119
120 // The rest arrives; the frame completes on the next pump.
121 try proto.writeAllFd(c.peer, frame.items[3..]);
122 _ = try srv.pumpOnce(20);
123 try std.testing.expectEqual(@as(u16, 100), srv.colsNow(0));
124 try std.testing.expectEqual(@as(u16, 30), srv.rowsNow(0));
125 try std.testing.expectEqual(@as(usize, 0), srv.clients[0].?.inbound.items.len);
126 }
127 ```
128
129 - [ ] **Step 2: Run the test to verify it fails**
130
131 Run: `make test > $SCRATCH/t1.log 2>&1; echo rc=$?; grep -n -E 'half a frame|PumpBlockedOnHalfFrame|rescue thread' $SCRATCH/t1.log | head`
132
133 Expected: rc≠0; the log holds `pumpOnce blocked on a half frame; the rescue thread had to complete it` and `error.PumpBlockedOnHalfFrame`, ~2 s after the test starts. (There is no test filter in this build; the whole unit suite runs.)
134
135 - [ ] **Step 3: Replace the blocking read in `serviceClient`**
136
137 In `src/server.zig`, replace the body from `const frame = proto.readFrame(self.alloc, fd) catch {` through `self.handleFrame(i, frame);` (currently lines ~1720-1727) with:
138
139 ```zig
140 // One read per readiness, never a second: poll said there are bytes
141 // (or EOF), so this read returns at once, and a frame that is only
142 // partly here waits in `inbound` for the next pump. A blocking
143 // multi-read here parked the whole daemon behind one stalled peer.
144 var buf: [64 * 1024]u8 = undefined;
145 const n = std.posix.read(fd, &buf) catch {
146 self.dropClient(i);
147 return;
148 };
149 if (n == 0) {
150 self.dropClient(i);
151 return;
152 }
153 self.pushInbound(i, buf[0..n]);
154 ```
155
156 Update the doc comment above `serviceClient` (`/// A socket client is readable: pull one frame off its fd and handle it.`) to `/// A socket client is readable: one read, then whatever whole frames that made.` and delete the sentence `This is the only arm that reads a descriptor; everything downstream of the frame is shared with the injection path below.` — it is no longer true (both arms now push into `inbound`) and its replacement is the comment inside the function.
157
158 Also update `src/server.zig:1111-1114` (the `POLLOUT` comment inside the client loop): replace `its readFrame is a blocking read, so a writable-but-silent socket would hang the daemon` with `a read on a writable-but-silent socket has nothing to return and would block`.
159
160 - [ ] **Step 4: Run the test to verify it passes, and the earlier inbound tests still do**
161
162 Run: `make check > $SCRATCH/check1.log 2>&1; echo rc=$?; grep -E 'FAIL|error:|passed|half a frame' $SCRATCH/check1.log | tail`
163
164 Expected: `rc=0`. If docscheck complains about `server.zig` bytes, shorten the new comment — do not touch `docscheck.budget`.
165
166 - [ ] **Step 5: Commit**
167
168 ```bash
169 git add src/server.zig src/server_test_attach.zig
170 git commit -m "fix: a socket client is read once per readiness, never blocked on
171
172 A client that wrote part of a frame and stopped parked the daemon in
173 read(): every session and muxd stats with it. serviceClient now does one
174 read into the slot's inbound buffer and lets pushInbound delimit, the
175 same walk QUIC bytes already take."
176 ```
177
178 ---
179
180 ### Task 2: An observer carries an inbound buffer; one byte does not stall the pump
181
182 Observers (`muxd stats`, `muxd upgrade`, `muxa`, and every `mux` before it attaches) are bare fds today. They need the same buffer and walk, and promotion to a client slot must carry bytes that arrived after the `attach` frame in the same write (`wallview.zig:744-764` sends `attach` then `agent_offer` back to back).
183
184 **Files:**
185 - Modify: `src/server.zig:503` (field), `:898` (deinit), `:1014` (poll), `:1120` (dispatch), `:1189-1198` (`acceptConn`), `:1703-1706` (`dropObserver`), `:2275-2420` (`serviceObserver`), `:3325-3328` (`execUpgrade`)
186 - Test: `src/server_test_attach.zig` (append)
187
188 **Interfaces:**
189 - Produces: `pub const Observer = struct { fd: std.posix.fd_t, inbound: std.ArrayList(u8) = .empty };` and `observers: [max_observers]?Observer`. Tests read `srv.observers[i].?.fd`.
190 - Consumes: `Server.pushInbound`, `proto.delimitFrame(buf) !?Delimited` (`src/protocol.zig`, `Delimited = { type, payload, consumed }`), `awaitFrame` (`src/server_test_harness.zig:161`), `firstStateFrame` (`:61`).
191
192 - [ ] **Step 1: Write the failing tests**
193
194 Append to `src/server_test_attach.zig`:
195
196 ```zig
197 test "Server: an observer that sends one byte does not stall the pump, and finishes its frame later" {
198 const alloc = std.testing.allocator;
199
200 var tmp = try TmpDir.make();
201 defer tmp.cleanup();
202 const dir_path = tmp.path();
203 const sock_path = try std.fmt.allocPrint(alloc, "{s}/obs.sock", .{dir_path});
204 defer alloc.free(sock_path);
205
206 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
207 defer srv.deinit();
208
209 // Through the real listener, so the accept path seats it as the daemon
210 // would seat `muxd stats`.
211 const obs = try std.net.connectUnixSocket(sock_path);
212 defer obs.close();
213 _ = try srv.pumpOnce(20); // accept
214 try std.testing.expect(srv.observers[0] != null);
215
216 var frame: std.ArrayList(u8) = .empty;
217 defer frame.deinit(alloc);
218 try proto.appendFrame(&frame, alloc, .stats_req, "");
219
220 try proto.writeAllFd(obs.handle, frame.items[0..1]);
221 var rescue: HalfFrameRescue = .{ .peer = obs.handle, .rest = frame.items[1..] };
222 const th = try std.Thread.spawn(.{}, HalfFrameRescue.run, .{&rescue});
223 defer th.join();
224 _ = try srv.pumpOnce(20);
225 rescue.stop.store(true, .release);
226 if (rescue.fired.load(.acquire)) {
227 std.debug.print("pumpOnce blocked on a one-byte observer; the rescue thread had to complete it\n", .{});
228 return error.PumpBlockedOnHalfFrame;
229 }
230 try std.testing.expect(srv.observers[0] != null); // held, not dropped
231
232 // A second observer is served while the first is still mid-frame: the
233 // daemon is answering everyone, which is the whole claim.
234 const obs2 = try std.net.connectUnixSocket(sock_path);
235 defer obs2.close();
236 try proto.writeFrame(obs2.handle, .stats_req, "");
237 const r2 = (try awaitFrame(alloc, &srv, obs2.handle, .stats_reply, 100)) orelse
238 return error.NoStatsReplyWhileAnotherObserverStalls;
239 r2.deinit(alloc);
240
241 try proto.writeAllFd(obs.handle, frame.items[1..]);
242 const r1 = (try awaitFrame(alloc, &srv, obs.handle, .stats_reply, 100)) orelse
243 return error.NoStatsReplyAfterCompletion;
244 r1.deinit(alloc);
245 }
246
247 test "Server: bytes after an attach in the same write reach the promoted client" {
248 const alloc = std.testing.allocator;
249
250 var tmp = try TmpDir.make();
251 defer tmp.cleanup();
252 const dir_path = tmp.path();
253 const sock_path = try std.fmt.allocPrint(alloc, "{s}/promote.sock", .{dir_path});
254 defer alloc.free(sock_path);
255
256 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
257 defer srv.deinit();
258
259 const c = try std.net.connectUnixSocket(sock_path);
260 defer c.close();
261
262 // attach + resize in ONE write: the resize lands in the observer's
263 // buffer behind the attach and must follow the fd into the client slot.
264 var both: std.ArrayList(u8) = .empty;
265 defer both.deinit(alloc);
266 try proto.appendFrame(&both, alloc, .attach, &proto.encodeAttach(80, 24, 0, 0));
267 try proto.appendFrame(&both, alloc, .resize, &proto.encodeSize(132, 50));
268 try proto.writeAllFd(c.handle, both.items);
269
270 const first = try firstStateFrame(alloc, c.handle, 10_000);
271 try std.testing.expect(first != null);
272 var i: usize = 0;
273 while (i < 100 and srv.colsNow(0) != 132) : (i += 1) _ = try srv.pumpOnce(5);
274 try std.testing.expectEqual(@as(u16, 132), srv.colsNow(0));
275 try std.testing.expectEqual(@as(u16, 50), srv.rowsNow(0));
276 // Nothing left behind on either side of the promotion.
277 for (srv.observers) |o| try std.testing.expect(o == null);
278 try std.testing.expectEqual(@as(usize, 0), srv.clients[0].?.inbound.items.len);
279 }
280 ```
281
282 - [ ] **Step 2: Run the tests to verify they fail**
283
284 Run: `make test > $SCRATCH/t2.log 2>&1; echo rc=$?; grep -n -E 'one-byte observer|after an attach|error:' $SCRATCH/t2.log | head`
285
286 Expected: compile error on `srv.observers[0].?` being a bare fd / `o == null` is fine but the first test fails with `PumpBlockedOnHalfFrame` once it compiles. Either way rc≠0. If the build fails on the type before the run, that IS the red; proceed.
287
288 - [ ] **Step 3: Introduce `Observer` and route every use through it**
289
290 In `src/server.zig`:
291
292 Near `max_observers` (line 38) add:
293
294 ```zig
295 /// An observer is a connection that has not attached: stats, upgrade,
296 /// muxa, and every CLI client between accept and its attach frame. Its
297 /// bytes are buffered like a client's, because the same one-write-then-
298 /// silence that stalls a client can arrive here first.
299 pub const Observer = struct {
300 fd: std.posix.fd_t,
301 inbound: std.ArrayList(u8) = .empty,
302 };
303 ```
304
305 Change line 503 to `observers: [max_observers]?Observer = @splat(null),`.
306
307 `deinit` (~898) and `execUpgrade` (~3325): `if (slot) |o| std.posix.close(o.fd);` — and in `execUpgrade` free the buffer too: `for (&self.observers) |*slot| { if (slot.*) |*o| { o.inbound.deinit(a); std.posix.close(o.fd); } }` before the `@memset`. In `deinit` free `o.inbound` with `self.alloc` the same way.
308
309 Poll assembly (~1014): `.fd = if (slot) |o| o.fd else -1,`.
310
311 `acceptConn` (~1191): `slot.* = .{ .fd = conn.stream.handle };`.
312
313 `dropObserver` (~1703):
314
315 ```zig
316 fn dropObserver(self: *Server, i: usize) void {
317 if (self.observers[i]) |*o| {
318 o.inbound.deinit(self.alloc);
319 std.posix.close(o.fd);
320 }
321 self.observers[i] = null;
322 }
323 ```
324
325 - [ ] **Step 4: Split `serviceObserver` into one read plus a drain**
326
327 Replace the head of `serviceObserver` (from `const fd = self.observers[i].?;` through the `defer frame.deinit(self.alloc);` and the `switch (frame.type) {` opener) with:
328
329 ```zig
330 /// An observer is readable: one read, then every whole frame that made.
331 fn serviceObserver(self: *Server, i: usize) void {
332 const o = &self.observers[i].?;
333 var buf: [64 * 1024]u8 = undefined;
334 const n = std.posix.read(o.fd, &buf) catch {
335 self.dropObserver(i);
336 return;
337 };
338 if (n == 0) {
339 self.dropObserver(i);
340 return;
341 }
342 o.inbound.appendSlice(self.alloc, buf[0..n]) catch {
343 self.dropObserver(i);
344 return;
345 };
346 self.drainObserver(i);
347 }
348
349 /// Frames off an observer's buffer until it holds only a partial one,
350 /// the slot was dropped, or an attach promoted it — after which any
351 /// bytes behind the attach are the CLIENT's and go through pushInbound.
352 fn drainObserver(self: *Server, i: usize) void {
353 while (self.observers[i]) |*o| {
354 const d = proto.delimitFrame(o.inbound.items) catch {
355 self.dropObserver(i);
356 return;
357 } orelse return;
358 const payload = self.alloc.alloc(u8, d.payload.len) catch {
359 self.dropObserver(i);
360 return;
361 };
362 defer self.alloc.free(payload);
363 @memcpy(payload, d.payload);
364 const consumed = d.consumed;
365 const rest = o.inbound.items.len - consumed;
366 std.mem.copyForwards(u8, o.inbound.items[0..rest], o.inbound.items[consumed..]);
367 o.inbound.shrinkRetainingCapacity(rest);
368 self.handleObserverFrame(i, .{ .type = d.type, .payload = payload });
369 }
370 }
371
372 /// One observer frame. Slot `i` is live on entry; the `.attach` arm is
373 /// the one that ends the slot without dropping it.
374 fn handleObserverFrame(self: *Server, i: usize, frame: proto.Frame) void {
375 const fd = self.observers[i].?.fd;
376 switch (frame.type) {
377 ```
378
379 The rest of the old switch body stays as the body of `handleObserverFrame`. Two edits inside it:
380
381 1. In the `.attach` arm, replace `self.observers[i] = null; // promote without closing` with:
382
383 ```zig
384 // Promote: the fd and whatever arrived behind the attach
385 // both move; the buffer's owner changes, its bytes do not.
386 var moved = self.observers[i].?;
387 self.observers[i] = null;
388 self.clients[slot] = .{ .sink = .{ .socket = fd }, .session = si, .inbound = moved.inbound };
389 moved.inbound = .empty;
390 ```
391
392 and delete the existing `self.clients[slot] = .{ .sink = .{ .socket = fd }, .session = si };` line that followed. At the END of the `.attach` arm (after `self.sendCmdStateTo(si, slot);`) add:
393
394 ```zig
395 // Frames the same write carried behind the attach (a wall
396 // tile sends agent_offer on its heels) are handled now, as
397 // the client they were addressed to.
398 self.pushInbound(slot, &.{});
399 ```
400
401 2. Every `self.dropObserver(i)` inside the switch stays as is — `drainObserver` re-tests the slot each iteration, so a dropped observer ends the loop.
402
403 Confirm nothing else in `server.zig` references `proto.readFrame`: `grep -n 'proto.readFrame' src/server.zig` must print nothing.
404
405 - [ ] **Step 5: Run the tests**
406
407 Run: `make check > $SCRATCH/check2.log 2>&1; echo rc=$?; grep -E 'FAIL|error:|one-byte observer|after an attach' $SCRATCH/check2.log | tail`
408
409 Expected: `rc=0`, both new tests pass, every existing observer test (`server_test_upgrade.zig` uses observers heavily via `awaitFrame`) still passes.
410
411 - [ ] **Step 6: Commit**
412
413 ```bash
414 git add src/server.zig src/server_test_attach.zig
415 git commit -m "fix: observers buffer their bytes; promotion carries the tail
416
417 The observer slot was a bare fd read with the blocking frame walk. It
418 now holds an inbound buffer and takes the same one-read-then-delimit
419 path as a client, and an attach moves the buffer with the fd so frames
420 sent on the attach's heels are not lost in the promotion."
421 ```
422
423 ---
424
425 ### Task 3: End-to-end pin — one raw byte cannot stop `muxd stats`
426
427 The unit tests drive a real socket but not the built binary. This is the exact repro against `muxd` as installed, and it needs a way to write one byte to a unix socket from `sh`; the suite already hard-requires `nvim` and `curl` with a loud check, and `python3` joins them the same way.
428
429 **Files:**
430 - Modify: `test/e2e.sh` (after the `curl` guard, ~line 60)
431 - Modify: `test/e2e_01_boot.sh` (append at end of file)
432
433 - [ ] **Step 1: Require python3 in the runner**
434
435 After the `curl` guard in `test/e2e.sh` add:
436
437 ```sh
438 # And a fourth: the half-frame leg has to put ONE byte on a unix socket and
439 # hold the connection open, which no shell builtin can do. python3 is the
440 # one tool every box this suite runs on already has; a skip here would let
441 # a daemon that blocks on a slow peer pass green.
442 command -v python3 > /dev/null 2>&1 || {
443 echo "e2e FAIL: this suite needs python3 (the half-frame scenario opens a raw"
444 echo " unix socket); install it, or lose the check that one stalled"
445 echo " peer cannot stop the daemon"
446 exit 1; }
447 ```
448
449 - [ ] **Step 2: Write the scenario**
450
451 Append to `test/e2e_01_boot.sh`:
452
453 ```sh
454 # --- one stalled peer cannot stop the daemon --------------------------------
455 # A connection that writes the first byte of a frame and then holds still.
456 # Before this was fixed the daemon blocked in read() on that fd and every
457 # other connection — stats included — waited behind it (2026-08-27, found
458 # on a live wall). The witness is `muxd stats` answering within its timeout
459 # WHILE the half frame is still outstanding; the peer is only released after.
460 SOCKHF="${TMPDIR:-/tmp}/muxd-e2e-halfframe-$$.sock"
461 start_daemon "$SOCKHF" "$OUT.hf.d" "half-frame daemon never bound" --shell /bin/sh
462 DHFPID=$DPID
463 python3 - "$SOCKHF" <<'EOF' &
464 import socket, sys, time
465 s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
466 s.connect(sys.argv[1])
467 s.sendall(b'\x06') # stats_req's kind byte, and nothing after it
468 time.sleep(60)
469 EOF
470 HFPID=$!
471 defer_kill "$HFPID"
472 sleep 0.5
473 set +e
474 timeout 5 "$MUXD" stats --sock "$SOCKHF" > "$OUT.hf.st" 2>&1
475 HFRC=$?
476 set -e
477 [ "$HFRC" -eq 0 ] || {
478 echo "e2e FAIL: half-frame: stats exited $HFRC with a one-byte peer outstanding"
479 echo " (124 = the daemon is blocked in read() behind that peer)"
480 cat "$OUT.hf.st" "$OUT.hf.d"; exit 1; }
481 grep -q 'sessions=' "$OUT.hf.st" || {
482 echo "e2e FAIL: half-frame: stats answered but not with a stats line:"; cat "$OUT.hf.st"; exit 1; }
483 softkill "$HFPID" || true
484 HFPID=""
485 softkill "$DHFPID" || true
486 DHFPID=""
487 ok "one peer holding half a frame does not stop muxd stats"
488 ```
489
490 - [ ] **Step 3: Run the scenario RED against the pre-fix binary, then GREEN**
491
492 Prove the leg can fail (a leg that never fired is a leg that passes green): put the pre-fix `server.zig` back for one run. The tree is clean at this point (Tasks 1-2 are committed), so restoring it to HEAD afterwards loses nothing.
493
494 ```bash
495 git restore --source="$BASE" src/server.zig # the blocking read, for one run
496 make e2e > $SCRATCH/e2e-red.log 2>&1; echo rc=$?
497 grep -n 'half-frame' $SCRATCH/e2e-red.log | head -3
498 git restore src/server.zig # back to HEAD (the fix)
499 git status --short src/server.zig # must print nothing
500 ```
501
502 Expected: `rc≠0`, log contains `e2e FAIL: half-frame: stats exited 124`.
503
504 Then: `make e2e > $SCRATCH/e2e-green.log 2>&1; echo rc=$?; grep -E 'e2e OK \(|half a frame' $SCRATCH/e2e-green.log`
505
506 Expected: `rc=0`, `e2e OK: one peer holding half a frame does not stop muxd stats`, and the closing count is 83 scenarios (was 82).
507
508 - [ ] **Step 4: Commit**
509
510 ```bash
511 git add test/e2e.sh test/e2e_01_boot.sh
512 git commit -m "test: e2e — one peer holding half a frame does not stop muxd stats"
513 ```
514
515 ---
516
517 ### Task 4: Adopted fds get `FD_CLOEXEC` back after an upgrade
518
519 `execUpgrade` (`src/server.zig:3293-3308`) clears `FD_CLOEXEC` on the listener, the QUIC socket, every pty master and every agent listener so they cross the `execve`. `initFromManifest` adopts them (`:697`, `:747`, `:782`) and never sets the flag again, so every shell spawned by the upgraded daemon inherits them. Observed 2026-08-27: session shells born after the 13→14→15 in-place upgrades hold the daemon's `muxd.sock` and `agent-0.sock` listeners on fd 3 and fd 5 (`ss -xlp`), while the pre-upgrade shell holds none.
520
521 **Files:**
522 - Modify: `src/server.zig` `initFromManifest` (~686-790) and the QUIC adoption just below it
523 - Test: `src/server_test_upgrade.zig` (append)
524
525 **Interfaces:**
526 - Consumes: `Server.clearCloexec(fd) !void` (`src/server.zig:3245`, pub) — add its inverse `pub fn setCloexec(fd: std.posix.fd_t) !void` beside it.
527
528 - [ ] **Step 1: Write the failing test**
529
530 Append to `src/server_test_upgrade.zig`. It reuses the manifest round trip of the test at line 107; the only claim is the flag.
531
532 ```zig
533 /// FD_CLOEXEC, read back from the kernel — a daemon reporting on its own
534 /// fd table cannot catch itself being wrong.
535 fn hasCloexec(fd: std.posix.fd_t) !bool {
536 const flags = try std.posix.fcntl(fd, std.posix.F.GETFD, 0);
537 return flags & std.posix.FD_CLOEXEC != 0;
538 }
539
540 test "initFromManifest: every adopted fd is CLOEXEC again, so the next shell inherits none of them" {
541 const alloc = std.testing.allocator;
542
543 var tmp = try TmpDir.make();
544 defer tmp.cleanup();
545 const dir_path = tmp.path();
546 const sock_path = try std.fmt.allocPrint(alloc, "{s}/cloexec.sock", .{dir_path});
547 defer alloc.free(sock_path);
548
549 var srv = try Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" });
550 const memfd = try std.posix.memfd_create("mux-cloexec-test", 0);
551 defer std.posix.close(memfd);
552 try srv.writeManifestTo(memfd, "0.0.1-99");
553
554 // What execUpgrade does on the way out: the flag is cleared so the fds
555 // cross the exec. The adopting side must put it back.
556 try Server.clearCloexec(srv.listener.stream.handle);
557 try Server.clearCloexec(srv.sessions.table[0].?.pty.master);
558 if (srv.sessions.table[0].?.agent_listener != -1)
559 try Server.clearCloexec(srv.sessions.table[0].?.agent_listener);
560
561 {
562 const s = &srv.sessions.table[0].?;
563 s.tracker.deinit(alloc);
564 s.freePending(alloc);
565 if (s.title_sent) |t| alloc.free(t);
566 s.eng.deinit();
567 if (s.agent_path) |p| alloc.free(p);
568 }
569 if (srv.agents.dir) |d| alloc.free(d);
570 srv.shellint_arena.deinit();
571
572 var file = std.fs.File{ .handle = memfd };
573 try file.seekTo(0);
574 const buf = try file.readToEndAlloc(alloc, 4 * 1024 * 1024);
575 defer alloc.free(buf);
576 var parsed = try upgrade.parseManifest(alloc, buf);
577 defer parsed.deinit();
578
579 var srv2 = try Server.initFromManifest(alloc, &parsed, "0.0.1-100");
580 defer srv2.deinit();
581
582 try std.testing.expect(try hasCloexec(srv2.listener.stream.handle));
583 const s2 = &srv2.sessions.table[0].?;
584 try std.testing.expect(try hasCloexec(s2.pty.master));
585 if (s2.agent_listener != -1) try std.testing.expect(try hasCloexec(s2.agent_listener));
586 }
587 ```
588
589 - [ ] **Step 2: Run the test to verify it fails**
590
591 Run: `make test > $SCRATCH/t4.log 2>&1; echo rc=$?; grep -n -E 'CLOEXEC again|expected true' $SCRATCH/t4.log | head`
592
593 Expected: rc≠0, the test fails on the first `expect` (listener flag is 0).
594
595 - [ ] **Step 3: Set the flag on adoption**
596
597 Beside `clearCloexec` (`src/server.zig:3245`) add:
598
599 ```zig
600 /// The adopting side of clearCloexec: a flag cleared for the exec that
601 /// stays cleared is inherited by every shell this daemon spawns next.
602 pub fn setCloexec(fd: std.posix.fd_t) !void {
603 const flags = try std.posix.fcntl(fd, std.posix.F.GETFD, 0);
604 _ = try std.posix.fcntl(fd, std.posix.F.SETFD, flags | @as(usize, std.posix.FD_CLOEXEC));
605 }
606 ```
607
608 In `initFromManifest`:
609 - right after the `.listener = .{ ... .stream = .{ .handle = d.listener_fd } }` struct is built and `srv` exists: `try setCloexec(d.listener_fd);`
610 - in the session loop, after `.pty = Pty.adopt(rec.pty_fd, rec.child_pid),` is in the slot: `try setCloexec(rec.pty_fd);`
611 - after `s.agent_listener = rec.agent_fd;`: `try setCloexec(rec.agent_fd);`
612 - in the `switch (d.quic.arm)` just below the session loop, in the arm that calls `quic_server.Listener.initFromFd(… d.quic.fd …)`: `try setCloexec(d.quic.fd);` before that call.
613
614 - [ ] **Step 4: Run the tests**
615
616 Run: `make check > $SCRATCH/check4.log 2>&1; echo rc=$?; grep -E 'FAIL|error:|CLOEXEC again' $SCRATCH/check4.log | tail`
617
618 Expected: `rc=0`.
619
620 - [ ] **Step 5: Commit**
621
622 ```bash
623 git add src/server.zig src/server_test_upgrade.zig
624 git commit -m "fix: fds adopted across an upgrade are CLOEXEC again
625
626 execUpgrade clears the flag so the listener, the ptys and the agent
627 sockets cross the exec; nothing put it back, so every shell spawned by
628 the upgraded daemon inherited the daemon's own listeners."
629 ```
630
631 ---
632
633 ### Task 5: End-to-end — a shell spawned after the exec holds no daemon sockets
634
635 Asked of `/proc`, not the daemon. The agent-upgrade leg (`test/e2e_14_upgrade.sh:397-482`) already has a daemon that has exec'd and a `-A` client re-attached to it; the assertion needs a shell born AFTER the exec, which that leg does not yet create.
636
637 **Files:**
638 - Modify: `test/e2e_14_upgrade.sh` — insert after `pipe_detach "agent-upgrade client"` (~line 474) and before `assert_stopped "$SOCK71" …`
639
640 **Interfaces:**
641 - Consumes (`test/e2e_lib.sh`): `pipe_mux OUT ERR CMD…` (one pipe client at a time — it owns fd 9, which is why this goes AFTER the `-A` client's `pipe_detach`), `pipe_send FMT`, `wait_grid SOCK NEEDLE LABEL [SESSION]`, `dump_session SOCK [SESSION]`, `pipe_detach LABEL`. A pipe client with `--session NAME` births that session (`test/e2e_05_session.sh:57`).
642
643 - [ ] **Step 1: Add the assertion**
644
645 Insert after `pipe_detach "agent-upgrade client"`:
646
647 ```sh
648 # A shell born of the UPGRADED daemon: a fresh named session, so its shell
649 # was forked by the exec'd image. Its fd table is the witness — asked of
650 # /proc, because a daemon cannot see its own leak. Before the fix every
651 # post-exec shell held the daemon's listener and agent sockets on 3 and 5.
652 pipe_mux "$OUT.uagn" "$OUT.uagn.err" timeout 60 "$MUX" --sock "$SOCK71" --session post
653 pipe_send 'echo newsh=$$\n'
654 wait_grid "$SOCK71" "newsh=[0-9]" "agent-upgrade: the post-exec session never printed its pid" post
655 UPNEWSH=$(dump_session "$SOCK71" post | sed -n 's/.*newsh=\([0-9][0-9]*\).*/\1/p' | head -1)
656 [ -n "$UPNEWSH" ] || {
657 echo "e2e FAIL: agent-upgrade: no post-exec shell pid read off the grid"; dump_session "$SOCK71" post; exit 1; }
658 UPLEAK=$(ls -l "/proc/$UPNEWSH/fd" 2>/dev/null | grep -c 'socket:' || true)
659 [ "$UPLEAK" -eq 0 ] || {
660 echo "e2e FAIL: agent-upgrade: the post-exec shell holds $UPLEAK socket fd(s) — the daemon's"
661 echo " listeners crossed the exec without FD_CLOEXEC and were inherited:"
662 ls -l "/proc/$UPNEWSH/fd"; exit 1; }
663 pipe_detach "post-exec session client"
664 ```
665
666 - [ ] **Step 2: Run RED then GREEN**
667
668 ```bash
669 git restore --source="$BASE" src/server.zig # pre-fix daemon (also drops Tasks 1-2; this group does not exercise them)
670 E2E_ONLY=14_upgrade make e2e > $SCRATCH/e2e14-red.log 2>&1; echo rc=$?
671 grep -n 'post-exec shell holds' $SCRATCH/e2e14-red.log | head -2
672 git restore src/server.zig # back to HEAD
673 git status --short src/server.zig # must print nothing
674 E2E_ONLY=14_upgrade make e2e > $SCRATCH/e2e14-green.log 2>&1; echo rc=$?
675 grep -E 'agent socket crosses|e2e OK \(' $SCRATCH/e2e14-green.log
676 ```
677
678 Expected: red log shows `the post-exec shell holds 2 socket fd(s)`; green log `rc=0`. `E2E_ONLY` takes the group name (`test/e2e.sh` `E2E_GROUPS`); `14_upgrade` stands alone.
679
680 - [ ] **Step 3: Commit**
681
682 ```bash
683 git add test/e2e_14_upgrade.sh
684 git commit -m "test: e2e — a shell born after the exec inherits no daemon sockets"
685 ```
686
687 ---
688
689 ### Task 6: Full gate, history, retro
690
691 - [ ] **Step 1: The delivery gate**
692
693 Run: `make ci > $SCRATCH/ci.log 2>&1; echo rc=$?; grep -E 'e2e OK \(|FAIL|error:' $SCRATCH/ci.log | tail -5`
694
695 Expected: `rc=0`, `e2e OK (83 scenarios, …)`.
696
697 - [ ] **Step 2: Read the story**
698
699 Run: `git log --oneline main..HEAD` — five commits, each standing alone. No fixups to squash; if any were made, `git rebase -i --autosquash main` is fine here because nothing has been announced done yet.
700
701 - [ ] **Step 3: RETRO line (do not commit the file)**
702
703 Append to `RETRO.md`:
704
705 ```
706 - 2026-08-27 daemon wedge: the blocking readFrame in the pump was named as a hazard in a comment (server.zig POLLOUT arm) two months before it bit; a comment that says "this would hang" is a test that was never written
707 ```
708
709 - [ ] **Step 4: Hand back**
710
711 Report to the user: commits, `make ci` rc and scenario count, and that the live daemon (pid 236934, v0.0.1-15) still runs the old binary — rolling it is `make install` + `muxd upgrade`, their call.
712
713 ---
714
715 ## Open, not in this plan
716
717 - **Which client wrote the half frame.** It happened during a `Ctrl-\ w` fold on a three-tile wall (client pid 1466402, three pump threads, each writing its own socket from `pumpTile`; SIGWINCH only sets a flag). Every transport write goes through `proto.writeAllFd`, which loops until done, so a short write alone cannot explain it — a thread that died mid-write, or a `Transport` swapped under a reconnect (`wallview.zig:12-16` names that seam), are the leads. After this plan a repeat is a stalled tile, not a stalled daemon; chase it from the client side if it recurs.
718 - `src/server_agent.zig:290` already does one read per readiness and needs nothing.
docs/superpowers/plans/2026-08-27-wall-of-hosts.md
Old New
@@ -1,1119 +0,0 @@
1 # Wall of Hosts Implementation Plan (phase 1: daemon + CLI)
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:** The wall file lists daemons; tiles are whatever those daemons have live. One session list, `Ctrl-\ x` ends a session, `Ctrl-\ d` disconnects.
6
7 **Architecture:** A new `hosts` module owns the host file and grammar (no `#`). `wallview` grows a per-host poller thread that asks `sessions_req` once a second on a side (observer) connection and posts the list to the keyboard thread, which diffs it against the tiles it has: births through the existing `birthTile`, removals through the existing `.vanish` walk (`forgetTile` minus the file write). The daemon gains `end_req`/`end_reply` and an observer arm for `sessions_req`. `wall.zig` stays untouched for the hub (phase 2) and for the layout-sidecar helpers.
8
9 **Tech Stack:** Zig 0.15.2 (`deps/zig/zig`), POSIX sockets, existing e2e shell harness (`test/e2e_lib.sh`).
10
11 **Spec:** `docs/superpowers/specs/2026-08-27-wall-of-hosts-design.md`
12
13 ## Global Constraints
14
15 - Branch `wall-of-hosts` (already exists, off `main` at `3dcd811`). Never commit to `main`. Never `git add -A` (untracked `RETRO.md` must stay untracked).
16 - Never `cat` `src/wallview.zig`, `src/server.zig`, `src/interact.zig`, `test/e2e.sh`, `docs/decisions.md`: `grep -n SYMBOL` then `sed -n 'A,Bp'`. Line numbers in this plan are from `460bacd` and drift as tasks land — re-grep by symbol before editing.
17 - `make check` (fmt + unit tests + shell syntax + docscheck) green before every commit; capture `$?` before piping (`make check > log 2>&1; echo rc=$?`).
18 - `docscheck.budget` is an EXACT prose-byte figure per file (`tools/docscheck.zig`, `zig build check`). Files at `0` (`client.zig`, `mux_main.zig`, `protocol.zig`, `server.zig`, `interact.zig`, `server_sessions.zig`) admit no `///` block heavier than its declaration — keep new doc comments to one or two lines. `wallview.zig` is `1841`; every task that touches its comments re-reads the figure from `zig build check`'s message and edits the budget line DOWN in the same commit. A budget line may never go up. New file `hosts.zig` needs its own line, at `0`.
19 - Comments say why, never what. A claim a test owns is not repeated in a comment.
20 - Tests assert behaviour: plural by default (two hosts / two sessions / two clients); one is the extra case. A test must FAIL with the fix reverted — demonstrate RED before GREEN for every new test.
21 - Wire: `end_req = 0x11`, `end_reply = 0x94`; payloads exactly as the spec's "Wire" section. No existing frame changes shape.
22 - The hub (`webhub.zig`, `webhub_main.zig`, `e2e_06_web.sh`, `e2e_05_session.sh:234`, `e2e_13_birth.sh` hub legs) is out of scope; it keeps reading `$XDG_STATE_HOME/mux/wall` and must keep passing untouched.
23 - Any hand rig exports an isolated `XDG_STATE_HOME` (the e2e lib already does).
24 - File: `$XDG_STATE_HOME/mux/hosts`. The old `wall` file is not read by the CLI and not migrated.
25
26 ## File structure
27
28 | File | Responsibility after this plan |
29 |---|---|
30 | `src/protocol.zig` | `end_req`/`end_reply` values + codecs (Task 1) |
31 | `src/interact.zig` | route `end_reply` to the driver; chord `x` = `.end_session` (Tasks 1, 6) |
32 | `src/server.zig` | `endSession`, observer/client `end_req` arms, observer `sessions_req` arm (Task 2) |
33 | `src/hosts.zig` (new, layer 1) | host grammar, `Hosts` list, file load/save/record/forget, `statePath` (Task 3) |
34 | `src/wallview.zig` | `Host` + poller + list diff + stripe + entry from hosts; `x` two-step; `n`/`p` walk tiles; deletions (Tasks 4, 6) |
35 | `src/cli/mux_main.zig` | `mux` = wall of hosts; `mux TARGET` records the host; `mux hosts [add\|rm]`; `mux wall` gone (Task 5) |
36 | `src/client.zig` | `SwitchIntent` gains `end`/`end_force`; `recordTile`/`recordOnState`/`hydratedCreates` deleted (Tasks 4, 6) |
37 | `build.zig`, `docscheck.budget` | `hosts` module rows; budget lines (Tasks 3, 6) |
38 | `test/e2e_09_hosts.sh` (new, replaces `e2e_09_wallhist.sh`), edits in 07/08/11/12/13, `test/e2e.sh` pin | (Task 7) |
39 | `README.md`, `docs/decisions.md`, `test/xversion.sh` | (Task 8) |
40
41 ---
42
43 ### Task 1: Wire — `end_req` / `end_reply`
44
45 **Files:**
46 - Modify: `src/protocol.zig` (MsgType enum L12-55; tail helpers L810-899; tests near L1516-1549)
47 - Modify: `src/interact.zig` (`Core.frame` L1728-1806; `drivers_own` pin L4284-4292)
48
49 **Interfaces:**
50 - Produces: `proto.MsgType.end_req = 0x11`, `proto.MsgType.end_reply = 0x94`, `proto.end_req_len = 1`, `proto.end_req_max_len`, `proto.encodeEndReq(buf: *[end_req_max_len]u8, force: bool, name: []const u8) []const u8`, `proto.EndReply = struct { accepted: bool, others: u8, reason: []const u8 }`, `proto.end_reply_min_len = 2`, `proto.encodeEndReply(buf: []u8, accepted: bool, others: u8, reason: []const u8) []const u8`, `proto.parseEndReply(payload: []const u8) ?EndReply`.
51
52 - [ ] **Step 1: Failing tests** — append to `src/protocol.zig` beside `test "sessions message values are pinned"`:
53
54 ```zig
55 test "end message values are pinned" {
56 try std.testing.expectEqual(@as(u8, 0x11), @intFromEnum(MsgType.end_req));
57 try std.testing.expectEqual(@as(u8, 0x94), @intFromEnum(MsgType.end_reply));
58 }
59
60 test "end_req/end_reply codecs round-trip, and a short reply is refused" {
61 var rq: [end_req_max_len]u8 = undefined;
62 const req = encodeEndReq(&rq, true, "work");
63 try std.testing.expectEqual(@as(u8, 1), req[0]);
64 try std.testing.expectEqualStrings("work", req[end_req_len..]);
65 const bare = encodeEndReq(&rq, false, "");
66 try std.testing.expectEqual(@as(usize, end_req_len), bare.len);
67
68 var rp: [64]u8 = undefined;
69 const refused = encodeEndReply(&rp, false, 2, "others attached");
70 const parsed = parseEndReply(refused) orelse return error.ReplyDidNotParse;
71 try std.testing.expect(!parsed.accepted);
72 try std.testing.expectEqual(@as(u8, 2), parsed.others);
73 try std.testing.expectEqualStrings("others attached", parsed.reason);
74 const ok = parseEndReply(encodeEndReply(&rp, true, 0, "")) orelse return error.ReplyDidNotParse;
75 try std.testing.expect(ok.accepted);
76 try std.testing.expect(parseEndReply(&.{0}) == null);
77 }
78 ```
79
80 - [ ] **Step 2: RED** — `deps/zig/zig build test > /tmp/t.log 2>&1; echo rc=$?; grep -E 'error:' /tmp/t.log | head -3` → compile error naming `end_req`.
81
82 - [ ] **Step 3: Implement** — in the enum, after `upgrade_req = 0x10,`:
83
84 ```zig
85 end_req = 0x11, // payload: u8 flags (bit0 force) ++ optional session-name tail (empty = default session)
86 ```
87 after `upgrade_reply = 0x93, ...`:
88 ```zig
89 end_reply = 0x94, // payload: u8 status (0 accepted, 1 refused) ++ u8 others ++ reason text
90 ```
91 After `encodeDebugDumpNamed` (L891-899):
92 ```zig
93 pub const end_req_len = 1;
94 pub const end_req_max_len = end_req_len + session_name_max;
95
96 pub fn encodeEndReq(buf: *[end_req_max_len]u8, force: bool, name: []const u8) []const u8 {
97 buf[0] = if (force) 1 else 0;
98 @memcpy(buf[end_req_len..][0..name.len], name);
99 return buf[0 .. end_req_len + name.len];
100 }
101
102 pub const EndReply = struct { accepted: bool, others: u8, reason: []const u8 };
103 pub const end_reply_min_len = 2;
104
105 pub fn encodeEndReply(buf: []u8, accepted: bool, others: u8, reason: []const u8) []const u8 {
106 buf[0] = if (accepted) 0 else 1;
107 buf[1] = others;
108 @memcpy(buf[end_reply_min_len..][0..reason.len], reason);
109 return buf[0 .. end_reply_min_len + reason.len];
110 }
111
112 pub fn parseEndReply(payload: []const u8) ?EndReply {
113 if (payload.len < end_reply_min_len) return null;
114 return .{ .accepted = payload[0] == 0, .others = payload[1], .reason = payload[end_reply_min_len..] };
115 }
116 ```
117
118 - [ ] **Step 4: Route it in `interact.zig`** — `Core.frame`: add `.end_reply` to the arm `.exit_status, .taken_over, .sessions_reply => return .not_mine` (L1763); add `.end_req` to the skip list (L1779-1801). Add `.end_reply` to `drivers_own` (L4284-4292). Run: `deps/zig/zig build test > /tmp/t.log 2>&1; echo rc=$?` → 0. Without the `drivers_own` edit the pin at L4305 fails — that is the test owning the routing; watch it fire once by leaving the edit out first.
119
120 - [ ] **Step 5: Commit**
121 ```bash
122 make check > /tmp/c.log 2>&1; echo rc=$?
123 git add src/protocol.zig src/interact.zig
124 git commit -m "feat: end_req/end_reply on the wire"
125 ```
126
127 ---
128
129 ### Task 2: Daemon — end a session; answer `sessions_req` to an observer
130
131 **Files:**
132 - Modify: `src/server.zig` (`hasClientsIn` L1711; `handleFrame` L1811-1840; `handleObserverFrame` L2341-2489)
133 - Test: `src/server_test_session.zig` (model: `test "Server: sessions_req answers every live name, whoever asks"` L1346-1377; harness `attachNamed`, `awaitFrame`, `firstStateFrame` in `src/server_test_harness.zig`)
134
135 **Interfaces:**
136 - Consumes: Task 1 codecs.
137 - Produces: observer verbs `.end_req` (reply `end_reply` on the same fd) and `.sessions_req` (reply `sessions_reply`); client verb `.end_req` (reply queued, the asker's own slot excluded from `others`).
138
139 - [ ] **Step 1: Failing tests** — append to `src/server_test_session.zig`. Read L1346-1377 first and copy its setup (Server.init with `.shell = "/bin/sh"`, `connectUnixSocket`, `attachNamed`, `firstStateFrame`).
140
141 ```zig
142 fn shellPidOf(srv: *Server, name: []const u8) std.posix.pid_t {
143 return srv.ses(srv.sessions.find(proto.wireName(name)).?).pty.child;
144 }
145
146 fn alive(pid: std.posix.pid_t) bool {
147 std.posix.kill(pid, 0) catch return false;
148 return true;
149 }
150
151 test "Server: end_req with another client attached is refused with the count; forced, both see the exit" {
152 // two sessions, so the count is per session and not daemon-wide
153 // ... Server.init(alloc, .{ .sock_path = sock_path, .shell = "/bin/sh" }) as in the model ...
154 const a1 = try std.net.connectUnixSocket(sock_path);
155 defer a1.close();
156 try attachNamed(a1.handle, 80, 24, "a");
157 _ = (try firstStateFrame(alloc, &srv, a1.handle, 400)) orelse return error.NoState;
158 const a2 = try std.net.connectUnixSocket(sock_path);
159 defer a2.close();
160 try attachNamed(a2.handle, 80, 24, "a");
161 _ = (try firstStateFrame(alloc, &srv, a2.handle, 400)) orelse return error.NoState;
162 const b1 = try std.net.connectUnixSocket(sock_path);
163 defer b1.close();
164 try attachNamed(b1.handle, 80, 24, "b");
165 _ = (try firstStateFrame(alloc, &srv, b1.handle, 400)) orelse return error.NoState;
166 const pid_a = shellPidOf(&srv, "a");
167
168 // an observer asks: every attached client is "other"
169 const obs = try std.net.connectUnixSocket(sock_path);
170 defer obs.close();
171 var rq: [proto.end_req_max_len]u8 = undefined;
172 try proto.writeFrame(obs.handle, .end_req, proto.encodeEndReq(&rq, false, "a"));
173 const r1 = (try awaitFrame(alloc, &srv, obs.handle, .end_reply, 200)) orelse return error.NoEndReply;
174 defer r1.deinit(alloc);
175 const v1 = proto.parseEndReply(r1.payload) orelse return error.BadEndReply;
176 try std.testing.expect(!v1.accepted);
177 try std.testing.expectEqual(@as(u8, 2), v1.others);
178 try std.testing.expect(alive(pid_a));
179
180 // a1 asks over its own link: it is not its own "other"
181 try proto.writeFrame(a1.handle, .end_req, proto.encodeEndReq(&rq, false, "a"));
182 const r2 = (try awaitFrame(alloc, &srv, a1.handle, .end_reply, 200)) orelse return error.NoEndReply;
183 defer r2.deinit(alloc);
184 try std.testing.expectEqual(@as(u8, 1), (proto.parseEndReply(r2.payload) orelse return error.BadEndReply).others);
185 try std.testing.expect(alive(pid_a));
186
187 // forced: accepted, the shell dies, BOTH clients on "a" get exit_status, "b" is untouched
188 try proto.writeFrame(a1.handle, .end_req, proto.encodeEndReq(&rq, true, "a"));
189 const r3 = (try awaitFrame(alloc, &srv, a1.handle, .end_reply, 200)) orelse return error.NoEndReply;
190 defer r3.deinit(alloc);
191 try std.testing.expect((proto.parseEndReply(r3.payload) orelse return error.BadEndReply).accepted);
192 const x2 = (try awaitFrame(alloc, &srv, a2.handle, .exit_status, 400)) orelse return error.NoExitOnSibling;
193 x2.deinit(alloc);
194 var waited: u32 = 0;
195 while (alive(pid_a) and waited < 3000) : (waited += 50) {
196 _ = try srv.pumpOnce(20);
197 std.Thread.sleep(30 * std.time.ns_per_ms);
198 }
199 try std.testing.expect(!alive(pid_a));
200 try std.testing.expect(alive(shellPidOf(&srv, "b")));
201 }
202
203 test "Server: end_req alone on a session ends it at once, and an unknown name is refused" {
204 // ... setup with one client on "solo" ...
205 const pid = shellPidOf(&srv, "solo");
206 var rq: [proto.end_req_max_len]u8 = undefined;
207 try proto.writeFrame(c.handle, .end_req, proto.encodeEndReq(&rq, false, "solo"));
208 const r = (try awaitFrame(alloc, &srv, c.handle, .end_reply, 200)) orelse return error.NoEndReply;
209 defer r.deinit(alloc);
210 try std.testing.expect((proto.parseEndReply(r.payload) orelse return error.BadEndReply).accepted);
211 var waited: u32 = 0;
212 while (alive(pid) and waited < 3000) : (waited += 50) {
213 _ = try srv.pumpOnce(20);
214 std.Thread.sleep(30 * std.time.ns_per_ms);
215 }
216 try std.testing.expect(!alive(pid));
217
218 const obs = try std.net.connectUnixSocket(sock_path);
219 defer obs.close();
220 try proto.writeFrame(obs.handle, .end_req, proto.encodeEndReq(&rq, true, "nosuch"));
221 const r2 = (try awaitFrame(alloc, &srv, obs.handle, .end_reply, 200)) orelse return error.NoEndReply;
222 defer r2.deinit(alloc);
223 const v = proto.parseEndReply(r2.payload) orelse return error.BadEndReply;
224 try std.testing.expect(!v.accepted);
225 try std.testing.expectEqualStrings("no such session", v.reason);
226 }
227
228 test "Server: an observer's sessions_req is answered with every live name" {
229 // ... two attached clients on "x" and "y" (two, so the reply is a list) ...
230 const obs = try std.net.connectUnixSocket(sock_path);
231 defer obs.close();
232 try proto.writeFrame(obs.handle, .sessions_req, "");
233 const r = (try awaitFrame(alloc, &srv, obs.handle, .sessions_reply, 200)) orelse return error.NoSessionsReply;
234 defer r.deinit(alloc);
235 try std.testing.expect(std.mem.indexOf(u8, r.payload, "x") != null);
236 try std.testing.expect(std.mem.indexOf(u8, r.payload, "y") != null);
237 }
238 ```
239 Fill the elided setup verbatim from the model test; the elisions are this plan's, not the implementer's.
240
241 - [ ] **Step 2: RED** — run the test build; all three fail (`awaitFrame` returns null → the named errors). Confirm the FIRST fails at `NoEndReply`, not at setup.
242
243 - [ ] **Step 3: Implement** — beside `hasClientsIn`:
244
245 ```zig
246 fn clientsIn(self: *const Server, si: usize, exclude: ?usize) u8 {
247 var n: u8 = 0;
248 for (0..max_clients) |i| {
249 if (exclude == i) continue;
250 if (self.inSession(i, si)) n +|= 1;
251 }
252 return n;
253 }
254
255 /// Accepting only hangs up: the next `reap` pass tears the session down
256 /// exactly as it does for a shell that ended itself, so there is one
257 /// teardown path and every attached client gets the same exit_status.
258 fn endSession(self: *Server, payload: []const u8, exclude: ?usize) proto.EndReply {
259 if (payload.len < proto.end_req_len) return .{ .accepted = false, .others = 0, .reason = "bad frame" };
260 const force = payload[0] & 1 != 0;
261 const si = self.sessions.find(payload[proto.end_req_len..]) orelse
262 return .{ .accepted = false, .others = 0, .reason = "no such session" };
263 const others = self.clientsIn(si, exclude);
264 if (others > 0 and !force) return .{ .accepted = false, .others = others, .reason = "others attached" };
265 self.ses(si).pty.requestExit();
266 return .{ .accepted = true, .others = others, .reason = "" };
267 }
268 ```
269 `handleObserverFrame`, before `else => {}`:
270 ```zig
271 .end_req => {
272 const v = self.endSession(frame.payload, null);
273 var buf: [proto.end_reply_min_len + 32]u8 = undefined;
274 proto.writeFrame(fd, .end_reply, proto.encodeEndReply(&buf, v.accepted, v.others, v.reason)) catch self.dropObserver(i);
275 },
276 .sessions_req => {
277 var buf: [sessions_text_len]u8 = undefined;
278 proto.writeFrame(fd, .sessions_reply, self.sessions.text(&buf)) catch self.dropObserver(i);
279 },
280 ```
281 `handleFrame`, before `else => {}`:
282 ```zig
283 .end_req => {
284 const v = self.endSession(frame.payload, i);
285 var buf: [proto.end_reply_min_len + 32]u8 = undefined;
286 _ = self.queueFrame(i, .end_reply, proto.encodeEndReply(&buf, v.accepted, v.others, v.reason));
287 },
288 ```
289 `requestExit` closes the master (→ `-1`) and TERMs the child; check that the pump's pty poll tolerates `master == -1` on the very next pass (grep `pty.master` in `pumpOnce`'s fd build; if the fd is placed unconditionally, guard it with `if (s.pty.master >= 0)`). The forced test's `exit_status` on `a2` is what proves the reap path ran.
290
291 - [ ] **Step 4: GREEN** — test build rc=0. Then a mutation: change `if (others > 0 and !force)` to `if (false)` → the first test fails at `expect(!v1.accepted)`. Revert with the inverse edit, never `git checkout`.
292
293 - [ ] **Step 5: Commit**
294 ```bash
295 make check > /tmp/c.log 2>&1; echo rc=$?
296 git add src/server.zig src/server_test_session.zig
297 git commit -m "feat: muxd ends a session on request, refusing first when others are attached"
298 ```
299
300 ---
301
302 ### Task 3: `hosts.zig` — the host file
303
304 **Files:**
305 - Create: `src/hosts.zig`
306 - Modify: `build.zig` (module table: add a row next to `wall`'s L198 — `.layer = 1`, imports `protocol`, `wall`; add `"hosts"` to the imports of `wallview` L295 and `mux` L309 rows, and to the name list at L622), `docscheck.budget` (add `hosts.zig 0`)
307
308 **Interfaces:**
309 - Consumes: `wall.saveBytes(path, bytes) !void`, `wall.spellingFromArgv`, `wall.flagLike`, `wall.ArgvError` (phase 2 moves them here when `wall.zig` dies).
310 - Produces:
311 - `hosts.Spec = union(enum) { sock: []const u8, host: []const u8, quic: []const u8 }`
312 - `hosts.ParseError = error{ HasSession, EmptySpec, BadByte }`
313 - `hosts.parse(line: []const u8) ParseError!Spec`
314 - `hosts.reason(err: anyerror) []const u8`
315 - `hosts.Hosts = struct { lines: std.ArrayList([]u8) = .empty, deinit(self,*alloc), add(self, alloc, spelling) !bool, remove(self, alloc, spelling) bool, has(self, spelling) bool }`
316 - `hosts.load(alloc, path) !Hosts` (strict; missing file = empty), `hosts.save(h: *const Hosts, path) !void`
317 - `hosts.record(alloc, path, spelling) !bool` (load+add+save; false = already there), `hosts.forget(alloc, path, spelling) !bool`
318 - `hosts.statePath(alloc) ![]const u8`, `hosts.statePathFrom(alloc, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8` → `.../mux/hosts`
319 - `hosts.Argv` — same shape as `wall.Argv` (`alloc`, `tiles` → `list: std.ArrayList([]const u8)`, `err`, `positional`, `extra`, `deinit`) but validating with `hosts.parse`.
320
321 - [ ] **Step 1: Failing tests** — in the new file, below the code:
322
323 ```zig
324 test "hosts.parse: three spellings classify; a '#' is refused by name" {
325 try std.testing.expectEqualStrings("/tmp/a.sock", (try parse("--sock /tmp/a.sock")).sock);
326 try std.testing.expectEqualStrings("box", (try parse("box")).host);
327 try std.testing.expectEqualStrings("10.0.0.2:4433", (try parse("quic://10.0.0.2:4433")).quic);
328 try std.testing.expectError(error.HasSession, parse("box#build"));
329 try std.testing.expectError(error.HasSession, parse("--sock /tmp/a.sock#0"));
330 try std.testing.expectError(error.EmptySpec, parse(""));
331 try std.testing.expectError(error.EmptySpec, parse("--sock "));
332 try std.testing.expectError(error.BadByte, parse("bo\x01x"));
333 try std.testing.expect(std.mem.indexOf(u8, reason(error.HasSession), "daemons") != null);
334 }
335
336 test "hosts: add dedups, remove reports, load/save round-trip two hosts in order" {
337 const alloc = std.testing.allocator;
338 var tmp = try TmpDir.make();
339 defer tmp.cleanup();
340 const path = try std.fmt.allocPrint(alloc, "{s}/mux/hosts", .{tmp.path()});
341 defer alloc.free(path);
342
343 var h = try load(alloc, path); // absent file = empty
344 defer h.deinit(alloc);
345 try std.testing.expectEqual(@as(usize, 0), h.lines.items.len);
346 try std.testing.expect(try h.add(alloc, "--sock /tmp/a.sock"));
347 try std.testing.expect(try h.add(alloc, "box"));
348 try std.testing.expect(!try h.add(alloc, "box"));
349 try std.testing.expectError(error.HasSession, h.add(alloc, "box#x"));
350 try save(&h, path);
351
352 var back = try load(alloc, path);
353 defer back.deinit(alloc);
354 try std.testing.expectEqual(@as(usize, 2), back.lines.items.len);
355 try std.testing.expectEqualStrings("--sock /tmp/a.sock", back.lines.items[0]);
356 try std.testing.expectEqualStrings("box", back.lines.items[1]);
357 try std.testing.expect(back.remove(alloc, "box"));
358 try std.testing.expect(!back.remove(alloc, "box"));
359
360 try std.testing.expect(try record(alloc, path, "quic://h:1"));
361 try std.testing.expect(!try record(alloc, path, "quic://h:1"));
362 try std.testing.expect(try forget(alloc, path, "quic://h:1"));
363 try std.testing.expect(!try forget(alloc, path, "quic://h:1"));
364 }
365
366 test "hosts.load is strict: a session line in the file is an error, not a skipped line" {
367 const alloc = std.testing.allocator;
368 var tmp = try TmpDir.make();
369 defer tmp.cleanup();
370 const path = try std.fmt.allocPrint(alloc, "{s}/hosts", .{tmp.path()});
371 defer alloc.free(path);
372 try wall.saveBytes(path, "box\nbox#old\n");
373 try std.testing.expectError(error.HasSession, load(alloc, path));
374 }
375
376 test "hosts.statePathFrom: XDG_STATE_HOME wins, HOME falls back, file is mux/hosts" {
377 const alloc = std.testing.allocator;
378 const a = try statePathFrom(alloc, "/x", "/h");
379 defer alloc.free(a);
380 try std.testing.expectEqualStrings("/x/mux/hosts", a);
381 const b = try statePathFrom(alloc, null, "/h");
382 defer alloc.free(b);
383 try std.testing.expectEqualStrings("/h/.local/state/mux/hosts", b);
384 }
385 ```
386 (`TmpDir` = `@import("testtmp").TmpDir` — add `testtmp` to the module's imports in `build.zig` like `wall`'s row does; check `wall`'s row for the exact imports list.)
387
388 - [ ] **Step 2: RED** — the module does not exist; `zig build test` fails to resolve `hosts` once the build.zig rows are in. Add the rows first, then the file with only the tests → compile errors on `parse` etc.
389
390 - [ ] **Step 3: Implement** — `src/hosts.zig`:
391
392 ```zig
393 //! The wall: an ordered list of DAEMONS, one per line of
394 //! `$XDG_STATE_HOME/mux/hosts` — `--sock PATH` | `HOST` |
395 //! `quic://HOST[:PORT]`. Tiles are whatever those daemons have live, so
396 //! nothing here names a session and nothing here can resurrect one.
397 //! Strict on load: a host line is authored intent.
398 const std = @import("std");
399 const wall = @import("wall");
400
401 pub const Spec = union(enum) { sock: []const u8, host: []const u8, quic: []const u8 };
402 pub const ParseError = error{ HasSession, EmptySpec, BadByte };
403
404 pub fn parse(line: []const u8) ParseError!Spec {
405 for (line) |b| if (b < 0x20 or b == 0x7f) return error.BadByte;
406 if (std.mem.indexOfScalar(u8, line, '#') != null) return error.HasSession;
407 if (std.mem.startsWith(u8, line, "--sock ")) {
408 const p = line["--sock ".len..];
409 return if (p.len == 0) error.EmptySpec else .{ .sock = p };
410 }
411 if (std.mem.startsWith(u8, line, "quic://")) {
412 const h = line["quic://".len..];
413 return if (h.len == 0) error.EmptySpec else .{ .quic = h };
414 }
415 return if (line.len == 0) error.EmptySpec else .{ .host = line };
416 }
417
418 pub fn reason(err: anyerror) []const u8 {
419 return switch (err) {
420 error.HasSession => "names a session after '#': the wall lists daemons and shows every session they have",
421 error.EmptySpec => "empty host",
422 error.BadByte => "control byte in host",
423 error.MissingSockPath => "names no path",
424 error.SockPathTooLong => "socket path too long to bind",
425 else => @errorName(err),
426 };
427 }
428
429 pub const Hosts = struct {
430 lines: std.ArrayList([]u8) = .empty,
431
432 pub fn deinit(self: *Hosts, alloc: std.mem.Allocator) void {
433 for (self.lines.items) |l| alloc.free(l);
434 self.lines.deinit(alloc);
435 }
436
437 pub fn has(self: *const Hosts, spelling: []const u8) bool {
438 for (self.lines.items) |l| if (std.mem.eql(u8, l, spelling)) return true;
439 return false;
440 }
441
442 /// False when it was already listed; the file is a set in list order.
443 pub fn add(self: *Hosts, alloc: std.mem.Allocator, spelling: []const u8) !bool {
444 _ = try parse(spelling);
445 if (self.has(spelling)) return false;
446 try self.lines.append(alloc, try alloc.dupe(u8, spelling));
447 return true;
448 }
449
450 pub fn remove(self: *Hosts, alloc: std.mem.Allocator, spelling: []const u8) bool {
451 for (self.lines.items, 0..) |l, i| {
452 if (std.mem.eql(u8, l, spelling)) {
453 alloc.free(self.lines.orderedRemove(i));
454 return true;
455 }
456 }
457 return false;
458 }
459 };
460
461 pub fn load(alloc: std.mem.Allocator, path: []const u8) !Hosts {
462 var h: Hosts = .{};
463 errdefer h.deinit(alloc);
464 const bytes = std.fs.cwd().readFileAlloc(alloc, path, 1 << 20) catch |e| switch (e) {
465 error.FileNotFound => return h,
466 else => return e,
467 };
468 defer alloc.free(bytes);
469 var it = std.mem.splitScalar(u8, bytes, '\n');
470 while (it.next()) |line| {
471 if (line.len == 0) continue;
472 _ = try h.add(alloc, line);
473 }
474 return h;
475 }
476
477 pub fn save(h: *const Hosts, path: []const u8) !void {
478 var buf: std.ArrayList(u8) = .empty;
479 defer buf.deinit(std.heap.page_allocator);
480 for (h.lines.items) |l| {
481 try buf.appendSlice(std.heap.page_allocator, l);
482 try buf.append(std.heap.page_allocator, '\n');
483 }
484 try wall.saveBytes(path, buf.items);
485 }
486
487 pub fn record(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool {
488 var h = try load(alloc, path);
489 defer h.deinit(alloc);
490 if (!try h.add(alloc, spelling)) return false;
491 try save(&h, path);
492 return true;
493 }
494
495 pub fn forget(alloc: std.mem.Allocator, path: []const u8, spelling: []const u8) !bool {
496 var h = try load(alloc, path);
497 defer h.deinit(alloc);
498 if (!h.remove(alloc, spelling)) return false;
499 try save(&h, path);
500 return true;
501 }
502
503 pub fn statePath(alloc: std.mem.Allocator) ![]const u8 {
504 return statePathFrom(alloc, std.posix.getenv("XDG_STATE_HOME"), std.posix.getenv("HOME"));
505 }
506
507 pub fn statePathFrom(alloc: std.mem.Allocator, xdg_state_home: ?[]const u8, home: ?[]const u8) ![]const u8 {
508 if (xdg_state_home) |x| if (x.len > 0) return std.fmt.allocPrint(alloc, "{s}/mux/hosts", .{x});
509 const h = home orelse return error.NoHome;
510 return std.fmt.allocPrint(alloc, "{s}/.local/state/mux/hosts", .{h});
511 }
512
513 /// cliflags hooks for `mux hosts add|rm SPELLING...`: positional words and
514 /// the two-word `--sock PATH` form, each validated as a host.
515 pub const Argv = struct {
516 alloc: std.mem.Allocator,
517 list: std.ArrayList([]const u8) = .empty,
518 err: ?struct { word: []const u8, err: (wall.ArgvError || ParseError) } = null,
519
520 pub fn deinit(self: *Argv) void {
521 for (self.list.items) |t| self.alloc.free(t);
522 self.list.deinit(self.alloc);
523 }
524 pub fn positional(self: *Argv, word: []const u8) bool {
525 return self.take(word);
526 }
527 pub fn extra(self: *Argv, rest: []const [:0]const u8) usize {
528 const n = wall.spellingFromArgv(self.alloc, rest, 0) catch |e| {
529 if (e != error.FlagLikeTarget) _ = self.refuse(rest[0], e);
530 return 0;
531 };
532 defer self.alloc.free(n.spelling);
533 return if (self.take(n.spelling)) n.consumed else 0;
534 }
535 fn take(self: *Argv, spelling: []const u8) bool {
536 const copy = self.alloc.dupe(u8, spelling) catch return self.refuse("", error.OutOfMemory);
537 self.list.append(self.alloc, copy) catch {
538 self.alloc.free(copy);
539 return self.refuse("", error.OutOfMemory);
540 };
541 _ = parse(copy) catch |e| return self.refuse(copy, e);
542 return true;
543 }
544 fn refuse(self: *Argv, word: []const u8, e: (wall.ArgvError || ParseError)) bool {
545 self.err = .{ .word = word, .err = e };
546 return false;
547 }
548 };
549
550 const TmpDir = @import("testtmp").TmpDir;
551 ```
552 Check `wall.statePathFrom` L315-327 for how it treats an empty `XDG_STATE_HOME` and match it. `save` uses `wall.saveBytes` (atomic temp+rename, `make_path`) — do not write a second atomic writer.
553
554 - [ ] **Step 4: GREEN** — `zig build test` rc=0; `zig build check` → `hosts.zig` reported `.unlisted` until the budget line is added; add `hosts.zig 0` (alphabetical position) and re-run → 0.
555
556 - [ ] **Step 5: Commit**
557 ```bash
558 make check > /tmp/c.log 2>&1; echo rc=$?
559 git add src/hosts.zig build.zig docscheck.budget
560 git commit -m "feat: hosts.zig — the wall file lists daemons, never sessions"
561 ```
562
563 ---
564
565 ### Task 4: `wallview` — a host's live sessions are its tiles
566
567 The largest task. Read the map's §3 windows before starting: `Tile` L329-493, `birthTile` L2117-2175, `addSessionTile` L2178-2214, the `.vanish` walk L2933-2951, `run` L2654-3190, `pumpTile` L1098-1660, `restoreFoldedLayoutFrom` L2523-2556, `State` L186-200.
568
569 **Files:**
570 - Modify: `src/wallview.zig`
571 - Modify: `src/client.zig` (a side-connection list helper beside `birthSession` L1013)
572 - Modify: `docscheck.budget` (`wallview.zig` figure)
573
574 **Interfaces:**
575 - Consumes: `hosts.Spec`/`hosts.parse` (Task 3); `client.Transport.open/pollFd/timeoutMs/service/readFrame/writeFrame/close`; daemon observer `sessions_req` (Task 2).
576 - Produces:
577 - `wallview.HostSpec = struct { spelling: []const u8, target: client.Target }`
578 - `wallview.resolveHost(alloc, spelling: []const u8, key: ?[]const u8, idle_ms: u32) ResolveError!HostSpec` (replaces `resolveSpelling`'s target half; no session)
579 - `wallview.run(alloc, hosts: []const HostSpec, entry: Entry) !u8` — `Entry` loses `record0`, `hydrate`, `hydrated`; gains `entry_host: ?usize` (index into `hosts` of the tile `pre` attaches to) and `entry_session: []const u8`.
580 - `client.listSessions(alloc, target: client.Target, out: *[proto.sessions_text_len_max]u8, budget_ms: i64) ![]const u8` — hmm: `sessions_text_len` lives in server.zig; define `pub const sessions_text_max = 32 * (session_name_max + 1)` in protocol.zig next to `session_name_max` and use it on both sides (server keeps its own constant equal to it; add `comptime std.debug.assert(sessions_text_len == proto.sessions_text_max)` in server.zig).
581 - Pure diff: `wallview.planHostDiff(tiles: []const Tile, present: []const bool, live: usize, host: usize, list: []const u8, births: *std.BoundedArray([]const u8, max_tiles), vanish: *std.BoundedArray(usize, max_tiles)) void` — names in `list` (the `sessions_reply` text) with no present tile of `host` → births; present tiles of `host` whose name is not in `list` → vanish. Tiles carry `host: ?usize`.
582
583 - [ ] **Step 1: Failing unit tests** (in `wallview.zig`, beside the `birthTile` tests ~L3271):
584
585 ```zig
586 test "planHostDiff: names the daemon has and the wall does not are births; tiles the daemon dropped vanish; other hosts' tiles are untouched" {
587 const alloc = std.testing.allocator;
588 // two hosts, two tiles on host 0 (a, b) and one on host 1 (a) — the
589 // same name on two hosts must not alias
590 var tiles: [3]Tile = undefined;
591 var present = [_]bool{ true, true, true };
592 var shared: Shared = undefined; // never touched by planHostDiff
593 const t0: client.Target = .{ .sock = "/tmp/h0.sock" };
594 const t1: client.Target = .{ .sock = "/tmp/h1.sock" };
595 try initTile(&tiles[0], .{ .target = t0, .label = "--sock /tmp/h0.sock#a", .session = "a" }, .{}, &shared, 0);
596 try initTile(&tiles[1], .{ .target = t0, .label = "--sock /tmp/h0.sock#b", .session = "b" }, .{}, &shared, 1);
597 try initTile(&tiles[2], .{ .target = t1, .label = "--sock /tmp/h1.sock#a", .session = "a" }, .{}, &shared, 2);
598 tiles[0].host = 0;
599 tiles[1].host = 0;
600 tiles[2].host = 1;
601 defer for (&tiles) |*t| closeTileFds(t);
602
603 var births = std.BoundedArray([]const u8, max_tiles){};
604 var vanish = std.BoundedArray(usize, max_tiles){};
605 planHostDiff(&tiles, &present, 3, 0, "a\nc\n", &births, &vanish);
606 try std.testing.expectEqual(@as(usize, 1), births.len);
607 try std.testing.expectEqualStrings("c", births.get(0));
608 try std.testing.expectEqual(@as(usize, 1), vanish.len);
609 try std.testing.expectEqual(@as(usize, 1), vanish.get(0));
610
611 // host 1's "a" survives host 0's list saying nothing about it
612 births.len = 0;
613 vanish.len = 0;
614 planHostDiff(&tiles, &present, 3, 1, "a\n", &births, &vanish);
615 try std.testing.expectEqual(@as(usize, 0), births.len + vanish.len);
616
617 // an empty list vanishes everything the host had
618 planHostDiff(&tiles, &present, 3, 0, "", &births, &vanish);
619 try std.testing.expectEqual(@as(usize, 2), vanish.len);
620 _ = alloc;
621 }
622
623 test "planHostDiff: a vanished tile is not present, so a later list does not vanish it twice" {
624 // present[1] = false after a vanish → a list without b yields nothing for b
625 // ... same setup, then present[1] = false; planHostDiff(host 0, "a\n") → vanish.len == 0 ...
626 }
627 ```
628 (`closeTileFds` = close the wake pipe `initTile` created — grep `wake_r` for the existing teardown and factor it into a `fn closeTileFds(t: *Tile) void` if none exists; the existing `birthTile` tests show how they clean up.)
629
630 And in `client.zig`, beside the `birthSession` tests (L1902):
631
632 ```zig
633 test "listSessions: answers with the daemon's list over a side connection, and names the timeout" {
634 // Server.init + serverThread from server_test_harness are not importable here
635 // (layer): model on the existing birthSession tests — they stand a real
636 // daemon up; follow their fixture exactly.
637 // attach two named sessions ("p", "q"), then:
638 var out: [proto.sessions_text_max]u8 = undefined;
639 const list = try listSessions(alloc, .{ .sock = sock_path }, &out, 2000);
640 try std.testing.expect(std.mem.indexOf(u8, list, "p") != null);
641 try std.testing.expect(std.mem.indexOf(u8, list, "q") != null);
642 // a socket nobody listens on
643 try std.testing.expectError(error.Transport, listSessions(alloc, .{ .sock = "/nonexistent.sock" }, &out, 200));
644 }
645 ```
646
647 - [ ] **Step 2: RED** — compile errors on `planHostDiff`, `Tile.host`, `listSessions`.
648
649 - [ ] **Step 3: Implement the pieces**
650
651 (a) `protocol.zig`: `pub const sessions_text_max = 32 * (session_name_max + 1);` next to `session_name_max`. `server.zig`: keep `sessions_text_len` and add `comptime { std.debug.assert(sessions_text_len == proto.sessions_text_max); }`.
652
653 (b) `client.zig`, after `birthSession`:
654 ```zig
655 /// One `sessions_req` on a side connection: the wall's per-host poll.
656 pub fn listSessions(alloc: std.mem.Allocator, target: Target, out: *[proto.sessions_text_max]u8, budget_ms: i64) ![]const u8 {
657 var tr = Transport.open(alloc, target, null, -1) catch return error.Transport;
658 defer tr.close();
659 try tr.writeFrame(.sessions_req, "");
660 const deadline = std.time.milliTimestamp() + budget_ms;
661 while (true) {
662 const left = deadline - std.time.milliTimestamp();
663 if (left <= 0) return error.Timeout;
664 var fds = [_]std.posix.pollfd{.{ .fd = tr.pollFd(), .events = std.posix.POLL.IN, .revents = 0 }};
665 _ = std.posix.poll(&fds, tr.timeoutMs(@intCast(@min(left, 100)))) catch return error.Transport;
666 tr.service();
667 if (fds[0].revents == 0 and tr.link != .quic) continue;
668 while (true) {
669 switch (tr.readFrame(alloc) catch return error.Transport) {
670 .incomplete => break,
671 .closed => return error.Transport,
672 .frame => |f| {
673 defer f.deinit(alloc);
674 if (f.type != .sessions_reply) continue;
675 if (f.payload.len > out.len) return error.Transport;
676 @memcpy(out[0..f.payload.len], f.payload);
677 return out[0..f.payload.len];
678 },
679 }
680 }
681 }
682 }
683 ```
684 Opening a fresh connection per poll is deliberate for phase 1: the observer idle deadline (10 s) and the redial backoff are then someone else's problem, and a `--via` host (an ssh per open) is the case that will make a persistent side connection worth its state — measure before adding it.
685
686 (c) `wallview.zig`:
687
688 - `Tile` gains `host: ?usize = null` (index into the run's host table) and `stripe: bool = false`.
689 - `State` gains `.unreachable` and `.empty` with words `"[unreachable]"` and `"[no sessions]"`.
690 - New:
691 ```zig
692 pub const HostSpec = struct { spelling: []const u8, target: client.Target };
693
694 pub fn resolveHost(alloc: std.mem.Allocator, spelling: []const u8, key: ?[]const u8, idle_ms: u32) ResolveError!HostSpec
695 ```
696 — body is `resolveSpelling`'s target switch (L54-99) driven by `hosts.parse` instead of `wall.parseSpelling`; `resolveSpelling` itself stays for now (Task 6 deletes it with `addSpelledTile`'s rewrite).
697
698 - Host runtime state:
699 ```zig
700 const Host = struct {
701 spec: HostSpec,
702 shared: *Shared,
703 idx: usize,
704 poke: std.atomic.Value(bool) = .init(false),
705 list_mu: std.Thread.Mutex = .{},
706 list: [proto.sessions_text_max]u8 = undefined,
707 list_len: usize = 0,
708 list_ready: std.atomic.Value(bool) = .init(false),
709 reachable: std.atomic.Value(bool) = .init(true),
710 reported: std.atomic.Value(bool) = .init(false), // first list (or first failure) seen
711 };
712
713 const host_poll_ms: u64 = 1000;
714
715 fn pollHost(h: *Host) void {
716 var out: [proto.sessions_text_max]u8 = undefined;
717 while (h.shared.running.load(.acquire)) {
718 const got = client.listSessions(std.heap.page_allocator, h.spec.target, &out, 2000) catch null;
719 if (got) |list| {
720 h.list_mu.lock();
721 @memcpy(h.list[0..list.len], list);
722 h.list_len = list.len;
723 h.list_mu.unlock();
724 h.reachable.store(true, .release);
725 h.list_ready.store(true, .release);
726 } else h.reachable.store(false, .release);
727 h.reported.store(true, .release);
728 ringKeyboard(h.shared);
729 var slept: u64 = 0;
730 while (slept < host_poll_ms and !h.poke.swap(false, .acq_rel) and h.shared.running.load(.acquire)) : (slept += 50)
731 std.Thread.sleep(50 * std.time.ns_per_ms);
732 }
733 }
734 ```
735 - The diff:
736 ```zig
737 fn planHostDiff(tiles: []const Tile, present: []const bool, live: usize, host: usize, list: []const u8,
738 births: *std.BoundedArray([]const u8, max_tiles), vanish: *std.BoundedArray(usize, max_tiles)) void {
739 var it = std.mem.splitScalar(u8, list, '\n');
740 while (it.next()) |name| {
741 if (name.len == 0) continue;
742 var found = false;
743 for (tiles[0..live], present[0..live]) |*t, p| {
744 if (p and !t.stripe and t.host == host and std.mem.eql(u8, proto.resolveName(t.r.session), name)) { found = true; break; }
745 }
746 if (!found) births.append(name) catch return;
747 }
748 for (tiles[0..live], present[0..live], 0..) |*t, p, i| {
749 if (!p or t.stripe or t.host != host) continue;
750 var keep = false;
751 var it2 = std.mem.splitScalar(u8, list, '\n');
752 while (it2.next()) |name| if (std.mem.eql(u8, proto.resolveName(t.r.session), name)) { keep = true; break; };
753 if (!keep) vanish.append(i) catch return;
754 }
755 }
756 ```
757 - Applying it, on the keyboard thread (a new `fn applyHostList(alloc, tiles, present, live: *usize, shared, host_table: []Host, hi: usize) void` called from the keyboard loop where `ans_ready` is consumed today, L2894): copy the list out under `list_mu`; `planHostDiff`; for each vanish index `v`: the `.vanish` walk minus its message — `present[v] = false; tiles[v].gone.store(true, .release); ring(&tiles[v]); shared.tree.remove(@intCast(v));` (and if `shared.sel == v`, `shared.sel = stepPresent(present, v, true) orelse v`); for each birth name: `birthTile(alloc, tiles, present, live, shared, .{ .r = .{ .target = host.spec.target, .label = tileLabel(...), .session = dupe(name), .agent = false }, .from = shared.sel, .place = .beside_focus, .creates = false, .record = false, .born_from = null })`, set `tiles[at].host = hi`, `spawnPump`. When `birthTile` returns null (the wall is full) count the births left unplaced and `setNotice(&shared, "[+N not shown]")` with that N — the spec's bound, said out loud rather than dropped. Then the stripe rule: if the host has no present non-stripe tile → ensure one stripe tile exists for it (state `.unreachable` if `!reachable` else `.empty`); if it has one → vanish the stripe. Finish with one `relayout`.
758 - Stripe tiles: `spawnPump` on a tile with `stripe = true` runs `fn pumpStripe(t: *Tile) void` — paints its label (`paintLabel(t, state)`) and blocks on the wake pipe until `gone`; no transport, no attach. `endedTile`/`endAction` must skip stripes (they never `end`).
759 - `Ctrl-\ c` on a stripe: `addSessionTile(..., from = z, name = "0", .beside_focus)` needs a target — take it from `tiles[z].r.target` (a stripe's `r.target` is its host's). Its `creates = true` attach births `0` on that host; the poll then dedups.
760 - `run(alloc, hosts: []const HostSpec, entry: Entry)`: allocate `host_table = alloc(Host, hosts.len)`, spawn `pollHost` per host (detached threads like pumps); the tile array starts EMPTY except the entry tile (`entry.pre != null`): `initTile` it with `host = entry.entry_host`, `pre`, `creates = true`, `retry_cold = false`, focus 0 — the `focus0` path L2809-2818 minus `record`. `entry.hydrated` and `entry.hydrate` are gone; the sidecar restore at L2688-2695 moves to "after every host has `reported`, or 2 s after start, whichever first", using `restoreFoldedLayoutFrom` (L2523-2556; keep it, rename to `restoreLayoutFrom`) — it already maps dense sidecar indices onto the real tiles present at that moment. `save_hydrated` becomes `always true` when `hosts.len > 0` and the run was not `entry.argv_view` — there is no argv view any more, so the sidecar is saved on every `.finish`/`.detach`. The keyboard loop's 100 ms poll cap (L2877) already wakes it for the restore timer; add `var restore_due: i64 = now + 2000; var restored = false;` and check per iteration.
761 - The `.vanish` walk's exit rule: a tile the daemon dropped is a `.exited` end for `endAction` purposes when it was the ONLY tile — `mux` with one session whose shell exits must still return with the exit code (README:89). Its pump already gets `exit_status` → `.exited` → `endAction` runs before the poller notices, so no change; the poll's vanish only cleans up tiles whose pump has not seen the end (e.g. a session ended by another client while this tile was reconnecting). Pin: existing `e2e_09_wallhist.sh:636` scenario, rewritten in Task 7.
762
763 - [ ] **Step 4: GREEN + budget** — `zig build test` rc=0; `zig build check` prints the new `wallview.zig` byte figure → set the budget line to it (it must be ≤ 1841; if higher, trim the new comments, never raise). Run `E2E_ONLY=07_wallcli make e2e` — it FAILS now (`mux wall` is still the entry, the run signature changed): expected; Task 5 restores the entry and Task 7 rewrites the group. Note the failure in the ledger, do not fix it here.
764
765 - [ ] **Step 5: Commit**
766 ```bash
767 make check > /tmp/c.log 2>&1; echo rc=$?
768 git add src/wallview.zig src/client.zig src/protocol.zig src/server.zig docscheck.budget
769 git commit -m "feat: a host's live sessions are its tiles — per-host poll, diff, stripe"
770 ```
771
772 ---
773
774 ### Task 5: Entry — `mux` is the wall of hosts; `mux hosts`
775
776 **Files:**
777 - Modify: `src/cli/mux_main.zig` (usage L26-60; `main` L252-441; `wallMain` L466-549 → `hostsMain`; `wallEdit` L551-653 → `hostsEdit`; test L716)
778 - Modify: `src/wallview.zig` `runAttach` L2559-2607 (records the host; passes `entry_host`)
779
780 **Interfaces:**
781 - Consumes: Task 3 `hosts.*`, Task 4 `wallview.run(alloc, hosts, entry)`, `wallview.resolveHost`, `wallview.HostSpec`.
782 - Produces: the CLI surface of the spec's "Command line" section.
783
784 - [ ] **Step 1: Failing test** — replace `test "wall: a bad spelling is refused at parse, before any tile is dialed"` (L716-731) with:
785
786 ```zig
787 test "hosts: add refuses a '#' by name; rm of an unlisted host says so; list prints in file order" {
788 const alloc = std.testing.allocator;
789 var tmp = try TmpDir.make();
790 defer tmp.cleanup();
791 // XDG_STATE_HOME is read inside hostsMain via hosts.statePath — point it at tmp
792 // (setenv is process-global; the existing wall test at L716 shows the pattern this repo uses,
793 // or pass the path explicitly: give hostsMain a `state_path: ?[]const u8` parameter the
794 // production caller fills with null → hosts.statePath).
795 try std.testing.expectEqual(@as(u8, 2), try hostsMain(alloc, &.{ "add", "box#build" }, tmp_hosts_path));
796 try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &.{ "add", "box" }, tmp_hosts_path));
797 try std.testing.expectEqual(@as(u8, 0), try hostsMain(alloc, &.{ "add", "--sock", "/tmp/x.sock" }, tmp_hosts_path));
798 try std.testing.expectEqual(@as(u8, 1), try hostsMain(alloc, &.{ "rm", "nowhere" }, tmp_hosts_path));
799 var h = try hosts.load(alloc, tmp_hosts_path);
800 defer h.deinit(alloc);
801 try std.testing.expectEqual(@as(usize, 2), h.lines.items.len);
802 try std.testing.expectEqualStrings("box", h.lines.items[0]);
803 try std.testing.expectEqualStrings("--sock /tmp/x.sock", h.lines.items[1]);
804 }
805 ```
806 Use the explicit-path parameter (second option in the comment): `fn hostsMain(alloc, args: []const [:0]const u8, state_path: ?[]const u8) !u8`.
807
808 - [ ] **Step 2: RED** — `hostsMain` undefined.
809
810 - [ ] **Step 3: Implement**
811
812 `main`:
813 ```zig
814 if (args.len > 1 and std.mem.eql(u8, args[1], "hosts"))
815 return hostsMain(alloc, args[2..], null);
816 ```
817 (delete the `"wall"` dispatch). After `parseArgs`, the no-argv case: `parseArgs` today yields `.attach` with `sock = null` for bare `mux`. Add a check BEFORE the transport switch:
818 ```zig
819 const bare = args.len == 1;
820 if (bare) return wallOfHosts(alloc);
821 ```
822 ```zig
823 /// `mux`: the wall. An empty file is the local daemon with `#0`, zoomed —
824 /// a first run is still just a shell.
825 fn wallOfHosts(alloc: std.mem.Allocator) !u8 {
826 var arena_state = std.heap.ArenaAllocator.init(alloc);
827 defer arena_state.deinit();
828 const arena = arena_state.allocator();
829 const path = try hosts.statePath(arena);
830 var h = hosts.load(arena, path) catch |e| {
831 std.debug.print("mux: {s}: {s}\n", .{ path, hosts.reason(e) });
832 return 2;
833 };
834 if (h.lines.items.len == 0) {
835 const sock_path = try sockpath.defaultSockPath(arena);
836 // the same auto-start + refusals `mux --sock` gets: reuse attachLocal
837 return attachLocal(alloc, sock_path, "", false);
838 }
839 const specs = try arena.alloc(wallview.HostSpec, h.lines.items.len);
840 const key = std.posix.getenv(xdg.key_env);
841 for (specs, h.lines.items) |*s, line| {
842 s.* = wallview.resolveHost(arena, line, key, client.quic_idle_ms_default) catch |err| {
843 std.debug.print("mux: bad host '{s}': {s}\n", .{ line, hosts.reason(err) });
844 return 2;
845 };
846 }
847 return wallview.run(arena, specs, .{});
848 }
849 ```
850 Refactor the existing `.attach` arm's local path (L349-424: default sock path, `insideThisSession`, sun_path check, auto-start, `runAttach`) into `fn attachLocal(alloc, sock_path: []const u8, session: []const u8, agent: bool) !u8` so `wallOfHosts` and `mux --sock` share it — one owner for auto-start. `runAttach` (wallview) gains the host record: after `Transport.open` succeeds, `if (client.hostSpelling(&buf, target)) |s| _ = hosts.record(alloc, hosts.statePath(alloc) catch ..., s) catch |e| std.debug.print("mux: hosts file not updated: {t}\n", .{e});` — `client.hostSpelling(out, target) SpellingError![]const u8` is `wallSpelling` without the `#NAME` (add it beside `wallSpelling` L915; `.via` → `NoSpelling`, records nothing, as before). Then `run(alloc, &.{host_spec}, .{ .focus0 = true, .pre = transport, .carry = carry.items, .entry_host = 0, .entry_session = name, ... })`. Every other host on the file must ALSO be on this wall (`mux HOST` opens the wall zoomed on HOST's session): load the file, put the recorded host first, the rest after.
851
852 `hostsMain`:
853 ```zig
854 fn hostsMain(alloc: std.mem.Allocator, args: []const [:0]const u8, state_path: ?[]const u8) !u8 {
855 var arena_state = std.heap.ArenaAllocator.init(alloc);
856 defer arena_state.deinit();
857 const arena = arena_state.allocator();
858 const path = state_path orelse try hosts.statePath(arena);
859 if (args.len == 0) {
860 var h = try hosts.load(arena, path);
861 for (h.lines.items) |line| {
862 const spec = wallview.resolveHost(arena, line, std.posix.getenv(xdg.key_env), client.quic_idle_ms_default) catch {
863 std.debug.print("{s}\t[bad host]\n", .{line});
864 continue;
865 };
866 var out: [proto.sessions_text_max]u8 = undefined;
867 const list = client.listSessions(arena, spec.target, &out, 2000) catch {
868 std.debug.print("{s}\t[unreachable]\n", .{line});
869 continue;
870 };
871 std.debug.print("{s}\t{d}\n", .{ line, std.mem.count(u8, list, "\n") + @intFromBool(list.len > 0 and list[list.len - 1] != '\n') });
872 }
873 return 0;
874 }
875 if (!(std.mem.eql(u8, args[0], "add") or std.mem.eql(u8, args[0], "rm"))) {
876 std.debug.print("mux hosts: add or rm, not '{s}'\n{s}", .{ args[0], usage });
877 return 2;
878 }
879 var argv = hosts.Argv{ .alloc = arena };
880 _ = cliflags.parse(struct { _argv: hosts.Argv, pub fn positional(self: *@This(), w: []const u8) bool { return self._argv.positional(w); } pub fn extra(self: *@This(), r: []const [:0]const u8) usize { return self._argv.extra(r); } }, ...);
881 ```
882 — simpler: mirror `wallEdit` L551-653 line for line, substituting `hosts.Argv`/`hosts.parse`/`hosts.load`/`hosts.save`/`hosts.reason` and the sun_path check for `.sock` (`sockpath.max_sun_path`), messages `mux hosts add: ...` / `mux hosts rm: not on the wall: {s}`; exit 2 on a bad spelling, 1 on `rm` of an unlisted host, 0 otherwise. Use `std.io.getStdOut()` for the list, not stderr.
883
884 Usage text: replace L27 with `usage: mux [HOST | --sock PATH | --via CMD | quic://HOST[:PORT]]` (unchanged) and the `mux wall` blocks L41-56 with:
885 ```
886 mux the wall: every session every listed daemon has live
887 mux hosts list the daemons on the wall, with their live session counts
888 mux hosts add SPELLING put a daemon on the wall without opening it
889 mux hosts rm SPELLING take one off (its sessions keep running)
890 ```
891 `cliflags.assertDocumented` for `WallOpts` (L456) goes with `WallOpts`.
892
893 - [ ] **Step 4: GREEN** — unit tests rc=0. Hand check with an isolated state home:
894 ```bash
895 export XDG_STATE_HOME=/tmp/claude-1000/-home-xanderle-code-rad-mux/67658ccb-644a-4152-a35f-92a13ceefbe9/scratchpad/state
896 deps/zig/zig build
897 ./zig-out/bin/mux hosts add 'box#x'; echo rc=$? # 2, names the rule
898 ./zig-out/bin/mux hosts add box; ./zig-out/bin/mux hosts # box [unreachable]
899 ./zig-out/bin/mux wall; echo rc=$? # 2, usage (wall is a HOST spelling now → attach to host "wall"? — no: `mux wall` parses as HOST=wall and tries ssh. Acceptable and documented in README (Task 8); the ssh refusal names the host.)
900 ```
901
902 - [ ] **Step 5: Commit**
903 ```bash
904 make check > /tmp/c.log 2>&1; echo rc=$?
905 git add src/cli/mux_main.zig src/wallview.zig src/client.zig
906 git commit -m "feat: mux opens the wall of hosts; mux hosts add/rm; mux wall is gone"
907 ```
908
909 ---
910
911 ### Task 6: Chords — `x` ends, `d` disconnects, `n`/`p` walk tiles; delete the attach-history machinery
912
913 **Files:**
914 - Modify: `src/interact.zig` (`Action` L94-113; decode L210-236)
915 - Modify: `src/client.zig` (`SwitchIntent` L126; delete `recordTile` L965-979, `recordOnState` L1089-1098, `hydratedCreates` L994-996 + their tests L1737-1862 except the ring tests; `warnWall` if unused)
916 - Modify: `src/wallview.zig` (chord arms L3048-3157; pump `ask` send L1329-1345 and `.sessions_reply` arm L1457-1470; delete `forgetTile` L1831-1859, `hydrate` L2449-2504, `.wall` fold arm L3065-3077, record path L1405-1412, `Tile.record/creates(keep creates)/wall_warned`; `addSpelledTile` L2240-2296 → takes a host spelling AND a session? — no: the `:` prompt now takes a HOST spelling only and adds the host (`hosts.record`) — its sessions arrive by poll)
917 - Modify: `build.zig` (drop `wall` from `client`'s imports L271 if nothing else in client.zig uses it)
918 - Modify: `docscheck.budget` (`wallview.zig`)
919
920 **Interfaces:**
921 - Consumes: Task 1 codecs, Task 2 verbs, Task 4 `Host.poke`.
922 - Produces: `interact.Action.end_session` (was `.forget`); `client.SwitchIntent = enum { none, new, next, prev, end, end_force }`; `Tile.end_armed_until: i64 = 0`.
923
924 - [ ] **Step 1: Failing tests** — `interact.zig` beside the chord test at L4350:
925 ```zig
926 test "PrefixFilter: x is end_session, w is wall, n/p are focus walks" {
927 var f: PrefixFilter = .{};
928 var buf: [8]u8 = undefined;
929 try std.testing.expectEqual(Action.end_session, f.feed(&buf, "\x1cx").action);
930 try std.testing.expectEqual(Action.wall, f.feed(&buf, "\x1cw").action);
931 try std.testing.expectEqual(Action.next_session, f.feed(&buf, "\x1cn").action);
932 }
933 ```
934 (match the real `feed` signature from the neighbouring test.) `wallview.zig`:
935 ```zig
936 test "endAction: a tile the daemon ended while it was the only one finishes with the shell's code" // exists as L3651-family — keep; add:
937 test "x on a tile whose daemon refused arms a second press, and the window closes after 3 s" {
938 var t: Tile = undefined; // initTile as in the birthTile tests
939 // first press, refusal arrives:
940 onEndReply(&t, .{ .accepted = false, .others = 2, .reason = "others attached" }, 1000);
941 try std.testing.expect(t.end_armed_until == 4000);
942 try std.testing.expectEqual(client.SwitchIntent.end_force, intentForEnd(&t, 2500));
943 try std.testing.expectEqual(client.SwitchIntent.end, intentForEnd(&t, 4001));
944 }
945 ```
946
947 - [ ] **Step 2: RED**.
948
949 - [ ] **Step 3: Implement**
950 - `interact.zig`: rename `forget` → `end_session` in `Action`; `'x' => .end_session`. `'w'` stays `.wall` (now: unzoom only).
951 - `client.zig`: `pub const SwitchIntent = enum { none, new, next, prev, end, end_force };`
952 - `wallview.zig`:
953 - keyboard `.end_session` arm:
954 ```zig
955 .end_session => if (z < live and present[z] and !tiles[z].stripe) {
956 tiles[z].ask.store(@intFromEnum(intentForEnd(&tiles[z], std.time.milliTimestamp())), .release);
957 ring(&tiles[z]);
958 },
959 ```
960 ```zig
961 fn intentForEnd(t: *Tile, now: i64) client.SwitchIntent {
962 return if (now < t.end_armed_until) .end_force else .end;
963 }
964 fn onEndReply(t: *Tile, r: proto.EndReply, now: i64) void {
965 t.end_armed_until = if (r.accepted) 0 else now + end_arm_ms;
966 }
967 const end_arm_ms: i64 = 3000;
968 ```
969 - pump send site (L1329-1345): `asked` `.end`/`.end_force` → `transport.writeFrame(.end_req, proto.encodeEndReq(&buf, asked == .end_force, proto.wireName(t.r.session)))`; arm `pending` the same way (expiry banner text for these intents: `"[daemon too old to end a session]"` — make the expiry banner pick its text by `pending.intent`).
970 - pump `.end_reply` arm (in the `.not_mine` switch): `const r = proto.parseEndReply(frame.payload) orelse break; _ = pending.take(); onEndReply(t, r, now); if (!r.accepted) { var b: [64]u8 = undefined; core.banner(std.fmt.bufPrint(&b, "[{d} other{s} attached — x again to end]", .{ r.others, if (r.others == 1) "" else "s" }) catch "[others attached — x again to end]"); }` — for `"no such session"` banner the reason verbatim. Accepted → nothing: `exit_status` follows and the existing `.exited` path narrates.
971 - `n`/`p`: replace the two `ask` arms with `focusAnswer(alloc, tiles[0..live], present[0..live], &shared, false, stepPresent(present[0..live], z, true/false) orelse z)`. `c` unchanged (its `.sessions_reply` → `nextFreeName` → `addSessionTile` path stays; after the birth `host_table[tiles[z].host.?].poke.store(true, .release)`); `|`/`-`/`:` likewise poke.
972 - `.wall` arm: `relayout(...)` only (unzoom); delete `hydrate`, `hydrated`, `save_hydrated` (Task 4 made saving unconditional).
973 - `:` prompt (`add_tile`): `hosts.record(alloc, path, spelling)`; on success append a `HostSpec` to the run's host table if there is room (`hosts` table allocated at `max_tiles` capacity, like tiles) and spawn its poller; on parse failure `badTarget` banner with `hosts.reason`.
974 - Delete: `forgetTile`, `hydrate`, `restoreFoldedLayout` (the `From` variant survives as Task 4's `restoreLayoutFrom`), record path at L1405-1412, `Tile.record`, `Tile.wall_warned`, `Birth.record`, `resolveSpelling` (now unused — `addSpelledTile` is rewritten above; `showsSelf` stays, used by `wallOfHosts`? — bare `mux` from inside a session: the local host line is the shell's own daemon; refuse the WALL? No: the wall shows every session including this one, which is the feedback loop `insideThisSession` refuses. Ruling: skip the tile whose target+session matches `MUX_SOCK`/`MUX_SESSION` in `planHostDiff` (never born), exactly what `hydrate` did with `showsSelf`. Add the two env values to `Shared` at `run` start and test it: a list containing the shell's own session births nothing for it.)
975 - `client.zig` deletions listed in Files; `wallSpelling` stays (sidecar keys + `hostSpelling`).
976 - The unit tests the map lists at §3/§4 as "delete/rewrite": delete the ones for deleted symbols; rewrite `"birthTile: a prompt-born tile creates, records, and offers no agent"` → `"birthTile: a chord-born tile creates and offers no agent"`.
977
978 - [ ] **Step 4: GREEN + budget** — unit tests rc=0; `zig build check` → set `wallview.zig`'s figure (down). Mutation: make `intentForEnd` always return `.end` → the arming test fails.
979
980 - [ ] **Step 5: Commit** (two commits, in this order, so the story reads feature-then-deletion):
981 ```bash
982 git add src/interact.zig src/client.zig src/wallview.zig
983 git commit -m "feat: Ctrl-\\ x ends the session (twice when others are attached); n/p walk the wall"
984 git add src/wallview.zig src/client.zig build.zig docscheck.budget
985 git commit -m "refactor: the attach-history machinery goes — no record, no hydrate, no fold"
986 ```
987 (Stage the deletions separately with `git add -p` if both land in one edit pass; the two commits are the deliverable.)
988
989 ---
990
991 ### Task 7: e2e — the hosts wall, and every scenario that named `mux wall`
992
993 **Files:**
994 - Delete: `test/e2e_09_wallhist.sh`
995 - Create: `test/e2e_09_hosts.sh`
996 - Modify: `test/e2e.sh` (`E2E_GROUPS` L115: `09_wallhist` → `09_hosts`; count pin L180), `test/e2e_07_wallcli.sh`, `test/e2e_08_mouse.sh`, `test/e2e_11_select.sh`, `test/e2e_12_panes.sh`, `test/e2e_13_birth.sh` (CLI legs only: :180 and :351)
997
998 Read `test/e2e_09_wallhist.sh` whole (it is under 700 lines) for the helper idioms: `start_daemon SOCK LOG LABEL --shell /bin/sh`, `pipe_mux OUT ERR env XDG_STATE_HOME=... timeout 40 "$MUX" ...`, `pipe_send 'bytes\n'`, `wait_grid SOCK NEEDLE LABEL`, `wait_sessions SOCK N LABEL`, `pipe_detach LABEL`, `pipe_waitexit`, `real_pid`, `ok "..."`. Every `mux wall "--sock $S#a" "--sock $S#b"` becomes `mux --sock "$S"` with sessions `a`/`b` created beforehand (`"$MUXA" ... --session a` or a prior `mux --sock $S --session a` + detach) — the wall then shows them because they are live.
999
1000 - [ ] **Step 1: Write `test/e2e_09_hosts.sh`** — five scenarios, each `ok`:
1001
1002 ```sh
1003 # shellcheck shell=sh
1004 # e2e_09_hosts.sh — sourced by test/e2e.sh after e2e_lib.sh.
1005 # The wall is a list of DAEMONS: two of them here, because the claim is
1006 # that the tiles come from the daemons and not from a file, and one daemon
1007 # cannot show which of the two a tile belongs to.
1008 SOCKH1="${TMPDIR:-/tmp}/muxd-e2e-hosts1-$$.sock"
1009 SOCKH2="${TMPDIR:-/tmp}/muxd-e2e-hosts2-$$.sock"
1010 HSTATE="${TMPDIR:-/tmp}/mux-e2e-hosts-state-$$"
1011 mkdir -p "$HSTATE"; defer_rm "$HSTATE"
1012 start_daemon "$SOCKH1" "$OUT.h1.d" "hosts daemon 1" --shell /bin/sh
1013 start_daemon "$SOCKH2" "$OUT.h2.d" "hosts daemon 2" --shell /bin/sh
1014
1015 # 1. an empty file: `mux --sock` records the HOST line, not a session line, and lands in #0
1016 pipe_mux "$OUT.hw1" "$OUT.hw1.err" env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" --sock "$SOCKH1"
1017 pipe_send 'printf "hw1-%%s\\n" pin\n'
1018 wait_grid "$SOCKH1" "hw1-pin" "hosts: the default session's marker"
1019 pipe_detach "hosts client 1"
1020 grep -qxF -- "--sock $SOCKH1" "$HSTATE/mux/hosts" || { echo "e2e FAIL: hosts file lacks the daemon line"; cat "$HSTATE/mux/hosts"; exit 1; }
1021 grep -q '#' "$HSTATE/mux/hosts" && { echo "e2e FAIL: a session leaked into the hosts file"; exit 1; }
1022 ok "mux --sock records the daemon on the wall, never a session"
1023
1024 # 2. sessions born elsewhere are tiles: muxa creates b on daemon 1, c on daemon 2 (added via hosts add);
1025 # a bare `mux` shows all three, and Ctrl-\ n walks to each (digits 1..3 on the bars)
1026 env XDG_STATE_HOME="$HSTATE" "$MUX" hosts add "--sock $SOCKH2" || { echo "e2e FAIL: hosts add"; exit 1; }
1027 "$MUXA" --sock "$SOCKH1" --session b run 'printf "hb-%s\n" pin' > "$OUT.hb" 2>&1 || true
1028 "$MUXA" --sock "$SOCKH2" --session c run 'printf "hc-%s\n" pin' > "$OUT.hc" 2>&1 || true
1029 wait_sessions "$SOCKH1" 2 "hosts: daemon 1 should hold 0 and b"
1030 wait_sessions "$SOCKH2" 1 "hosts: daemon 2 should hold c"
1031 pipe_mux "$OUT.hw2" "$OUT.hw2.err" env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX"
1032 wait_for "$OUT.hw2" '3 ' 10 # three label bars = three tiles (see paintLabel: "N> " / "N ")
1033 pipe_send '\x1cn'
1034 pipe_send 'printf "walk-%%s\\n" one\n'
1035 # the marker lands on SOME session — the point is that n reached a live one without a file naming it
1036 wait_for_any() { :; } # use dump_session over both sockets: exactly one of 0/b/c shows walk-one
1037 pipe_detach "hosts wall client"
1038 ok "every live session of every listed daemon is a tile, and n walks them"
1039
1040 # 3. x ends: two clients on b; the first x is refused with the count and the shell lives;
1041 # the second x ends it, the other client exits with the shell's code, the tile is gone from a fresh mux
1042 pipe_mux "$OUT.hx1" "$OUT.hx1.err" env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX" --sock "$SOCKH1" --session b
1043 pipe_send 'echo bpid=$$\n'
1044 wait_grid "$SOCKH1" "bpid=" "hosts: b's shell pid"
1045 BPID=$(dump_session "$SOCKH1" b | sed -n 's/.*bpid=\([0-9]*\).*/\1/p' | tail -1)
1046 # second client, on its own pipe pair (see e2e_05_session.sh for two concurrent pipe_mux clients — use the lib's second-client idiom there)
1047 ...
1048 pipe_send '\x1cx'
1049 wait_for "$OUT.hx1" "1 other attached" 5
1050 kill -0 "$BPID" || { echo "e2e FAIL: first x ended the shell despite another client"; exit 1; }
1051 pipe_send '\x1cx'
1052 wait_pid_gone "$BPID" 5 "hosts: b's shell after the forced x"
1053 pipe_waitexit ... # the other client returns (exit_status)
1054 ok "x refuses while others are attached, then ends; the other client sees the exit"
1055
1056 # 4. daemon restart re-creates NOTHING: stop daemon 2, start it empty, `mux` shows its stripe and 0 sessions
1057 "$MUXD" stop --sock "$SOCKH2"; assert_stopped ...
1058 start_daemon "$SOCKH2" "$OUT.h2b.d" "hosts daemon 2 reborn" --shell /bin/sh
1059 pipe_mux "$OUT.hw4" "$OUT.hw4.err" env XDG_STATE_HOME="$HSTATE" timeout 40 "$MUX"
1060 wait_for "$OUT.hw4" '\[no sessions\]' 10
1061 pipe_detach "hosts wall after restart"
1062 [ "$(timeout 5 "$MUXD" stats --sock "$SOCKH2" | sed -n 's/.*sessions=\([0-9]*\).*/\1/p')" = "0" ] || { echo "e2e FAIL: the wall resurrected a session"; exit 1; }
1063 ok "a restarted daemon comes back as an empty stripe, and nothing is re-created"
1064
1065 # 5. hosts rm leaves sessions running; d disconnects and leaves the other client alone
1066 env XDG_STATE_HOME="$HSTATE" "$MUX" hosts rm "--sock $SOCKH1"
1067 wait_sessions "$SOCKH1" 2 "hosts rm: daemon 1's sessions must survive"
1068 grep -qF -- "$SOCKH1" "$HSTATE/mux/hosts" && { echo "e2e FAIL: rm did not remove"; exit 1; }
1069 ok "hosts rm takes the daemon off the wall and ends nothing"
1070 ```
1071 The `...` in scenario 3 are the two-client idiom the implementer copies from `e2e_05_session.sh`; everything else is literal. Replace the `wait_for_any` stub with a loop over `dump_session "$SOCKH1" 0`, `dump_session "$SOCKH1" b`, `dump_session "$SOCKH2" c` counting `walk-one` hits (expect exactly 1).
1072
1073 - [ ] **Step 2: RED** — `E2E_ONLY=09_hosts make e2e` against the PREVIOUS commit's binaries would not even parse `mux hosts`; instead demonstrate RED per assertion by mutation on the current tree: (a) comment out `hosts.record` in `runAttach` → scenario 1 fails at the grep; (b) make `planHostDiff` return no births → scenario 2 fails at `wait_for '3 '`; (c) `endSession` `if (false)` → scenario 3's `kill -0` fails. Revert each with the inverse edit.
1074
1075 - [ ] **Step 3: Rewrite the other groups**
1076 - `e2e_07_wallcli.sh:71` and every `mux wall` argv (07, 08 L355/466/538/602, 11 L44/73/108, 12 L69/141/199/286/364/416/486/526-560/609/774): sessions are pre-created with `$MUXA --sock S --session NAME run true` (or exist already), then `mux --sock S`. Assertions on tile content are unchanged — the same sessions are on the same wall.
1077 - `e2e_07_wallcli.sh:281/:368` (`c` and the ring): `c` still births beside the focus; `n`/`p` now step tiles — rewrite the ring assertion to "n from the last tile wraps to the first" using the bar digits.
1078 - `e2e_08_mouse.sh` dead-tile leg L572-640: delete (a nonexistent session cannot be spelled). Add its replacement to 09 scenario 4 (already there: the stripe).
1079 - `e2e_12_panes.sh:743` "heals on wall-file drift": rewrite as live drift — end session `b` with `muxa`/`x` between the two runs; the survivor keeps its leaf, the sidecar heals.
1080 - `e2e_13_birth.sh:180`: drop the "recorded" assertions L170-173; the `:` prompt now takes a HOST spelling — the leg's `--sock $SOCK#name` prompt input becomes `--sock $SOCK` (adds the second daemon; its session then appears). `:351`: delete (hydratedCreates), its replacement is 09 scenario 4.
1081 - `test/e2e.sh`: `E2E_GROUPS` name; the count pin — run the whole suite, read the new `OK_COUNT`, set the pin to it in the same commit (state the number in the commit message).
1082
1083 - [ ] **Step 4: GREEN** — `make e2e > /tmp/e.log 2>&1; echo rc=$?; tail -3 /tmp/e.log` → `e2e OK (N scenarios, ...)`. Then `make ci`.
1084
1085 - [ ] **Step 5: Commit** (one commit per group file is fine; the last carries the pin)
1086 ```bash
1087 git add test/e2e_09_hosts.sh test/e2e.sh && git rm -q test/e2e_09_wallhist.sh
1088 git commit -m "test: e2e — the wall of hosts (two daemons, x two-step, restart re-creates nothing)"
1089 git add test/e2e_07_wallcli.sh test/e2e_08_mouse.sh test/e2e_11_select.sh test/e2e_12_panes.sh test/e2e_13_birth.sh test/e2e.sh
1090 git commit -m "test: e2e — mux wall argv becomes live sessions on a listed daemon; pin N"
1091 ```
1092
1093 ---
1094
1095 ### Task 8: Docs, decisions, xversion leg
1096
1097 **Files:**
1098 - Modify: `README.md` (L45-60, L62-92 chord table + paragraph, L219-224, L278-296, L298-338, L340-352, L374-388), `docs/decisions.md` (append), `docs/roadmap.md` (one line at the top: "wall = hosts landed 2026-08-xx; the wall-file items below are closed"), `test/xversion.sh` (new leg), `docs/superpowers/specs/2026-08-19-wall-home-screen-design.md` (one line at top: superseded by the 2026-08-27 spec)
1099
1100 - [ ] **Step 1: README** — rewrite the "**`mux` IS the wall.**" block to the spec's Model section in user words; replace the `mux wall` command block with the `mux hosts` block from the spec's Command line; chord rows: `Ctrl-\ x` → "end the focused session (asks twice when others are attached)", `Ctrl-\ d` → "disconnect: leave every tile, interrupt nobody", `Ctrl-\ n`/`p` → "next / previous tile", `Ctrl-\ :` → "add a daemon by spelling", add a `Ctrl-\ w` row "zoom out to the wall"; replace "The wall is your attach history" (L298-338) with "**The wall is your hosts**": the file, what a line means, that nothing is resurrected, `[unreachable]`/`[no sessions]` stripes, `hosts add/rm`. Note under `mux HOST` that a host literally named `wall` or `hosts` is reached by `--via`/`quic://` spelling. `-A` paragraph: "-A on `mux`/`mux HOST`; tiles the poll births never offer an agent".
1101
1102 - [ ] **Step 2: decisions.md** — append `## 2026-08-27 — the wall lists daemons; tiles are their live sessions` covering: the two-lists problem (with the `Ctrl-\ n` surprise and the resurrection), why hosts not sessions, `x` two-step daemon-refused rather than client-guessed, poll not push (with the measurement to make before a push), fresh connection per poll, stripe only when a host has no tiles (tiles keep reconnecting through a blip), sidecar restore moved to "first lists in", what was deleted and which legs went with it, hub deferred to phase 2 with the old file untouched.
1103
1104 - [ ] **Step 3: xversion leg** — in `test/xversion.sh` after leg 8 (L~548): new client vs old daemon: attach, `\x1cx`, expect the banner `daemon too old to end a session` in the capture within 3 s and the session count unchanged (`muxd stats` off the old daemon). Old client vs new daemon needs nothing (no frame changed shape). Run `make xversion-build xversion` if the old worktree exists (`XVER_OLD_WORKTREE`, default `..`; see memory `mux-xversion-rig`); if it does not, record that in the ledger and leave the leg for the reviewer to run.
1105
1106 - [ ] **Step 4: Commit**
1107 ```bash
1108 make check > /tmp/c.log 2>&1; echo rc=$?
1109 git add README.md docs/decisions.md docs/roadmap.md docs/superpowers/specs/2026-08-19-wall-home-screen-design.md test/xversion.sh
1110 git commit -m "docs: the wall lists daemons — README, decision, xversion leg"
1111 ```
1112
1113 ---
1114
1115 ## Self-review
1116
1117 - Spec coverage: Model → T4; Command line → T5; Chords → T6 (`d` unchanged, `x` two-step, host removal not a chord, `n`/`p` walk); Wire → T1/T2 (`end_req 0x11`, `end_reply 0x94`, observer `sessions_req`, poll not push); The file → T3 (strict, `mux/hosts`, no migration); Hub → out of scope, guarded by the untouched web legs; What goes → T6/T7; Bounds → `max_tiles` unchanged, `+N not shown` rail text → T4 step 3(c); Testing → T2/T3/T4/T7/T8 (two hosts baseline in 09; the "old client's unknown verb" pin: covered by `else => {}` in both switches — add one line to T2's tests: an observer sending `.end_req` to a daemon built without the arm is the xversion leg, not a unit test).
1118 - Placeholders: T2 setup elisions and T7 scenario 3's two-client idiom reference existing tests by file and line; T5's `hostsMain` add/rm body says "mirror `wallEdit` L551-653" — acceptable because the model is a complete function in the tree, but the implementer must produce the full body.
1119 - Type consistency: `proto.EndReply` (T1) is what `endSession` (T2) returns and `onEndReply` (T6) consumes; `sessions_text_max` (T4) used by `listSessions` and `Host.list`; `HostSpec`/`resolveHost`/`run(alloc, hosts, entry)` (T4) consumed by T5; `SwitchIntent.end/.end_force` (T6) matches `intentForEnd`; `Tile.host: ?usize` set in T4's births and T5's entry tile.
docs/superpowers/plans/2026-08-28-host-picker.md
Old New
@@ -1,75 +0,0 @@
1 # Host Picker 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:** The wall shows live sessions only; hosts live in a `Ctrl-\ s` picker; a daemon lives until `muxd stop`; a new tile takes the lowest free digit.
6
7 **Architecture:** Stripes leave `wallview.zig`; the picker is a `PrefixFilter` mode in `interact.zig` (key table, unit-testable) painted by `wallview.zig` over paused tiles; `SessionTable.reap` stops returning an exit code on empty; `Birth` picks the lowest free slot.
8
9 **Tech Stack:** Zig 0.15.2 (`deps/zig/zig` only), e2e in sh on `ptyclient`.
10
11 **Spec:** `docs/superpowers/specs/2026-08-28-host-picker-design.md` (amends `2026-08-27-wall-of-hosts-design.md`).
12
13 ## Global Constraints
14
15 - Read `CLAUDE.md` first; never `cat` `src/wallview.zig` / `src/server.zig` / `docs/decisions.md` / `test/e2e.sh` — grep, then `sed -n`.
16 - `make check` rc=0 before every commit; `docscheck.budget` is exact and only ever LOWERED (`wallview.zig 1596` today — deleting stripes lowers it; write the new figure).
17 - Comments say WHY. No history codenames in src comments.
18 - Hand rigs export an isolated `XDG_STATE_HOME` and a SHORT `XDG_RUNTIME_DIR` (`/tmp/hp-run`; socket paths cap at 107 bytes). Never `/run/user/1000`, never `/tmp/mux-demo-run/*`.
19 - `git add` named files only (RETRO.md is untracked, not yours). No push, no merge, no `main`. Branch `wall-of-hosts`.
20 - Capture `$?` directly to a file/echo (zsh has no PIPESTATUS). Run `make e2e` / `E2E_ONLY=<group> make e2e` in the FOREGROUND.
21 - Tests are named for the claim; a fixture that holds a dimension constant is blind to it — two hosts and an off-origin tile are the default fixture, one and origin are extra cases.
22 - A claim about a pid or a process is asserted with `kill -0` / `/proc`, never by asking the daemon.
23
24 ---
25
26 ### Task 1: The wall shows sessions only
27
28 **Files:** Modify `src/wallview.zig` (`Tile.stripe` ~400, `Birth.stripe` ~2274, `State.unreachable/.empty` ~234, `stripeRule` ~2929, `stripePaints`, `applyHostList` ~3005, `vanishTile` ~1997 callers, `paintEmptyWallLocked` ~1875, the stripe tests ~4349+, `endAsk` ~588), `test/e2e_09_hosts.sh` (+ any group asserting `unreachable`/`no sessions`: `grep -ln 'unreachab\|no sessions' test/`), `test/e2e.sh` pin, `README.md` (stripe prose in the wall section), `CLAUDE.md` (the "A host contributes ONE stripe" sentence), `docscheck.budget`.
29
30 **Steps:**
31 - [ ] `git revert --no-edit 02bd4c5` (x-on-stripe; a stripe no longer exists).
32 - [ ] Failing unit test: `applyHostList` with a host whose list is empty/unreachable adds NO tile (`live` unchanged), beside the existing `applyHostList` tests. Run `deps/zig/zig build test 2>&1 | tail -5`; expect the new test to fail because a stripe is born.
33 - [ ] Delete stripes: the two `stripe` fields, the two `State` members and their `word`s, `stripeRule`, `stripePaints`, the `t.stripe` checks in `endAsk`, `n`/`p`/digit walks, the diff, and every stripe test. A host with no tiles contributes nothing. Keep `Host.reachable`/lists — Task 3 reads them.
34 - [ ] `paintEmptyWallLocked` text: `"the wall is empty"` / `"Ctrl-\\ s to pick a host - Ctrl-\\ d to leave"` (Task 3 wires `s`; the text lands now so one commit owns the words).
35 - [ ] e2e: every leg that asserted a stripe now asserts the host contributes no bar — `grep -c` of that host's spelling on the bar row is 0 while its daemon is down, and its tiles appear when it comes up (keep the heal claim). Pin in `test/e2e.sh` unchanged unless a scenario is deleted.
36 - [ ] `E2E_ONLY=09_hosts make e2e` foreground, rc to a file. `make check` rc=0. Lower `wallview.zig`'s budget line to the new figure `zig build doc-report` prints.
37 - [ ] Commit: `refactor: the wall shows sessions only — stripes go`.
38
39 ### Task 2: A daemon lives until `muxd stop`
40
41 **Files:** Modify `src/server_sessions.zig` (`SessionTable.reap` ~190), `src/server.zig` (~1005 `if (self.sessions.reap(self)) |code| return code;`, ~1210, `validateUpgrade`), `src/cli/main.zig` (the exit code `muxd run` reports), `src/server_test_session.zig`, `test/e2e_05_session.sh` / `01_boot` / `14_upgrade` and any leg waiting for exit-on-empty (`grep -n 'assert_stopped\|wait.*exit\|last session' test/e2e_*.sh`), `test/xversion.sh`, `README.md` (`grep -n 'last session\|exits' README.md`), `CLAUDE.md`, `docs/decisions.md` (≤15-line entry: `x` ends a session, never a box; one idle process per machine is the cost; `muxd stop` is the end).
42
43 **Steps:**
44 - [ ] Failing unit test in `server_test_session.zig`: end the only session; `reap` returns null and the server keeps pumping (`pumpOnce` returns null on the next pass); `stats` text contains `sessions=0`. Run, expect a non-null exit code.
45 - [ ] `reap` returns null when the table empties; `last_code` becomes the per-shell fact it always was (the `exit_status` frame still goes to that session's clients). `muxd run` exits 0 on `muxd stop`, non-zero only on boot failure — say so where the code is chosen.
46 - [ ] `mux --sock PATH` / a wall birth on an empty daemon creates `0`: assert with an e2e leg (end the last session, `kill -0 $DPID` after 2 s, `muxd stats` says `sessions=0`, a piped `mux --sock` then finds session `0` again — `wait_sessions 1`).
47 - [ ] `xversion.sh`: legs that expect the OLD daemon to exit on empty branch on which side is old (the script knows); the new side must be alive. Run `make xversion` if `../mux-xver-old` exists; report rc or "unavailable".
48 - [ ] `make e2e` in full, foreground, rc to a file. `make check` rc=0. Commit: `feat: a daemon lives until muxd stop — emptiness is not an exit`.
49
50 ### Task 3: The picker
51
52 **Files:** Modify `src/interact.zig` (`PrefixFilter`: the `prompting` mode ~123-160 and the `':'` arm ~229 are the model; add a `picking` mode and actions), `src/wallview.zig` (paint + the keyboard loop arms ~3530-3830; `addHost` ~2429; `paintEmptyWallLocked`; `tilePaintEnd` ~799 — tiles do not paint while the picker is open), `src/paint.zig` if the popup needs a primitive, `test/e2e_09_hosts.sh` (new scenario at the END), `test/e2e.sh` pin, `README.md` chord table + wall section, `CLAUDE.md` invariant (`Ctrl-\ :` sentence → `Ctrl-\ s` picker), `docs/decisions.md` (≤15 lines: why a popup and not stripes; why tiles pause).
53
54 **Interfaces:**
55 - Produces in `interact.zig`: `Action.pick_open`, `Action.pick_move(±1)`, `Action.pick_select(n)`, `Action.pick_birth`, `Action.pick_forget`, `Action.pick_add_open`, `Action.pick_close`; `PrefixFilter.picking: bool`; the existing prompt actions reused for `a`.
56 - Consumes from Task 1: `Host.reachable`, `Host.list`/`list_len` (session count = lines in the list text; see `client.listSessions`' format and `proto.sessions_text_max`).
57
58 **Steps:**
59 - [ ] Failing unit tests in `interact.zig` (beside the `:` prompt tests, `grep -n 'prompting' src/interact.zig`): `s` after the prefix yields `.pick_open`; in picking mode `j`/`k`/`\x1b[B`/`\x1b[A` → `.pick_move`, `1`-`9` → `.pick_select`, Enter/`c` → `.pick_birth`, `x` → `.pick_forget`, `a` → `.pick_add_open`, Esc/`s` → `.pick_close`, and prose (`hello\n`) forwards NOTHING while picking. `:` no longer opens the prompt (test the negative). Run, expect failures.
60 - [ ] Implement the mode in `PrefixFilter`.
61 - [ ] Failing unit test in `wallview.zig`: `pickerRows(host_table, live tiles)` renders `N sessions` / `no sessions` / `unreachable` / `connecting` from a fixture of four hosts (two with tiles, one reachable-empty, one unreachable, one never polled) with the selected row marked; a spelling wider than the box keeps its tail.
62 - [ ] Implement paint: a box centred on `shared.size`, painted under `paint_mu` by the keyboard thread on open, on every `list_ready`, and on every key; `shared.picker_open: bool` makes `tilePaintEnd`/pump paints skip drawing while set (replicas still apply deltas); close bumps `repaint_gen`; cursor hidden (`\x1b[?25l`) on open, restored by the focused pump's next paint.
63 - [ ] Keyboard arms: `pick_open` (pre-select the focused tile's host), `pick_birth` (the same create the `c` arm sends, target = selected host's `spec.target`, then close — the tile arrives via the birth path and takes focus), `pick_forget` (`hosts.forget` on `hosts_path`, poller `forgotten` flag exits `pollHost`, `vanishTile` each of its tiles — sessions keep running — notice `[forgot SPELLING]`), `pick_add_open` (the old `:` editor; on Enter → `addHost`, back to the picker), `pick_close`. An empty wall (`live == 0` after a vanish, or at open with no tiles) opens the picker itself.
64 - [ ] e2e scenario (END of `09_hosts.sh`, ptyclient `--cols 100 --rows 30`, two daemons, hosts file of both, tiles from both): `\x1cs`, expect the box (`hosts` header and both spellings on the capture), `2` then `\r`, expect a new bar; `muxd stats --sock $SOCKH2` session count +1 (oracle); `\x1cs`, `1`, `x`, `mux hosts` shows one line and daemon 1's shell pid still `kill -0`; `\x1cs`, `a`, type `--sock $SOCKH1\r`, `mux hosts` shows two again; Esc closes and typing reaches the focused session again (`echo picker-$$` lands in that daemon's grid via `wait_grid`). Bump the pin.
65 - [ ] `E2E_ONLY=09_hosts make e2e` foreground, rc to a file. `make check` rc=0 (budgets exact: pay for new comments in the same file). Commit per increment (`feat: interact — the picker mode`, `feat: the host picker`, `docs: …`).
66
67 ### Task 4: A new tile takes the lowest free digit
68
69 **Files:** Modify `src/wallview.zig` (`Birth`, the birth sites `grep -n 'live += 1\|live +=' src/wallview.zig`, `vanishTile`, per-tile fields ~396-520, the pump exit), `test/e2e_13_birth.sh` or `09_hosts.sh` (pick the harness that fits; say which), `test/e2e.sh` pin, `docs/decisions.md` (≤10 lines: reuse over dense renumbering; why a slot whose pump has not returned is not free), `README.md` (`1`-`9` row: "a new tile takes the lowest free digit").
70
71 **Steps:**
72 - [ ] Failing unit test: three births, vanish the middle, a fourth birth lands in slot 1 (0-based) with `ever_up=false`, `missed_once=false`, `end_armed_until=0`, no `host` carried over; a vanished slot whose pump has not signalled its return is skipped and the birth appends. Run, expect slot 3.
73 - [ ] Add `Tile.pump_done: atomic bool` set as the pump thread's LAST act (find the pump function's return); a slot is free iff `!present[i] and pump_done`. Births take the lowest free slot; `live` grows only when there is none. Reset every per-tile field at reuse; free the old label/session copies; KEEP the wake pipe (never closed — read the comment at its creation).
74 - [ ] e2e: tiles 1 2 3 (`c` twice), `x` on 2, wait for its tile to leave (`wait_sessions`), `c`, expect ` 2>` on the bar and `muxd stats` holds the session the bar names. Bump the pin.
75 - [ ] `E2E_ONLY=<group> make e2e` foreground, rc captured; `make check` rc=0. Commit: `feat: a new tile takes the lowest free digit`.
docs/superpowers/specs/2026-08-09-m10-quic-ergonomics-design.md
Old New
@@ -1,217 +0,0 @@
1 # M10: QUIC ergonomics — keygen, defaults, `muxd start`
2
3 **Goal:** the whole QUIC story becomes four honest commands — three of
4 them run once:
5
6 ```sh
7 muxd keygen # once
8 ssh HOST 'mkdir -p -m 700 ~/.config/mux && cat > ~/.config/mux/key \
9 && chmod 600 ~/.config/mux/key' < ~/.config/mux/key # once per host
10 ssh HOST 'muxd start --quic 0.0.0.0' # once per host boot
11 mux quic://HOST # every attach
12 ```
13
14 No `head -c 32 /dev/urandom`, no `setsid nohup … >/dev/null 2>&1 &`, no
15 port or key path typed anywhere. The trust posture is unchanged: 32-byte
16 PSK, permissive key files refused, no unauthenticated mode.
17
18 **Context:** M10 usability detour, re-cut 2026-08-09. The earlier cut
19 (daemon auto-start on attach, git history of this file) solved the
20 unix-socket path first; the user's daily transport is QUIC, so the
21 ergonomics of the *explicit* QUIC flow are the lower-hanging fruit. The
22 spawn helper built here is deliberately the auto-start machinery under an
23 explicit flag — the follow-on stages reuse it.
24
25 **Follow-on stages, banked, in order** (sketches preserved so nothing
26 re-derives them):
27
28 - **Attach auto-start:** `muxd proxy` and local `mux` call the spawn
29 helper so `mux user@host` works on a box where nothing is running.
30 Unix-socket daemons only; silence stays meaningful (fast path prints
31 nothing).
32 - **QUIC handoff (the mosh shape):** `muxd endpoint` over ssh ensures a
33 daemon (ephemeral-port QUIC listener), prints port+key; client caches
34 both and tries QUIC first with a ~2s attach deadline; the coordination
35 ssh stays alive as the fallback proxy so a UDP-blocked network costs one
36 deadline, not two. The key belongs to the host-user, on disk, outliving
37 the daemon: restarts invalidate only cached ports (self-healing via
38 ssh), never keys on other machines. Rotation = delete key + restart;
39 every client re-fetches over its own authenticated ssh. Per-client
40 revocation stays impossible with a shared PSK (certs/TOFU, parked).
41 A stale cache can only cost time, never correctness: PSK auth is
42 mutual, so a wrong key attaches to nothing, and every cached-path
43 failure falls through to ssh, which is authoritative. Measure the
44 wrong-key failure mode (prompt alert vs idle-timeout silence) before
45 trusting any deadline number.
46
47 ## Non-goals
48
49 - No auto-start on attach yet; `mux quic://HOST` against a stopped
50 daemon still fails (with an honest message).
51 - No registry, no client-side cache, no `mux deploy`, no config file.
52 - Binary placement stays manual (`scp`). The claim is "everything after
53 the scp is three commands", not "mux installs itself".
54 - No multi-session, no per-client keys.
55
56 ## Components
57
58 ### 1. `muxd keygen`
59
60 Writes 32 random bytes to the default key path (§2), directory created
61 with mode 0700, file mode 0600, and prints the path. Refuses to overwrite an existing
62 key — rotation is `rm` + `keygen`, deliberate on both counts — and says
63 so with the path. Exit 0 on creation, 1 on refusal. Round-trip pinned by
64 test: a generated key must load through the existing `quic.Key.load`.
65
66 ### 2. Default key path, both binaries
67
68 `$XDG_CONFIG_HOME/mux/key`, defaulting to `~/.config/mux/key`.
69 Resolution order, client and daemon alike: `--key` flag, then
70 `MUX_KEY_FILE`, then the default path if the file exists. Parse purity
71 is preserved: parse returns "no key named" and `main` resolves the
72 default, so parse tests stay filesystem-free. Nothing about key
73 *checking* changes — permissive files are refused wherever they came
74 from.
75
76 When QUIC is named and no source yields a key:
77
78 ```
79 mux: no key: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default ~/.config/mux/key)
80 ```
81
82 (same triad from muxd, `muxd:`-prefixed; the parenthetical names the
83 *resolved* path, so under a non-default `XDG_CONFIG_HOME` it tells the
84 truth rather than reciting `~/.config`). This replaces the current
85 `quic_without_key` message on both sides.
86
87 ### 3. Default port 4433, both binaries
88
89 `mux quic://HOST` means `quic://HOST:4433`; `muxd run --quic 0.0.0.0`
90 (and `muxd start --quic 0.0.0.0`) means `0.0.0.0:4433`. An explicit
91 `:PORT` wins, parsed exactly as today. Applied in the parse, pinned by
92 parse tests. Host semantics (literal address for the daemon bind)
93 unchanged.
94
95 ### 4. `muxd start` — the daemonizer
96
97 `muxd start [same flags as run]`: probe the socket path; if a daemon
98 answers, print `muxd: already running on SOCK (stop it first if you
99 meant different flags)` and exit 0 — re-runnable by construction, which
100 is what makes it safe in scripts and in `ssh HOST 'muxd start …'`. If
101 nothing answers: fork, `setsid`, stdin from `/dev/null`, stdout+stderr
102 truncating `~/.local/state/mux/muxd.log` (`$XDG_STATE_HOME` honored,
103 directory created), exec own binary (`/proc/self/exe`) as `muxd run`
104 with all flags forwarded. Then poll the socket every 50ms until accept,
105 deadline 2s.
106
107 Core lives in `src/spawn.zig` as `ensureDaemon` (probe / spawn-detached
108 / poll, typed errors `BinaryNotFound`/`SpawnFailed`/`NeverAnswered`) so
109 the attach-auto-start stage is a call site, not a rewrite. The spawned
110 pid is not killed on deadline — a daemon that comes up at 2.5s should be
111 there for the retry, not murdered for tardiness. The concurrent-start
112 race needs no new code: the losing daemon exits on
113 `DaemonAlreadyRunning` (server.zig `claimSockPath`); a test pins that
114 both starters report success and exactly one daemon survives.
115
116 Progress on stderr: `muxd: starting…`, one dot per 250ms while polling
117 (tty only), then ` up (0.4s) pid=1234`, or on deadline:
118
119 ```
120 muxd: daemon did not answer within 2s — log: ~/.local/state/mux/muxd.log
121 ```
122
123 ("daemon", not "muxd", in the message body: the prefix is the program
124 speaking, and the same format string serves the future mux-side caller,
125 where `mux: daemon did not answer` reads correctly too.)
126
127 Non-tty stderr (scripts, e2e): no dots, same start and outcome lines.
128 The dead-socket case needs no handling in the prober: the spawned
129 daemon's own `claimSockPath` clears dead sockets and refuses live ones.
130
131 ### 5. Honest errors
132
133 - **`--via` command dies before the first protocol frame** (today:
134 `mux: connection to muxd lost`): if no frame ever arrived, say
135 `mux: transport command failed before connecting (is muxd installed
136 on the host?)`. The transport's own stderr (ssh's `command not
137 found`) already passes through and stays so.
138 - **Key missing:** the §2 triad message.
139 - **`muxd start` deadline:** the §4 failure line, naming the log.
140
141 ### 6. `--version` on both binaries
142
143 `mux --version` and `muxd --version` print name and version (e.g.
144 `mux 0.0.1-3`; single source: a `version` constant in build.zig passed
145 via build options), exit 0. Bumped at tag time. Answers "did the scp
146 land" — the stale-binary trap from decisions.md M7, now askable in one
147 command.
148
149 ### 7. Delete the systemd remnants
150
151 No legacy at 0.0.1; git history is the archive, and socket activation
152 was verified working on 2026-08-09, so the removing commit documents a
153 known-good pattern that can be resurrected with confidence if a
154 `KillUserProcesses=yes` box ever earns it back.
155
156 - **`contrib/muxd.service` + `contrib/muxd.socket`: deleted.** Already
157 wrong (hardcoded checkout path) and now redundant — `muxd start` is
158 the supported way to get a daemon up, and sessions do not survive
159 reboot anyway.
160 - **`LISTEN_FDS` socket activation in server.zig: deleted** —
161 `listenFdFromSystemd`, the `systemd_fd` branch of `Server.init`, and
162 the `owns_sock_file = false` case it existed for. `Server.init`
163 always binds and always owns the socket file afterward. The dev/ino
164 identity check in teardown stays: "only unlink the socket you
165 created" guards against a different hazard than activation was.
166 - **README:** QUIC quick start rewritten to the goal commands; the
167 ssh quick start's `setsid nohup` line becomes `muxd start`; drop the
168 `loginctl enable-linger`/systemd sentence; add one caveat: survival
169 after logout assumes logind's default `KillUserProcesses=no`
170 (verified default; hostile boxes kill user processes at logout).
171
172 ## Testing
173
174 Unit:
175 - keygen: creates 0600/32-byte key that `quic.Key.load` accepts; second
176 run refuses, exit 1, file untouched.
177 - key resolution: flag > env > default-file > triad error; parse itself
178 never touches the filesystem.
179 - port default: `quic://HOST` → 4433, `quic://HOST:9` → 9, both
180 binaries' parses.
181 - ensureDaemon: already-running (no spawn, no output) · spawn on empty
182 path (socket answers, log exists) · two concurrent starts (both
183 succeed, one daemon — assert by connect and by pid liveness) · binary
184 missing (`BinaryNotFound` before any fork) · stub that never binds
185 (`NeverAnswered` at ~2s, spawned pid still alive).
186
187 E2E:
188 - `muxd start` on a fresh path; `mux --sock` attaches; marker; detach;
189 second `muxd start` says already-running exit 0; reattach sees marker.
190 - `muxd keygen` + `muxd start --quic 127.0.0.1:PORT` + `mux
191 quic://127.0.0.1:PORT` with no `--key` anywhere attaches over QUIC via
192 the defaulted key path (hermetic `XDG_CONFIG_HOME`). The port default
193 is pinned at parse level only: an e2e that binds 4433 would collide
194 with any real daemon on the machine running the suite.
195 - `--via "sh -c 'exit 127'"` → "transport command failed", not
196 "connection to muxd lost"; exit 1.
197 - `mux --version` / `muxd --version` → version string, exit 0.
198 - progress lines pinned: non-tty spawn path emits exactly the start and
199 outcome lines.
200
201 Mutations written first (M9 rule) for: the race test (break loser-exit
202 handling), the keygen-refusal test (allow overwrite), the port-default
203 test (default to 4434), the `--via` honesty test (restore the old
204 message). A test whose mutation doesn't fire doesn't count.
205
206 **Kill criterion:** on the LAN box, starting from no running daemon and
207 no key anywhere: the goal commands, typed exactly as the goal states
208 them, land in a live session on the first attach; `muxd start` run
209 again is a no-op saying so; a marker typed in the session survives
210 detach and full ssh logout; ten attach/detach cycles never start a
211 second daemon (counted by tracked pids only). Real box, not loopback.
212
213 ## Milestone close
214
215 Update roadmap.md (trial-friction tier: daemon lifecycle → this shipped;
216 attach auto-start and QUIC handoff banked with their sketches) and
217 decisions.md (kill-criterion evidence, plus anything found en route).
docs/superpowers/specs/2026-08-09-m11-e2e-hardening-design.md
Old New
@@ -1,156 +0,0 @@
1 # M11: e2e hardening — render-vs-dump convergence, scenario pin, soak, and a mutation campaign
2
3 **Goal:** the e2e suite stops asserting "a marker string appeared" and
4 starts asserting "the screen the client painted equals the screen the
5 daemon holds" — then proves, by deliberately breaking the code, what it
6 catches now that it could not catch before.
7
8 **Context:** M11, following M10. Pays down the debt banked twice
9 (decisions.md M7 and M9: "full render-vs-dump convergence harness") plus
10 two cheap structural riders, then validates the whole suite
11 adversarially. Two phases: build (harness, pin, soak), then campaign
12 (break the code, run e2e, record what survives). The campaign is the
13 milestone's verification, not an afterthought — its output is the
14 kill-criterion evidence.
15
16 ## Non-goals
17
18 - No ASAN/valgrind pass (banked, rides behind this milestone).
19 - No retroactive mutation sweep of unit tests (separate banked item;
20 this campaign targets the e2e layer).
21 - No parallel e2e execution; soak is serial repetition.
22 - No changes to what scenarios *do* — only to what they assert.
23
24 ## Phase 1 — build
25
26 ### 1. `test/render.zig` — the client-side grid renderer
27
28 Third test helper binary beside rawmode/delaypipe. Reads a captured
29 client stdout stream on stdin — the real escape stream: SGR, cursor
30 motion, synchronized-update wrappers, alternate-screen enter/exit —
31 feeds it through its own ghostty-vt engine, and prints the final grid
32 in exactly `muxd dump`'s text format.
33
34 **The restore-boundary rule:** the client's exit path emits
35 `ESC[?1049l` (leave alternate screen), which discards the very grid
36 under test. The helper snapshots the grid state at the moment of
37 alternate-screen *exit* and prints that snapshot at EOF. A stream that
38 never enters the alternate screen (client died before first frame)
39 renders the primary screen as-is. Multiple enter/exit cycles: the last
40 exit wins (matches what a human saw last).
41
42 Engine dimensions: taken from CLI args `--cols N --rows M` (default
43 80×24, the non-tty client default), because the helper cannot learn
44 them from a stream that assumes the terminal already has a size.
45
46 ### 2. `assert_converged` in e2e.sh
47
48 ```sh
49 assert_converged CLIENT_OUT SOCK NAME
50 ```
51
52 Renders `CLIENT_OUT` through the helper, takes `muxd dump --sock SOCK`,
53 diffs. On mismatch: FAIL with the unified diff (bounded by the output
54 guard pattern), both grids left in files for inspection. Normalization:
55 trailing whitespace per line is stripped on both sides before diffing —
56 a padded-vs-unpadded row end is a formatting difference between two
57 correct grids, not a divergence.
58
59 **Placement discipline:** convergence is a scenario's *last* act, after
60 quiesce (output settled) and after the client detached — never
61 mid-scenario, because the convergence client's own attach claims the
62 grid at its size under latest-wins. Scenarios whose whole point is a
63 size fight or a mid-stream state keep their existing assertions and add
64 convergence only at their settled end. Every scenario that ends with a
65 live daemon gets one; the target is every scenario, with any exception
66 named in a comment where it is declined.
67
68 **Prediction interaction:** the client paints predictions (SGR 4
69 underline) that reconcile before detach on a quiesced session; a
70 converged grid must contain zero unreconciled prediction cells. This is
71 free coverage of M9's overlay-never-becomes-state invariant.
72
73 ### 3. Scenario-count pin
74
75 An `ok MSG` helper prints `e2e OK: MSG` and increments a counter; the
76 suite's final line asserts the counter equals a **literal** (the
77 assert-the-literal rule — the count is a contract with the reader of
78 the suite's output). Adding or removing a scenario means updating the
79 literal; that friction is the feature. The existing eight scenario
80 lines convert to `ok`.
81
82 ### 4. `test/soak.sh` + `make soak`
83
84 Runs the full e2e N times (`SOAK_N`, default 10, ~20min at today's
85 ~114s/run). Per-run: pass/fail plus which FAIL line. Summary table:
86 scenario → failures/N, in the decisions.md convention (rate with run
87 attribution). Exit nonzero if any run failed. Serial; each run already
88 isolates by `$$`. The suite's per-run tmp hygiene (zero strays, zero
89 files) is asserted between runs, so a leak in run 3 cannot blame run 7.
90
91 ## Phase 2 — the mutation campaign
92
93 Break the code deliberately, one mutation at a time, in an isolated
94 worktree; run the full e2e per mutation; record caught/survived. Every
95 mutation is run against BOTH assertion sets where feasible — the suite
96 as it stood before Phase 1 (markers only) and after (markers +
97 convergence + pin) — because the catch-rate delta is the harness's
98 measured value, the same way M9 measured prediction against its own
99 controls.
100
101 **Mutation sites, minimum set** (the campaign may add more; it may not
102 skip these). Chosen where the product's correctness actually lives:
103
104 - **Paint path (client)**: drop the last row delta's paint; paint a
105 delta one row off; skip clearing a shrunk region; break SGR reset so
106 a style bleeds; omit one synchronized-update wrapper.
107 - **Replica sync (client)**: apply a delta but skip feeding the engine;
108 ignore a snapshot's cols/rows prefix; accept a delta for a stale
109 seq without requesting resync.
110 - **Delta production (daemon)**: emit a delta that skips one dirty row;
111 emit rows in the wrong order; mark a changed cell clean.
112 - **Seq/epoch**: reuse a seq number; answer a reconnect's known-seq
113 with a delta when a snapshot is owed; wrong epoch in a snapshot
114 prefix.
115 - **Prediction overlay**: leave a confirmed prediction's underline on;
116 feed a prediction into the replica engine (the M9 invariant).
117 - **Scrollback/clipping**: off-by-one in the clip bound; scroll
118 position not reset on resync.
119
120 **Recording rule:** each mutation gets one line in the campaign table:
121 site, mutation, old-suite verdict, new-suite verdict. A mutation both
122 suites miss is a FINDING, not a footnote — it becomes either (a) a new
123 assertion landed in this milestone, with the mutation re-run to prove
124 it now fires, or (b) a banked entry with a written reason why the gap
125 is accepted. No third bucket; "noted" without a decision is the
126 failure mode the campaign exists to prevent.
127
128 **Hygiene rules carried over:** mutations live only in the worktree,
129 never the main checkout; one mutation at a time; restore verified by
130 diff before the next; kill by tracked pid only (a mutated daemon that
131 strands itself is reaped by the pid its up-line printed, and a mutation
132 that breaks the up-line is reaped by the pid `ensureDaemon`'s caller
133 captured).
134
135 ## Kill criterion
136
137 Floor-form, two legs, both required:
138
139 1. **Convergence holds and is livable:** every scenario ends in
140 `assert_converged` (exceptions named in place), the pinned count is
141 exact, and `make soak` with `SOAK_N=10` is 10/10 green on this
142 machine. NOT EXERCISED below `SOAK_N=10`.
143 2. **The harness catches what markers cannot, measured:** the campaign
144 table is complete over the minimum set; at least the paint-path
145 mutations — which today's suite is structurally blind to (markers
146 grep the byte stream, so a wrong *paint* of a right *byte* passes)
147 — are caught by the new suite and demonstrably survived the old
148 one; and zero campaign mutations end the milestone unrecorded or
149 undecided.
150
151 ## Milestone close
152
153 decisions.md gets the campaign table in full (it is the milestone's
154 measurement, the way M9's latency table was). roadmap.md: this item
155 closes; ASAN/valgrind and the unit-layer mutation sweep stay banked,
156 each annotated with whatever the campaign learned about them.
docs/superpowers/specs/2026-08-10-m12-ptyclient-design.md
Old New
@@ -1,172 +0,0 @@
1 # M12 — ptyclient: a pty-driving e2e client fixture
2
3 **Status:** executed 2026-08-10 — verdicts and deviations in decisions.md M12.
4 **Queue:** this is M12; the trial-friction bundle (auto-start + `muxd stop` +
5 error audit) moves to M13.
6
7 ## Why
8
9 Every e2e client captures stdout to a file, so `isatty()` answers no and the
10 client never takes its tty-gated branches: raw mode, the alternate screen,
11 real dimensions, SIGWINCH, scroll mode (`src/client.zig:476`, `:502`, `:723`).
12 The M11 mutation campaign sized this debt precisely: two mutations (row 7,
13 snapshot resize prefix; row 18, scroll-view exit on resync) were not merely
14 uncaught but **ungradeable** — the mutated code never ran, so no assertion of
15 any kind could see it break. And the scroll-suppression fix (412f38f) carries
16 a unit-level contract test but is still owed its end-to-end pin, recorded in
17 decisions.md as riding this debt.
18
19 The fix is a fixture that gives the client a real pty while still producing
20 the capture files the convergence machinery already consumes.
21
22 ## What it is
23
24 `test/ptyclient.zig`: a fixture binary that opens a pty pair, runs the real
25 `mux` client on the slave side, and holds the master. The client's `isatty()`
26 answers yes; every frozen branch opens. Built by `build.zig` exactly like
27 `render_exe` and handed to `test/e2e.sh` as the next artifact argument (and to
28 the soak step).
29
30 Existing non-tty scenarios are **not migrated**. Their pins and timing margins
31 were earned in M11; the fixture adds scenarios, it does not churn green ones.
32
33 ## Fixture interface
34
35 ```
36 ptyclient --cols C --rows R --out FILE --err FILE -- <client argv…>
37 ```
38
39 Script on stdin, line-oriented, four verbs:
40
41 | verb | meaning |
42 |---|---|
43 | `send STRING` | Decode C-style escapes (`\x1b`, `\n`, …) and write to the master as **one write**. The client's scroll-key parser exact-matches a whole read (`client.zig:880-882` — `\x1b[5;2~` Shift+PageUp, `\x1b[6;2~` Shift+PageDown), so a split sequence is a different keystroke. |
44 | `expect STRING DEADLINE_MS` | Wait until STRING appears in the accumulated master stream, with expect(1)-style **consume-on-match** semantics: the search starts at a cursor that each match advances past itself, so bytes painted before an earlier verb can never satisfy a later expect (tp1's post-scroll expect depends on this). A substring split across two reads still matches. On timeout: exit nonzero and print the escaped tail of what actually arrived. |
45 | `resize COLS ROWS` | `TIOCSWINSZ` on the master; the kernel delivers SIGWINCH to the client. |
46 | `waitexit DEADLINE_MS` | Wait for the client to exit; propagate its status. |
47
48 Rules:
49
50 - **Synchronization is always expect-with-deadline, never sleep.** Deadlines
51 are computed numbers with the computation stated at the site (the M11 rule —
52 a timing margin is a number you compute, not a sentence you write — applied
53 at birth rather than retrofitted).
54 - Everything read from the master tees to `--out` continuously. The existing
55 convergence machinery — `assert_converged`, the `render` replay, the styled
56 byte-exact `cmp` — consumes the same capture files it does today, unchanged.
57 - Distinct exit codes for: expect timeout, client died early (status
58 propagated and said so), pty setup failure. Error messages follow the
59 standing rule: say what happened, name the way out, don't guess a cause a
60 lower layer already named.
61 - The fixture kills its child by tracked pid only, never by name.
62
63 ## Pty decisions
64
65 - **`src/pty.zig` grows a spawn-argv variant.** The existing `spawn` hardcodes
66 the user's shell; the fixture needs arbitrary argv. The daemon path is
67 unchanged. The child keeps the SIG_DFL resets for INT/QUIT/PIPE — e2e.sh
68 backgrounds the fixture with `&`, so the same SIG_IGN-survives-exec hazard
69 that motivated those resets applies to the fixture's child too.
70 - **Slave termios stays at pty defaults, ONLCR included.** The capture is what
71 a real terminal emulator would receive. The client's raw-mode setup touches
72 only input-side flags (`client.zig:506-510`); it never clears OPOST, so any
73 bare `\n` it writes gets translated to `\r\n` in the capture — exactly as it
74 would on a user's screen. If that translation breaks convergence, that is a
75 client bug surfaced, not harness noise, and it gets fixed in the client.
76 - **stderr rides a separate pipe to the `--err` file.** This diverges from a
77 real tty (where stderr shares the terminal) and the divergence is recorded
78 here deliberately: M11's stats-split convention (`.err` siblings parsed for
79 predict counters) depends on stderr staying out of the capture.
80 - The fixture sets `TERM=xterm-256color` in the client's environment, matching
81 what the daemon sets for session shells.
82
83 ## Scenarios — exactly the two debts
84
85 **tp1 — reconnect while scrolled** (the missing e2e pin for 412f38f, and row
86 18's branch):
87
88 1. Attach under the pty; run a session that emits enough output to build
89 scrollback; expect a marker.
90 2. `send \x1b[5;2~` (Shift+PageUp); expect scrollback content on screen.
91 3. Force a resync the same way existing tear scenarios do.
92 4. Expect the live view restored — the scroll-view-exit-on-resync branch, row
93 18's territory, now observed.
94 5. Type a keystroke in a predictable context; assert via predict stats in the
95 `.err` file that prediction resumed after the resync (`made ≥ 1` post-tear).
96 This is the end-to-end pin the scroll fix is owed: with 412f38f reverted,
97 prediction stays silently suppressed and this step fails.
98 6. `assert_converged` as the scenario's last act, per standing rule.
99
100 **tp2 — resize** (row 7's branch):
101
102 1. Spawn the client at 100×30 — the first non-80×24 client in the suite.
103 2. Attach; the snapshot must carry the resize prefix; `assert_converged` with
104 `render --cols 100 --rows 30`.
105 3. Mid-session `resize 90 28` → SIGWINCH → client sends `.resize` → daemon
106 answers with a snapshot (latest-wins); expect the repaint.
107 4. `assert_converged` again at 90×28.
108
109 Side effect, named because it is load-bearing: pty clients actually enter and
110 leave the alternate screen, so `test/render.zig`'s restore-boundary rule (feed
111 to the **last** `\x1b[?1049l`) — pinned only by unit tests since M11, because
112 non-tty clients never produced the sequence — is exercised end-to-end for the
113 first time. The doctored-stream control extends to a pty capture.
114
115 ## Kill criterion — two legs, M11 grammar
116
117 **Leg 1 — the suite holds.** Every new scenario ends in `assert_converged`;
118 the checkpoint and convergence-point pins are bumped and stay **literals**
119 (exact numbers set in the plan); `SOAK_N=10 make soak` is 10/10 on shipping
120 code with the pty scenarios in the rotation; no wait in the fixture or the
121 scenarios is a sleep.
122
123 **Leg 2 — the regrade.** Three resurrections run against the new suite, out of
124 the same binaries:
125
126 | resurrection | prior score |
127 |---|---|
128 | M11 campaign row 7 mutation (snapshot resize prefix) | ungradeable — code unreachable |
129 | M11 campaign row 18 mutation (scroll-view exit on resync) | ungradeable — code unreachable |
130 | revert of 412f38f (scroll-suppression fix) | no e2e pin existed |
131
132 All three must now be **caught**. Controls, because a check that cannot fail
133 proves nothing: a doctored pty capture must fail convergence, and an expect
134 that cannot match must time out and fail its scenario.
135
136 ## Testing the fixture itself
137
138 - `src/pty.zig` spawn-argv variant: unit tests — argv executes, exit status
139 propagates, winsize applied at spawn.
140 - Script engine: unit tests for escape decoding and for the
141 expect-substring-split-across-reads case.
142 - Every new assertion is written mutation-first, per standing rule.
143
144 ## Files
145
146 - Create: `test/ptyclient.zig`
147 - Modify: `src/pty.zig` (spawn-argv variant + tests), `build.zig` (ptyclient
148 exe, e2e + soak artifact args), `test/e2e.sh` (tp1, tp2, pins, controls)
149 - Docs at close: `docs/roadmap.md` (M12 verdict; friction bundle renumbered
150 M13), `docs/decisions.md` (M12 section)
151
152 ## Considered alternative: tmux as the fixture
153
154 Viable only via `pipe-pane -o` (raw capture); `capture-pane -e` re-emits
155 tmux's own emulator's rendering and kills the byte-exact styled leg outright.
156 Rejected even in the viable form: no expect primitive (the sync engine gets
157 written either way, in worse shell), key encoding coupled to tmux's key
158 tables where the client exact-matches whole reads, stderr merged into the
159 pane (breaks the `.err` stats convention), harness semantics varying with the
160 box's tmux version, and — decisive — an opaque third-party event loop inside
161 the measurement path of a suite whose flake hunts have all required owning
162 every layer. tmux stays welcome for ad-hoc manual repro; it does not become
163 load-bearing. Building ptyclient costs ~300–400 lines around `src/pty.zig`,
164 which already owns spawn/resize/read/write, and gives the product's own pty
165 module a second consumer.
166
167 ## Non-goals
168
169 - No migration of existing non-tty scenarios to the pty (their pins are
170 earned; churn risks a green suite).
171 - No resize matrix beyond tp2's two sizes — banked, unlocked by this fixture.
172 - No prediction-polish work, though the fixture is a prerequisite it sets up.
docs/superpowers/specs/2026-08-10-m13-trial-friction-design.md
Old New
@@ -1,283 +0,0 @@
1 # M13 — trial friction: attach auto-start, `muxd stop`, error audit
2
3 **Status:** executed 2026-08-10 — verdicts and deviations in decisions.md M13.
4 **Queue:** M13 = the full trial-friction bundle. The ssh→QUIC handoff and
5 first-backoff tuning stay banked.
6
7 ## Why
8
9 Three field findings from a week of real usage on a jump-host box, one
10 natural bundle:
11
12 - `mux user@host` against a box where no daemon runs fails with
13 `muxd proxy: cannot connect` — a correct message about the wrong thing.
14 The user asked for a session, not a daemon. The spawn machinery
15 (`spawn.ensureDaemon`, built by M10 explicitly so this stage would be a
16 call site, not a rewrite) is already shipping.
17 - There is no `muxd stop`. `muxd start --quic` against a running default
18 daemon says "stop it first" and offers no way to do that but a manual
19 pid hunt. Small alone; not small once auto-start makes socket-squatting
20 common.
21 - The `--via` failure hint `(is muxd installed on the host?)` guessed
22 wrong when ssh itself failed on a host-key rejection, and sent the
23 reading in the wrong direction. The standing rule — say what happened,
24 name the way out, don't guess a cause a lower layer already named —
25 gets applied across every user-facing failure path, as an audit with a
26 closed list.
27
28 ## Decisions locked at design review
29
30 - **No QUIC on auto-start, ever.** The spawned daemon is `muxd run`
31 bare, even when a default-path key exists. A key on disk means keygen
32 ran once, not that this box should open a UDP listener as a side
33 effect of an attach. Remote attach from cold works over ssh (the proxy
34 auto-starts the daemon); QUIC stays explicit (`muxd start --quic`)
35 until the banked handoff milestone makes it automatic *and asked for*.
36 Note the asymmetry is structural, not chosen: `mux quic://HOST`
37 against a stopped daemon has nothing listening to do the spawning —
38 only the ssh path can bootstrap.
39 - **No opt-out.** No `MUX_NO_AUTOSTART`, no flag. Auto-start is the
40 behavior; the suite tests pure-attach by pointing at a socket a daemon
41 already serves. A knob appears when a real script needs it.
42 - **Stop is a protocol verb, not a pidfile.** A pidfile adds a
43 stale-file lifecycle nobody else needs, and its only advantage —
44 killing a daemon too wedged to service frames — is illusory: a daemon
45 that wedged that hard can't be probed either, and `stop` reports that
46 honestly instead of pretending `kill -9` semantics.
47
48 ## Component 1 — attach auto-start
49
50 Two call sites, both in front of an existing unix-socket connect, both
51 calling `spawn.ensureDaemon` (probe / spawn-detached / poll, 2s
52 deadline, same as `muxd start`):
53
54 - **`muxd proxy`**: ensure before `proxy.run` — the call sits in
55 `main.zig`'s `.proxy` dispatch arm, not in `proxy.zig`, whose import
56 list is deliberately bare (the M6 byte-pump thesis). `exe_path` =
57 `/proc/self/exe`, resolved the way `startCmd` already does. This is
58 the field fix: `mux user@host` now works on a cold box, the daemon
59 spawned by the proxy the ssh session runs.
60 - **Local `mux`** (`src/mux_main.zig`, the `--sock`/default branch,
61 before `client.attach`): ensure before attaching. `mux` and `muxd`
62 are separate binaries, so the exe path comes from a new PATH-search
63 helper in `src/spawn.zig` (walk `$PATH`, return the first
64 `access(X_OK)` hit for `muxd`). No `muxd` on PATH is not itself fatal
65 — a live daemon needs no binary to start it — so that case probes the
66 socket and fails only when both are absent:
67 `mux: no daemon on <sock> and no muxd in PATH to start one`. The call
68 site lives in `mux_main`, not `client.zig` — `client.attach` stays
69 transport-pure.
70
71 Rules, from the banked M10 sketch, all kept:
72
73 - **Unix-socket daemons only.** No auto-start for `quic://` (structural,
74 above) and none for `--via` on the client side — the remote proxy is
75 the auto-starter there, and a local spawn would be a daemon on the
76 wrong machine.
77 - **`muxd dump` and `muxd stats` keep fail-fast.** Observability
78 commands must not create the thing they claim to observe.
79 - **Silence stays meaningful.** Probe answers → connect, print nothing.
80 Spawn path prints the existing `Progress` output (`mux: starting…` /
81 `muxd proxy: starting…` — caller-chosen prefix, dots animate on a
82 tty), and the up-line on success. Any output at all means something
83 unusual happened.
84 - **The spawned daemon gets `--sock <path>`** — the exact path the
85 caller was about to connect to, so a non-default `--sock` attach
86 auto-starts on that socket, not the default one.
87 - Failure messages come from the typed errors, `mux:`-prefixed at the
88 `mux` call site and `muxd proxy:`-prefixed at the proxy one:
89 `BinaryNotFound` → the PATH message above (proxy: cannot happen for
90 `/proc/self/exe`; its guard message names the exe); `SpawnFailed` →
91 `could not spawn <exe>: <err>`; `NeverAnswered` → the existing shape
92 that names the daemon log path.
93 - The two-racers case needs no new code and gets none: the losing
94 daemon exits on `DaemonAlreadyRunning` (`server.zig` claimSockPath)
95 and the loser's poll connects to the winner — already pinned by a
96 spawn test.
97
98 **Amendments (Task 4 review):**
99
100 - **Auto-start spawns must not truncate the shared log.** ensureDaemon's
101 null-log default truncates the per-user `muxd.log` on every spawn;
102 with auto-start, one ssh attach (no `XDG_RUNTIME_DIR` → `/tmp`
103 socket) can truncate a live interactive daemon's log mid-write — a
104 NUL hole, the same pathology decisions.md records from the test side.
105 Decision: auto-start opens the log **append-only**; truncation is
106 reserved for `muxd start`, the one caller whose user explicitly asked
107 for a (re)start — which also keeps the existing e2e truncation pin
108 exactly where it is. Per-socket log files (`logPathFor(sock)`) are
109 banked as a candidate, not taken now. Named cost, accepted: a user
110 who only ever runs `mux` (M13's whole point) now has a log that
111 grows monotonically across daemon lifetimes, with no rotation and no
112 verb that resets it — a growing log beats a NUL-holed one, and
113 rotation belongs to the banked `logPathFor` work, not to auto-start.
114 - **The spawn-block trio collapses into a helper.** The two attach call
115 sites are sixteen identical lines modulo two strings, and the 2s
116 deadline was written three times unnamed across two binaries — the
117 cross-binary-drift defect class decisions.md has recorded three times
118 already. `spawn.ensureForAttach(alloc, exe, sock_path, prefix)` owns
119 the shape and a single `pub const start_deadline_ms`; `muxd start`
120 stays spelled out (it forwards user flags and reports already-running
121 where attach is silent — only one of the two was asked for).
122
123 ## Component 2 — `muxd stop`
124
125 New protocol frame `stop_req = 0x07` (client→daemon, empty payload) in
126 `src/protocol.zig`, next value in the request space after
127 `stats_req = 0x06`. Daemon handler: set the existing `shutdown_flag` —
128 the exact path SIGTERM/SIGINT already take — so shutdown is the
129 already-tested one: poll loop exits (returns 130), `deinit` closes
130 client slots, unlinks the socket, tears down the pty (the session
131 shell gets SIGHUP from its tty). No new shutdown code.
132
133 The arm goes in **both** dispatches — `serviceObserver` (~server.zig:
134 1188) and `handleFrame` (~1091) — because a connection starts life as
135 an *observer* and is only promoted on `.attach`; `muxd stop` never
136 attaches, so its frame lands in the observer switch, the same place
137 `debug_dump` and `stats_req` already answer bare connections. (The
138 survey's earlier citation, server.zig:1517, is a test helper's switch,
139 not the daemon's.)
140
141 `muxd stop [--sock PATH]` in `src/main.zig`, alongside `dump`/`stats`:
142
143 1. Connect. Nothing answers →
144 `muxd stop: nothing listening on <sock>` and **exit 0** —
145 idempotent like `muxd start`, safe in scripts and in
146 `ssh HOST 'muxd stop'`. A stale socket file is left for the next
147 start's claimSockPath, which already handles it.
148 2. Send `stop_req`. Poll every 50ms until `connectUnixSocket` fails
149 (the unlink is what makes connect fail; a live listener's backlog
150 accepts regardless of the event loop, so "connect refused or file
151 gone" is the true signal, and only shutdown produces it). Deadline
152 2s, mirroring start's poll.
153 3. Confirmed → `muxd: stopped` (mirrors `muxd: up …` from start),
154 exit 0. Deadline passed →
155 `muxd stop: <sock> still answering after 2s (if it was started
156 detached, its log is <log>)`, exit 1 — conditional on purpose: a
157 foreground `muxd run` logs to its own stderr, and naming the xdg
158 path unconditionally would guess.
159
160 Old-daemon safety, re-verified: `MsgType` is a **non-exhaustive** enum
161 (`_,` at protocol.zig:31), so an unknown wire byte is a well-defined
162 value that lands in both dispatches' `else => {}` arms — not merely
163 unhandled but safe at the `@enumFromInt`. A new `muxd stop` against an
164 old daemon times out into the honest exit-1 message rather than
165 misbehaving.
166
167 Stopping a daemon kills its session — that is the point of the verb,
168 not a side effect to warn about. `muxd stop` does not ask.
169
170 ## Component 3 — error-message audit
171
172 Every user-facing failure message in both binaries, graded against the
173 rule. The closed list, with verdicts — **keep** means the message
174 already says what happened, names the way out, and guesses nothing:
175
176 | site | message (abbrev.) | verdict |
177 |---|---|---|
178 | client.zig:67 `lostMsg` | `transport command failed before connecting (is muxd installed on the host?)` | **Reword** → `mux: transport command failed before a session started`. The parenthetical guessed; ssh's stderr passes through and already named the real cause (host key, DNS, refused). The lower layer spoke — don't talk over it. |
179 | client.zig:438 | `mux: cannot connect to <sock> (is muxd running?)` | **Reword** → `mux: cannot connect to <sock>`. Post-auto-start this line is reachable only when a daemon answered the probe (or was just spawned) and then vanished before the connect — "is muxd running?" is now a guess that was checked moments ago. Auto-start's own failures print their typed messages instead. |
180 | proxy.zig:20 | `muxd proxy: cannot connect to <sock>` | **Keep** — survey correction: it already carries no guess (no parenthetical to drop). Post-auto-start it is reachable only in the probe-then-vanished window, which the plain wording states correctly. |
181 | main.zig:384 dump | `cannot connect to <sock> (is \`muxd run\` running?)` | **Reword** → `muxd dump: cannot connect to <sock> (no daemon; \`muxd start\` starts one)`. Keeps fail-fast, gains the way out, drops the rhetorical question. |
182 | main.zig:402 stats | same shape | **Reword** identically, `muxd stats:` prefix. |
183 | main.zig:446 start already-running | `already running on <sock> (stop it first if you meant different flags)` | **Reword** → `(\`muxd stop\` it first if you meant different flags)`. The way out now has a name; use it. |
184 | main.zig:358 | `<sock> exists and is not a socket` | **Reword** → `muxd: <sock> exists and is not a socket (move it, or name another with --sock)`. Way out was missing. |
185 | mux_main usage / conflict (`name one transport…`) | | keep |
186 | mux/muxd `no key:` triad (`pass --key, set MUX_KEY_FILE, or run \`muxd keygen\` (default <path>)`) | | keep — the audit's exemplar |
187 | key refusals (missing / permissive `chmod 600 it` / malformed `want 32 raw bytes or 64 hex`) both binaries | | keep |
188 | QUIC attach errors (client.zig:400-434: resolve, handshake `wrong key, or no muxd --quic there`, abort) | | keep — M10 wrote these to the rule |
189 | `mux: cannot start --via command: <cmd>` | | keep — the OS-level spawn failed; ssh never ran, nothing lower spoke |
190 | muxd parse errors (`needs a value`, `needs a positive number`, `--key without --quic…`) | | keep |
191 | `--quic wants HOST:PORT with a literal address` | | keep |
192 | udp `already listening` / `cannot listen` | | keep |
193 | `a daemon is already running on <sock>` (run) | | keep — refusal is the behavior; start is the retry loop |
194 | `cannot find own binary via /proc/self/exe`, `could not spawn <exe>: <err>`, NeverAnswered-names-the-log | | keep — auto-start reuses these shapes |
195 | keygen refusal (`rotation is \`rm\` + \`keygen\`, deliberately`) | | keep |
196
197 Anything the implementation discovers outside this table is a spec
198 amendment, recorded — not silently reworded.
199
200 **Amendment (found during Task 3 review):** a socket path longer than
201 `sun_path`'s 108 bytes surfaces as `daemon did not answer within 2s` —
202 a timeout story — while the log holds a raw stack trace from
203 `claimSockPath`'s `else => return err` arm (main.zig:365). The audit
204 gains a row: bind/claim failures on a too-long path get a named
205 refusal, `muxd: socket path too long (<len> bytes, max 107): <path>`,
206 checked before the spawn/bind so `start` refuses immediately instead of
207 polling a daemon that can never answer.
208
209 *Amendment to the amendment (Task 5 review):* the client-side carve-out
210 was written for a world without auto-start and went stale the moment
211 Task 4 landed: `mux --sock <too-long>` never reaches
212 `connectUnixSocket` — it finds `muxd` on PATH, spawns a child that
213 refuses instantly, and polls the full 2s into `daemon did not answer`,
214 the exact timeout story the guard exists to kill. The same check is
215 therefore added in `mux_main.zig` before `findInPath`, `mux:`-prefixed,
216 same 107 boundary. Two further audit-quality refinements from the same
217 review: the dump/stats parenthetical `(no daemon; …)` was itself a
218 guess — a stale socket file or EACCES also refuse connects — and
219 becomes `nothing listening on <sock>` (`stopCmd`'s already-shipped
220 phrasing) with the way out kept; and `start`'s already-running hint
221 becomes fully actionable under `--sock`:
222 `(stop it first with \`muxd stop --sock <sock>\` if you meant
223 different flags)` — the bare `muxd stop` it named targets the default
224 path, not the one the message just printed.
225
226 ## Testing and kill criterion — two legs, M11 grammar
227
228 **Leg 1 — the suite holds.**
229
230 - Unit: PATH-search helper (finds, misses, skips non-executable);
231 `stop_req` sets the shutdown flag and the poll loop returns
232 (mutation-first: delete the dispatch arm, the test must fail);
233 protocol round-trip for the new frame type.
234 - e2e, new scenario (auto-start + stop, one arc): a socket path with
235 **no daemon** → attach through `muxd proxy` (the `--via` shape the
236 suite already uses) → the proxy auto-starts the daemon → converge →
237 detach → warm re-attach prints **no** spawn progress (silence pin) →
238 `muxd stop --sock` that path → assert exit 0 and the socket gone.
239 Controls: `muxd stop` against the now-empty path exits 0 with
240 `nothing listening`. The `lostMsg` reword needs no new leg — the
241 dead-`--via` scenarios at e2e.sh:458 and :483 already grep the old
242 wording behaviorally; the audit updates those greps, and adds the
243 can-fail control: the OLD wording must be *absent* from the same
244 capture the new-wording grep just passed on.
245 `SHELL=/bin/sh` is pinned on every auto-starting invocation — the
246 spawned daemon gets no `--shell` flag and resolves `$SHELL`
247 (main.zig:335), and the suite must not inherit the developer's.
248 - Local-`mux` auto-start leg: under the M12 pty fixture (a local `mux`
249 needs a tty), cold socket → `mux --sock` → converge → `muxd stop`.
250 - Suite pins bumped as literals; `SOAK_N=10 make soak` 10/10; no new
251 wait is a sleep (every wait is expect-with-deadline or the
252 fixture's settle).
253
254 **Leg 2 — the regrade.** Resurrections, each of which must be caught:
255
256 | resurrection | predicted catch |
257 |---|---|
258 | revert the proxy auto-start call site | cold-attach scenario fails at its first expect with today's `cannot connect` |
259 | revert the `stop_req` dispatch arm | stop leg times out: exit 1 and the `still answering` message where exit 0 was pinned |
260 | revert the `lostMsg` reword | the updated greps at the existing dead-`--via` legs fail on the old wording |
261
262 Baseline green before any resurrection, per standing rule.
263
264 ## Files
265
266 - Modify: `src/spawn.zig` (PATH-search helper + tests),
267 `src/proxy.zig` (ensure call), `src/mux_main.zig` (ensure call),
268 `src/protocol.zig` (`stop_req`), `src/server.zig` (dispatch arm),
269 `src/main.zig` (`stop` command; dump/stats/start/claim rewords),
270 `src/client.zig` (`lostMsg` + connect-message rewords),
271 `test/e2e.sh` (scenario + controls + pins), `build.zig` only if the
272 spawn module isn't yet importable from `mux`.
273 - Docs at close: `docs/roadmap.md` (M13 verdict; candidates list
274 re-cut), `docs/decisions.md` (M13 section).
275
276 ## Non-goals
277
278 - No QUIC listener on auto-start; no opt-out knob; no pidfile; no
279 `muxd restart`; no change to dump/stats fail-fast semantics.
280 - No `stop --force`/pid-hunting: a wedged daemon gets an honest exit 1
281 naming the log, not an escalation ladder.
282 - ssh→QUIC handoff, first-backoff tuning, unit-layer mutation sweep:
283 banked, unchanged.
docs/superpowers/specs/2026-08-11-m14-ssh-quic-handoff-design.md
Old New
@@ -1,245 +0,0 @@
1 # M14: ssh→QUIC handoff — design
2
3 Status: approved design, plan not yet written.
4
5 ## Why
6
7 `mux user@host` today rides ssh for the whole session: TCP head-of-line
8 blocking on a lossy link, plus ssh's own framing, for every byte of a
9 long-lived interactive session. The QUIC transport has existed since M8
10 and its ergonomics since M10, but using it remotely is ceremony: keygen
11 on the host, start the daemon with `--quic`, copy the key, know the
12 port. The handoff makes `mux HOST` the fast path with zero ceremony —
13 the mosh shape, banked in the M10 spec's context section and deferred
14 until auto-start existed.
15
16 M13 paid most of the cost in advance: `spawn.ensureForAttach` gets its
17 third caller, and the no-QUIC-on-auto-start rule is not violated —
18 the spawned daemon is still bare; the listener appears only because
19 `muxd endpoint` *asks* for one.
20
21 ## Locked decisions
22
23 - **Always-on for bare HOST.** No new knob, consistent with M13's
24 no-opt-out stance. The escape hatch already ships:
25 `--via 'ssh HOST muxd proxy'` is the manual pure-ssh spelling.
26 `--sock`, `--via`, and `quic://` are untouched.
27 - **endpoint subsumes proxy.** `muxd endpoint` is `muxd proxy` with a
28 one-line announce first. This is what makes "one ssh, one deadline,
29 never two" literally true: the same ssh that fetched the coordinates
30 is already a working byte pump if QUIC loses.
31 - **ssh closes once QUIC attaches.** The session runs pure QUIC.
32 Mid-session QUIC death goes through M7's existing reconnect loop,
33 which re-runs the whole handoff from the cache-hit step. No hot
34 standby, no mid-session transport swap.
35 - **Lazy QUIC bind is a protocol verb** (`endpoint_req`), not a daemon
36 restart with different flags. The port is only knowable by asking
37 the daemon — the same reasoning that made `stop` a protocol verb
38 rather than a pidfile.
39 - **Fallback prints exactly one stderr line.** The QUIC-success path
40 stays silent (warm-path attach silence is contractual, e2e-pinned
41 since M13).
42 - **Key model unchanged from the M10 sketch.** The key belongs to the
43 host-user, on disk, outliving daemons. Rotation = delete the key,
44 restart the daemon; every client re-fetches over its own
45 authenticated ssh. A stale cache costs time, never correctness:
46 PSK auth is mutual, so a wrong key attaches to nothing, and every
47 cached-path failure falls through to ssh, which is authoritative.
48
49 ## Component 1: `muxd endpoint [--sock PATH]`
50
51 Runs on the remote box, over ssh, in the client's `--via` plumbing.
52 In order:
53
54 1. **Ensure daemon** via `spawn.ensureForAttach` (bare spawn, exactly
55 as `proxy` does, progress prefix `muxd endpoint: `).
56 2. **Ensure key.** Resolve the default key path (`xdg.keyPath`); if no
57 file exists, create one (the `keygen` write path — the mosh-server
58 move). An existing permissive key file is refused here as
59 everywhere; that refusal skips the announce rather than killing the
60 pump (case 4 below).
61 3. **Ask for the port.** Connect as an observer, send `endpoint_req`,
62 read `endpoint_reply` under a bounded wait (reuse
63 `spawn.start_deadline_ms`; a daemon that never replies is treated
64 as port 0).
65 4. **Announce, then pump.** Exactly one announce line is always
66 printed as the *first bytes on stdout*, before any frame traffic:
67 `endpoint <port> <hex-key>` + newline on success, `endpoint none` +
68 newline when coordinates could not be produced (no key, bind
69 failure, no reply). Then fall through into the exact `proxy` pump
70 either way.
71
72 *Amended during planning:* the original design had the failure case
73 skip the announce entirely, with the client detecting "ordinary
74 proxy traffic first". That cannot work: the daemon side of this
75 protocol sends nothing unprompted — frames only flow after the
76 client's first write — so a client waiting for an announce that
77 isn't coming is indistinguishable from one waiting on a slow ssh,
78 and any timeout either races a cold remote spawn or taxes every
79 announce-less attach. A mandatory one-line announce (`none` as the
80 explicit negative) makes the read structural: the client blocks on
81 one newline-terminated line, bounded by the same trust `--via`
82 already extends to ssh itself. On `endpoint none` the client stays
83 on ssh with no deadline paid and no fallback line — but `muxd
84 endpoint` itself may say why on stderr, which ssh already carries
85 to the user's terminal (one line, e.g. `muxd endpoint: no usable
86 key; staying on ssh`); the daemon-side reasons stay in the daemon
87 log.
88
89 The announced key is the file the endpoint *process* resolves. A
90 daemon started with a different explicit `--key` will mismatch; the
91 client's QUIC attempt fails and ssh carries the session — the
92 stale-cache invariant already covers this. Recorded, not defended.
93
94 ## Component 2: `endpoint_req = 0x08` / `endpoint_reply = 0x89`
95
96 - `endpoint_req` (payload: empty) arms in **both** dispatches —
97 `serviceObserver` and `handleFrame` — like `stop_req`, and for the
98 same reason: `muxd endpoint` never attaches, so the observer path is
99 the load-bearing one.
100 - Daemon behavior: if `quic_listener` is already set, reply with its
101 bound port. Otherwise resolve a key exactly as the `run --quic` path
102 does (`MUX_KEY_FILE`, then the default path — the daemon holds no
103 `--key` flag when it was auto-started bare), bind `0.0.0.0:0`
104 (kernel-assigned ephemeral port), construct the listener, set the
105 field. The poll loop already treats `quic_listener` as optional and
106 re-checks it every iteration (`server.zig` fd setup), so lazy bind
107 is structurally "set the field" — no loop surgery.
108 - `endpoint_reply` payload: u16 LE port. **0 means "could not"** (no
109 key resolvable, bind failure); the reason goes to the daemon log,
110 never into the frame. A second `endpoint_req` against a daemon that
111 already bound replies with the same port.
112 - A daemon started with explicit `--quic` replies with its existing
113 configured port — endpoint reports, it never rebinds.
114 - Old-binary safety is the same story as `stop_req`: `MsgType` is
115 non-exhaustive, and the endpoint CLI's bounded reply-wait converts
116 an old daemon's silence into the announce-less path.
117
118 ## Component 3: client handoff (the bare-HOST arm)
119
120 New file `src/handoff.zig` owns the pieces (cache I/O, announce
121 parse, host-to-dial-address strip); `mux_main.zig`'s `.host` arm
122 orchestrates. `--sock`, `--via`, and `quic://` arms are untouched.
123
124 - **Cache:** `$XDG_CACHE_HOME/mux/hosts/<HOST>` (default
125 `~/.cache/mux/hosts/<HOST>`), file mode 0600, directories 0700 —
126 the file holds the key. HOST is used verbatim as the filename; a
127 HOST containing a path separator is never cached (the attach still
128 works, just always cold). Contents: the exact announce line,
129 `endpoint <port> <hex-key>` + newline. *(Amended during Task 3: the
130 original text said the fields without the `endpoint ` prefix — a
131 second grammar one parser apart from the first. One line, one
132 grammar, one parser; the cache is written and read only through
133 handoff.writeCache/readCache.)*
134 - **Dial address:** HOST with any `user@` prefix stripped, port from
135 cache or announce. ssh aliases and ProxyJump hosts simply fail to
136 resolve or connect and fall through — that is the designed behavior,
137 not an error; `--via` remains the spelling for those setups if the
138 per-attach deadline annoys.
139 - **Warm path (cache hit):** dial QUIC directly under
140 `handoff_deadline_ms`. Success → session, **no ssh process at
141 all**. Resolve failure → instant fallthrough to the cold path (DNS
142 refusal is fast; no deadline burned). Connect failure/timeout →
143 cold path.
144 - **Cold path:** spawn `ssh HOST muxd endpoint` through the existing
145 via plumbing and block on the mandatory first announce line.
146 Coordinates → write the cache, try QUIC under the deadline; success
147 → kill the ssh child, session on QUIC. `endpoint none` → session on
148 the open pipe, silent, no deadline. Coordinates but the QUIC
149 attempt fails →
150
151 ```
152 mux: quic://DIALHOST:PORT unreachable, attaching over ssh
153 ```
154
155 on stderr (one line, only on actual fallback-with-coordinates; the
156 announce-less case says nothing — the daemon log has the reason and
157 the client learned nothing it could report honestly), and the
158 session continues over the pipe that is already open.
159 - **Deadline:** `handoff_deadline_ms = 2000`, one shared constant,
160 explicitly provisional until Component 4 reports.
161
162 ## Component 4: measure before the deadline is trusted
163
164 Banked with the sketch and kept as an explicit early task: on the LAN
165 box, observe what a **wrong PSK** actually does to the client (fast
166 handshake refusal vs. silent idle-timeout) and what a **blackholed UDP
167 port** does (ICMP refusal vs. silence). Record both in decisions.md
168 and pin `handoff_deadline_ms` from the evidence. If wrong-key hangs to
169 idle-timeout, the deadline is what bounds it; the number must be
170 chosen knowing that.
171
172 ## Testing
173
174 Unit:
175
176 - Announce line: format → parse round-trip, `endpoint none` included;
177 parse rejects junk (no port, odd-length hex, oversized key).
178 - Cache: write → read round-trip; 0600/0700 modes observed; a
179 path-separator HOST refuses to cache; a permissive existing cache
180 file is refused on read.
181 - `user@host` → `host` strip (and `host` → `host` unchanged).
182 - `endpoint_req`/`endpoint_reply` framing round-trips through the
183 protocol tests like every other verb.
184 - Server: `endpoint_req` against a daemon with no listener binds
185 lazily and replies a nonzero port; a second request replies the
186 *same* port; a daemon with no resolvable key replies 0 and keeps
187 running; an explicit-`--quic` daemon replies its configured port.
188
189 E2E (no real ssh — a fake `ssh` shim on PATH, a script that discards
190 the HOST argument and execs the rest locally, so `ssh fakehost muxd
191 endpoint` runs the real binary against a local socket):
192
193 - **Cold handoff:** no daemon, no cache → attach converges, the cache
194 file exists afterward with the announced port, and the shim's pid is
195 observed dead *while the session keeps converging* — bytes can only
196 be flowing over QUIC (observation, not inference: the ssh pipe no
197 longer exists).
198 - **Warm handoff:** cache present → attach converges with **no ssh
199 shim process observed** (ps, per the observation rule; the plan pins
200 the exact match so unrelated ssh sessions can't false-positive).
201 - **Stale-cache self-heal:** cache poisoned with an unbound port → the
202 cold path refreshes it, QUIC succeeds on the fresh coordinates, **no
203 fallback line** (a stale cache costs time, never correctness — this
204 is the invariant's pin), and the cache file afterward holds the real
205 port.
206 - **Fallback line:** daemon started with an explicit `--key` different
207 from the default-path key → endpoint announces the default key, the
208 QUIC attempt fails under the deadline, the fallback stderr line
209 appears, and the session works over the pipe. (This is also the
210 key-mismatch case from Component 1, pinned end to end.)
211 - **Announce-less daemon:** endpoint against a daemon that cannot
212 produce coordinates → silent pure-ssh session, no fallback line, no
213 deadline paid (wall-clock bounded well under `handoff_deadline_ms`).
214
215 Kill criterion, on the real LAN box (`ubuntu@192.168.0.109`): cold
216 `mux` attach lands on QUIC; a second attach spawns no ssh (observed by
217 ps, not inferred); with inbound UDP dropped, attach still succeeds
218 over ssh within one deadline and prints the fallback line. All three
219 with zero manual steps beyond having `muxd` on the remote PATH.
220
221 ## Files
222
223 - `src/protocol.zig` — two `MsgType` values + round-trip tests.
224 - `src/server.zig` — `endpoint_req` in both dispatches; lazy bind;
225 unit tests.
226 - `src/main.zig` — `endpoint` subcommand: usage, parse, `endpointCmd`
227 (announce + reuse of the proxy pump).
228 - `src/handoff.zig` — new: cache I/O, announce parse/format, dial-host
229 strip, `handoff_deadline_ms`.
230 - `src/mux_main.zig` — `.host` arm becomes the handoff orchestration.
231 - `src/xdg.zig` — cache path helper beside `keyPath`/`logPath`.
232 - `test/e2e.sh` — ssh shim + five scenarios.
233 - `docs/roadmap.md`, `docs/decisions.md` — M14 close-out.
234
235 ## Non-goals
236
237 - No per-client keys or revocation (parked with certs/TOFU since M10).
238 - No negative caching of "UDP blocked" — network state changes;
239 correctness over convenience.
240 - No handoff for the `quic://` spelling — it stays direct-dial with
241 fail-fast semantics.
242 - No version-skew shim — an old remote `muxd` fails the via handshake
243 legibly, and that is the story; binaries are lockstep.
244 - No config file, no per-host settings, no port pinning on the lazy
245 bind (explicit `--quic` remains the way to choose a port).
docs/superpowers/specs/2026-08-11-web-client-design.md
Old New
@@ -1,307 +0,0 @@
1 # Web client: a wall of devices in a browser — design
2
3 Status: approved design, plan not yet written. Sequencing resolved
4 (2026-08-13): M14 and M15 have both landed since this was written.
5 The hub inherits QUIC-first for free — dialing through the client's
6 `Transport` now means the ssh→QUIC handoff comes with it. M15 also
7 moved the seams this spec builds on: the client's paint path lives in
8 `paint.zig`, the daemon's delta tracking in `delta.zig`, and
9 `Transport` is a typed `Target`/`Link` union — so the `replica.zig`
10 factor-out (replay + grid readout, still in `client.zig`) is smaller
11 than the Files section assumes. Everything else stands as approved.
12
13 ## Why
14
15 One page, tiles of live terminals — every device running mux, visible
16 at a glance, and any tile a real client when clicked: attach, type,
17 resize, scrollback. Chosen over a native client deliberately: the
18 expensive subsystems of a native terminal are font rasterization and
19 the platform shell (Wayland protocol, surfaces, damage), and the
20 browser provides both for free. What's left to build is exactly the
21 part this project already owns — the replica — plus a transport
22 bridge and input encoding.
23
24 The second purpose is explicit: this milestone builds the *organs* of
25 the eventual native Wayland client. The replica core, keymap, replay
26 logic, scale-to-fit policy, and focus model are all platform-neutral
27 and land here, tested; a native client later is a new shell around
28 known-good parts, not a rewrite.
29
30 The architecture already did the hard thinking. The daemon is
31 authoritative and clients are replicas — a browser tab is just another
32 replica. Snapshots and deltas are styled-VT byte streams any
33 interpreter can replay; multi-client broadcast (8 clients, per-client
34 write queues) shipped in M5/M6. **Devices need zero changes.**
35
36 ## Locked decisions
37
38 - **Full client, not a viewer.** The wall renders every device live;
39 a zoomed tile takes keyboard input, resize, and scrollback. The
40 browser is a real mux client with all that implies.
41 - **One hub on the desktop** (`muxweb`, a third binary). It serves
42 the page and one WebSocket per tile, and dials each device the way
43 the CLI client does — unix socket, `ssh HOST muxd proxy`, QUIC —
44 reusing the client's `Transport`. To each muxd the hub is one of
45 the 8 broadcast clients. Rejected: per-device web listeners (N auth
46 stories, N exposed surfaces, bigger daemon) and WebTransport
47 direct-to-QUIC (browsers demand HTTP/3 + real certificates; the
48 PSK model doesn't fit).
49 - **Same engine both ends: ghostty-vt compiled to wasm.** The browser
50 replica is byte-for-byte the same interpreter as the daemon — the
51 replica model stays literal, and the own-the-engine-path rule
52 extends to the web. Rejected: xterm.js — a second, foreign VT
53 implementation whose divergence from ghostty would surface as
54 replica drift unfixable from our side. A feasibility spike gates
55 this (see The gate).
56 - **The keymap lives in the portable core, in Zig.** Needed for
57 native Wayland anyway; built once. Interface: normalized key event
58 (key id + modifiers) → VT bytes. It never sees a browser
59 `KeyboardEvent` or an xkb keysym — each shell produces the
60 normalized form. Tables are unit-tested natively, no browser in
61 the loop.
62 - **Wall tiles are passive; only the zoomed tile may resize.** A
63 session has one authoritative grid size shared by all clients; a
64 small tile sending `resize` would shrink the session under the
65 real terminal's feet. Wall tiles render the device's true grid
66 scaled-to-fit on canvas. Click to zoom; the zoomed tile is where
67 keys go, and it sends `resize` only if its computed cols×rows
68 actually differ from the session's.
69 - **Frames ride the WebSocket verbatim.** The hub never parses what
70 it pumps (the proxy thesis). One envelope byte distinguishes mux
71 frames from hub control messages.
72 - **Localhost only.** The hub binds `127.0.0.1`; remote viewing is
73 `ssh -L` — authenticated by ssh like everything else in this
74 project. No auth code to get wrong in v1. Token auth / LAN
75 exposure can layer on later without rework.
76
77 ## Component 1: `muxweb` — the hub
78
79 `muxweb TARGET [TARGET ...] [--port N]` where each TARGET is a tile,
80 in the transport spellings mux already parses: bare `HOST`,
81 `--sock PATH`, or `quic://HOST[:PORT]` (with `--key` as in mux). The
82 TARGET string is the tile's label. No config file — the device list
83 is launch arguments, consistent with the standing non-goal.
84
85 - Serves the static page, JS glue, and wasm blob from memory via
86 `@embedFile` — a single self-contained binary, no assets directory.
87 - Binds `127.0.0.1` on `--port` (default 7681). Never any other
88 interface; there is no flag to change that in v1.
89 - One WebSocket endpoint per tile (`/ws/<tile-index>`). On upgrade,
90 the hub dials the tile's transport and becomes a byte pump between
91 WebSocket messages and protocol frames. The *browser* sends the
92 attach frame (it knows the tile's cols×rows); the hub pumps it like
93 any other frame.
94 - **The hub owns reconnection; the browser owns re-attach.** On
95 transport death the hub re-dials with the existing M7 backoff and
96 narrates via control messages; the browser's WebSocket to the hub
97 stays up across device-side tears. But resume state —
98 `have_seq`/`have_epoch` — lives in the browser's replica, so on
99 the `up` that follows a `reconnecting`, the *core* re-sends attach
100 with what it has, and M7's snapshot-vs-delta resolution does the
101 rest. The hub stays a dumb pump even across tears.
102 - **WebSocket Origin check, non-negotiable.** Any webpage open in
103 the browser can attempt `ws://127.0.0.1:PORT` — localhost binding
104 does not stop cross-origin WebSocket dials, and this socket carries
105 shell input to every device. The hub refuses any upgrade whose
106 `Origin` is not exactly its own `http://127.0.0.1:PORT` (and
107 `http://localhost:PORT`). Pinned by test.
108 - HTTP and WebSocket are hand-rolled on `std.http` primitives + the
109 RFC 6455 server side (SHA-1 accept key from `std.crypto`, no
110 masking required server→client). Small, and consistent with the
111 hand-rolled wire ethos; a dependency is not warranted for one
112 upgrade handshake and length-prefixed frames.
113
114 Client cap note, recorded: every open tab's tile is a distinct client
115 connection — two tabs showing the same device consume two of its 8
116 broadcast slots. Accepted for v1; the daemon already refuses the 9th
117 politely.
118
119 ## Component 2: the wire
120
121 WebSocket binary messages, one envelope byte:
122
123 - `0x00` + mux protocol frame, verbatim, both directions.
124 - `0x01` + UTF-8 JSON control message, hub→browser only:
125 `{"state": "connecting" | "up" | "reconnecting" | "gone"}` — what
126 the tile paints in its status chrome. Raw frames can't say "the
127 ssh died"; this is the entire vocabulary that can.
128
129 That is the whole hub↔browser protocol. Anything the mux protocol
130 learns to say later transits untouched.
131
132 ## Component 3: the portable core (Zig → wasm)
133
134 One Zig module, no platform imports, compiled to `wasm32` for this
135 milestone and natively for its tests (and, later, the Wayland
136 client):
137
138 - **Replica:** ghostty-vt plus the snapshot/delta replay logic the
139 CLI client already has, factored out of `client.zig` where it
140 currently lives so both clients share it rather than copy it.
141 - **Grid readout:** styled cells (glyph, fg, bg, attrs) exposed over
142 the wasm boundary for the renderer, plus cursor position and a
143 damage hint (which rows changed since last readout) so the painter
144 can skip clean rows.
145 - **Keymap:** normalized key event in, VT bytes out. v1 scope:
146 printable input, control characters, arrows/home/end/page
147 navigation, function keys, modifier-encoded CSI variants,
148 bracketed paste. Explicitly deferred: the full kitty/CSI-u
149 keyboard protocol (recorded as the follow-up when a TUI that needs
150 it shows up).
151 - **Scrollback state:** the scroll-mode logic (position, fetch
152 windows via `fetch_scrollback`) mirrors the CLI client's.
153
154 The wasm ABI (exports, memory ownership, event struct layout) is
155 plan-level detail; the design constraint is only that the JS side
156 stays glue — every decision lives on the Zig side of the boundary.
157
158 ## Component 4: the web shell (thin by construction)
159
160 - Translates `KeyboardEvent` → normalized key events → core. IME and
161 dead keys via a hidden input element focused while a tile is
162 zoomed; composed text enters as paste-shaped input.
163 - Pumps WebSocket messages ↔ core (frames in, input/resize/
164 fetch_scrollback frames out).
165 - Paints the core's grid readout to a canvas per tile — monospace
166 cell metrics measured once, dumb cell painting, damage-hinted.
167 Wall tiles paint scaled-to-fit via canvas transform (a cell
168 renderer can do this; a DOM terminal cannot); the zoomed tile
169 paints 1:1.
170 - Wall layout is CSS grid; tile chrome shows the label and the
171 control-channel state. Click zooms, click-away unzooms (session
172 stays attached either way); keys go nowhere when nothing is
173 zoomed. Wheel in the zoomed tile enters scroll mode; wall tiles
174 don't scroll.
175
176 ## The gate: wasm feasibility spike
177
178 Task zero of the plan, before anything else is built: compile
179 ghostty-vt plus a minimal replay harness to `wasm32` under the pinned
180 Zig 0.15.2, load it in a browser, apply a captured snapshot frame,
181 and read one styled cell back from JS. Ghostty upstream has wasm
182 history, so this is expected to pass — but the renderer decision
183 rests on it, and if it fails the fallback discussion (xterm.js and
184 what replica drift would cost) must happen in hour one, not task
185 five. The spike doubles as evidence for the native client: it tests
186 "does the portable core compile to a second target," which is the
187 property native needs.
188
189 ## Testing
190
191 - **Unit, native (no browser):** keymap tables (table-driven:
192 event → exact bytes, the modifier matrix included); replay logic
193 (already covered by the suite today — the factor-out must keep
194 those tests green); envelope encode/decode; Origin check
195 (accepted and refused upgrades); announce of tile state
196 transitions from the hub's reconnect path.
197 - **E2E, harness-driven:** the hub against real local daemons, with
198 a scripted WebSocket client standing in for the browser — upgrade
199 (with correct Origin), attach, type bytes, assert the replayed
200 grid; kill the daemon-side transport, observe
201 `reconnecting` → `up` control messages and a converged re-attach;
202 a wrong-Origin upgrade refused. All processes tracked by pid,
203 teardown via `muxd stop`, cleanup verified by observation, per
204 standing rules.
205 - **Browser proper:** manual kill-criterion pass; browser automation
206 is not wired into the suite in v1.
207
208 Kill criterion: a wall of three real devices — this desktop, the LAN
209 box, the WAN box — live in one browser page; zoom the LAN tile, run
210 vim, type, scroll back, unzoom; the desktop's CLI client on the same
211 session never glitches (passive tiles provably didn't resize it);
212 pull the LAN box's network and watch the tile go `reconnecting` and
213 recover. Viewed once over `ssh -L` from another machine to prove the
214 remote-viewing story.
215
216 ## Files
217
218 - `src/webhub_main.zig` (entry, beside `main.zig`/`mux_main.zig`) +
219 `src/webhub.zig` (HTTP, WebSocket, per-tile pump, reconnect,
220 Origin check).
221 - `src/replica.zig` — the factor-out from `client.zig`: replay +
222 grid readout, shared by CLI client and core.
223 - `src/keymap.zig` — normalized events → VT bytes, tables + tests.
224 - `src/wasm_core.zig` — wasm exports wrapping replica + keymap +
225 scroll state.
226 - `web/` — `index.html`, glue JS, embedded via `@embedFile`.
227 - `build.zig` — third binary + wasm compile step.
228 - `test/e2e.sh` — hub scenarios.
229 - `docs/roadmap.md`, `docs/decisions.md` — close-out.
230
231 ## Non-goals
232
233 - No auth, no tokens, no LAN/WAN exposure, no TLS — localhost +
234 `ssh -L` is the whole access story in v1.
235 - No native Wayland client — this milestone builds its organs, not
236 its shell.
237 - No multi-session-per-daemon; one tile is one daemon's one session,
238 as everywhere.
239 - No kitty/CSI-u keyboard protocol; no touch/mobile input handling.
240 - No config file; the tile list is argv.
241 - No browser automation in the test suite.
242 - No prediction/local echo in the web client (same standing deferral
243 as the CLI, same measured bar to clear).
244
245 ## Amendments (2026-08-13, pre-plan survey + wasm spike)
246
247 The feasibility spike (task zero) PASSED: ghostty-vt compiles to
248 wasm32-freestanding under the pinned Zig with zero wasm imports;
249 345KB raw / 107KB gzipped; 51/51 host assertions across styles, wide
250 CJK, alt-screen, resize, and a 20k-line memory soak. The xterm.js
251 fallback is dead. The survey that followed (daemon internals, client
252 replay, transport, build, e2e harness) corrects five points of this
253 spec at the source:
254
255 1. **Wall tiles attach at 1×1 — the passivity mechanism is already
256 in the daemon.** The locked decision "the browser sends the attach
257 frame (it knows the tile's cols×rows)" is REVERSED for wall tiles:
258 an attach at the tile's own size resizes the shared session and
259 broadcasts a repaint to every client (server.zig:1183-1196), which
260 is exactly what the kill criterion forbids — and a tile cannot
261 know the grid before attaching. Instead: wall tiles attach at 1×1.
262 `applySize` refuses cols<2/rows<2, so the attach is answered with
263 a unicast snapshot carrying the true grid, the slot stays 0×0, and
264 `claimGrid` refuses it on every future keystroke — permanent
265 passivity, zero daemon changes (server.zig:1239, :1265-1270). The
266 zoomed tile sends a real `resize` only when its computed size
267 differs, as already specified. A new e2e scenario pins the
268 1×1-attacher-cannot-move-the-grid contract.
269 2. **The hub parses frame HEADERS, not payloads.** "The hub never
270 parses what it pumps" survives in spirit only: WebSocket messages
271 are message-delimited while the daemon socket is a byte stream, so
272 the hub must read the 5-byte frame header (type + u32 LE length)
273 in both directions to re-frame. It still never looks inside a
274 payload. The reference re-framer is server.zig's pushInbound
275 (:1003-1045).
276 3. **std.http ships the WebSocket server side.** Zig 0.15.2's
277 std.http.Server has upgradeRequested/respondWebSocket (accept-key
278 SHA-1 included) and read/write message primitives; the hand-rolled
279 RFC 6455 paragraph is obsolete. Two caveats are ours to own: the
280 Origin check (std does not check it — iterate headers, refuse
281 before upgrade, pinned by test), and the single buffer that bounds
282 BOTH max HTTP header size and max inbound WS message —
283 readSmallMessage rejects fragmented messages outright, so a large
284 browser-side paste must fit; size it generously and state the
285 limit. Hub→browser has no such limit (u64 lengths; snapshots are
286 safe).
287 4. **Replay logic has ZERO unit tests today** — the Testing section's
288 "already covered by the suite" is false; coverage is e2e-only. The
289 replica.zig extraction must WRITE the replay unit tests. The grid
290 readout is likewise NEW code (client.zig re-serialises VT rows to
291 a tty; no per-cell readout exists outside the spike, which is its
292 reference implementation). server.zig's applyFrame test helper
293 (:1500-1516) is a replay duplicate that replica.zig absorbs.
294 5. **Transport reuse is a visibility pass PLUS three unbindings**:
295 Transport and all ten methods are private; waitReady and
296 readAnnounceAbortable poll hardcoded STDIN_FILENO for the Ctrl-\
297 abort (the hub needs an injectable abort fd); readFrame blocks
298 until a frame completes (the hub runs one pump thread per tile,
299 which makes blocking correct rather than fatal); reconnect() is
300 tty-shaped — the hub reuses the backoff SCHEDULE (0→200ms→×2 cap
301 2s, no retry cap) and the have_seq/have_epoch re-attach contract,
302 not the function.
303
304 Sequencing note: the M15 refactor extracted paint.zig (tty painting
305 stays CLI-side) and delta.zig (daemon-side tracking); the replica
306 factor-out is the remaining cut, and it is a struct-extraction from
307 session()'s local state (client.zig:974-1007), not a file move.
docs/superpowers/specs/2026-08-12-m15-refactor-design.md
Old New
@@ -1,308 +0,0 @@
1 # M15 — refactor: typed invariants, single owners, module seams
2
3 **Status:** draft for user review.
4 **Provenance:** three independent read-only code surveys (daemon core /
5 client side / transport+infra) run 2026-08-12 against a green tree
6 (aa247b3; `make test` 0, e2e 20/33, soak 10/10 on d9b17d5). Line numbers
7 below are from that tree and must be re-verified at plan time.
8
9 ## Goal
10
11 Pay down the structural debt the surveys found, in two tiers: (1) close
12 the latent defects and free wins, (2) turn documented invariants into
13 types and give every duplicated policy exactly one owner. No feature
14 work. Two — and only two — user-visible behavior changes, both named
15 below and both re-measured, not just re-worded.
16
17 ## Non-goals (explicitly out of scope, stay banked)
18
19 - `session()` decomposition, `src/term.zig`, `src/transport.zig`
20 extraction, dispatch dedup (`completeAttach`/reply seam), `PollSet`,
21 the error-tier decision table + FailingAllocator sweep, the test-file
22 split — all Tier 3, deferred to a later milestone informed by this one.
23 - ngtcp2 egress-loop unification. Highest-risk code in the repo
24 (two prior UAFs); `quic_client.zig` has no in-file loopback test, so
25 there is nothing to hold the ladder. Only the trivial `pathFrom`
26 helper for the five path literals rides along with the `quic.zig`
27 extraction; the loop bodies stay put.
28 - `addCSourceFiles` port of `deps/quic/build-deps.sh`. The survey
29 confirmed build.zig:3-15's argument (wolfSSL's cmake-generated
30 `options.h`, ~200 feature switches). Banked unchanged. The available
31 win — declaring the three archives as real step outputs so the build
32 graph can cache instead of `has_side_effects = true` — is included in
33 Tier 1 as a build hygiene item.
34 - Prediction `retired` channel (designed+tested in predict.zig, never
35 read by client.zig): flagged, but adopting or deleting it is a
36 product decision about rollback repaint, not a refactor. Recorded for
37 the M16 queue; not touched here.
38
39 ## Tier 1 — defects and free wins
40
41 ### 1.1 `frameBytes` is a latent framing defect (server.zig:1064-1072)
42
43 Writes only `payload[0]` into a fixed `[6]u8` while stamping
44 `payload.len` into the header — correct only for `payload.len == 1`.
45 One caller (quicOnOpen's refusal frame, :896). Zero pins.
46
47 **Shape:** replace with `fn refusalFrame() [6]u8` — no parameters, no
48 lie — and delete the stale "two places" comment. Add a unit pin on the
49 literal bytes (type, length field, payload byte).
50
51 ### 1.2 `drain()` swallows ECONNREFUSED (quic_client.zig:450) — banked M15 item
52
53 On a connected UDP socket the queued ICMP refusal lands on the `send`
54 in `drain()`, whose `catch return` discards it; `readable()`'s
55 `ConnectionRefused` branch (:358-368) is unreachable in practice. A
56 refused port costs the full 2s deadline instead of ~1 RTT.
57
58 **Shape:** lift `readable()`'s two-arm switch into one shared helper so
59 the two paths cannot drift; `drain()` sets `dead = true` on
60 `ConnectionRefused`. The listener-side `sendto ... catch break`
61 (quic_server.zig:1246-1252) is NOT affected (unconnected socket, no
62 ICMP delivery) — state that in a comment while there.
63
64 **Re-pin obligations (re-time, never delete — the M14 rule):**
65 - client.zig:1996-2035 "dead coordinates fall back to the pipe": the
66 `elapsed >= 300` lower bound rests on the quirk. Replace with a
67 witness that the dial happened (`t.quic == null and t.child != null`
68 plus upper bound), per the test's own comment.
69 - handoff.zig:11-34 deadline_ms prose: "every failure runs the budget
70 out" becomes false for loopback-refused. Re-measure all three failure
71 classes (refused / blackhole / wrong-PSK) and rewrite the paragraph
72 from the new numbers. The 2000ms value itself is expected to stand —
73 it is set by blackhole and wrong-PSK, which still run the budget out.
74 - e2e (d) `elapsed >= floor`: re-derive the floor from the new
75 measurement.
76
77 ### 1.3 `QuicTarget.deadline_ms` — split the attach budget off `idle_ms`
78
79 `idle_ms` serves three jobs; the third (attach budget via
80 `waitReady(cl, q.idle_ms, ...)` at client.zig:171) is already known
81 wrong-shaped — `HandoffTarget` has a separate `deadline_ms` precisely
82 for this, and the direct `quic://` path never got the split. Cost
83 today: `mux quic://dead-host` hangs ~15s on the default idle timeout.
84
85 **Shape:** `QuicTarget.deadline_ms: u32 = handoff.deadline_ms`;
86 `waitReady` gets `q.deadline_ms`. Two fields, one call site.
87 quic_server.zig:35-45's doc comment shrinks to two jobs.
88
89 **This is user-visible behavior change #2** (dead `quic://` target:
90 ~15s → 2s) and it is bundled with 1.2 because both change how fast a
91 dead QUIC target gives up — one round of re-measurement covers both.
92 Check test/wan.sh:720-751 (sleeps `idle_ms * 1.6`) and any e2e bound
93 leaning on the long budget before landing.
94
95 ### 1.4 `keepAliveNs` duplicated, one copy tested
96
97 Identical bodies in quic_server.zig:1279-1281 (u64) and
98 quic_client.zig:483-485 (u32); only the client copy carries the
99 literal pins (5s/500ms/1ms/1ms). The exact drift hazard
100 `default_idle_ms` was created to prevent, reintroduced as a function.
101
102 **Shape:** one `pub fn keepAliveNs(idle_ms: u64) u64`. Lands in
103 quic_server.zig first (the current shared module), moves into
104 `quic.zig` with 2.3. Tests move with it.
105
106 ### 1.5 Small items (each XS)
107
108 - `0x1c` detach-chord literal at client.zig:486, 568, 1193, 1529 →
109 `const detach_key: u8 = 0x1c` (doc at line 7 already names it).
110 - SIGPIPE-ignore duplicated at client.zig:780-790 vs
111 `proxy.ignoreSigpipe` — one implementation; either import or move to
112 a tiny shared home. proxy.zig:23-27's comment already explains the
113 client's obligation.
114 - Misplaced doc comment: `close`'s docstring sits above `pollFd`
115 (client.zig:310-324).
116 - `Pty.read` is dead in production (server reads `pty.master` raw at
117 server.zig:610) — delete it or route the daemon through it; decide,
118 don't leave both.
119 - `sendPtyModeTo` mutates `mode_sent` inside an `orelse blk:` —
120 rename to `ensurePtyModeSent` so the write stops hiding.
121 - build.zig:26 `has_side_effects = true` on the deps step → declare
122 the three archives as step outputs so the graph caches honestly.
123
124 ## Tier 2 — types and single owners
125
126 ### 2.1 The transport recipe and the live link become unions (banked M15 item)
127
128 Survey measured the blast radius: ~17 mechanical edits, all inside
129 client.zig + mux_main.zig; nothing else repo-wide names `Transport`.
130 Load-bearing finding: the recipe and the live link are DIFFERENT
131 unions — a `hand` recipe produces either a QUIC or a pipe link,
132 decided inside `openHandoff`. Doing only the recipe union leaves the
133 two-optional live discriminant in place; both are in scope:
134
135 ```zig
136 const Target = union(enum) { sock: []const u8, via: []const u8,
137 quic: QuicTarget, hand: HandoffTarget };
138 const Link = union(enum) { fd, pipe: std.process.Child,
139 quic: *quic_client.Client };
140 ```
141
142 - `attach(alloc, target)`, `session(...)`, `reconnect(...)` collapse
143 their four-nullable parameter triples to one `Target`.
144 - `lostMsg(via, epoch)` → `target == .via`.
145 - reconnect's `quiet_hand` → `var t = target; if (t == .hand)
146 t.hand.report_fallback = false;`.
147 - mux_main.zig already holds a union (`ParseResult`) and destructures
148 it into nullables at the boundary — it passes `.{ .quic = ... }`
149 directly instead. Its 10 parseArgs tests are untouched.
150 - `close`/`writeFrame`/`readFrame`/`service` become exhaustive
151 switches over `Link`; `buffersFrames` disappears into the switch.
152 - The 5 Transport.open positional-call tests (client.zig:1953, 1981,
153 2018, 2090, 2115) are rewritten call-shape-only; their assertions
154 (which link kind resulted, timing bounds) survive verbatim.
155
156 ### 2.2 `attach`'s error-reporting switch becomes a pure function
157
158 client.zig:662-753: 90 lines, eight user-facing messages, two
159 exit-0 abort paths, zero pins — the most policy-per-untested-line in
160 the file, while three-word `lostMsg` has a whole test.
161
162 **Shape (after 2.1):** `fn openErrorMsg(buf: []u8, target: Target,
163 err: anyerror) ?[]const u8` — null means abort-exit-0 — literal-pinned
164 like `lostMsg` and `formatPredictStats`. `attach` shrinks to
165 open / print / session.
166
167 ### 2.3 Extract `src/quic.zig` — the shared QUIC vocabulary
168
169 quic_server.zig doubles as the common module; build.zig:91-96 already
170 names the module `quic`, and quic_client.zig:27-30 documents why it
171 must import the listener (one `@cImport` = one type universe) — a
172 shared-module argument wearing a client-imports-server costume.
173
174 **Shape:** `src/quic.zig` owns the `c` cImport, `Key`, `key_len`, wire
175 constants (`default_port`, `default_idle_ms`, `psk_identity`,
176 `psk_ciphersuite`, `alpn`, `max_udp`, `egress_cap`), `Egress`,
177 `WriteAction`, `accountWrite`, `timestampNs`, `keepAliveNs`, `randCb`,
178 `getNewCidCb`, plus `pathFrom(...)` (kills five path literals — the
179 only egress-adjacent change allowed in this milestone). Both existing
180 files `@import("quic")`; build.zig repoints `quic_mod` and adds
181 `quic_server_mod`. Everything moving is already `pub` — a move, not a
182 redesign. The cImport must end up in exactly one file; the proof is
183 `make build`, not reading.
184
185 Tests that travel: `Key.load` ×3, `Egress` ×3, `accountWrite`,
186 `keepAliveNs`.
187
188 ### 2.4 Server QUIC ownership becomes a union
189
190 server.zig:401-409: `quic_listener: ?*Listener` + `quic_owned: bool`
191 — a nullable+bool typing four states where three are valid;
192 `attachQuic` defends the fourth with a runtime assert.
193
194 **Shape:** `quic: union(enum) { none, borrowed: *quic.Listener,
195 owned: *quic.Listener }` with a `listener()` accessor; `deinit`
196 switches; `attachQuic` requires `.none` exhaustively. Both ownership
197 arms already carry literal pins (borrowed survives deinit :3876;
198 owned released via the bind latch :4605, :4664-4669).
199
200 ### 2.5 Extract `src/delta.zig` and `src/sockpath.zig`
201
202 - `DeltaTracker` (server.zig:89-235) is fully self-contained (Engine +
203 proto only); moves with its two unit tests verbatim. Absorb the
204 3-clause can-serve predicate at :1610-1612 as `canServe(have_seq)`
205 (pinned by :3164, :3259).
206 - Socket-path ownership: `claimSockPath` (:461-504) + the dev/ino pair
207 (:367-378) + deinit's `ours` block (:527-536) become
208 `sockpath.PathId { of(), stillAt() }` + `claim()`. The dev/ino
209 comment records a real field incident — evidence the pair wants to
210 be a type. Six literal tests move with it (:3610-:3738, :4397,
211 :4421).
212
213 ### 2.6 Extract `src/paint.zig` (client side)
214
215 client.zig:1258-1327 (`clampCursor`, `renderClipped`,
216 `paintDeltaClipped`), :1486-1507 (banner), :1621-1635
217 (`renderScrollback`) — already pure-ish and the best-tested code in
218 the file; simply in the wrong file. A `SyncPaint` helper owns the
219 `"\x1b[?2026h\x1b[?25l"` prologue/epilogue pair that currently exists
220 in three copies (:1272, :1298, :1374) — the test at :2341 documents
221 that a dropped wrapper is invisible to both e2e suites, which is why
222 the pair must exist exactly once. Seven tests move verbatim. This is
223 the safest extraction and lands FIRST in Tier 2 to prove the pattern.
224
225 ### 2.7 Key-refusal messages get one owner
226
227 Four literal copies of the same three sentences, held in sync by prose
228 comments: main.zig:317-337, main.zig:744-763, server.zig:1016-1044,
229 client.zig:667-678. Wordings have already drifted in the catch-alls
230 (`cannot read` vs `cannot load key`).
231
232 **Shape:** one pure `keyRefusalBody(buf, err, path) ![]const u8`
233 (lives beside `Key` in quic.zig, or xdg.zig — implementer's call,
234 recorded in the commit) returning the middle sentence; call sites keep
235 their own prefix/suffix (`muxd:` / `muxd endpoint:` +
236 `; staying on ssh` / `muxd: endpoint_req:` / `mux:`). ONE literal-
237 pinned test asserting all bodies. e2e's loose pin
238 (`^muxd endpoint: .*staying on ssh`) is unaffected.
239
240 ### 2.8 `main.zig` subcommand spec table
241
242 Nine subcommands; adding one costs edits in five places (usage
243 literal, enum, if/else chain, `uses_socket` switch, dispatch switch),
244 and only the dispatch switch is compiler-checked.
245
246 **Shape:** `const specs = [_]Spec{ .{ .name, .cmd, .uses_socket,
247 .flags } ... }`; parse becomes a lookup; keygen's no-flags exception
248 becomes data (`.flags = .none`). The dispatch `switch (o.cmd)` STAYS a
249 switch (real bodies, exhaustiveness worth having). `usage` stays a
250 hand-tuned literal, but gains a test asserting every `Cmd` name
251 appears in it — the one leg the compiler can't check. The 8 existing
252 parseArgs behavior tests must pass unchanged.
253
254 ### 2.9 Rides-along dedup (client)
255
256 - `spawnPipe(alloc, cmd)` + `pipeTransport(child)`: the six-line
257 child-spawn block appears twice and the wrap-as-Transport literal
258 three times (client.zig:178-190, 226-249, 274-279).
259 - `quicTransport(...)`: `open`'s quic arm and `openQuicEndpoint`
260 differ only in where the inputs come from. CAUTION the survey
261 flagged: the `waitReady` call in `openQuicEndpoint` is type-load-
262 bearing (sole source of `error.UserAbort`, named by two switch arms
263 in `openHandoff`) — the shared helper must keep it or the build
264 breaks loudly. Expected, acceptable.
265 - `muxd dump`/`muxd stats` (main.zig:425-465) are byte-identical
266 modulo three tokens → `oneShotQuery(...)`; `askEndpointPort` stays
267 as-is (genuinely different shape, documented). Add the missing
268 nobody-serving unit test mirroring `stopCmd`'s.
269 - The log-path-hint clause written twice with a comment saying so
270 (main.zig:514-531, :640-654) → `logHint(...)`.
271
272 ## Behavior changes — the complete list
273
274 1. A refused UDP port fails the QUIC dial in ~1 RTT instead of the
275 full deadline (1.2).
276 2. A dead `quic://` target gives up in `deadline_ms` (2000ms default)
277 instead of `idle_ms` (~15s) (1.3).
278
279 Everything else in this milestone is behavior-preserving; any test
280 whose literal must change beyond the re-pins named in 1.2/1.3 is a
281 defect in the task, not an amendment to make.
282
283 ## Kill criterion
284
285 - `make test` and `make e2e` green after EVERY task commit; suite
286 scenario/convergence counts unchanged except where a re-pin from
287 1.2/1.3 names the delta in its commit message.
288 - `SOAK_N=10 make soak` 10/10 on the final tree.
289 - The two behavior changes demonstrated by measurement on the LAN box
290 (192.168.0.109): refused port ≪ deadline; dead-target `quic://`
291 attach fails at ~2s not ~15s. Numbers recorded in decisions.md.
292 - Regrade leg (3 mutants, each must compile, each catch legible and
293 ordered before anything it could hang, per M13/M14 rules):
294 (a) revert `drain()`'s refusal handling → caught by the re-pinned
295 refused-port bound; (b) swap two arms of the new `Target` dispatch →
296 caught by an existing transport-decision test; (c) break one
297 key-refusal body → caught by the new literal pin.
298 - Zero net-new public API beyond the named modules/types; `wc -l
299 src/server.zig` and `src/client.zig` both strictly smaller.
300
301 ## Task ordering constraint (for the plan)
302
303 Tier 1 first (1.1 → 1.4/1.5 → 1.2+1.3 bundled with their measurement
304 round). Tier 2: 2.6 (paint, safest, proves the pattern) → 2.1 → 2.2 →
305 2.9 → 2.3 (+1.4's move) → 2.4 → 2.5 → 2.7 → 2.8. Rationale: 2.1
306 before 2.2/2.9 (they consume `Target`); 2.3 before 2.4 (the union
307 names `quic.Listener`); extractions after the unions so extracted
308 APIs are the clean shapes, not the nullables.
docs/superpowers/specs/2026-08-13-agent-surface-design.md
Old New
@@ -1,242 +0,0 @@
1 # Agent surface: native LLM integration for mux
2
3 **Date:** 2026-08-13
4 **Status:** approved (design), pending implementation plan
5 **Branch:** feat/agent-surface
6
7 ## Problem
8
9 LLM agents drive terminals today by shelling out to `tmux send-keys` /
10 `capture-pane`: blind polling, arbitrary sleeps, no signal for "the command
11 returned", no exit codes, no mode awareness, full-screen captures on every
12 poll. mux owns the PTY and runs an authoritative server-side ghostty-vt
13 emulator, so it can hand an agent the signals tmux structurally cannot:
14 command boundaries with exit codes, wait-for-return semantics, structured
15 screen state, and token-efficient diffs — locally and over QUIC.
16
17 Goal: an agent (Claude via Bash, or any LLM harness) drives interactive
18 programs in a mux session and *knows* when a program has returned, with its
19 exit code, without polling or guessing.
20
21 ## Non-goals (v1)
22
23 - No MCP server. Agents drive the standalone CLI via their shell tool; an
24 MCP wrapper can be layered later without protocol changes.
25 - No read-only / scoped auth. The existing model stands: unix socket gated
26 by filesystem permissions, QUIC by the shared PSK key, one key = full
27 control. A capability-scoped or observer-only role is a noted follow-up.
28 - No multi-session listing or management. One daemon = one session stands.
29 - No semantic event *subscriptions* beyond `cmd_state` push (no "prompt
30 detected" pub/sub surface).
31
32 ## Design
33
34 Three components: command-boundary detection in the daemon, three new
35 protocol frame pairs, and a standalone agent binary `muxa`.
36
37 ### 1. Command-boundary detection (daemon-side)
38
39 **Shell integration, auto-injected.** muxd forks the session shell itself,
40 so it injects OSC 133 integration at spawn with no rc-file edits:
41
42 - zsh: point `ZDOTDIR` at a mux-owned shim directory whose `.zshrc` sources
43 the integration, restores the user's original `ZDOTDIR`, then sources the
44 user's real `.zshrc`.
45 - bash: launch with `--init-file <shim>`; the shim sources the user's normal
46 startup files first, then the integration.
47 - fish: prepend a mux directory to `XDG_DATA_DIRS`; fish auto-sources
48 `fish/vendor_conf.d/*.fish`.
49
50 Scripts are vendored from ghostty's shell-integration (battle-tested against
51 multi-line prompts, Ctrl-C at an empty prompt, prompt redraws), trimmed to
52 the OSC 133 marks mux consumes. Opt-out: `MUX_SHELL_INTEGRATION=0`,
53 necessarily read from the *daemon's* environment (the daemon sets the
54 child's env). Detection is by basename of the shell being exec'd (from
55 `--shell`/`$SHELL`; mux execs the shell directly, no login `-` prefix, so
56 bash `--init-file` is safe); unknown shells get no injection and rely on
57 fallbacks. Two mechanics: `Pty.spawn` builds a one-element argv today —
58 injection extends it via the existing `spawnArgv`; and "restore the user's
59 original `ZDOTDIR`" means the shim exports the `ZDOTDIR` captured at spawn
60 if one was set, else *unsets* it — a daemon started from ssh/scripts often
61 carries no user `ZDOTDIR` at all, and exporting an empty one would break
62 zsh's fallback to `$HOME`.
63
64 **Mark interception.** ghostty-vt already parses OSC 133 including the
65 `err` exit code, but its stock `stream_terminal.Handler` discards the code
66 and exposes no semantic-prompt callback. The mechanism: `vt.Stream(H)` is
67 generic over the handler, so mux defines its own handler that wraps the
68 stock one, intercepts `.semantic_prompt` in `vt()`, and forwards everything
69 else — no byte-stream scanning, no dep patch. The engine's hardcoded
70 `vt.TerminalStream` becomes `vt.Stream(MuxHandler)`. The handler drives a
71 per-session command state machine:
72
73 at_prompt --(133;C, record start_row)--> running
74 running --(133;D;code, record end_row)--> returned(code)
75 returned --(133;A or 133;C)--> at_prompt / running
76
77 **Output spans are rows, not seqs.** The codebase's `seq` is a viewport
78 delta generation (bumped once per diff pass over the viewport), not a byte
79 or row offset — a whole command's output can share one seq, and rows lose
80 their seq once scrolled into history. So marks record absolute screen-space
81 rows (`historyRows() + cursor.y` at mark time), and output recovery is the
82 existing `fetch_scrollback` (start row + count), unchanged. Caveats stated
83 plainly: row spans are meaningless while the alt screen is active, and row
84 indices shift once the scrollback ring prunes — consumers should fetch
85 spans promptly after `returned`. `seq` is still used, but only for await
86 ordering ("a return at seq >= since_seq"), defined as the tracker seq
87 after the post-feed update so it can't be off-by-one against the rows of
88 the same PTY chunk.
89
90 **Fallback stack.** The daemon always knows its detection regime and reports
91 it in every reply:
92
93 1. `marks` — OSC 133 seen recently: exit codes + exact output spans.
94 2. `pgid` — no marks (nested ssh, docker exec, unknown shell): the daemon
95 polls `tcgetpgrp` on the PTY master only while an await is outstanding;
96 fg pgid returning to the shell's pgid means the foreground job returned.
97 No exit code, no exact span.
98 3. `settle` — last resort: no output for N ms (N from the await request).
99
100 Regime selection: marks are trusted if the current command was opened by a
101 `133;C`; otherwise pgid; settle only if explicitly requested as a floor or
102 pgid is unavailable.
103
104 ### 2. Protocol additions
105
106 Three frame pairs on the existing non-exhaustive `MsgType` enum(u8) — they
107 transit `muxd proxy` untouched (`proxy` is genuinely frame-agnostic), and
108 old clients tolerate an unexpected push. Values: client→daemon `await_req`
109 0x09, `status_req` 0x0a; daemon→client `cmd_state` 0x8a, `await_reply`
110 0x8b, `status_reply` 0x8c (leaving the existing 0x83 hole alone). Payloads
111 follow house style: explicit `encode*/decode*` helpers over fixed
112 little-endian buffers, `packed struct(u8)` only for flag bytes.
113
114 - `cmd_state` (daemon→client, pushed): emitted on every state-machine
115 transition to attached clients. Carries: state (running | returned),
116 exit code (when known), start_row/end_row, seq at transition, detection
117 mechanism. Pushes exist only in the marks regime — pgid/settle
118 transitions are observed only while an await is outstanding and surface
119 via `await_reply`.
120 - `await_req` (client→daemon) / `await_reply` (daemon→client): "wake me
121 when a command returns after `since_seq`; if `settle_ms` is nonzero,
122 also resolve after that much output silence." If a return already
123 happened past `since_seq`, the daemon replies immediately — this is
124 what makes re-issuing an await after a reconnect safe. The daemon holds
125 the await server-side — no client polling, identical behaviour over
126 unix socket and QUIC. Awaits fit the existing single poll pump without
127 blocking: their deadlines (settle, timeout, pgid poll cadence) fold into
128 the loop's `wait_ms` computation the same way the QUIC `timeoutMs`
129 already does. Reply carries the same payload as `cmd_state` plus a
130 reason (returned | settled | timeout).
131 - `status_req` / `status_reply`: one structured snapshot — cols/rows,
132 cursor position, alt-screen flag, pty_mode bits (icanon/echo), current
133 command state, detection regime, current stream seq.
134
135 Attach, input, resize, snapshot, delta are unchanged; an agent is a normal
136 attached client — with one load-bearing rule: **`muxa` attaches with
137 cols=rows=0**. Attaching at a real size would claim the grid and resize the
138 human's session, and every `input` frame re-claims at the sender's size.
139 The 0x0 escape hatch already exists (`applySize` refuses cols<2, and a 0x0
140 slot "makes no claim" in `claimGrid`); the spec makes it a contract.
141 Multiple outstanding awaits (e.g. agent + test harness) are each answered.
142
143 ### 3. `muxa` — standalone agent binary
144
145 A separate binary in this repo, reusing `protocol.zig`, `quic_client.zig`,
146 and `xdg.zig` key resolution. Same connection flags as `mux`: `--sock PATH`,
147 `quic://host:port` targets, `--key` / `$MUX_KEY_FILE` / XDG default.
148 Remote-over-QUIC is in scope from day one; every verb works identically
149 against a local socket and a WAN daemon.
150
151 All verbs print a single JSON object on stdout; exit code 0 on success,
152 nonzero with a JSON error object on failure.
153
154 - `muxa run "make test"` — flagship. Sends the command line (plus newline),
155 awaits return, prints `{exit_code, output, mechanism, duration_ms,
156 start_row, end_row}`. `--settle N` sets the fallback floor; `--timeout N`
157 bounds the wait (nonzero exit, state reported, on expiry).
158 - `muxa send "keys"` — raw bytes to the PTY (escapes for control keys),
159 no waiting. The TUI-driving path.
160 - `muxa capture [--diff-since SEQ] [--vt]` — current grid as text (or with
161 SGR), or the delta rows since SEQ with the new seq. Token-efficient
162 re-reads for TUI driving.
163 - `muxa status` — the status_reply as JSON.
164 - `muxa await [--settle N] [--timeout N]` — wait without sending, for
165 commands typed by a human or another agent.
166
167 TUI driving composes `send` + `status` (alt-screen flag answers "am I in a
168 TUI") + `capture --diff-since`. `run` is for shell command lines only; its
169 JSON reports `mechanism` so the agent knows whether `exit_code` is real
170 (`marks`) or absent (`pgid` / `settle`).
171
172 ### Ephemeral sessions (first-class use case)
173
174 Agents often want a throwaway session, not an attachment to a standing one:
175 spawn a TUI fresh, poke it, read the screen, quit, tear down. (Field
176 specimen: a Claude session hand-rolled a Python `pty.fork` driver with
177 sleep-based drains writing raw VT bytes to a file — a worse muxd in 30
178 lines.) The workflow composes from existing verbs and is blessed and tested
179 as such, not given new machinery:
180
181 muxd start --sock <tmp> -- <program> # throwaway daemon
182 muxa send/await/capture ... # drive it
183 muxd stop --sock <tmp> # reap it
184
185 Against a session whose root process is a TUI rather than a shell, no marks
186 ever appear: the regime is pgid/settle from frame one, `status` says so,
187 and the program quitting surfaces as the existing `exit_status` frame —
188 `muxa` verbs report it as a structured "session ended" result rather than a
189 transport error. A single-shot `muxa drive -- CMD` wrapper is deferred; the
190 composition covers it.
191
192 ## Error handling
193
194 - Daemon without the new frames (version skew): old daemons silently drop
195 unknown frames, so there is no faster signal than the client-side
196 timeout — the timeout *is* the detection, and `muxa` reports it as a
197 structured "no reply — daemon too old or hung" error. (House doctrine is
198 lockstep binaries, not wire compatibility; this is a courtesy error, not
199 a compat promise.)
200 - Injection failure (shim unwritable, unknown shell): session starts
201 normally without marks; regime degrades to pgid and `status` says so.
202 - Marks from a lying/nested program: the state machine only trusts `133;D`
203 that closes a seen `133;C`; stray marks reset to at_prompt.
204 - QUIC drop mid-await: `muxa` carries its own small reconnect loop over
205 `quic_client.Client` (whose connect/send/pump API is cleanly reusable;
206 the *existing* reconnect path is entangled with the attach client's
207 replica/raw-terminal loop and is not reused). An await outstanding
208 across a reconnect is re-issued with the original `since_seq`, so a
209 return that landed during the gap is answered immediately, not missed.
210
211 ## Testing
212
213 House fixture style (`test/wsclient.zig` precedent):
214
215 - State-machine unit tests: scripted byte streams with OSC 133 marks
216 (normal exit, signal death 128+n, Ctrl-C at empty prompt, nested/stray
217 marks, interleaved output).
218 - Injection tests per shell: spawn zsh/bash/fish under the daemon, assert
219 marks appear and user rc still runs (PATH/prompt sentinel).
220 - Fallback tests: shell with integration disabled — `run` resolves via
221 pgid; raw `cat` session — resolves via settle.
222 - `muxa` end-to-end over unix socket: `run` returns real exit codes
223 (`true`/`false`/`sleep`), `capture --diff-since` matches `expectgrid`
224 fixtures.
225 - One QUIC end-to-end: `muxa run` against a daemon on `quic://127.0.0.1`.
226 - Ephemeral TUI end-to-end: `muxd start -- <tui fixture>`, `muxa send` a
227 key, `await --settle`, `capture` asserts the rendered grid, quit,
228 `muxa` reports session ended via exit_status.
229 - Version-skew test: `muxa status` against an old-protocol daemon reports
230 the structured error.
231
232 Per house doctrine, pinned regression tests go before any test the same
233 hang could wedge.
234
235 ## Follow-ups (explicitly deferred)
236
237 - MCP server wrapping `muxa` semantics.
238 - Read-only / capability-scoped auth (observer role for watching agents).
239 - Input attribution (marking agent-injected input so attached humans can
240 see who typed).
241 - Semantic event subscriptions (prompt-detected, alt-screen-entered) for
242 observer agents.
docs/superpowers/specs/2026-08-14-hygiene-kit-design.md
Old New
@@ -1,238 +0,0 @@
1 # Hygiene kit: codified architecture rules and mechanical gates
2
3 **Date:** 2026-08-14
4 **Status:** approved in discussion (strict-first ruling); spec-reviewed 2026-08-14, 8 findings folded
5 **Branch:** feat/hygiene-kit
6
7 ## Problem
8
9 The repo's architectural rules live in comments, doctrine memories, and
10 review habits — enforced by attention, not mechanism. Go projects codify
11 this with deadcode, import-direction linters, golangci-lint, and
12 modernize; Zig's ecosystem offers less tooling, but its compiler and
13 build system can enforce more than Go's linters can: the build graph is
14 the only door to an import, and the debug allocator already tracks every
15 allocation. This kit converts the rules we already follow into mechanisms
16 that fail loudly when broken.
17
18 ## What Zig gives us (the tool map, recorded so nobody re-derives it)
19
20 - `zig fmt --check`: canonical style gate. Compiler natively errors on
21 unused locals/params, shadowing, unreachable code — no linter needed
22 for those classes.
23 - Dead code: WORSE than Go — lazy compilation means unreferenced
24 container-level decls are never semantically analyzed at all (the
25 "comptime error = silent module loss" hazard, already bitten once).
26 `std.testing.refAllDecls` forces analysis; actual detection has no
27 mature tool.
28 - Import direction: the build system is the mechanism. A file can only
29 `@import("x")` if build.zig granted it. Deriving the grants from a
30 declared table makes violations impossible rather than detected.
31 - Leaks: `std.testing.allocator` already fails every leaking unit test
32 (242 sites). The daemons already run `std.heap.DebugAllocator` — but
33 discard its leak verdict (`defer _ = gpa.deinit()`).
34 - No modernize equivalent; version migrations arrive as compile errors
35 at toolchain bumps. Out of scope.
36 - Community linters: zlint (active), ziglint (dormant). Trial, not gate.
37
38 ## Design — six items
39
40 ### 1. Format gate: `zig build fmt`
41
42 A build step running `zig fmt --check` over `build.zig`, `src/`, and any
43 `.zig` under `test/`. The tree is NOT clean at HEAD (`test/wsclient.zig`
44 was touched after the last fmt sweep); the item's first commit formats it,
45 then the step pins cleanliness.
46 Also an umbrella step `zig build check` = fmt + test (e2e/agent/soak stay
47 separate — they are minutes-long and process-spawning; check is the
48 seconds-long pre-commit gate).
49
50 ### 2. Force-analysis: `refAllDecls` in every module root
51
52 Each module root in the native test loop gains a
53 `test { std.testing.refAllDecls(@This()); }` (recursive variant where it
54 compiles cleanly; plain where recursion pulls in platform-divergent
55 decls). Scope honesty: `refAllDecls` walks **pub** decls only
56 (`std.meta.declarations` lists nothing private), so private unreferenced
57 decls stay dark — this narrows the silent-module-loss hazard to
58 non-pub code rather than retiring it outright, and the plan says so.
59
60 Known hazards, adjudicated:
61 - `quic.zig` has `pub const c = @cImport(...)`; the recursive variant
62 would force-analyze the whole wolfSSL/ngtcp2 namespace. Plain (not
63 recursive) in `quic`, `quic_server`, `quic_client`.
64 - `wasm_core.zig` is not in the native test loop, so a refAllDecls there
65 never runs. Excluded explicitly; the coverage claim is "every module
66 root in the test loop," not "every module root."
67 - Dual-instantiated modules (engine/protocol/replica/keymap under the
68 wasm target) are safe: test blocks are only analyzed under native
69 `addTest`.
70
71 ### 3. Layer table, strict-first: the import graph as declared data
72
73 build.zig's 87 scattered `addImport` calls are replaced by a declared
74 table; the wiring loop derives every grant from it. Table row per module:
75 name, root source, layer, production imports, test-only imports.
76
77 The strict rule: a production import may point only to a strictly lower
78 layer. Layer numbers are not hand-assigned — they are the topological
79 strata of today's production graph, computed once during implementation
80 and then FROZEN in the table (a first hand-drawn draft of this spec
81 placed engine "mid" while four other mids imported it, proving
82 hand-assignment is guesswork). Expected shape, subject to that
83 computation:
84
85 0: protocol, engine*, quic, pty, keymap, script, testtmp
86 (imports nothing internal in production)
87 1: xdg-consumers and single-dep wrappers (spawn, shellint,
88 quic_client, quic_server, cmd, delta, replica, paint, predict,
89 handoff, proxy, xdg, sockpath — exact strata as computed)
90 2: server, client
91 2+: webhub (imports client, so it lands strictly above client —
92 the computation decides its exact stratum)
93 3: binaries and fixtures (exe/muxd, mux, muxa, webhub_main,
94 wasm_core, ptyclient, wsclient, render, rawmode, delaypipe)
95
96 *engine wraps the ghostty-vt dependency; dep edges are outside the
97 table's jurisdiction and stay explicit.
98
99 Once frozen, the table is the law: a new import that would flatten or
100 invert a stratum fails the build; deliberately re-stratifying requires
101 editing the table, which is the point — the architecture changes by
102 declared decision, not by accretion.
103
104 Validation is structural where possible (the loop can only wire what the
105 table declares) plus a build-time check that every declared edge points
106 downward. Test-only imports (the `testtmp` pattern: production modules
107 importing test scaffolding used exclusively inside `test` blocks, kept
108 out of release binaries by lazy compilation) are a first-class column —
109 the rule "test scaffolding never ships" becomes stated, not incidental.
110
111 Adjudications (verdicts reached during spec review, 2026-08-14):
112 - `server → replica`: **test-only.** Sole use is `replica_mod.Replica`
113 in `applyFrame` (src/server.zig:1915), a documented test helper whose
114 call sites are all inside `test` blocks. Moves to the test_imports
115 column.
116 - `client → proxy`: **production.** `proxy.ignoreSigpipe()` runs in the
117 live attach path (src/client.zig:886, SIG_IGN-after-spawn ordering).
118 Grandfathered with the comment already at client.zig:20 naming the
119 edge as debt; relocating ignoreSigpipe to a leaf is future work, not
120 this kit.
121
122 No other suspect edges exist (graph extracted and checked 2026-08-14).
123
124 Five things resist naive tabling; the table design must carry them
125 explicitly rather than discover them mid-refactor:
126 - `version_opts.createModule()` is called three times, producing three
127 distinct instances of a non-file-backed module. Either the table
128 whitelists this in the before/after diff, or the refactor shares one
129 instance (a benign graph-shape change that the diff must then
130 whitelist instead).
131 - webhub_main's anonymous imports include `wasm_exe.getEmittedBin()` —
132 an artifact edge that forces creation order (wasm exe before
133 webhub_main wiring). It stays explicit, outside the table.
134 - The wasm twin modules use a separate resolved target, hard-pinned
135 ReleaseSmall, a conditional ghostty wasm dep, and deliberately NO
136 use_llvm/use_lld (spike-proven — a naive "apply llvm/lld to all exes"
137 loop breaks the wasm build). Table rows carry a wasm flag driving a
138 second instantiation pass.
139 - linkQuic membership (4 exes + a 10-module test list) becomes a
140 per-row flag.
141 - Test registration order is doctrine-laden (wedge legibility: cheap
142 modules before server_mod) and does NOT follow strata. The table
143 keeps an explicit ordered test list and the doctrine comment moves
144 onto it; test order is never derived from layers.
145
146 All existing steps (test/e2e/agent/soak/install) must survive the
147 refactor byte-for-byte in behavior. This is the riskiest item; it is a
148 pure refactor whose proof is "the derived graph equals the old graph"
149 (extract-and-diff before/after) plus all gates green.
150
151 ### 4. Dead-code report: `tools/deadcode.sh`
152
153 Non-gating. Lists pub decls not referenced outside their defining file
154 (grep heuristic; allowlist for binary entrypoints, wire-format API kept
155 for protocol completeness, and test-only helpers). Run by hand or in a
156 periodic sweep; its output is a review prompt, not a failure. Honesty
157 requirement: the script states its false-positive classes in its header.
158
159 ### 5. zlint trial (when obtainable; drop-for-now is the default)
160
161 zlint is not installed and not in this box's repos; it needs a network
162 fetch and a build against a matching Zig version (0.15 support
163 unverified). If it can be obtained without fighting the toolchain, run
164 it once over src/ and record findings count, signal quality, and an
165 adopt/drop verdict in decisions.md. If not, record "unobtainable
166 2026-08-14, drop-for-now" and move on — the compiler's native
167 strictness already covers the highest-value lint classes. Never wired
168 into `check` unless a future trial's verdict says so.
169
170 ### 6. Leak gates
171
172 - **6a (the cheap, high-value piece):** muxd/mux/muxweb currently
173 discard the DebugAllocator verdict. Change all three to check it
174 (`if (gpa.deinit() == .leak)` — deinit returns `std.heap.Check`):
175 on `.leak`, print a single grep-able marker line to stderr
176 (`<binary>: LEAK: allocations outlived deinit`) in all build modes.
177 NOT a panic and NOT an exit-code change — muxd's exit code carries the
178 session shell's code and e2e asserts on codes throughout.
179
180 Capture reality (spec-review finding): agent.sh already captures
181 daemon stderr (detached `muxd start` logs via xdg.logPath into a
182 hermetic XDG_STATE_HOME), so its teardown grep is additive. e2e.sh
183 does NOT — every daemon there is a foreground `muxd run ... &` with
184 no stderr redirection, so 6a includes restructuring e2e.sh: per-daemon
185 `2>` redirections into per-scenario log files plus teardown greps.
186 Exit-path note: SIGTERM runs the clean-shutdown handler so `kill
187 $DPID` teardowns still reach the defer; only `kill -9` skips the
188 check silently (no false positive, just no coverage on that path).
189 - **6b:** soak.sh today is a loop of independent e2e runs — every
190 daemon dies inside one run, so there is nothing to measure a
191 RSS-delta across. 6b therefore adds a persistent-daemon mode: one
192 muxd surviving N attach/drive/detach cycles, with RSS and fd-count
193 sampled between cycles and asserted within bounds (catches C-side
194 leaks, fd leaks, and unbounded accumulation — the classes 6a cannot
195 see).
196 - **6c:** a documented valgrind recipe (tools/ or docs note) for
197 periodic deep runs over the QUIC/C stack, plus ONE real run during
198 implementation — daemon under valgrind through a marks session and a
199 QUIC dial (timing-tolerant scenarios only), findings recorded in the
200 close-out (clean bill or filed leaks) so the recipe is proven, not
201 aspirational. Non-gating — valgrind's 10–50× slowdown distorts the
202 timing-sensitive paths too much to gate on; Zig debug builds carry
203 valgrind client requests natively.
204
205 ## Non-goals
206
207 - No zlint in CI unless the trial's verdict says so.
208 - No 0.16 modernize work (separate project at toolchain bump).
209 - No C-dependency allocator instrumentation beyond the valgrind recipe.
210 - No CI/hosting changes — everything runs through `zig build` steps and
211 existing scripts.
212 - No layer-rule exceptions beyond the (at most two) grandfathered edges.
213
214 ## Testing
215
216 - Item 3's proof: mechanical graph equality before/after (extract
217 addImport edges from the old build.zig at HEAD and from the derived
218 loop's input table; diff must be empty apart from the whitelisted
219 deltas — server→replica's column move, and the version_opts instance
220 sharing if that route is taken), plus a deliberate-violation check:
221 add an upward edge to the table locally, confirm the build refuses
222 it, revert.
223 - Item 2: deliberate-break — introduce a compile error into an
224 unreferenced decl in one module, confirm `zig build test` now fails
225 (it would previously pass), revert.
226 - Item 6a: deliberate leak in a daemon teardown path under a test
227 socket, confirm the marker appears and the e2e grep catches it,
228 revert.
229 - All gates green at every commit: build, test, e2e, agent, plus the
230 new fmt/check steps.
231
232 ## Success criteria
233
234 The architecture rules a reviewer would state in prose (import
235 directions, test-scaffolding scoping, format cleanliness, forced
236 analysis, leak-free lifecycles) each have exactly one mechanical owner
237 that fails loudly, and the layer table reads as the architecture
238 document it replaces.
docs/superpowers/specs/2026-08-15-side-channel-passthrough-design.md
Old New
@@ -1,527 +0,0 @@
1 # Side-channel passthrough: clipboard, bracketed paste, title, bell
2
3 **Date:** 2026-08-15
4 **Status:** design, approved for planning
5 **Target:** replace tmux as a daily driver (roadmap, "Target, stated 2026-08-15")
6
7 ---
8
9 ## 1. What this is
10
11 mux's client paints a grid. It does not forward bytes. Everything a
12 terminal carries that is *not* grid state therefore falls on the floor
13 between the daemon's engine and your terminal — silently, with no error
14 anywhere.
15
16 This design recovers four of those things: **the clipboard (OSC 52)**,
17 **bracketed paste (mode 2004)**, **the window title**, and **the bell**.
18
19 It is not a list of four fixes. It is two mechanisms — sampled state and
20 queued events — plus the rule that decides what a reconnecting client
21 gets. The four features are the first data in those mechanisms; bell and
22 title are one arm each precisely because the mechanisms carry them.
23
24 ### The evidence this is built on
25
26 Measured 2026-08-15 on a real pty (`test/ptyclient --out`, which captures
27 exactly the bytes a host terminal would receive), recorded in
28 decisions.md under that date:
29
30 | what the session emitted | reached the host tty |
31 |---|---|
32 | `ESC[?2004h` (bracketed paste) | **0** |
33 | `ESC[?1000h`, `ESC[?1006h` (mouse) | **0** |
34 | `OSC 52` (clipboard set) | **0** |
35 | — positive control: `ESC[?1049h` | 1 |
36 | — positive control: `ESC[?2026h` | 1 |
37 | — the session's own marker text | 1, and present in `muxd dump` |
38
39 So the daemon's engine saw all of it and the client forwards none of it.
40
41 The **inbound** direction was measured separately and **already works**: a
42 literal `ESC[200~pasted line oneESC[201~` typed at the client arrives at
43 the session's pty byte for byte (observed via `cat -v`). Nothing in this
44 design touches the input path.
45
46 And the user-visible consequence, on real nvim in a real mux session:
47
48 | | third line of a pasted block |
49 |---|---|
50 | paste as it arrives today | ` b = 2` — 8 spaces, autoindent staircase |
51 | identical bytes, bracketed | ` b = 2` — correct |
52
53 Protocol support today for title, bell, cursor shape, hyperlinks and
54 focus: **none**. Verified by grep over `protocol.zig` and `paint.zig`.
55
56 ---
57
58 ## 2. Non-goals, and why
59
60 **Mouse mode mirroring.** The client never enables mouse reporting, which
61 is *why* your terminal's own drag-selection works over a mux screen. The
62 moment mux mirrors the session's mouse modes, the host stops owning the
63 mouse and that selection path disappears — correct behaviour, and what
64 every terminal does under tmux, but it trades a working copy path for one
65 that does not exist yet. Mouse mirroring belongs with a copy-mode, not
66 before it. The `term_modes` frame below reserves the bits so adding it
67 later needs no new frame type.
68
69 **Copy-mode over the replica grid.** Tracked separately (`063cec67`).
70
71 **Queries that need the real terminal to answer** — `OSC 10/11`
72 (background colour, i.e. how vim picks a light/dark theme), DA/DSR. Today
73 the daemon answers these from its own engine defaults, so applications
74 inside a session get muxd's idea of your colours rather than Alacritty's.
75 Recovering them needs a round trip out to a client and back, plus a policy
76 for which client answers when several are attached. Deferred and named.
77
78 **OSC 52 read (query) direction.** Refused, deliberately — see §7.
79
80 **The browser client.** `muxweb` tiles are clients and will receive these
81 frames. v1 does nothing with them there: `navigator.clipboard.write`
82 generally requires a user gesture, which a passive wall tile does not
83 have. `web/mux.js:373` switches on frame type with a `default:` arm at
84 :422; the implementation must confirm that arm is a no-op for unknown
85 types and add nothing else. Browser clipboard is its own design.
86
87 **Cursor shape, focus reporting, hyperlinks.** Same mechanisms, later.
88
89 ---
90
91 ## 3. The classification
92
93 Every dropped side channel falls into exactly one of three groups, and
94 the group decides the recovery mechanism. Getting this right is most of
95 the design; the individual features are small once it is settled.
96
97 ### Group 1 — sampled state
98
99 Lives in the engine and can be read at any moment. There is no history to
100 preserve: a returning client needs the *current* value, not the sequence
101 of values.
102
103 - bracketed paste: `term.modes.get(.bracketed_paste)` (verified:
104 `modes.zig:46 pub fn get(self: *const ModeState, mode: Mode) bool`, and
105 `bracketed_paste` is `modes.zig:290`, value 2004)
106 - title: `term.getTitle()` → `?[:0]const u8`, null when unset (verified:
107 `Terminal.zig:2951`)
108
109 **Mechanism:** the daemon samples after each feed, ships when the value
110 changed, and ships unconditionally on every attach. The client applies it
111 to the host tty and undoes it on detach.
112
113 ### Group 2 — events
114
115 Happen once, carry a payload, leave no state behind to sample. Recovering
116 them means intercepting at the stream and queueing.
117
118 - clipboard: `.clipboard_contents` → `{ kind: u8, data: []const u8 }`
119 - bell: `.bell`, empty value (verified: `stream.zig:771`,
120 `.BEL => self.handler.vt(.bell, {})`)
121
122 **Mechanism:** already exists in-tree. `MuxHandler` (`engine.zig:16`) was
123 written for exactly this reason — the stock ghostty-vt handler swallows
124 OSC 133 — and `engine.zig:28` is the whole interception:
125
126 ```zig
127 if (comptime action == .semantic_prompt) self.onSemanticPrompt(value);
128 ```
129
130 `mark_events` is already queued on the Engine and drained by the server
131 after each feed (`server.zig:2059`–`2084`). A second and third event kind
132 is data in an existing shape, not new architecture.
133
134 ### Group 3 — queries
135
136 Need an answer only the real terminal has. Out of scope (§2).
137
138 ---
139
140 ## 4. Disconnect and reattach — the rule
141
142 This is the part that decides whether the clipboard is trustworthy, so it
143 is stated before the wire format rather than after.
144
145 mux already distinguishes "this client is returning to a session it has a
146 valid watermark for" from "this client is starting fresh", and it does so
147 in one place: `sendResync` (`server.zig:2243`). The condition is
148 `server.zig:2249`:
149
150 ```zig
151 if (have_epoch == s.epoch and s.tracker.canServe(have_seq)) // → delta
152 // else → snapshot
153 ```
154
155 `epoch` is a random non-zero u64 minted per session instance
156 (`server.zig:464`), so it survives a client reconnect and dies with the
157 session.
158
159 > **The rule: the side channel follows the grid's own resync verdict.**
160 > A client that earns a **delta** also receives the side-channel events
161 > from the gap. A client that receives a **snapshot** receives none of
162 > them.
163
164 No new policy knob, no second notion of freshness, and it reuses
165 machinery whose semantics are already pinned by
166 `test/e2e.sh` and by `Server: reattach needs a recent have_seq AND this
167 daemon's epoch to get a delta` (`server.zig:3859`).
168
169 Applied per group:
170
171 **Group 1 (modes, title) — re-sent on every attach, delta or snapshot.**
172 State, not history: a returning client must be told the truth
173 unconditionally. This is also what makes the title correct after a
174 reattach, which it never has been.
175
176 **Group 2 (clipboard, bell) — watermarked, and collapsed to one slot per
177 kind.**
178
179 - Yank in vim → link tears → reconnect 2s later → delta → **the copy
180 lands.** This is the case that decides whether anyone trusts the
181 feature; silently losing a yank because the link blinked is how a copy
182 feature becomes one you stop believing.
183 - Detach for twenty minutes → snapshot on return → the event is dropped.
184 A twenty-minute-old clipboard write hijacking your clipboard *now* is a
185 bug wearing a feature's clothes.
186 - The queue is **one slot per kind, not a log**. Two copies during the
187 gap: last wins, which is what the user meant. Forty bells: one ding.
188 This also disposes of unbounded growth — a detached session that bells
189 ten thousand times holds exactly one pending bell.
190 - "Per kind" means per `term_event` kind, **not** per clipboard target: a
191 later `p` (primary) set replaces an earlier `c` (clipboard) set in the
192 single clipboard slot, because the slot holds the last clipboard event
193 and its target rides along inside it. Two targets set during one gap is
194 a case nobody has, and one slot is worth more than the generality.
195
196 **The rule's argument and its reach are not the same size.** The
197 snapshot-branch drop is argued above from *staleness* — a twenty-minute-old
198 clipboard write must not hijack the clipboard now. But the rule also drops a
199 **50ms-old** event: a bell rung before any client has ever attached is
200 recorded (the tracker is built with nobody watching, because `resyncSnapshot`
201 rebuilds unconditionally so it is ready for the next attach), and then
202 discarded when that first-ever client takes the snapshot branch.
203
204 That is the right answer, and the reason is sound — a snapshot-served client
205 is a stranger to the session, and a first-ever attach is the archetypal
206 stranger — but it is "you were not there yet" rather than "it is stale".
207 Recorded because the staleness argument alone would not predict this case,
208 and someone auditing the rule against it could conclude the rule was
209 misfiring when it is doing exactly what it says.
210
211 **Retention has no wall-clock bound, by design.** A pending slot survives
212 until the next rebuild, so a client whose gap contained *no visible output*
213 can be delta-served an arbitrarily old clipboard — the twenty-minute-old
214 write this rule argues against, arriving through the delta door rather than
215 the snapshot one. That is a consistent consequence of the chosen rule
216 (earning a delta means nothing changed in between, so there is no "later"
217 for the event to be stale relative to) and it is kept. Noted because it is
218 the one case where the rule's intuition and its mechanism come apart.
219
220 **A limit found in implementation, recorded rather than papered over.**
221 `tracker.seq` advances only when a row hash changes, the cursor moves, or
222 history grows (`delta.zig:105-124`). So an event produced by a chunk that
223 changes nothing visible — a bare BEL, or an OSC 52 with no redraw behind
224 it — is stamped at the seq the gap *began* on, and `pending.seq >
225 have_seq` excludes it. **Such an event is not replayed.**
226
227 The alternative, `>=`, hands a duplicate to every client that was present
228 for the event, which is worse: a redundant clipboard set is a silent
229 overwrite of whatever the user copied since. `>` is kept, and live
230 delivery is unaffected either way — this is only about the replay path,
231 and only for events with no visible output. Copying from vim, or anything
232 that repaints, is the normal case and is covered.
233
234 **The event's watermark is the grid's own seq**, captured when the event
235 is queued. No second clock. Delivery test on resync is
236 `pending.seq > have_seq`, evaluated only on the delta branch.
237
238 **Self-cleaning.** A pending event whose seq has fallen outside what
239 `tracker.canServe` will serve can never be delivered to anyone, so it is
240 dropped at that point rather than held. This matters beyond tidiness: the
241 pending clipboard slot holds **the user's copied text in daemon memory**,
242 and text you copied an hour ago should not still be sitting there.
243
244 **Ordering.** On a resync the delta goes first, then any pending events —
245 the grid should be consistent before something acts on it.
246
247 **Two consequences stated rather than discovered later:**
248
249 1. The pending clipboard goes to **whoever attaches next with a valid
250 watermark**, which need not be the machine that yanked. This is inside
251 the existing trust boundary — one key is already full control of the
252 session — but it is a real property and belongs in the code comment,
253 not in someone's head.
254 2. A mid-session tear and heal never touches the host tty. The client
255 process stays alive, `?2004h` stays set because the session still
256 wants it, and the daemon re-sends sampled state on heal so the two
257 reconcile. Only a clean detach unsets it.
258
259 ---
260
261 ## 5. Wire format
262
263 Three new frames, all daemon→client. `MsgType` is non-exhaustive (`_,` at
264 `protocol.zig:39`), so an unknown type is representable rather than a
265 parse failure, and a new client against an old daemon simply never
266 receives one. That an old *client* ignores an unknown type is asserted in
267 §8 and verified there rather than assumed here. Free bytes after
268 `status_reply = 0x8c`:
269
270 ```
271 term_modes = 0x8d // payload: u32 LE bitset
272 term_title = 0x8e // payload: UTF-8 title bytes (may be empty)
273 term_event = 0x8f // payload: 1 byte kind ++ kind-specific bytes
274 ```
275
276 **Not** a widened `pty_mode` (0x88): `protocol.zig:447` is
277 `if (payload.len != pty_mode_len) return error.BadPayload`, so a longer
278 payload is refused outright by every shipped client.
279
280 **Three frames, not one combined frame.** Shells set the title on *every
281 prompt* (OSC 0/2 from `PS1`) and readline flips `?2004` around *every
282 prompt*. A combined frame would re-send the title on every mode flip and
283 the modes on every title change. Send-on-change is only cheap if the
284 frames are split.
285
286 ### `term_modes` (0x8d)
287
288 ```
289 u32 LE bitset
290 bit 0 bracketed paste (DEC 2004)
291 bit 1.. reserved for mouse tracking / format, focus, cursor shape
292 ```
293
294 Reserved bits are sent as 0 and **ignored on receipt**, which is what
295 lets the mouse bits land later without a new frame type or a version
296 check.
297
298 Client action: bit set/clear transitions write `ESC[?2004h` / `ESC[?2004l`
299 to the host tty.
300
301 ### `term_title` (0x8e)
302
303 Payload is the title bytes, possibly empty (the engine's `getTitle()`
304 returns null for "never set", which encodes as a zero-length payload).
305 Capped at 1 KiB at the daemon — a title is a window decoration, and
306 anything longer is either a bug or an attempt to smuggle something.
307
308 Client action: write `ESC]0;<title>BEL` to the host tty.
309
310 ### `term_event` (0x8f)
311
312 ```
313 u8 kind
314 0 = clipboard payload: 1 byte target ++ base64 bytes
315 1 = bell payload: empty
316 ```
317
318 Clipboard target is the OSC 52 `kind` byte verbatim (`c`, `p`, …).
319
320 **The base64 is carried verbatim, not decoded and re-encoded.** ghostty
321 does not decode it for us — `ClipboardContents { kind: u8, data: []const
322 u8 }` hands over the raw payload string (verified: `stream.zig:368`,
323 `osc.zig:52`) — and every transform is a chance to corrupt a payload
324 neither end ever needs to read. The daemon **validates the base64
325 alphabet** before queueing, which is what guarantees the bytes cannot
326 contain `ESC` or `BEL` and terminate the escape early on the client's
327 tty; the client **re-validates** rather than trusting the wire.
328
329 Client action: write `ESC]52;<target>;<base64>BEL` to the host tty. BEL
330 rather than `ESC\` as the terminator because it is what most emitters in
331 the wild use and every terminal that accepts one accepts it.
332
333 ---
334
335 ## 6. Daemon
336
337 **Interception** (`engine.zig`, `MuxHandler.vt`): two more comptime arms
338 beside the existing `.semantic_prompt` one, appending to a new
339 `side_events` queue on the Engine alongside `mark_events`. As with marks,
340 an append that fails under OOM is dropped rather than failing the feed —
341 a lost bell costs nothing, and a lost clipboard event is no worse than
342 today.
343
344 **Size cap.** mux builds its stream with `.initAlloc` (`engine.zig:121`),
345 so ghostty will faithfully buffer an arbitrarily large OSC 52 — its own
346 `stream.zig:442` comment warns that clipboard payloads "can be
347 arbitrarily large". The cap is therefore mux's job and not hypothetical.
348 **64 KiB of base64** (~48 KiB of text), refused with a daemon log line
349 naming the size. A refusal is not a failure of the feed.
350
351 **Sampling** (`server.zig`, after each feed, beside the `markEvents`
352 drain at :2059–:2084): read `modes.get(.bracketed_paste)` and
353 `getTitle()`, compare against the last values held per session, queue a
354 frame per changed value.
355
356 **Per-session, necessarily.** M18 made the daemon hold up to four
357 sessions; every piece of state above is per-session, exactly as
358 `cmd_state` pushes are (`Server: cmd_state pushes stay inside their
359 session`, `server.zig:6667`). A pin in the same shape is owed here.
360
361 **On attach:** `sendResync` sends the current mode bitset and title
362 unconditionally, and pending events only on the delta branch.
363
364 ---
365
366 ## 7. Security decisions
367
368 **OSC 52 query is refused.** The escape's read form (`ESC]52;c;?`) asks
369 the terminal to send the clipboard back *on the input stream*. Honouring
370 it would let anything running in any session — a remote box reached over
371 QUIC, a session an agent is driving — read whatever the human last
372 copied. mux refuses it: the arm drops the event and does not answer.
373 Alacritty's own default (`osc52 = "OnlyCopy"`) is the same posture, and
374 xterm has shipped it disabled for the same reason. **This belongs in a
375 comment at the refusal site**, because the next person to read that code
376 will otherwise "complete" the feature.
377
378 **The write direction is still a real capability**, and it is worth being
379 honest about it rather than pretending the cap makes it safe: any program
380 whose output reaches a mux session can put arbitrary text in the user's
381 clipboard. `cat` a hostile file and it can leave `rm -rf ~` there for you
382 to paste an hour later. This is true of every terminal that implements
383 OSC 52 and is not a reason to skip it, but the cap, the base64 validation
384 and the query refusal are the three things that keep it to *that* risk
385 and no larger one.
386
387 **The pending clipboard is user data in daemon memory.** Dropped as soon
388 as its watermark is unservable (§4) rather than held for the life of the
389 session.
390
391 ---
392
393 ## 8. Cross-version
394
395 Both directions, and `test/xversion.sh` gets a leg for each:
396
397 - **New client ↔ old daemon.** No new frames are ever sent; the client
398 sees today's behaviour exactly. Bracketed paste stays off, clipboard
399 stays dropped. No probe needed and no version check — the *absence* of
400 a frame is a complete and correct signal here, which is not true of the
401 named-session gap M18 documented.
402 - **Old client ↔ new daemon.** Unknown `MsgType` hits `else => {}` and is
403 ignored. This must be **verified on both dispatch paths, not assumed**:
404 M18 found that socket and QUIC differ in exactly this area (a bad
405 attach drops the connection on the socket path and is ignored in
406 silence over QUIC). The xversion leg is what turns that from a belief
407 into a fact.
408
409 ---
410
411 ## 9. Testing
412
413 ### Unit
414
415 - `engine`: OSC 52 set queues an event with target and payload intact;
416 `?` query queues nothing; a payload over the cap queues nothing; bell
417 queues an event; a feed split mid-escape across two `feed()` calls
418 still produces exactly one event (ghostty's parser is stateful across
419 feeds — this is the boundary that a hand-rolled byte scanner would get
420 wrong, and pinning it is what says we did not hand-roll one).
421 - `protocol`: round-trip plus **golden bytes** for all three frames, per
422 the standing rule — assert the literal, never the constant the code
423 under test reads.
424 - `server`: mode/title change detection fires once per change and not per
425 feed; sampled state is sent on both resync branches; a pending event is
426 sent on the delta branch and **not** on the snapshot branch; a pending
427 event whose seq falls out of servable range is dropped; events stay
428 inside their session on a multi-session daemon.
429 - `client`: escape synthesis for each frame; base64 re-validation refuses
430 a malformed payload; teardown emits `?2004l`.
431
432 ### e2e — the discovery experiments, inverted
433
434 Run on the `ptyclient` rig, asserting against the host capture, which is
435 the file that already proved these were absent:
436
437 1. Session emits OSC 52 → capture contains `ESC]52;c;<base64>`.
438 2. Session runs nvim → capture contains `ESC[?2004h`; after detach,
439 `ESC[?2004l`.
440 3. Session sets a title → capture contains `ESC]0;<title>`.
441 4. Session emits BEL → capture contains a BEL that is not part of another
442 escape.
443 5. **Tear/heal**: clipboard event emitted while the link is down, healed
444 within the servable window → the event arrives after the delta.
445 Emitted, then a *fresh* attach → it does not.
446
447 ### e2e — the behavioural pin, and one fixture change
448
449 A byte-grep for `?2004h` proves the frame arrived, not that pasting
450 works. `ptyclient` gains a **`paste` verb** that wraps its payload in
451 `ESC[200~`/`ESC[201~` **only if it has seen `?2004h` in the stream** —
452 i.e. the fixture behaves like a real terminal instead of asserting like a
453 test. Then:
454
455 > session runs `nvim -u NONE -c "set autoindent"`, script pastes a
456 > three-line indented block, and the **file nvim writes** must contain
457 > ` b = 2` and not ` b = 2`.
458
459 This is the assertion that fails if the mirror regresses in any way that
460 a grep would miss, and it is the exact A/B already run by hand (§1).
461
462 ---
463
464 ## 10. Assumptions, and how to falsify them
465
466 Written down because they were not verified when this spec was written.
467 **Assumptions 1 and 2 were both confirmed by the operator on 2026-08-15**,
468 in a bare Alacritty window outside tmux — annotated inline below:
469
470 1. **Alacritty honours OSC 52 writes. — CONFIRMED 2026-08-15.** The
471 operator ran the falsification below in a bare Alacritty window and the
472 clipboard was set. So the whole OSC 52 chain this branch builds is
473 visible at the far end rather than silently correct-but-dead, and the
474 feature can be demonstrated rather than only tested. Original note
475 follows. Untested — the operator could not
476 test at design time, and this session runs inside tmux, which
477 intercepts OSC 52 itself and only forwards it when `set-clipboard` is
478 on. Falsify with `printf '\033]52;c;aGVsbG8=\007'` in a bare Alacritty
479 window, outside tmux, then paste. If it fails, **nothing in this
480 design changes** — the daemon and client are still correct and the fix
481 is an Alacritty config line — but the feature will appear dead, and
482 that is worth knowing before blaming mux.
483 2. **The xterm title stack works in Alacritty. — CONFIRMED 2026-08-15.**
484 The operator ran the push/set/pop probe and **the title returned**. So
485 mux implements restore-on-detach (push `ESC[22;0t` on attach, pop
486 `ESC[23;0t` in the teardown) rather than tmux's leave-it-set fallback.
487 This is what makes the title feature safe to ship: mux can set your
488 title bar because it can put it back. Original note follows. The clean way to restore
489 your terminal's title on detach is push (`ESC[22;0t`) on attach and
490 pop (`ESC[23;0t`) on detach; mux cannot read the current title back to
491 restore it manually. If Alacritty does not implement the stack, the
492 fallback is to leave the title mux set, which is what tmux does. Decide
493 by observation during implementation, not by reading a table.
494 3. **The browser's `default:` arm at `web/mux.js:422` is a no-op for
495 unknown frame types.** Confirm before shipping; the hub forwards
496 frames it does not interpret.
497
498 ---
499
500 ## 11. Kill criteria
501
502 - If bracketed paste cannot be made to survive a detach/reattach without
503 leaving the host terminal in a mode mux turned on, the mode mirror is
504 wrong and should be reconsidered rather than shipped with a caveat. A
505 multiplexer that corrupts your terminal on exit is worse than one that
506 does not paste well.
507 - If the pending-event rule cannot be pinned — specifically if "delivered
508 on delta, dropped on snapshot" cannot be asserted end to end — then the
509 clipboard should ship **live-only** (no replay at all) rather than with
510 an untested replay path. An untested clipboard replay is a clipboard
511 that changes under you for reasons nobody can reproduce.
512
513 ---
514
515 ## 12. Order of work
516
517 Independent of each other; this order front-loads the thing that was
518 asked for and puts the shared machinery under it first.
519
520 1. `term_event` + clipboard (the mechanism, the cap, the query refusal,
521 the base64 validation) — the shortest gap to usability.
522 2. `term_modes` + bracketed paste, including the `ptyclient paste` verb
523 and the nvim staircase pin.
524 3. `term_title` and bell — one arm each, on mechanisms that now exist.
525 4. The pending/watermark rule and its tear/heal pins. Landing this last
526 means steps 1–3 ship live-only first, which is also the kill-criterion
527 fallback if the rule turns out not to be pinnable.
docs/superpowers/specs/2026-08-16-web-copy-paste-client-core-design.md
Old New
@@ -1,324 +0,0 @@
1 # Web copy/paste and shared client semantics — design
2
3 Status: approved in conversation; awaiting review of this written spec.
4
5 ## Why
6
7 The web client can already send pasted text, but it always adds bracketed-paste
8 markers because it ignores the daemon's `term_modes` sample. It cannot act on
9 the `term_event` frames that carry OSC 52 clipboard writes, and its canvas has
10 no user selection model. The native CLI already handles the first two protocol
11 families, but that interpretation currently lives inside `client.zig` rather
12 than behind a boundary the web client can reuse.
13
14 This work adds browser copy/paste while making the CLI and web clients consume
15 the same semantic terminal contract. It does not forward raw OSC or CSI bytes
16 to browser policy code. The daemon and shared Zig core remain the terminal
17 protocol authorities; platform adapters only perform platform operations.
18
19 ## Tracker coverage
20
21 There is no dedicated muxweb clipboard ticket. Existing open issues cover the
22 underlying work:
23
24 - `7c777ec6`, "OSC 52 from the session never reaches the host clipboard": the
25 native set-only path has shipped, and the issue body already identifies the
26 browser as another consumer.
27 - `ee062dd9`, "the client never mirrors the session's terminal modes to the host
28 tty": native bracketed-paste mirroring has shipped, while muxweb still ignores
29 the frame.
30 - `063cec67`, "copying scrollback: the host terminal only ever sees one painted
31 screen": related to user-driven copying, but its native keyboard copy-mode
32 scope remains separate from this browser mouse-selection work.
33
34 The first implementation slice should update the first two issues. The second
35 slice should be tracked as the muxweb mouse-selection part of copy/scrollback,
36 either by narrowing `063cec67` with a comment or opening a dedicated ticket if
37 the tracker requires independently closeable work.
38
39 ## Goals
40
41 - Make web paste respect the session's bracketed-paste mode.
42 - Make application-driven OSC 52 copy reach the browser clipboard, with a
43 reliable user-gesture fallback where browser policy rejects an automatic
44 write.
45 - Add retained, mouse-drag text selection across the live viewport and fetched
46 scrollback, followed by explicit copy.
47 - Give the CLI and web clients one shared semantic interpretation of terminal
48 state, one-shot host effects, and correlated replies.
49 - Establish an extension point for later terminal semantics without creating a
50 generic event bus or raw-escape forwarding channel.
51
52 ## Non-goals
53
54 - Keyboard-driven/tmux-style copy mode.
55 - Copy-on-select or primary-selection behavior.
56 - OSC 52 clipboard queries or clipboard clears; the current refusal policy
57 remains.
58 - Replacing the browser's canvas renderer with a DOM or hidden text renderer.
59 - Moving transport, reconnect, painting, tty setup/teardown, or browser API
60 policy into the shared core.
61 - Handling every OSC/CSI as a client action. Grid mutations remain replica
62 state, and terminal query responses remain daemon-to-PTY traffic.
63
64 ## Semantic client contract
65
66 The portable client layer accepts a mux protocol frame and returns a normalized
67 result. It is shared Zig code, built natively and for `wasm32-freestanding`.
68
69 ```text
70 Unix/SSH/QUIC or WebSocket
71 |
72 protocol frame
73 |
74 ClientCore
75 / | \
76 State Effect Reply
77 | | |
78 CLI adapter or Web adapter
79 ```
80
81 The result families are:
82
83 - **State:** the latest sampled value, such as terminal modes or title. A valid
84 sample is delivered to the adapter even when equal to the stored value. This
85 preserves the native client's intentional attach/re-attach behavior and lets
86 an adapter reassert host state.
87 - **Effect:** a one-shot operation that cannot be reconstructed from current
88 terminal state, such as clipboard set or bell.
89 - **Reply:** the response to a client-owned request, correlated by request ID,
90 such as extracted selection text.
91 - **Replay/control:** existing snapshot, delta, resync, exit, and scrollback
92 outcomes. These can migrate behind the same entry point incrementally; the
93 copy/paste slices do not require rewriting their already-shared `Replica`.
94 - **Ignored:** unknown frames and valid frames with no action for this client.
95
96 The contract is semantic, not a generic raw OSC forwarding API. Likely future
97 members include desktop notification effects and sampled progress, working
98 directory, pointer-shape, mouse-reporting, and focus-reporting state. SGR,
99 cursor motion, colors, images, and OSC 8 hyperlink metadata remain in the
100 terminal/grid model. DA, DSR, mode, size, and similar queries are answered by
101 the daemon's authoritative engine and never become client host actions.
102
103 ## Slice 1: prove state and effects using existing wire messages
104
105 Slice 1 is independently mergeable and adds no new mux message types. It
106 refactors the existing native behavior into shared interpretation, then gives
107 the web adapter two real consumers.
108
109 ### Shared core
110
111 The core stores terminal modes and classifies `term_modes` and `term_event`.
112 It owns validation that is independent of the host platform:
113
114 - `term_modes` must have its exact fixed width. A malformed sample neither
115 changes stored state nor emits an adapter result.
116 - A clipboard event must have a known event kind, a permitted xterm OSC 52
117 target (`c`, `p`, `q`, `s`, or `0` through `7`), a non-empty payload no
118 larger than `clipboard_base64_max`, and only the safe base64 alphabet.
119 - A bell event must have exactly its fixed payload shape.
120 - Unknown frame and event kinds are ignored rather than guessed.
121
122 Clipboard action data borrows the inbound frame buffer. Every adapter must copy
123 or consume it synchronously before accepting the next frame.
124
125 ### CLI adapter
126
127 The CLI loop passes these frames through the shared core. Its adapter retains
128 the current platform behavior:
129
130 - terminal mode sample -> `DECSET`/`DECRST 2004` on the owned host tty;
131 - clipboard effect -> reconstructed OSC 52 sequence on the host tty;
132 - bell effect -> BEL.
133
134 Escape construction and tty ownership checks stay native. Existing tests that
135 pin exact output bytes remain the compatibility oracle; this refactor must not
136 change the bytes or the all-or-nothing write rule.
137
138 ### WASM and web adapter
139
140 The WASM entry point accepts the raw type and staged payload, calls the shared
141 core, and exposes only a semantic result kind plus result-specific getters.
142 JavaScript does not parse mux payload layouts. Any borrowed getter is valid
143 only until the next staged frame is applied, matching the existing staging
144 buffer discipline.
145
146 For paste, JavaScript consults the core's current bracketed-paste state:
147
148 - disabled -> send pasted UTF-8 bytes without markers;
149 - enabled -> send one `ESC[200~`, all chunks, then one `ESC[201~`.
150
151 Chunking never creates additional marker pairs. Composed IME text remains
152 typing rather than paste and is never wrapped.
153
154 For OSC 52, the web adapter base64-decodes the validated payload and accepts it
155 only when it is valid UTF-8 text. Only the zoomed tile may act on the effect; a
156 wall tile discards it so a background session cannot replace the user's
157 clipboard or raise competing prompts.
158
159 The adapter first attempts `navigator.clipboard.writeText`. If browser policy
160 rejects an automatic write, it retains only the latest valid request and shows
161 a transient Copy action. Clicking that action performs the write with user
162 activation. Success clears the retained request and reports a short success
163 state; failure reports a short error without retrying in a loop. A subsequent
164 valid request replaces the previous pending request.
165
166 Slice 1 proves the architecture when all of the following are true:
167
168 - native output remains byte-for-byte unchanged;
169 - browser paste changes with the sampled session mode;
170 - a real OSC 52 set reaches the browser clipboard through automatic write or
171 the explicit fallback;
172 - both platforms consumed the same validated state/effect result.
173
174 ## Slice 2: mouse selection through request/reply
175
176 The browser owns interaction and highlighting; the daemon owns terminal truth
177 and text extraction. This avoids duplicating Ghostty's soft-wrap, wide-cell,
178 multi-page, and trimming semantics in JavaScript or in the browser's one-page
179 scrollback scratch engine.
180
181 Only the zoomed tile supports selection. Pointer down creates an anchor cell,
182 pointer movement updates the active cell and highlight, and pointer up retains
183 the selection. Dragging beyond the viewport edge scrolls/fetches history while
184 continuing the selection. No keyboard selection mode is added.
185
186 Coordinates use the same screen-row space as `fetch_scrollback`: row zero is
187 the oldest retained history row, followed by the live viewport; columns are
188 zero-based terminal cells. The browser derives live rows from the history-row
189 count associated with its displayed replica and scrollback rows from the
190 echoed fetch range. These coordinates are best-effort anchors, like existing
191 engine mark rows: history pruning or resize reflow between display and request
192 may invalidate them. The daemon refuses an endpoint it can no longer resolve;
193 it never silently substitutes another cell.
194
195 On pointer up, the browser sends `selection_req` (`0x0b`) with a 16-byte
196 payload: u32 request ID, u32 anchor row, u16 anchor column, u32 active row, and
197 u16 active column, all little-endian. IDs are monotonically increasing
198 client-local u32 values and wrap naturally; only equality with the current ID
199 has meaning.
200
201 The daemon resolves both points on the authoritative active screen, constructs
202 a Ghostty `Selection`, and calls `Screen.selectionString` with soft-wrap
203 unwrapping and trailing-space trimming enabled. It returns `selection_reply`
204 (`0x90`): u32 request ID, one status byte, then UTF-8 text only for `ok`. The
205 status vocabulary is `ok`, `invalid` (including a coordinate lost to pruning
206 or reflow), `too_large`, and `unavailable` (an operational failure). Fixed-size
207 failure replies have no text tail; malformed replies are ignored.
208
209 `selection_text_max` is 1 MiB. Extraction must stop at that bound and return
210 `too_large`, never allocate an arbitrarily larger string merely to reject it
211 afterward and never truncate a successful copy. The WASM staging capacity must
212 hold the reply prefix plus `selection_text_max`.
213
214 Selection replies are sent only to the requesting client and are never
215 broadcast or replayed on attach.
216
217 The browser ignores replies whose ID is no longer current. A successful reply
218 is cached as the retained selection's copy text. The visual selection may be
219 drawn immediately during dragging, but copy is disabled until the matching
220 reply arrives. A failed or stale reply clears the copy text and presents a
221 short non-modal status.
222
223 ### Explicit copy behavior
224
225 - Mouse drag selects; releasing the mouse does not modify the clipboard.
226 - `Ctrl+Shift+C` copies when a valid retained selection exists and calls
227 `preventDefault`, suppressing Firefox's Inspector shortcut in that case.
228 - With no retained selection, `Ctrl+Shift+C` is left to the browser, so Firefox
229 Inspector remains available.
230 - On macOS, `Cmd+C` copies a retained selection.
231 - `Ctrl+C` copies when a retained selection exists; otherwise it continues to
232 send ETX to the terminal.
233 - A new plain click or terminal input clears the retained selection. Copying
234 does not have to clear it.
235
236 Browser clipboard writes initiated by these shortcuts occur inside the user
237 gesture and use the same web adapter path as the OSC 52 fallback.
238
239 ## Failure and security behavior
240
241 - Parsing and validation are all-or-nothing. Invalid state cannot replace a
242 prior valid sample, and an invalid effect cannot reach a platform adapter.
243 - No client sends terminal-generated query responses back to the PTY; the
244 daemon remains authoritative and prevents duplicate replies.
245 - OSC 52 query and empty-clear forms retain their existing refusal policy.
246 - Only the zoomed tile can affect the browser clipboard.
247 - A pending browser clipboard request is bounded to one validated payload and
248 replaced, not queued, preventing an inactive permission prompt from growing
249 memory.
250 - Selection extraction is bounded and returns explicit failure rather than
251 partial text.
252 - Unknown frame types remain forward-compatible and are ignored.
253
254 ## Validation
255
256 ### Shared Zig tests
257
258 Table-driven tests feed golden protocol payloads through the shared core:
259
260 - bracketed paste on, off, and repeated samples;
261 - malformed mode payload with prior state preserved;
262 - valid clipboard and bell effects;
263 - invalid target, alphabet, empty data, cap boundary, truncation, trailing
264 bytes, and unknown event kind;
265 - unknown message type -> ignored.
266
267 The shared module is instantiated for the native and wasm targets. Native unit
268 tests exercise its logic; building the wasm twin prevents platform imports.
269
270 ### Native compatibility tests
271
272 The current `client.zig` side-channel tests continue to assert the exact OSC
273 52, BEL, and DECSET/DECRST output generated by the CLI adapter. They also retain
274 the rule that malformed semantic input writes no bytes. No terminal escape is
275 partially emitted.
276
277 ### WASM contract tests
278
279 `web/verify.js` feeds the same golden frames to the compiled core and asserts:
280
281 - semantic result kind and clipboard getters;
282 - stored mode state and repeated samples;
283 - raw paste while disabled and exactly one marker pair while enabled, including
284 chunked paste;
285 - malformed input emits no action and leaves stored state intact;
286 - the documented borrowed-data lifetime and JavaScript/WASM export agreement.
287
288 Slice 2 adds golden request/reply codec tests, authoritative engine extraction
289 tests covering soft wraps, hard line breaks, wide characters, reversed drags,
290 viewport-to-history spans, invalid coordinates, and over-limit refusal. Server
291 tests prove the reply goes only to the requester. WASM tests prove request IDs
292 and stale-reply rejection.
293
294 ### Browser acceptance
295
296 In Firefox, against a real `muxd` through `muxweb`:
297
298 1. Generate OSC 52 in the session; confirm the fallback appears when automatic
299 clipboard access is rejected, clicking it copies the expected text, and a
300 wall tile cannot act on the effect.
301 2. Enable and disable DEC 2004 in the session; confirm multiline paste is
302 wrapped only while enabled.
303 3. Drag a selection across ordinary rows, a soft wrap, and fetched scrollback;
304 confirm copied text matches Ghostty's extraction.
305 4. Confirm `Ctrl+Shift+C` copies and suppresses Inspector only with a retained
306 selection; without one it continues to open Inspector.
307 5. Confirm `Ctrl+C` sends ETX without a selection and copies with one.
308
309 Automated gates are `make test`, `make build`, and `make e2e`. Browser clipboard
310 permission behavior remains a manual acceptance check because the repository
311 has no real-browser automation harness.
312
313 ## Delivery order
314
315 1. **Slice 1 — shared state/effect core:** refactor the existing CLI mode and
316 event interpretation, wire the same core through WASM, make web paste
317 mode-correct, and implement OSC 52 browser copy plus fallback.
318 2. **Slice 2 — shared request/reply path:** add selection codecs and daemon
319 extraction, mouse interaction/highlighting, retained selection, and explicit
320 browser copy shortcuts.
321
322 Each slice is mergeable and validated independently. Slice 1 is the smallest
323 proof of the common architecture; Slice 2 extends the proven contract rather
324 than introducing a parallel web-only path.
docs/superpowers/specs/2026-08-18-dynamic-wall-design.md
Old New
@@ -1,112 +0,0 @@
1 # Dynamic wall — design
2
3 2026-08-18. Approved in-chat (brainstorming: architectural path).
4
5 ## Problem
6
7 The muxweb wall is fixed at launch: tiles are argv, and adding or
8 removing a window means killing the hub and retyping the command line.
9 The user wants to add a tile (new host, or new session on a connected
10 host), remove one, and reorder the wall — from the browser, at runtime.
11 The mux CLI will grow the same ability later, so the mechanism must not
12 be trapped inside webhub.
13
14 ## Decisions
15
16 - **Hub owns the live wall and persists it.** Page reload or a second
17 browser shows the current wall; hub restart restores it from disk.
18 - **Reorder only.** Tiles stay a uniform grid; v1 moves a tile's
19 position in the order. Sizes/spans and free-form layout are out of
20 scope.
21 - **No daemon changes.** Attach-or-create (M18) already covers "new
22 session, same host": a tile dialing `host#name` creates the session.
23 - **Remove = detach.** Deleting a tile closes the hub's connection; the
24 daemon session lives on. Re-adding the target gets it back.
25
26 ## Section 1 — wall model & persistence (the consolidated path)
27
28 New module `src/wall.zig`, layer 1, importing only `xdg` (plus
29 `testtmp` for tests). The wall is an ordered list of TARGET spellings
30 stored verbatim, `#NAME` and all — the string the user typed is the
31 label, same doctrine as today's argv tiles.
32
33 Ops: `add`, `remove`, `reorder`. Validation is exactly what argv gets
34 today (session-name syntax after the last `#`); every other error keeps
35 surfacing at dial time.
36
37 Persistence: one TARGET per line at `$XDG_STATE_HOME/mux/wall` — the
38 file format is literally an argv list, no schema. Every mutation
39 rewrites it atomically (temp + rename). Both muxweb now and the mux CLI
40 later go through this one module and one file, so the wall built in the
41 browser is the wall the CLI sees. Two concurrent writers = last rename
42 wins; acceptable for a single user's state file.
43
44 Launch semantics: `muxweb` with no argv restores the file
45 (missing/empty → empty wall); argv present replaces the wall and
46 persists it — argv stays the explicit override.
47
48 ## Section 2 — HTTP surface
49
50 `/ws/<idx>` currently indexes wall order, which races once tiles can be
51 removed or reordered mid-flight. Tiles therefore get stable per-run ids
52 (monotonic counter, assigned at load/add). The persisted file stays
53 id-free; ids exist only in the running hub.
54
55 - `GET /tiles` — as today, plus `id`:
56 `[{"id":N,"label":…,"session":…}]` in wall order.
57 - `POST /tiles`, body = one TARGET spelling — argv-equivalent
58 validation, append + persist, respond `{"id":N}`. The browser then
59 opens `/ws/<id>`; dialing stays on WS open, so a bad host shows as an
60 in-tile `reconnecting`, not a failed POST.
61 - `DELETE /tiles/<id>` — close that tile's WS if open, drop from wall,
62 persist.
63 - `PUT /tiles`, body = the full id list in new order — reorder +
64 persist. Unknown or missing id → 409 carrying the current wall; the
65 page refetches.
66
67 All mutations serialize under one mutex covering wall + file write.
68 Everything sits behind the existing Origin gate; localhost-only stands,
69 no new auth surface.
70
71 ## Section 3 — browser UI
72
73 - **Add:** a "+" tile at the end of the grid; clicking it becomes a
74 single text input taking a TARGET spelling — mux's grammar is the
75 form, no host/port/session fields. Enter → POST → open `/ws/<id>` →
76 live tile. An empty wall is the "+" tile alone.
77 - **New session, same host:** a per-tile action pre-fills the input
78 with that tile's `host#` and focuses it. Sugar over add; no endpoint.
79 - **Remove:** per-tile `×` → DELETE. No confirm — it's a detach.
80 - **Reorder:** drag a tile to a new slot → PUT with the full id order.
81 - Controls follow the overlay rules: hidden while a tile is zoomed, and
82 they must not steal IME focus (the copy-control landmine).
83 - The page treats `GET /tiles` as truth: refetch on load and after each
84 of its own mutations, reconciling open WSes by id.
85
86 ## Section 4 — testing
87
88 - **`wall.zig` unit:** add/remove/reorder ordering; validation matches
89 argv's session-name rules; persistence round-trip through testtmp
90 (mutate → reload → same wall); missing/empty file → empty wall;
91 atomic rewrite leaves no partial file.
92 - **`webhub.zig` unit:** extend the route-table pins — verb/path
93 parsing for the three mutating verbs, id-parse edge cases (the
94 `/ws/x`, `-1` family), 409 on stale order, `/tiles` JSON with ids.
95 - **e2e (`test/e2e.sh` + `wsclient`):** no-argv hub → empty wall; POST
96 against a real daemon → wsclient on `/ws/<id>` gets the snapshot;
97 DELETE closes the WS but the daemon session survives (re-add, same
98 scrollback); PUT reorders and GET agrees; restart without argv
99 restores the wall; restart with argv replaces the file.
100 - Drag/DOM logic stays thin in `mux.js`; everything decidable
101 server-side is asserted through wsclient. Each new assertion is
102 watched failing once before it counts.
103
104 ## Out of scope
105
106 - Tile sizes/spans, free-form layout.
107 - Live multi-browser sync (push). The state model supports adding a
108 notify channel later without rework; today a second browser catches
109 up on reload/refetch.
110 - Per-target QUIC keys (single `--key` limitation stays filed).
111 - The mux CLI's own dynamic-add UX — it will reuse `wall.zig` when it
112 lands, and only that contract is fixed here.
docs/superpowers/specs/2026-08-19-muxd-upgrade-design.md
Old New
@@ -1,105 +0,0 @@
1 # muxd upgrade — sessions survive daemon replacement
2
3 Date: 2026-08-19. Status: approved shape (trigger, skew policy, and the
4 scrollback cut confirmed by the user); implementation not yet commissioned.
5
6 ## Problem
7
8 We roll versions fast. Today the only way to run a new muxd is `stop` +
9 `run`, which kills every session's shell. The daemon should be replaceable
10 under its sessions: shells keep running, clients reconnect, the operator
11 sees at most one snapshot repaint.
12
13 ## Shape
14
15 Manual, operator-driven, new-binary-pulls:
16
17 ```
18 make install && muxd upgrade --sock /run/user/.../mux.sock
19 ```
20
21 `muxd upgrade` is run AS the new binary. It dials the old daemon's unix
22 socket and requests a handover. This inverts "no socket stealing" the only
23 legitimate way: the old daemon consents and hands its fds over, rather than
24 a second daemon binding over it.
25
26 ## Handover protocol (unix socket, one connection)
27
28 1. New binary connects and sends `handover_req` carrying its own version
29 string and handover-payload version.
30 2. Old daemon checks skew policy (below). On refusal it replies
31 `handover_refused` with a reason and carries on unchanged.
32 3. On acceptance the old daemon stops accepting new connections and drops
33 every client connection (unix and QUIC). Shells and ptys are untouched.
34 4. Old streams the manifest: daemon config (quic bind addr, key material
35 path, shell, caps), then per session — name, seq, epoch, engine
36 full-state snapshot (the existing serialization: grid, styles, cursor,
37 modes; viewport only, never scrollback), marks regime, cwd — and the
38 pty master fd via `SCM_RIGHTS`. Last, the unix listener fd and the QUIC
39 UDP fd, also via `SCM_RIGHTS`.
40 5. New daemon rebuilds each session (fresh engine, apply snapshot, adopt
41 pty fd, **bump epoch** so every returning client takes the snapshot
42 path), stands up TLS on the passed UDP fd, and ACKs.
43 6. Old daemon exits 0 on the ACK. New daemon starts accepting.
44
45 Passing the listener fds (not rebind) means the socket path never unlinks
46 and the QUIC port never closes: a client dialling mid-swap queues in the
47 backlog instead of failing.
48
49 ## Transactionality
50
51 The old daemon stays authoritative until the ACK. Any failure before it —
52 new binary crashes, payload refused, fd passing errors — and the old daemon
53 resumes accepting; the new binary exits nonzero. The one-way door is the
54 ACK: after it the old side must exit without touching the fds it gave away.
55 Clients dropped in step 3 are reconnecting on their existing backoff either
56 way; they land on whichever daemon owns the listener.
57
58 ## Skew policy
59
60 - **Old → new only.** The old daemon refuses a handover to a peer whose
61 version is not strictly newer. No downgrades; recovering from a bad
62 release is `stop` + `run` of the old binary, losing sessions, same as
63 today.
64 - Handover payload is versioned independently of the release version, from
65 day one. New must parse every payload version an in-support old daemon
66 can emit; unknown fields skip (length-prefixed sections).
67
68 ## Client experience
69
70 Attached clients see a disconnect and their normal reconnect: backoff,
71 re-dial, epoch mismatch, one snapshot repaint. Prediction overlay resets
72 with the reconnect as it already does. No client change is needed — that
73 is the point of bumping the epoch on the new side.
74
75 ## Non-goals (v1)
76
77 - **Scrollback does not survive.** Backlogged (collab issue filed
78 2026-08-19). The engine snapshot deliberately excludes history; carrying
79 it is a payload extension later, behind the payload version.
80 - No automatic upgrade detection (`muxd start` noticing a newer binary on
81 disk). Manual only.
82 - No live-connection migration. Dropping clients is acceptable because
83 reconnect is already invisible-fast on LAN and one repaint on WAN.
84
85 ## Testing
86
87 - **xversion handover leg is the gate**: old release (container, musl)
88 daemon with a marker typed into a session; new build runs
89 `upgrade`; the marker is on the grid after reattach, the shell's pid is
90 unchanged, a second marker typed post-upgrade lands. Refusal leg: new →
91 old direction refused with the named reason.
92 - e2e same-binary leg: upgrade to self exercises the whole path without
93 a version boundary (skew check needs a `--force-same-version` test flag
94 or the leg pins the refusal — decide at implementation).
95 - Failure leg: kill the new binary mid-manifest; old daemon still answers
96 `muxa status`, session intact.
97 - Unit: manifest encode/decode round-trip, unknown-section skip.
98
99 ## Open at implementation time
100
101 - Where key material crosses: path in the manifest (new daemon re-reads
102 the file) vs bytes in the payload. Path is simpler and the file is
103 already on disk; bytes only if we hit a case where the path moved.
104 - Whether `upgrade` takes overrides (new quic addr etc.) or strictly
105 adopts the old config. Lean: strictly adopt, overrides are a later flag.
docs/superpowers/specs/2026-08-19-wall-home-screen-design.md
Old New
@@ -1,173 +0,0 @@
1 # The wall is the home screen; zoom is a lens
2
3 > **Superseded by `2026-08-27-wall-of-hosts-design.md`** for the SOURCE of
4 > tiles: the file lists daemons and tiles are their live sessions. The zoom
5 > lens, the layout tree, the sidecar and the "mux IS the wall" entry below
6 > all still hold.
7
8 Date: 2026-08-19. Status: model agreed in discussion; not yet commissioned.
9 Companion spec: `2026-08-19-muxd-upgrade-design.md` (independent work).
10
11 ## Problem
12
13 mux grew four input surfaces — argv, `Ctrl-\` chords, wall keys, the browser
14 add box — and every capability needs a spelling in each, each with its own
15 edge cases (ssh interactivity, `#` ambiguity, tile sizing, self-attach). The
16 object model underneath is simple: daemons hold sessions, a wall is a list of
17 session addresses. The ergonomics sprawl because the surfaces were invented
18 piecewise.
19
20 ## The model
21
22 One navigation axis. **Zoomed in** = one session, full screen. **Zoomed
23 out** = the wall. Nothing else.
24
25 - Zoom is a LENS, not a mode: the wall's tile pumps run through a zoom
26 (already true — `repaint_gen` exists because of it), so every replica
27 stays hot. Moving the zoom between tiles is a local repaint from a
28 current replica: no re-dial, no snapshot round trip, no terminal flash.
29 Skipping between two sessions becomes one chord at zero round trips.
30 - **The wall is your attach history.** `mux HOST --session S` puts
31 `HOST#S` on the wall (persisted to the wall file, deduped by spelling —
32 not identity: the same session reached as `HOST#S` and `quic://…#S` is
33 two tiles, deliberately; identity dedup would need an endpoint
34 handshake we don't want) and starts zoomed into it. There is no ephemeral state: no unsaved
35 tiles, no view-vs-wall split, no second wall discovered by accident.
36 Zoom out from anywhere and you see the same wall.
37 - Forgetting is explicit and safe: `x` on a tile / `mux wall rm` removes
38 it from the wall and NEVER kills the session ("remove is detach",
39 recorded in the dynamic-wall decisions). The daemon keeps everything;
40 the wall tracks what you look at.
41 - `mux HOST` and `mux wall` are the same program with a different entry
42 point: one enters zoomed (into the tile it just added), the other
43 enters zoomed out. Bare `mux` keeps its muscle memory: local daemon,
44 session 0, zoomed.
45
46 ## Invariant evolution
47
48 The passivity rule moves, in one sentence: **an unzoomed tile claims
49 nothing; zooming promotes the tile's existing connection, unzooming
50 demotes it.**
51
52 - Promote = resize the attach from 0×0 to the terminal's size and forward
53 input. Latest-wins already makes resize the legitimate claim mechanism;
54 the daemon changes not at all.
55 - Demote = client-local: the wall stops forwarding input and resumes
56 cropping; nothing is sent on the wire. The slot deliberately keeps its
57 promoted size at the daemon (`applySize` refuses sub-minimum resizes,
58 so 0×0 could not be re-claimed anyway, and a size nobody types at
59 claims nothing under latest-wins). "Claims nothing" is enforced by the
60 wall never forwarding input while unzoomed — not by the slot reading
61 0×0. The session keeps its full-size grid (no resize storm); the stripe
62 crops it, which stripes already do.
63 - This REVERSES the recorded "the wall's zoom is a real attach, and that
64 is the whole design" decision (dynamic-wall era). That argument — a
65 typing tile is a 0×0 client claiming a grid — assumed the tile types
66 at 0×0; promote resizes before the first keystroke, so the typing
67 client claims the grid the legitimate way and the rule keeps no
68 exception. decisions.md gets a superseded-by entry, house style.
69 - Zoom stops spawning a child `mux`. wallview grows the client's input
70 path — prediction overlay included — for the zoomed tile.
71 - **Only a human attach adds a tile — mechanically defined.** "Human" is
72 not detected: any `mux` attach that claims the grid (attaches at
73 nonzero size) writes its tile; muxa (0×0) and the wall's own tile
74 pumps (passive) never do. Bare `mux` therefore persists
75 `--sock <default>#0` — everyone's wall gains the local tile on first
76 use, which is the model telling the truth. Test fixtures (ptyclient)
77 satisfy the rule too; that is fine because tests run under testtmp XDG
78 isolation — do NOT add a tty check.
79
80 ## Keys (zoomed out = the wall)
81
82 - `j`/`k`/`1`-`9` select (exists), `Enter` zoom (exists, becomes in-place)
83 - `a` add: prompt for a target, dial, `sessions_req`, pick sessions to
84 add as tiles. Typed spellings (`host#name`) live HERE — the WALL
85 grammar (box, wall file, `mux wall add/rm`) is the `#` grammar's home;
86 the attach form `mux TARGET` never learns it (`--session` only). A tile
87 the box adds zooms itself (the recorded browser-wall decision) — under
88 this model that is exactly promote, and it is also what creates a
89 `#name` that doesn't exist yet, since creation needs a real size.
90 Tiles restored from the file never auto-zoom.
91 - `x` forget selected tile (never kills)
92 - expand gesture on a tile: fan out that daemon's other sessions via
93 `sessions_req`, pick to add — discovery on demand, on the one wall.
94
95 Zoomed in, the vocabulary shrinks to: `d` detach, `w` unzoom, `n`/`p`,
96 `c`, and a last-tile skip chord (tmux `prefix-l` analog; falls back to
97 unzoom when the remembered tile has been forgotten).
98
99 `n`/`p`/`c` while zoomed move the ZOOM, never a tile's connection — each
100 tile keeps its own pump, always. A sibling already on the wall is an
101 instant zoom move (hot replica). A sibling without a tile GETS one,
102 because visiting it is an attach and attach adds: one dial + snapshot,
103 the same cost as today's switch, and the wall grows by the truth. `c`
104 creates the session, adds its tile, zooms it. (The alternatives — a
105 roaming connection that claims full-size without earning a tile, or
106 re-pointing the zoomed tile's connection and leaving a tile labeled S
107 painting T — each break a rule this spec states elsewhere.)
108
109 ## First-contact dials
110
111 A HOST spelling reaches ssh through a shell; a fresh host can prompt
112 (hostkey, password) and needs the tty. The wall lends the terminal to the
113 dial — pause painting, dial, mark all stripes stale via `repaint_gen`,
114 repaint on return — the same discipline zoom already uses. Cached QUIC
115 coords make every later dial non-interactive.
116
117 ## What this dissolves
118
119 - Status line feature: the wall IS the status, one keystroke away.
120 - Which-key hints: the wall's bottom bar is where hints live — painted,
121 not injected into a session.
122 - `#` attach sugar debate: settled as "wall box only", for the recorded
123 ambiguity reasons (legal `#` in paths/aliases; `mux pi #build` shell
124 comment truncation silently attaching to the wrong session).
125
126 ## Boundaries (kept deliberately)
127
128 - No layout engine, ever. The wall is N whole sessions; splits belong to
129 the terminal emulator or window manager. tmux's pane tree is the
130 feature we refuse.
131 - Tiles are session-granular (`host#build`, never `host`).
132 - `max_clients = 8` and per-tile delta bandwidth become everyday numbers
133 once the wall is home; revisit caps when they pinch, not before.
134
135 ## Migration (each phase shippable, gated)
136
137 1. **In-place zoom** inside wallview: promote/demote a tile connection,
138 zoomed typing with prediction, last-tile skip. Child-spawn zoom
139 deleted. This proves the promoted-tile path.
140 2. **Attach adds; x forgets**: wall-file write on grid-claiming attach
141 (dedup by spelling), `x` key, `mux wall add/rm` verbs as the
142 scripting face. The wall write is best-effort: an unwritable wall
143 file warns and never blocks or fails the attach — the attach is the
144 act, the tile is the record.
145 3. **Convergence**: `mux [TARGET]` becomes wall-with-one-tile-zoomed;
146 client.zig's session loop and wallview merge into one interaction
147 core. Bare `mux` unchanged in feel.
148
149 During phases 1–2 two meanings of `Ctrl-\ w` coexist deliberately: a
150 plain client's `w` still spawns a child wall until phase 3 converges,
151 while inside that wall a zoomed tile's `w` means unzoom. Do not "fix"
152 either. At ship time CLAUDE.md's invariant list gains "an unzoomed tile
153 claims nothing; zoom promotes/demotes the tile's existing connection"
154 ("muxa attaches at 0×0" survives verbatim), and decisions.md's "the
155 wall's zoom is a real attach" entry gets its superseded-by note.
156
157 Deferred, compatible, later: `mux ls TARGET` (bare names; plumbing for
158 scripts — has utility, sequenced after the model lands), a `Ctrl-\ :`
159 command prompt as a fifth speller of the same verb set, configurable
160 prefix.
161
162 ## Testing sketch
163
164 - e2e: zoom-skip A↔B with markers proving no re-dial (daemon-side attach
165 count stays flat while the zoom moves); promote claims the grid (cols
166 change) and demote leaves it; a demoted tile's connection sends ZERO
167 input frames (the new invariant's teeth); muxa attach adds no tile
168 (wall file byte-identical); `x` leaves the session alive.
169 - The existing selector/zoom scenarios evolve rather than grow twins:
170 in-place zoom replaces child-spawn zoom in the same legs. Note the
171 existing pin "the zoom was a REAL attach, not a tile that started
172 forwarding" asserts exactly what phase 1 deletes — that assertion
173 INVERTS (attach count flat instead of growing), it doesn't just move.
docs/superpowers/specs/2026-08-20-agent-forwarding-design.md
Old New
@@ -1,132 +0,0 @@
1 # SSH agent forwarding: the daemon owns the socket, frames own the bytes
2
3 Date: 2026-08-20. Status: **phase 1 (CLI) shipped 2026-08-20** — frames,
4 daemon socket and routing, client pump and `mux -A`, under nine tasks and
5 three e2e legs; see `docs/decisions.md` for what the build changed about
6 this design. Phase 2 (`muxweb -A`) remains future.
7
8 ## Problem
9
10 `mux HOST` uses ssh only as a bootstrap: after the QUIC upgrade the ssh
11 process is gone, so `ssh -A` has nothing to forward through in steady
12 state and remote session shells have no working `SSH_AUTH_SOCK`. `git
13 push` on the remote fails. This is the last blocker to replacing tmux as
14 the daily driver (roadmap target, stated 2026-08-15).
15
16 tmux has the dual disease — the socket exists but goes stale on every
17 reconnect (`update-environment`, symlink hacks). Both problems have the
18 same root: the process that owns the agent socket dies with the
19 connection. Here the daemon outlives every connection, so let it own the
20 socket.
21
22 ## The model
23
24 **muxd owns a stable agent socket per session; clients that opted in
25 answer for it.** The socket path never changes, so the tmux staleness
26 disease cannot exist. Who answers changes with attachment, invisibly to
27 the shell.
28
29 - `mux -A HOST` opts an attach in, mirroring ssh's flag and its threat
30 model (a root on the remote can use — not read — your keys while you
31 are attached). Opt-in per attach; no config file in v1.
32 - muxd creates `agent-<session>.sock` (0600) next to its control socket
33 at session spawn and injects `SSH_AUTH_SOCK` into the shell via the
34 existing `Server.Options.extra_env` → `pty` spawn path. The path is
35 per-session so a connection to it names its session without any
36 protocol.
37 - A connection to that socket routes to the **latest-active client among
38 those that offered `-A`** on that session — the "latest wins" doctrine
39 applied to keys: the person typing is the person whose agent signs.
40 Routing is decided per connection at connect time; an in-flight
41 connection stays pinned to its client (agent connections live
42 milliseconds).
43 - No forwarder attached → muxd closes the connection immediately. ssh
44 sees "agent refused" and fails fast instead of hanging. The socket
45 always exists; only the answer comes and goes.
46 - The client end connects one local `$SSH_AUTH_SOCK` connection per
47 channel and pumps. Client without a local agent closes the channel
48 immediately — same fast failure.
49
50 ## Wire protocol
51
52 Agent traffic is protocol frames on the existing attach stream — never a
53 transport feature. This works identically over unix socket, QUIC, and
54 the ssh-via pipe, and keeps `proxy.zig`/`quic*` byte-blind (the
55 transport-is-dumb invariant).
56
57 New `MsgType`s:
58
59 | Frame | Direction | Payload |
60 |---|---|---|
61 | `agent_offer` | client → daemon | empty; sent once after `attach` when `-A` |
62 | `agent_open` | daemon → client | u32 LE channel id |
63 | `agent_data` | both | u32 LE channel id ++ opaque agent-protocol bytes |
64 | `agent_close` | both | u32 LE channel id |
65
66 - `agent_offer` as a separate post-attach frame, not an attach-payload
67 bit: `delimitFrame` reads the type byte through a non-exhaustive enum
68 and an unknown type is "the caller's to ignore", so an old daemon
69 drops the offer silently and the attach still works — the
70 cross-version gate passes without a capability negotiation.
71 - Channel ids are daemon-allocated (only the daemon opens channels),
72 monotonic per connection. `agent_data` for an unknown/closed channel
73 is dropped, mirroring the unknown-frame stance.
74 - The daemon never parses agent-protocol bytes. It is a per-channel
75 blind pump: no key material, no request structure, nothing to get
76 wrong. An agent message is ~1–2KB request/response; multiplexing on
77 the delta stream costs nothing measurable.
78
79 ## Who never forwards
80
81 - **`muxa` has no `-A`.** Agents do not wield the user's keys. Same
82 posture as the OSC 52 clipboard-read refusal: not a gap.
83 - **Browsers cannot forward** — no agent on the far side of a WebSocket.
84 Instead **the hub is just another client**: `muxweb -A` offers the hub
85 process's own `$SSH_AUTH_SOCK` on its per-tile attaches. muxweb binds
86 127.0.0.1 with no unauthenticated mode, so the hub host is either your
87 machine (its agent IS yours) or a box you reached over ssh (where
88 `ssh -A` chaining already put your agent in its env). Phase 2 — small
89 once the client plumbing exists, since webhub attaches through
90 `client`.
91
92 ## Phasing
93
94 1. **CLI**: frames, daemon socket + routing, client pump, `mux -A`.
95 2. **Hub**: `muxweb -A` passes the offer on every tile attach.
96
97 ## Testing
98
99 - Unit: frame encode/decode roundtrips; channel-table open/data/close
100 lifecycle; unknown-channel drop; routing picks latest-active offerer,
101 skips non-offerers; refused-fast when no offerer.
102 - e2e leg (real chain, no mocks): start a throwaway `ssh-agent`,
103 `ssh-add` a generated key, attach `ptyclient` with `-A`, run
104 `ssh-add -l` in the session shell, assert the key's fingerprint
105 appears. Negative leg: without `-A`, `ssh-add -l` reports no agent,
106 fast (assert wall-clock, not just output). Two-client leg: A and B
107 attach `-A` with different keys in their agents; `ssh-add -l` lists
108 whichever client typed last — assert the fingerprint flips with
109 activity.
110 - Cross-version: new client with `-A` against old daemon — attach works,
111 offer ignored, no agent socket; old client against new daemon —
112 socket exists, connects refused fast.
113
114 ## Out of scope (deliberate)
115
116 - Hub forwarding a *browser's* keys — impossible, and no WebAuthn/
117 WebCrypto bridge invented for it.
118 - Per-host config / default-on forwarding — revisit if typing `-A`
119 hurts in the field trial.
120 - Forwarding to `muxa` under any flag.
121 - Agent-protocol filtering or key confirmation prompts — the daemon
122 pumps blind; policy lives in the agent (`ssh-add -c` works unchanged).
123 - Port forwarding (`-L`/`-R`) — but it is the known generalization, and
124 the frame vocabulary here is its embryo: same open/data/close shape
125 with a target in the open payload, never an ssh side channel (steady
126 state has no ssh; pure `quic://` targets never did). What it adds that
127 agent traffic doesn't need: opener-scoped channel ids (a `-L` client
128 opens channels; today only the daemon does — SSH's each-side-names-its-
129 own model retrofits without breaking these frames) and per-channel flow
130 control with a data-frame size cap, so a bulk transfer cannot
131 head-of-line-block a repaint. Nothing in this spec assumes their
132 absence.
docs/superpowers/specs/2026-08-21-cli-drag-selection-design.md
Old New
@@ -1,391 +0,0 @@
1 # Drag copies, and the wall owns the mouse
2
3 Date: 2026-08-21. Status: model agreed in discussion; not yet commissioned.
4 Ticket: `063cec67` ("copying scrollback: the host terminal only ever sees one
5 painted screen"). Note that ticket's stated shape was a KEYBOARD copy mode;
6 this spec answers the same need with the mouse, and leaves the keyboard
7 version unbuilt.
8
9 ## Problem
10
11 mux paints on the alternate screen, so the host terminal's scrollback is
12 empty: everything mux shows is one repainted screenful. The daemon holds
13 ~10k rows the terminal has never seen and cannot select. Worse since the
14 mouse-mode mirror shipped — the client now asks its terminal for `?1000h`
15 `?1006h` to own the wheel, which switches off native drag-select, leaving
16 Shift+drag as the only way to copy anything at all.
17
18 The browser client has had drag selection since 2026-08-18. The CLI has the
19 same daemon behind it and no requester.
20
21 ### What the status quo actually does — measured 2026-08-21
22
23 `063cec67` has owed a hands-on check since August; it was run against this
24 build on a real terminal (Wayland, `wl-paste`), with a six-line fixture where
25 each line isolates one hazard.
26
27 **Dragging without Shift does nothing at all.** Not degraded selection —
28 none. Two layers each correctly decline: the terminal stops its native
29 selection because `?1000h`+`?1006h` are on, and mux then discards the
30 press/motion/release reports, because `wheelNotches` returns 0 for anything
31 that is not a wheel press and dropping them is what keeps `[<0;40;12M` out
32 of the user's shell. The ticket predicted this in the negative (the escapes
33 were absent); it is now positive evidence.
34
35 **Shift+drag works, with exactly one real defect.** Diffed byte-for-byte
36 against the fixture's own output. Four runs: alacritty and foot 1.27, twice
37 each. The re-runs came back byte-identical to one another apart from the
38 clock in the prompt line, so the two terminals do not differ.
39
40 | Hazard | Shift+drag | mux's own extraction |
41 |---|---|---|
42 | plain ASCII | exact | exact |
43 | wide/CJK (`漢字`) | exact | exact (`engine.zig`, the wide-cell extraction test) |
44 | trailing spaces | trimmed (13 B of 16) | trimmed (`.trim = true`) |
45 | tab | expanded to spaces | expanded — the grid holds cells, not tabs |
46 | **soft-wrapped line** | **split at the right edge** | **unwrapped** (`.unwrap = true`) |
47
48 **The soft-wrap defect is the finding.** It reproduced in every run, on both
49 terminals, at whatever column the window happened to be — 141 at one width,
50 110 at another. It is purely a spurious newline, never truncation: all 147
51 characters come back every time. That is the case that ruins copying a long
52 path, a URL or a stack frame, and it is where mux wins —
53 `extractSelection` sets `.unwrap = true` and `.trim = true` on
54 `ScreenFormatter` (`engine.zig` `extractSelection`), with the soft-wrap test at
55 `engine.zig`, the soft-wrap extraction test extracting `"deFG"` straight across a wrap.
56
57 Trailing spaces are trimmed by the terminal, which is also what mux's
58 extraction does — no difference, and no advantage either way.
59
60 So the fallback is honest for characters and wrong for wrapped lines. The
61 feature's concrete wins are, in order: history becomes selectable at all;
62 soft-wrapped lines survive; and no modifier is needed. Not, as the ticket
63 assumed, correctness of the characters themselves.
64
65 *One observation resists explanation and is recorded rather than smoothed
66 over: the first foot run returned the trailing-space line as exactly 16
67 bytes — the three written spaces, not padding out to the window width —
68 while three later runs trimmed it to 13. Never reproduced. If trailing
69 whitespace ever matters, this is the loose thread to pull.*
70
71 ## The model
72
73 **Drag copies on release. Selection is per stripe.**
74
75 Both halves are tmux's, deliberately — it is what the user already has in
76 their hands. Measured, not recalled:
77
78 ```
79 $ tmux list-keys -T copy-mode | grep MouseDragEnd1Pane
80 bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel
81 $ tmux show-options -gv set-clipboard
82 external
83 ```
84
85 `copy-pipe-and-cancel` fires on release; `set-clipboard = external` means
86 (man tmux) "tmux will attempt to set the terminal clipboard" — OSC 52 to the
87 outer terminal. There is no copy keystroke in tmux's default path, and a
88 `Ctrl+Shift+C` pressed afterwards is a no-op: it copies the *terminal's* own
89 selection, which is empty because tmux holds the mouse.
90
91 **No copy chord, therefore.** `Ctrl+Shift+C` cannot reach an application on a
92 plain tty at all — `Ctrl+C` is `letter & 0x1f` = `0x03` and the encoding has
93 no bit for Shift, which is precisely why terminals claimed `Ctrl+Shift+*` for
94 their own menus. Receiving it needs the kitty keyboard protocol (`CSI > 1 u`)
95 *and* a per-terminal unbind, and enabling that flag re-encodes Escape as
96 `CSI 27u`, which the session must never see — a keyboard-protocol translation
97 layer larger than this feature. Out of scope, and unnecessary once the drag
98 itself copies.
99
100 Selection is per stripe because a wall is panes: a drag inside one stripe
101 selects that stripe's session and nothing else. A plain click (press and
102 release with no movement) moves the wall's selection to that stripe —
103 tmux's `MouseDown1Pane → select-pane`.
104
105 ## Nothing new on the wire
106
107 The protocol half shipped with the browser client and is untouched here.
108
109 | Piece | Where |
110 |---|---|
111 | `selection_req` — id + anchor/active as absolute rows from the OLDEST retained row | `protocol.zig` `SelectionReq` |
112 | Extraction over real scrollback | `server.zig`, the `selection_req` arm → `engine.zig` `extractSelection` |
113 | `selection_reply` — id, status, `history_rows` watermark, text ≤ 1 MiB | `protocol.zig` `SelectionReply` |
114 | Request/reply correlation, latest-wins | `client_core.zig` `beginSelection` / `receiveSelectionReply` |
115 | OSC 52 write to the host tty | `interact.zig` `appendHostEffect` |
116
117 Wide and CJK cells are already correct daemon-side — ghostty-vt's `Selection`
118 owns inclusive wide-cell semantics, with the test at `engine.zig`, the wide-cell extraction test
119 (clicking either half of 漢 extracts the glyph). Trailing-whitespace trim and
120 soft-wrap unwrap likewise. **The client's only job is coordinates.**
121
122 ## Architecture: one pure module, two drivers
123
124 `select.zig` at layer 1. A pure drag state machine: press / move / release,
125 anchor-active normalization, and the highlight span for a given visible row.
126 No tty, no transport, no allocation beyond its own state — unit-testable
127 without a terminal.
128
129 **It must not know about `Stripe`.** `Stripe` lives in `wallview.zig` `Stripe`,
130 layer 4, and `build.zig`'s module table enforces "production imports must point at a
131 strictly lower layer". `select.zig` therefore takes already-resolved
132 `(tile, absolute row, col)`; the hit-test stays in `wallview`. This is also
133 what makes the side-by-side tile layout cheap later: a column layout changes
134 only wallview's hit-test and nothing below it.
135
136 **Two drivers feed it**, the `PrefixFilter` pattern from `interact.zig` header
137 — one shared meaning, each driver deciding what it does in its own world:
138
139 - **Zoomed → the tile's `interact.Core`.** It already receives the mouse
140 bytes unsplit (`wallview.zig` `sendKeys`), already knows whether the session's
141 application wants the mouse, and owns the replica, the transport and the
142 paint. Its mapping is trivial: `renderClipped` is top-anchored
143 (`paint.zig` `renderClipped`), so the tile sits at terminal offset 0.
144 - **Unzoomed → the wallview keyboard loop**, which owns the hit-test.
145
146 Routing all mouse events through wallview instead would force a per-tile
147 `app_mouse` atomic purely to decide whether to steal a click — state smeared
148 across two layers, which is the reason the alternative (selection living
149 inside each `Core`) was rejected in the first place.
150
151 `select.zig` needs its own row in `build.zig`'s module table at layer 1, and
152 `select` added to the imports of `interact` (layer 2) and `wallview`
153 (layer 4). Both are strictly higher, so the graph holds.
154
155 `MouseFilter` (`interact.zig` `MouseFilter`) grows an event output — button, motion,
156 release — beside the wheel notch it already returns. **Two instances**: the
157 existing one in `Core` (`interact.zig` `Core.mouse`), plus a new one in front of the
158 unzoomed key loop. The second is not optional; see below.
159
160 ## The wall becomes a mouse claimant
161
162 `wall_setup` (`interact.zig` `wall_setup`) claims no mouse modes today — only a
163 *promoted* tile writes `session_claim`. So an unzoomed wall receives zero
164 mouse reports. That has to change, and the existing rule is narrower than it
165 reads: `interact.zig`, above `client_mouse_capture` already states "the mouse enables are the CLIENT's
166 own, not a session's". The load-bearing invariant — the keyboard writes a
167 tile's mailbox only while zoomed, which is the whole enforcement of "an
168 unzoomed tile claims nothing" (`wallview.zig` `sendKeys`) — is untouched. A wall
169 drag never becomes tile input.
170
171 Three consequences, one non-consequence:
172
173 1. **`setZoom` wipes the claim on every unzoom.** It writes
174 `interact.session_release` (`wallview.zig` `setZoom`), and that constant is
175 `"\x1b[?2004l" ++ mouse_teardown` (`interact.zig` `session_release`), where
176 `mouse_teardown` turns off every mode in the wire table
177 (`interact.zig` `mouse_teardown`). `setZoom(no_zoom)` must re-arm the wall's modes
178 after the release, under the same `paint_mu` hold.
179 2. **The unzoomed key loop must filter, or reports become keystrokes.**
180 `wallview.zig` `sendKeys` states in prose that nothing arriving there can be
181 a mouse report. This feature falsifies it. SGR reports are full of digits
182 and digits are jump keys in `selectKey` — the same leak class the repo
183 already paid for once. The filter sits ahead of the byte loop. That doc
184 comment is check-gated and must be rewritten, not left to rot.
185 3. **Wheel reports now arrive at the wall with no consumer.** Discard them
186 explicitly and say why; a user who sees drag work will spin the wheel.
187 4. **Teardown does not leak.** `wall_teardown` = `terminal_teardown`
188 (`interact.zig` `wall_teardown`) ⊃ `session_release` ⊃ `mouse_teardown`, built from
189 `proto.mouse_modes` rather than typed out — so a mode added to the claim
190 is undone by every existing exit path, `?1002h` included.
191
192 **`?1002h` is claimed permanently**, added to `client_mouse_capture`
193 (`interact.zig` `client_mouse_capture`). Escalating per-drag is incoherent: motion reporting has
194 to be on *before* the press you need it for. The rejection comment at
195 `interact.zig`, above `client_mouse_capture` ("motion and drag reports would be bytes read and
196 thrown away thousands of times a session") was right when nothing consumed
197 them and is false once something does — rewrite it, don't delete it. Field
198 evidence: nvim with `set mouse=a` sets exactly `?1002h` + `?1006h` and holds
199 them for the whole session (measured).
200
201 Every new terminal write is gated on `Shared.is_tty` — a piped `mux` writes
202 no modes, as `setZoom` already gates at `wallview.zig` `setZoom`.
203
204 ## Threading
205
206 `wallview` runs a pump thread per tile plus the keyboard thread, with a paint
207 mutex and an atomic zoom. Most of the frightening cases are not races at all:
208 stdin, `x`, relayout and the zoom chords are all the one keyboard thread, so
209 "stripe re-cut mid-drag" and "tile forgotten mid-drag" are *sequenced* with
210 the drag. Rule: relayout or forget clears the drag and the selection.
211 Straight-line code, no lock.
212
213 The three that are real:
214
215 **The hit-test needs pump-published state.** A stripe paints a cursor-
216 anchored window — `start = @min(grid_rows -| view.rows, (cur_y+1) -| view.rows)`
217 (`paint.zig` `renderStripe`) — recomputed from the pump's replica on every paint. The
218 keyboard thread has no replica access; the `Engine` is pump-local. Each
219 `Tile` must publish `{history_rows, win_start}` as of its last paint, written
220 under `paint_mu` inside the stripe paint. **Anchors convert to absolute rows
221 at press time**: a terminal-row anchor silently re-selects a different line
222 when output scrolls the window mid-drag.
223
224 **The keyboard must never paint the highlight — on the WALL.** M3 found this
225 rule is narrower than written: `Core.forward` runs on the tile's *pump*
226 thread, which already owns the paint and the sink, so the zoomed driver
227 paints its own highlight directly and needs no shared state at all. The
228 relay below is a wall requirement, not a universal one, and M4's zoomed path
229 can call `beginSelection` from where it already stands.
230
231 On the wall it holds exactly as written. Pumps repaint stripes from
232 the replica on every frame under `paint_mu`; anything the keyboard drew dies
233 one delta later. Highlight spans live in shared state under `paint_mu`,
234 written by the keyboard and *rendered by the pump* inside its own stripe
235 paint; the keyboard makes it prompt by bumping `repaint_gen` and ringing, as
236 `setZoom` already does. The doorbell coalesces motion-rate updates for free.
237 Precedent: `Shared.sel` is under `paint_mu` for exactly this reason
238 (`wallview.zig` `Shared.sel`).
239
240 **`selection_req` must not be sent from the keyboard thread.**
241 `wallview.zig` header states it as a rule and not a preference: a `Transport`
242 is single-threaded, one thread per transport. Use the existing ask pattern —
243 the keyboard posts a pending-selection slot and rings (`Tile.ask`, `wallview.zig` `Tile.ask`, exists for exactly this request/reply shape); the *pump* calls
244 `beginSelection`, sends, receives the reply on its own link, checks the
245 watermark, and writes the OSC 52 effect under `paint_mu`. The reply path
246 never touches the keyboard thread.
247
248 **The watermark check is needed in the CLI too**, and is easy to forget
249 because the daemon holds no per-client selection state by design. Absolute
250 rows are counted from the oldest *retained* row, so an eviction between
251 request and reply renames the coordinate space and yields text that is `ok`,
252 valid UTF-8, and not what was highlighted (`protocol.zig` `SelectionReply`
253 doc). The pump samples `history_rows` when issuing and discards a reply whose
254 `history_rows` came back lower. The browser does exactly this.
255
256 **Reconnect mid-drag clears the selection.** After a resync the absolute row
257 space is renamed; in-flight requests are saved by the watermark, but a
258 retained highlight over a resynced replica is stale coordinates.
259
260 **A delta cannot paint a highlighted row**, which this spec missed entirely
261 and M3 found. `paintDeltaClipped` paints the rows the daemon sent AS SENT,
262 so a row under the inversion comes back plain and the selection grows holes
263 wherever the session is still writing. A held selection therefore takes the
264 full-repaint arm that resync and contradiction already share. The cost is
265 full repaints while a selection is held over a busy session — acceptable for
266 a transient user action, and worth remembering next to main's recent work on
267 not rendering rows nobody is watching.
268
269 ## Rendering the highlight
270
271 There is no VT sequence that inverts a sub-row span of what is already on
272 screen — the painters emit whole rows of raw SGR bytes via `dumpVtRow`. The
273 highlight therefore needs a new layer-0 `Engine` helper. It is a sibling of
274 an existing four-line function, not new rendering machinery:
275
276 ```zig
277 pub fn dumpVtRow(self: *Engine, alloc: std.mem.Allocator, y: u16) ![]u8 {
278 std.debug.assert(y < self.term.rows);
279 return self.formatSelection(alloc, "\x1b[0m", self.viewportRows(y, y));
280 }
281 ```
282
283 `viewportRows(y0, y1)` (`engine.zig` `viewportRows`) pins a `vt.Selection` from column 0
284 to `cols-1`; `formatSelection` (`engine.zig` `formatSelection`) renders any selection to VT
285 bytes. A span dump pins arbitrary columns instead. The ghostty-vt machinery
286 that already handles wide cells and styling does the work.
287
288 **Corrected against the real bytes when M3 built it** — three things this
289 section had wrong:
290
291 - The bracket is `\x1b[0m\x1b[7m` … `\x1b[0m`, **not** `\x1b[7m` … `\x1b[27m`.
292 Without the leading reset the head piece's SGR is still active inside the
293 highlight, which is the exact "styled cell escapes the inversion" case the
294 span dump exists to prevent. And `\x1b[27m` alone leaves the terminal clean
295 only by accident of `.emit = .plain`, while the tail's formatter assumes it
296 starts from default — a reset makes that true instead of lucky.
297 - **The formatter's `trim` defaults to true**, so the three pieces of a span
298 dump cannot be joined by counting characters: a head ending in blanks
299 leaves the cursor somewhere its byte length does not predict. Every piece
300 is addressed by CHA (`CSI n G`).
301 - **Reusing the machinery does not conjure cells.** `trim = false` restores
302 *written* spaces, but cells past the end of a row do not exist and no
303 option invents them. A drag off the end of a short line highlights to the
304 text and no further, whatever column the pointer reached.
305
306 ## The clipboard cap, which fails silently today
307
308 `selection_text_max` is 1 MiB (`protocol.zig` `selection_text_max`); `clipboard_base64_max` is
309 64 KiB (`protocol.zig` `clipboard_base64_max`), and `validClipboard` rejects anything larger
310 (`client_core.zig` `validClipboard`). `appendHostEffect` on a failed validation is:
311
312 ```zig
313 if (!client_core.validClipboard(clip.target, clip.base64)) return;
314 ```
315
316 No write, no error, no message. A 100 KiB selection would round-trip `.ok`
317 and then copy **nothing**, silently. The CLI must size-check before building
318 the effect and tell the user the selection is too large to copy. Never
319 truncate silently — a half-copied selection is worse than a refused one.
320
321 ## Coordinates
322
323 Terminal column maps 1:1 to grid column: every painter emits from column 1
324 with no x-offset (`renderClipped`, `paintDeltaClipped`, `renderStripe`) and DECAWM-off clips at the
325 right edge. A grid wider than the tty simply has an unselectable region —
326 accepted. A grid *narrower* than the tty needs a client-side clamp to
327 `cols-1`, because an out-of-range column makes the daemon answer `.invalid`
328 (`engine.zig` `extractSelection`) and burns a round trip to say so. Rows are the real
329 mapping problem, not columns.
330
331 ## Slices
332
333 Each ships and is usable on its own.
334
335 - **M1 — the mouse arrives.** `?1002h` in `client_mouse_capture`;
336 `MouseFilter` emits button/motion/release events; the wall claims mouse
337 modes and re-arms after `session_release`; the filter goes in front of the
338 unzoomed key loop; the two false doc comments are rewritten. Nothing
339 visible changes except that the wall stops treating a stray report as
340 keystrokes. Gate: a report at the wall is discarded, not typed.
341 - **M2 — click selects a stripe.** The hit-test, `{history_rows, win_start}`
342 published per tile under `paint_mu`, and plain click moves the wall
343 selection. First user-visible behaviour, no selection state yet.
344 - **M3 — drag paints.** `select.zig`, spans under `paint_mu`, pump-side
345 render via the new `Engine` span dump. Highlight only; nothing copies yet.
346 - **M4 — release copies.** The ask-relay to the pump (wall only — the zoomed
347 driver is already on the pump thread), `beginSelection`, the watermark
348 check, OSC 52 out, and the too-large refusal message.
349
350 M4 inherits one inconsistency it should decide rather than discover: the
351 highlight shows where the hand went, but `extractSelection` sets
352 `.trim = true`, so a selection ending in *written* blanks copies less than
353 it highlighted. Accepted rather than fixed — both terminals measured on
354 2026-08-21 trim trailing whitespace on copy too, and `.trim` is the
355 contract muxweb already shares, so changing it would change the browser's
356 answer to match a CLI edge case nobody asked for.
357
358 ## Deliberate exclusions
359
360 - **Scroll mode.** A drag while scrolled back does not select. Absolute-row
361 anchors from M2 onwards make this purely additive later, but
362 `renderScrollback` blits daemon VT bytes with no engine behind them
363 (`paint.zig` `renderScrollback`), so history rows are not addressable client-side
364 until they are fed through an engine. Named as the next slice, in the
365 ticket, rather than pretended away — the place a user goes looking for old
366 text is exactly where this feature is absent, and `063cec67` is titled
367 "copying scrollback".
368 - **When the zoomed session's application wants the mouse, the application
369 gets the drag** and mux selection is unavailable there. Same rule as tmux
370 forwarding on `mouse_any_flag`, and the same rule mux's existing mode
371 mirror already follows. Shift+drag remains the terminal's own escape hatch.
372 - **No copy chord**, for the encoding reason above.
373 - **Keyboard copy mode** — `063cec67`'s original shape — stays unbuilt.
374 - **Selection on an unzoomed browser tile.** muxweb gates selection on zoom
375 (`web/mux.js:977`); this spec does not change the browser.
376
377 ## Unverified
378
379 Named rather than assumed, in the house style:
380
381 - Host terminals impose their own OSC 52 size limits (xterm's is commonly
382 cited around 100 KB). Not measured here. The stated cap is honest
383 regardless of where the terminal's own ceiling sits.
384 - alacritty and foot behaviour was read from their manpages and binaries on
385 this machine, not driven live — there is no display on the build box.
386 - The 2026-08-21 measurement was taken on alacritty and foot 1.27, both on
387 Wayland, two runs each. The soft-wrap defect reproduced in all four.
388 Nothing was tried on X11, or over ssh to a remote terminal.
389
390 `063cec67`'s owed hands-on check is no longer owed — see "What the status
391 quo actually does" above.
docs/superpowers/specs/2026-08-22-status-bar-design.md
Old New
@@ -1,68 +0,0 @@
1 # Status bar — design
2
3 2026-08-22. Agreed, commissioned.
4
5 ## Problem
6
7 A zoomed session has no mark that says "this is mux". The client's two
8 message channels are ad hoc: `Core.banner` paints `[reconnecting]` over the
9 grid's corner, and the wall's `setNotice` defers a sentence to the *next*
10 zoom because the wall "has no status line of its own"
11 (`wallview.zig`, `restoreSavedWall`). Anything a background dial prints
12 (ssh's inherited stderr, see follow-up) lands wherever the cursor is.
13
14 ## Decision
15
16 The wall already paints a reverse-video bar per stripe: `label · state`.
17 Zoomed, the tile keeps that bar — on the bottom terminal row. There is one
18 bar painter, one notice slot, and no new widget.
19
20 ```
21 mux ubuntu@192.168.0.109#foo · up [saved wall truncated: no room]
22 ```
23
24 - Left: `mux ` + the tile's label + ` · ` + its state word (`connecting`,
25 `up`, `reconnecting`, `exited`, `refused`, plus the existing
26 `, input dropped` suffix).
27 - Right: the latest notice. One slot, latest wins, cleared by the next
28 state change of the tile it was said about. No timers.
29 - Truncation: the label side wins; the notice takes what is left
30 (`labelText`'s rule).
31
32 ### Size
33
34 Zoomed attach is `cols × (rows-1)`. `Core.size` / `renderClipped` see
35 `rows-1`, so the session never paints the bar's row. The `-1` is applied
36 exactly once, at the seam where the tty size becomes a tile size; SIGWINCH
37 flows through the existing `setWallSize` / `Core.winch` paths unchanged.
38 Wall layout is unchanged — stripes already own their bar.
39
40 ### Non-tty
41
42 No tty, no bar, full rows. No flag, no env var: `muxa` attaches at 0×0 and
43 never paints, so no agent needs one. Add a switch when a human asks.
44
45 ### Replaced
46
47 - `Core.banner` and `paint.paintBanner`: their callers set the notice.
48 - `setNotice`/`takeNotice` deferral: a notice paints into the bar now,
49 not on the next zoom.
50
51 ## Files
52
53 `paint.zig` — bar painter (pure fd-out) · `wallview.zig` — stripe bar calls
54 the shared painter, bottom position when zoomed, notice slot ·
55 `interact.zig` — view rows, banner callers · server untouched.
56
57 ## Tests
58
59 - `paint.zig` unit: replay bar + grid bytes into a `render` engine; assert
60 row `rows` is the bar and the grid ends at `rows-1` (oracle, not byte
61 grep).
62 - e2e: ptyclient attach at `R` rows → `muxa status` reports `R-1`; a
63 non-tty attach reports `R`; the notice text is visible on the bar row.
64
65 ## Follow-up (not this spec)
66
67 Route a wall redial's ssh stderr into the notice slot (pipe on redial,
68 inherit on first attach).
docs/superpowers/specs/2026-08-23-retire-zoom-design.md
Old New
@@ -1,140 +0,0 @@
1 # Retire zoom — design (multipane chunk 1 of 3)
2
3 Feature branch `worktree-multipane`. Nothing merges to main until chunk 3.
4
5 ## Goal
6
7 The wall stops being a preview you zoom into and becomes the thing itself:
8 every tile is a live, full-size session on its own rectangle. Zoom/unzoom,
9 promote/demote and the 0×0 stripe disappear. This chunk keeps the stripe
10 geometry (horizontal bands) so the change is "who claims what", not "where
11 things go" — chunk 2 replaces the geometry with a layout tree, chunk 3
12 finishes (wall file, docs, invariants).
13
14 ## Non-goals
15
16 - Seat counting, `max_clients`, one-connection-many-sessions: not touched.
17 A pane is a connection; that is the model now, deliberately.
18 - Column splits, borders, pane resize chords: chunk 2.
19 - muxweb: untouched; it has no zoom.
20 - Resize policy: `latest wins` stays as is (decisions.md:177). A session
21 shown in a stripe here and full-screen elsewhere will reflow on every
22 claim; today a stripe claimed nothing. Accepted — documented in chunk 3.
23
24 ## Model
25
26 | today | after |
27 |---|---|
28 | tile attaches at 0×0, renders a clipped crop of whatever grid the daemon holds | tile attaches at its rect (`tty.cols` × `stripe.rows − label`) and resizes on every relayout |
29 | one tile may be *zoomed*: promoted to the full terminal, receives input | one tile is *focused*: receives input, otherwise identical to the rest |
30 | wall keys (`n` `p` `1-9` `Enter` `q` `x`) bare; session keys only when zoomed | all typed bytes go to the focused tile; wall motion moves under the prefix |
31 | `mux TARGET` = wall of one tile, entered zoomed | `mux TARGET` = wall of one tile; the tile's rect is the terminal, no label bar |
32
33 Invariant replacing CLAUDE.md's zoom invariant:
34
35 > **Every tile claims its rectangle.** Attach sends the rect, relayout
36 > resends it. Focus is client-local and sends nothing. `mux [TARGET]` is a
37 > wall of one tile whose rect is the whole terminal — ONE interaction loop,
38 > a tile pump.
39
40 ## Changes
41
42 ### `wallview.zig`
43
44 Delete: `Shared.zoom`/`no_zoom`, `PaintMode` (collapses to `gone` bool),
45 `setZoom`, `ZoomMove`/`zoomChord`, `ZoomTo`/`zoomToSession`,
46 `paintDeadZoomLocked`, `last_zoom`, `EndAction.unzoom`/`.fall_back`, the
47 promote/demote branches in `pumpTile`, the `promoted` flag and its
48 winch-ownership rule.
49
50 Keep, renamed to focus: `Shared.sel` is already the selection; it becomes
51 the input target. `moveSelection` repaints both label bars as now.
52
53 `sendAttach`: the entry tile and chord-born tiles attach at the rect
54 (their attach may create); view tiles — wall argv, wall-file hydration —
55 attach at 0x0 so a view can never create a session, and claim the rect
56 with the `.resize` that follows at once. `relayout`: every tile whose rect
57 changed sends `.resize` from its own pump (a doorbell flag the pump reads
58 on wake, same path the promote used — the pump is the transport's only
59 writer and stays so).
60
61 Winch: the keyboard thread reads the process-wide flag (today the promoted
62 pump did), calls `relayout`, and every pump follows. No pump consumes
63 `winch` itself.
64
65 Label bar: drawn only when `live > 1`. A one-tile wall owns every row,
66 which is what keeps scripted `mux` on a pipe byte-identical to today.
67
68 Prediction overlay and scrollback: `interact.Core` paints them at the
69 rect the tile claims; the stripe crop (`paint.renderStripe`) goes away and
70 tiles paint through `paintDeltaClipped` at their own offset. If `Core`
71 cannot paint at a row offset this chunk adds the offset; it does not keep
72 two painters.
73
74 Recording (`t.record`): unchanged. Entry tile and chord-created tiles
75 record on first state; hydrated tiles never do. "An attach adds" still
76 holds — it is the wall file that defines the wall, not a claim.
77
78 ### Chords (`interact.PrefixFilter`)
79
80 | chord | today | after |
81 |---|---|---|
82 | `Ctrl-\ w` | unzoom to the wall | fold the saved wall into this one — the road from `mux TARGET` to the full wall |
83 | `Ctrl-\ n` / `p` | next/prev session on this daemon, adds a tile | unchanged; new tile appends, focus moves to it |
84 | `Ctrl-\ c` | new session, adds a tile | unchanged |
85 | `Ctrl-\ l` | last zoom | last focus |
86 | `Ctrl-\ 1-9` | — | focus tile N |
87 | `Ctrl-\ x` | — | forget focused tile (was bare `x`) |
88 | `Ctrl-\ d` | detach, leave | unchanged |
89 | bare `n p q x Enter 1-9` | wall motion | **typed through** to the focused tile |
90
91 `q` leaving the wall becomes `Ctrl-\ d`. Nothing bare is intercepted.
92
93 ### Mouse
94
95 Hit-test by rect, as today. A click focuses the tile it lands in, then
96 routes. Drag-select per tile unchanged.
97
98 ### `mux_main.zig`, `client.zig`
99
100 `mux TARGET` no longer passes an "enter zoomed" flag; `mux wall` and `mux
101 TARGET` differ only in how many tiles hydrate. `client.zig:1594`'s zoom
102 remark goes. `self_attach_refusal` unchanged.
103
104 ### Daemon
105
106 None. The wire is unchanged; the xversion gate passes as is.
107
108 ## Errors
109
110 - Tile ends: an exit leaves and the wall re-cuts; refused or lost
111 says so on the label bar, rect stays. Last tile ending still ends the
112 run with its code, as `.finish` does now.
113 - Relayout refuses (`TooSmall`): notice in the corner, no tile added — as
114 today's `[no room on the wall]`.
115 - `Ctrl-\ c` refused by the daemon: tile leaves with no trace, focus goes
116 back where the chord was typed (today's `.fall_back` minus the unzoom).
117
118 ## Testing
119
120 Unit (`wallview.zig` tests, existing style — pipes, no daemon):
121 - "every tile's attach carries its rect" — replaces the 0×0 attach test.
122 - "relayout resends the size of every tile whose rect changed".
123 - "a one-tile wall draws no label bar and paints row 1".
124 - "bare keys reach the focused tile; the prefix does not".
125 - "focus follows a click".
126 - Every test naming zoom/promote/demote is deleted or rewritten by claim.
127
128 e2e (`test/e2e.sh`, 223 zoom mentions): the plain-client legs must pass
129 unchanged — that is the regression gate. Wall legs that drive bare `n`/`p`
130 /`Enter` are rewritten to the prefix chords; legs asserting `[0x0]` attach
131 or "stripe shows daemon grid" are deleted with the behaviour.
132
133 `make check` green per commit; `make ci` before handing the chunk over.
134
135 ## Docs in this chunk
136
137 Only what lies otherwise: `wallview.zig` header, `interact.zig` chord
138 table comments, `mux_main.zig:7`, README's chord list. `docscheck.budget`
139 lines for those files go DOWN or stay. decisions.md and CLAUDE.md are
140 chunk 3 — until then the branch is allowed to contradict them.
docs/superpowers/specs/2026-08-24-layout-persistence-design.md
Old New
@@ -1,196 +0,0 @@
1 # Layout persistence (multipane chunk 3) — design
2
3 Approved 2026-08-24. Implements the deferral in
4 `2026-08-24-multipane-layout-tree-design.md` ("wall-file persistence of
5 the tree: chunk 3"). Chunk 3 also carries the delivery tail: CLAUDE.md
6 invariant reconciliation, xversion, merge, release.
7
8 ## Goal
9
10 A wall client's layout tree — splits, orientations, resize weights —
11 survives detach. Reattach restores the arrangement the user built
12 instead of the default aspect cut. The wall file itself does not change:
13 it stays attach history, one spelling per line, and every existing
14 consumer (older binaries, muxweb, hand edits) keeps working unmodified.
15
16 ## Decisions taken during brainstorm
17
18 - **Last detach wins.** One saved layout per machine state home; the
19 client that detaches last writes it. Matches the daemon's latest-wins
20 doctrine. Two differently-shaped clients simply overwrite each other
21 on their own detaches.
22 - **Saved tree verbatim.** Restore reproduces the saved shape —
23 orientations, split structure, weight ratios — regardless of the new
24 terminal's aspect. The aspect rule (`rootOrient`) applies only when no
25 saved layout matches. One chord re-splits if the user disagrees.
26 - **Sidecar file, not the wall file.** `wall.load` deliberately errors
27 on any line that is not a spelling (dropping a user-authored tile is
28 refused), so embedding layout lines would brick every older binary.
29 Daemon-side storage is rejected on doctrine: layout is client-local;
30 the daemon does not know tiles form a wall.
31 - **The web hub is out of scope.** muxweb changes nothing and never
32 opens the sidecar; its wall-file writes are absorbed by per-leaf
33 healing (below). A follow-up chunk teaches the browser the tree —
34 `layout.zig` is pure layer-1 Zig (wasm-compilable, the "one layout
35 core" move) and the hub already serves wall state over HTTP.
36 Tracked as a git-collab issue, filed with this spec.
37
38 ## Format
39
40 Path: `$XDG_STATE_HOME/mux/layout`, beside the wall file. Text, line
41 oriented, indentation is tree depth — one space per level, so a spelling
42 is "rest of line" exactly as in the wall file and needs no quoting or
43 escaping (spellings already refuse control bytes at parse).
44
45 ```
46 mux-layout 1
47 beside 0
48 leaf 40 --sock /tmp/x#a
49 stacked 39
50 leaf 12 --sock /tmp/x#b
51 leaf 11 --sock /tmp/x#c
52 focus 2
53 ```
54
55 - First line: `mux-layout 1`. Any other first line means "not mine":
56 the loader degrades (see Error posture), it never guesses.
57 - Every node line carries `CELLS` — the node's weight in its PARENT's
58 axis at save time. A container nested in a perpendicular parent has a
59 span its own leaves' cells do not sum to (the `stacked 39` above is 39
60 columns wide while its leaves count rows), so containers carry cells
61 like leaves do. The root has no parent; its cells are written as `0`
62 and ignored on parse.
63 - Container line: `beside CELLS` or `stacked CELLS`. Its children are
64 the immediately following lines one level deeper. A container needs at
65 least one child to be meaningful; the parser treats an empty one as
66 malformed.
67 - Leaf line: `leaf CELLS SPELLING`. `flatten` treats weights
68 proportionally, so a different terminal restores the same ratios at
69 its own size; absolute cells are a snapshot, not a promise.
70 - Focus line: `focus K`, optional, after all node lines. K is the
71 focused leaf's encounter index (0..n-1 in depth-first order). A
72 present but malformed focus record (K out of range, a second focus
73 line, any node line after it) makes the whole parse return null.
74 - The root line's `beside`/`stacked` IS the persisted root orientation.
75 When a restore applies, `rootOrient` (the aspect rule) is not
76 consulted.
77 - Fullscreen is NOT persisted. It is a transient view op; reattach is
78 always un-fullscreened.
79 - Focus IS persisted (amended 2026-08-25 after user hands-on). The
80 sidecar's optional `focus K` line names the focused leaf by encounter
81 index in the depth-first walk; restore maps it through the spelling
82 match and falls back to tile 0 when the leaf did not survive. The
83 Ctrl-\ w fold never moves focus — the user is typing when they fold.
84 - Size cap mirrors the wall file's load cap (1 MiB); a bigger file is
85 malformed.
86
87 The spelling written for each leaf is the same string the wall file
88 records for that tile — `wall.zig` stays the one owner of the spelling
89 grammar; the sidecar borrows it, never reinterprets it.
90
91 ## Save
92
93 - **Trigger:** clean detach (`Ctrl-\ d`) and exit-via-last-session-end.
94 Written once, at exit — not per chord. "Last detach wins" is the
95 semantic; per-chord writes would silently become last-chord-wins
96 across concurrent clients.
97 - **Who writes:** only a HYDRATED wall — one whose tiles came FROM the
98 wall file: `mux wall` with no targets (the wall-load path in mux_main),
99 or any wall after a `Ctrl-\ w` fold. A `mux TARGET` one-tile wall never
100 writes, and `mux wall` WITH argv targets is an explicit view — it
101 records nothing and saves nothing, so briefly attaching one session cannot
102 clobber a saved multi-pane arrangement. `muxa` and `--via` never wall
103 and never write.
104 - **How:** atomic temp + rename, the wall file's own idiom
105 (`saveLines`' posture: writer-vs-writer atomic, no fsync, no
106 crash-durability claim). A failed write warns on stderr and the
107 detach proceeds — persistence failure never blocks leaving.
108 - What is saved is the tree at detach: tiles that vanished are already
109 out (the existing remove path runs at vanish), so the sidecar never
110 names a tile the client itself watched die.
111
112 ## Restore
113
114 Runs at every hydration point: `mux wall` with no targets (the
115 wall-load path), and the `Ctrl-\ w` fold. Bare `mux` is a one-tile wall
116 of the default target (`runAttach`), not a hydration; `mux wall` with
117 argv targets is an explicit composition and gets the default cut.
118
119 1. Load and parse the sidecar. Any failure → no restore, default cut.
120 2. Match saved leaves to wall lines **by spelling, positionally**: walk
121 saved leaves in order, each takes the first not-yet-matched wall line
122 with an equal spelling. Duplicate spellings (the wall legitimately
123 shows one host twice) pair up in order.
124 3. Build the tree from the matched saved structure (weights = saved
125 cells). Saved leaves with no wall line are dropped via the existing
126 `remove` (containers collapse as they always do). Wall lines the
127 layout does not know are inserted with the existing beside-focus
128 placement, after the matched structure stands.
129 4. Zero matches → the sidecar is ignored entirely: default cut,
130 `rootOrient` aspect rule, exactly today's behavior.
131 5. `Ctrl-\ w` fold: the folded-in tiles go through the same match —
132 the saved layout applies to what the fold produces, the entry tile
133 included.
134
135 This per-leaf healing is why muxweb needs no changes and no staleness
136 hash: lines added or removed behind the client's back degrade the
137 restore gracefully, tile by tile, instead of invalidating it.
138
139 ## Error posture — a deliberate asymmetry
140
141 `wall.load` stays strict: a wall line is user-authored intent, and
142 silently dropping one is data loss. The layout sidecar is derived
143 convenience: wrong version, parse error, depth jump, empty container,
144 unreadable file, oversize — every failure degrades silently to the
145 default cut and NEVER refuses startup or prints past stderr. The
146 asymmetry is the point and gets a comment where the loader degrades.
147
148 ## Ownership
149
150 - `src/layout.zig` (layer 1, stays pure, no I/O):
151 - `serialize(tree, spellings: []const []const u8, writer) !void` —
152 leaf id indexes `spellings`.
153 - `parse(alloc, bytes) ?Parsed` where
154 `Parsed = { tree: Tree, spellings: [][]u8 }` — leaf id = index into
155 the returned list; `null` on any malformation (the degrade signal).
156 - Round-trip and rejection unit tests live here.
157 - `src/wall.zig` (owner of state files): sidecar path derivation beside
158 the wall's, `loadLayout` / `saveLayout` thin I/O using the existing
159 atomic idiom. No opinion about content.
160 - `src/wallview.zig`: wires save at detach/exit and restore at
161 hydration; owns the leaf↔wall-line matching (it knows the resolved
162 tiles).
163 - `src/webhub*`: untouched.
164
165 ## Testing
166
167 - Unit (`layout.zig`): serialize→parse round-trip on a nested tree with
168 uneven weights and duplicate spellings; parse returns null on wrong
169 version, depth jump, empty container, bad cells, oversize input.
170 - e2e legs (END of `test/e2e.sh`, one gate run per leg while writing):
171 1. **Restore:** build 3 panes with chords, resize one, detach,
172 reattach; assert the rail sits where the resize left it (capture
173 CUP grep) and a marker typed per pane lands in the same session as
174 before (muxa capture).
175 2. **Healing:** detach, mutate the wall file (`mux add` a line and
176 remove one), reattach; assert the surviving tiles keep their
177 arrangement, the new line is present beside focus, the removed
178 one is gone.
179 3. **Degrade:** corrupt the sidecar (bad version line), reattach;
180 assert the wall comes up on the default cut and nothing refused.
181 - xversion: old binaries never open the sidecar; the existing gate run
182 against main proves cross-version cleanliness. The new-old direction
183 needs no code — absence of the file is the v1 loader's own degrade
184 path.
185
186 ## Delivery checklist (chunk 3 = the last chunk)
187
188 1. Layout persistence lands (plan tasks, per-task gates).
189 2. CLAUDE.md invariant reconciliation: rewrite the invariants block for
190 the pane era — single flatten point, keyboard owns the tree,
191 gain-only resize contract, rails unreachable by tile clears, the
192 sidecar's strict/lenient asymmetry.
193 3. File the browser-layout follow-up issue (git-collab).
194 4. `make ci` green; `make xversion` against main.
195 5. User hands-on pass.
196 6. Merge to main, `make release`.
docs/superpowers/specs/2026-08-24-multipane-layout-tree-design.md
Old New
@@ -1,156 +0,0 @@
1 # Layout tree — design (multipane chunk 2 of 3)
2
3 Feature branch `worktree-multipane`. Nothing merges to main until chunk 3.
4
5 ## Goal
6
7 Replace the wall's horizontal stripe cut with an i3-style container tree:
8 side-by-side and stacked panes, nestable (session 1 | session 2 over 3),
9 directional focus on `hjkl`, pane resize, and fullscreen-as-a-layout-op.
10 Chunk 1 made every tile claim its rect; this chunk changes only *where the
11 rects come from*. The claim model, doorbells, one-writer pumps, and the
12 focus model are untouched.
13
14 ## Non-goals
15
16 - Tabbed/stacked containers, swap/move-pane chords: not in scope. The tree
17 is splits only; deeper i3 vocabulary can arrive later without reshaping it.
18 - Wall-file persistence of the tree: chunk 3. The tree is client-local and
19 in-memory; the wall file stays attach history.
20 - Prefix change: stays `Ctrl-\` (0x1c). Considered `Ctrl-B` with tmux's
21 double-press-sends-literal rule; deferred, nothing here precludes it.
22 - muxweb: untouched. muxa never walls.
23 - Resize policy across clients: `latest wins` stays. Two clients holding
24 the same session in different-shaped panes reflow on every claim, as
25 chunk 1 already accepted.
26
27 ## Model
28
29 | today | after |
30 |---|---|
31 | `layoutStripes(n, rows)` cuts horizontal bands, `Stripe{top,rows}` | `layout.zig` tree flattens to `Rect{top,left,rows,cols}` |
32 | new tile appends a stripe at the bottom | new tile inserts beside the focused pane, along its container's orientation |
33 | `Ctrl-\ l` = last session | `hjkl` = directional focus; last-session dropped (`n`/`p` cover cycling) |
34 | fullscreen impossible (was zoom, retired) | `Ctrl-\ f` = relayout: focused rect is the terminal, the rest claim 0×0 |
35
36 Constraints honored (agreed before this chunk): fullscreen is a layout op,
37 not a mode — no flag changes how keys are read; `Ctrl-\ 1-9` stays "focus
38 tile N in whatever layout stands".
39
40 ## `src/layout.zig` — new module, layer 1, pure
41
42 An i3 container tree. No terminal, no I/O; every operation is a tree
43 transform unit-testable by itself.
44
45 - Nodes: `leaf(tile_id)` or `container{orient, children[], weights[]}`.
46 `orient`: `.beside` (children left→right) or `.stacked` (children
47 top→bottom). Weights are `u16` parts, equal at insert; resize shifts
48 them.
49 - `insert(focus, tile)` — new leaf as the focused leaf's next sibling in
50 its parent container (i3's default insert). Root case: the first tile
51 IS the root.
52 - `splitRight(focus, tile)` / `splitBelow(focus, tile)` — the focused
53 leaf becomes a two-child container of the forced orientation holding
54 `[old, new]`.
55 - `remove(tile)` — delete the leaf; a container left with one child
56 dissolves into its parent (i3's collapse), so the tree never holds
57 degenerate nesting.
58 - `neighbor(focus, dir)` — geometric, computed on the flattened rects:
59 from the midpoint of the focused rect's `dir` edge, the pane whose
60 opposite edge abuts it and whose span contains the midpoint; nearest
61 edge on ties. Deterministic, no focus-history tiebreak.
62 - `resize(focus, dir, delta_cells)` — moves the flattened boundary by
63 whole cells, adjusting the weights of the focused pane's container
64 entry and its neighbor to match; walks up to the nearest container of
65 the right orientation, like i3.
66 - `flatten(term_rows, term_cols, fullscreen_focus: ?tile) → []Rect` —
67 assigns rects by weight. When `fullscreen_focus` is set, that tile gets
68 the whole terminal and every other rect is 0×0; the tree itself is not
69 consulted differently — fullscreen is a rect assignment, nothing more.
70
71 Floors: a cut whose result would violate `protocol.min_session_rows/cols`
72 (plus the label row) fails with `error.TooSmall`, exactly as
73 `layoutStripes` does today. Callers refuse the *operation* (split, insert,
74 resize step) and keep the standing layout; no sub-minimum pane ever exists.
75
76 ## `wallview.zig`
77
78 `Stripe{top,rows}` becomes `Rect{top,left,rows,cols}` from layout.zig;
79 `layoutStripes` and its tests retire in favor of the tree. Relayout
80 flattens and doorbells `.resize` to every tile whose rect changed — the
81 same path as today, wider rects. The pump's under-`paint_mu` snapshot
82 gains `left` and per-tile `cols`.
83
84 Hydration (`mux wall`, `Ctrl-\ w`): N wall-file tiles build one container,
85 orientation by terminal aspect — `cols ≥ 2 × rows` → `.beside`, else
86 `.stacked` (the 2× corrects for cell shape). The order is the file's order,
87 as today.
88
89 Separators: between `.beside` siblings the wall owns a 1-column `│` rail,
90 drawn by relayout in the label-bar style. Tiles never paint it — their
91 clears are span-bounded (below). Pane widths account for the rail before
92 weights are applied. A one-tile wall draws no bars and no rails, so the
93 plain client's byte stream is unchanged.
94
95 Vanish/forget: `remove(tile)` then relayout; survivors inherit the space
96 through the collapse rule. The chunk-1 vanish/narrate split is untouched.
97
98 Fullscreen: `Shared` carries the fullscreen target; `f` toggles it,
99 relayout flattens with it. A 0×0 rect claims nothing (the daemon rule muxa
100 and view-tile attach already rely on), so hidden panes stay attached,
101 paint nothing, and never shrink the session. Focus moves while
102 fullscreened (`hjkl`, digits) re-flatten, so the full rect follows focus —
103 `Ctrl-\ 2` fullscreened behaves like the old zoom-switch and still means
104 "focus 2".
105
106 ## `paint.zig` / `interact.zig` — column offset and span clears
107
108 `Core` gains `col_off` beside `row_off`; every renderer addresses
109 `(row_off + r, col_off + 1)`. Every whole-line erase (`\x1b[2K`) becomes
110 erase-N-characters (`\x1b[{cols}X`) after the CUP, so a pane's repaint
111 cannot blank a `.beside` neighbor. This is an audit of every clear in the
112 row painter, the banner, scrollback view, and the prediction overlay.
113 `owns_screen` keeps its chunk-1 meaning and stays an explicit caller
114 contract.
115
116 ## Chords — `PrefixFilter` (one table, as ever)
117
118 | key | action |
119 |---|---|
120 | `h` `j` `k` `l` | focus left / down / up / right (`Action.focus_dir`); no neighbor → silently dropped |
121 | `\|` (alias `\`) | new session in a pane to the right (`splitRight`) |
122 | `-` | new session in a pane below (`splitBelow`) |
123 | `c` | unchanged byte: new session inserted beside focus, container's orientation |
124 | `f` | fullscreen toggle |
125 | `r` | resize mode (below) |
126 | `d` `Ctrl-\` `n` `p` `w` `x` `1-9` | unchanged |
127 | `l` (old last-session) | **removed** — `n`/`p` cover cycling; README/--help updated |
128
129 Resize mode is the filter's first sticky state (`resizing: bool`, beside
130 `pending`): after `Ctrl-\ r`, bare `h`/`l` shrink/grow the focused pane's
131 width and `k`/`j` shrink/grow its height, one cell per press. Any other
132 byte ends the mode: `Esc` is swallowed, anything else is processed
133 normally (forwarded, or a fresh prefix) — typing your way out works, the
134 mode never eats prose. Unit-tested in interact.zig like the rest of the
135 table.
136
137 ## Testing
138
139 - `layout.zig` unit tests: every op — insert/split placement, weights,
140 collapse-on-remove, neighbor determinism (abutting-edge/midpoint,
141 including nested trees), resize walking up to the right container,
142 floor refusals, fullscreen flattening.
143 - `PrefixFilter` tests: new chords, resize-mode entry/exit (Esc swallowed,
144 prose forwarded), chord-ends-chunk unchanged.
145 - e2e legs: split births a session beside the focused pane; `hjkl` moves
146 focus (cursor + bar assertions); resize moves a boundary; fullscreen
147 hides then restores; hydration picks orientation by aspect.
148 - Span-clear regression asserted against an engine oracle, not by
149 grepping bytes: flood the left pane's session with output, replay the
150 wall client's emitted stream into an engine, assert the right pane's
151 grid survives.
152
153 ## Chunk 3 remains
154
155 Wall-file layout persistence, docs/invariant reconciliation (CLAUDE.md,
156 README, decisions.md), and the merge to main.
docs/superpowers/specs/2026-08-25-add-tile-prompt-design.md
Old New
@@ -1,166 +0,0 @@
1 # Add a tile by spelling (`Ctrl-\ :`) — design
2
3 Approved 2026-08-25. Follows the multipane chunks (v0.0.1-13).
4
5 ## Goal
6
7 From inside a running wall, name a target — any spelling the wall
8 grammar accepts — and get a tile for it beside the focus, without
9 leaving the wall. Today a chord-born tile inherits the focused tile's
10 daemon (`wallview.addSessionTile`), so adding a *host* means `Ctrl-\ d`,
11 `mux HOST`, `Ctrl-\ w`: three steps across two commands. That dance is
12 the real complaint behind "`mux` and `mux wall` should merge".
13
14 ## Decisions taken during brainstorm
15
16 - **The tree stays authored.** The alternative — argv extends the saved
17 wall on every invocation — places new tiles by the sidecar's healing
18 rule, a fallback for wall drift, not a road. A prompt inside the wall
19 keeps placement where the chords already put it: you stand where the
20 tile goes when you name it.
21 - **Asked-for creates.** The prompt is argv typed from inside: the tile
22 attaches with its rect (creates a missing session) and is recorded
23 into the wall file on first state, exactly as `mux TARGET` does. Fold
24 and wall-file tiles keep joining only.
25 - **`-A` never crosses hosts.** A chord-born sibling inherits the
26 offer because the target is the same; a prompt-born tile has a
27 target the user did not spell `-A` for. Not inherited, not
28 spellable in the prompt.
29 - **Deferred, in order:** the CLI merge (`mux [SPELLING...]`, bare
30 `#NAME` = default local socket, `wall` word and `--session` retired;
31 reserve a leading `@` as a usage error so wall names stay possible),
32 then named walls when a second workspace exists to name. Neither is
33 part of this spec.
34
35 ## Chord and prompt — `interact.PrefixFilter`
36
37 `Ctrl-\ :` enters prompt mode: a third modal flag beside `pending` and
38 `resizing`, plus a fixed line buffer (`prompt_max = 256` bytes) and its
39 length. Unlike resize mode the prompt **eats every byte** — it is a
40 line editor, and its bytes are never prose for the session.
41
42 Per byte while prompting:
43
44 | byte | effect |
45 |---|---|
46 | `0x20..0x7e` | appended; ignored once the buffer is full |
47 | `0x7f`, `0x08` | delete the last byte |
48 | `\r`, `\n` | submit: emit `.add_tile` with the line; empty line = cancel |
49 | `0x1b` | cancel |
50 | anything else | ignored |
51
52 Submit and cancel both **end the chunk** — `forward` is what preceded
53 the prefix and the rest of the read is dropped. For submit that is the
54 rule every chord already follows. For cancel it is what keeps an arrow
55 key honest: Esc `[` `A` in one read cancels the prompt and drops the
56 `[A` instead of forwarding it to the shell.
57
58 `.add_tile` carries a slice that borrows the filter's buffer; the
59 consumer copies before the next `feed`. The filter exposes whether it
60 is prompting and the current line so the keyboard thread can paint it.
61
62 ## Birth path — `wallview.birthTile`
63
64 One function, extracted from the two copies that exist today:
65 `addSessionTile` (same daemon: `Ctrl-\ c`, `|`, `-`) and the
66 per-spelling block inside `hydrate` (foreign target, `Ctrl-\ w`). The
67 shared body: capacity check (`max_tiles`, `wallFits`), `label_rows`,
68 tree placement, flatten, `rectOf`, `initTile`, present/live, spawn.
69 Callers differ only in the row they own of this table, and the table is
70 the contract:
71
72 | caller | target | place | creates | record | agent |
73 |---|---|---|---|---|---|
74 | `c` / `\|` / `-` | focused tile's | beside / right / below | yes | yes | inherited |
75 | fold (`w`) | wall line | after last | no | no | no |
76 | **`:` prompt** | resolved spelling | beside focus | **yes** | **yes** | **no** |
77
78 The prompt's consumer in the keyboard loop:
79
80 1. `resolveSpelling(alloc, line, entry.key, entry.idle_ms)` — the
81 wall's own key and idle budget; the prompt spells neither.
82 2. `showsSelf` → refuse (below).
83 3. A present tile whose `label` equals the line → focus it, no birth
84 (the dedup `addSessionTile` already does for sessions).
85 4. `birthTile` with the prompt row; `.moved` → `setFocus` on it.
86
87 The line is copied into `run`'s allocator before step 1 for the same
88 reason `addSessionTile` dupes its name: the pump outlives the read.
89
90 ## Display
91
92 The prompt paints through `wallBanner` — the corner status line the
93 wall already owns — redrawn on every feed while prompting, as
94 `: <line>` with a trailing cursor mark. No relayout, no rect change,
95 so it works on a one-tile `mux TARGET` wall that has no label bar.
96 Trade accepted: a busy focused tile may overpaint the prompt until the
97 next keystroke, the trade `setNotice` already makes.
98
99 On Esc or Enter the prompt's row must show the tile's own content again
100 within one paint. The implementer uses the tile's existing repaint
101 request; the e2e leg asserts the row, not the mechanism.
102
103 ## Refusals
104
105 All narrated as notices on the focused tile, none fatal, none exit:
106
107 | condition | notice |
108 |---|---|
109 | `resolveSpelling` error | `[bad target: <errorName>]` |
110 | `showsSelf` | `[that is the session this shell is inside]` |
111 | `birthTile` returns `.full` | the existing full-wall notice |
112
113 The wall file is untouched by every refusal — recording happens on
114 first state, and a refused tile never reaches one.
115
116 ## Persistence
117
118 The tile enters the tree by `insert`, so a hydrated wall's sidecar
119 saves it on detach. An unhydrated `mux TARGET` wall still writes no
120 sidecar — the existing rule stands — but the wall file has the line, so
121 the next `mux wall` heals it in. `Ctrl-\ w` remains the road to a
122 persisted layout from a one-tile wall.
123
124 ## Tests
125
126 Unit, `interact.zig` (filter):
127
128 - `prompt: bytes append, backspace deletes, Enter emits add_tile`
129 - `prompt: Esc cancels and drops the rest of the chunk` (Esc `[A`)
130 - `prompt: a chord split across two reads is still one prompt`
131 - `prompt: the buffer stops at prompt_max` (257 bytes → line of 256)
132 - `prompt: empty Enter is a cancel, not an add_tile`
133 - `prompt: bytes typed behind Enter are dropped`
134
135 Unit, `wallview.zig` (birth table — one test per row that is not the
136 chord row, which existing tests cover):
137
138 - `birthTile: a prompt-born tile creates, records, and offers no agent`
139 - `birthTile: a fold-born tile joins, does not record, offers no agent`
140
141 E2e, appended at the **end** of `test/e2e.sh`, scenario pin bumped:
142
143 1. Two daemons A and B. ptyclient runs `mux wall --sock $SOCK_A#0`.
144 2. `\x1c:` then `--sock $SOCK_B#b` then `\r`. Assert: a label bar
145 containing `--sock $SOCK_B#b`; `muxd stats --sock $SOCK_B` lists
146 `b`; the wall file gained exactly that line.
147 3. `\x1c:` then `x#bad name` then `\r`. Assert: `[bad target` in the
148 capture; no new bar; wall file unchanged (line count).
149 4. `\x1c:` then `zzz` then `\x1b`. Assert: no bar, and `zzz` never
150 appears in the focused tile's grid (the prompt ate it).
151 5. After 4, the prompt row shows tile content again (assert the row
152 against the replica, not the emitted bytes).
153
154 Gate: `make check` before commit, `make ci` before delivery.
155
156 ## Docs
157
158 `mux --help` usage text, README chord list, and CLAUDE.md's invariant
159 "the Ctrl-\ chords grow their tiles from inside the wall" gains the
160 clause that `:` grows one from a spelling with argv's semantics.
161
162 ## Out of scope
163
164 `--key` / `--quic-idle-ms` in the prompt; split-placement variants of
165 `:`; the CLI merge; named walls; muxweb (unaffected — it shares the wall
166 file and grammar).
docs/superpowers/specs/2026-08-26-muxd-upgrade-reexec-design.md
Old New
@@ -1,208 +0,0 @@
1 # muxd upgrade — re-exec in place
2
3 Date: 2026-08-26. Status: design for review. Supersedes the mechanism half of
4 `2026-08-19-muxd-upgrade-design.md`; the problem, the trigger, the skew
5 policy and the scrollback cut carry over unchanged.
6
7 ## Problem (unchanged)
8
9 Rolling a new muxd is `stop` + `run`, which kills every session's shell.
10 The daemon must be replaceable under its sessions: shells keep running,
11 clients reconnect, the operator sees one snapshot repaint.
12
13 ## Why not pass fds to a second process
14
15 The 2026-08-19 spec hands pty masters and listeners to a NEW process over
16 `SCM_RIGHTS`. An inventory of today's daemon (2026-08-26) shows every hard
17 problem in that design is caused by the process changing, not by the
18 binary changing:
19
20 - A shell whose pty crossed is still the OLD daemon's child. The new
21 daemon's `checkExited` → `waitpid` hits `ECHILD`, which std marks
22 `unreachable` — a ReleaseSafe panic on the first pump. Rebuilding exit
23 detection on master-EOF loses the shell's real exit code forever after.
24 - The shell-integration shim dir (`ZDOTDIR`, `--init-file`) and the agent
25 dir (`SSH_AUTH_SOCK`) are named after the daemon's **pid** and are held
26 open by every live shell. `Server.deinit` `deleteTree`s both, unlinks the
27 socket path and every agent socket, and SIGTERM/KILLs every shell.
28 - Zig 0.15.2 has no `recvmsg` wrapper, no `cmsghdr`, no `SCM_RIGHTS`, no
29 `CMSG_*`; all of it would be hand-rolled for this one feature.
30
31 `execve` keeps the pid, the children, the cwd, the environment, and every
32 fd not marked close-on-exec. It changes only the binary. So:
33
34 ## Shape
35
36 Manual, operator-driven, the new binary asks:
37
38 ```
39 make install && muxd upgrade --sock /run/user/1000/muxd.sock
40 ```
41
42 `muxd upgrade` runs AS the new binary. It resolves its own path
43 (`/proc/self/exe`), dials the old daemon as an observer, and sends
44 `upgrade_req` carrying its version string and that path. Then, in order:
45
46 1. **Old daemon validates, refusing on any doubt and carrying on
47 unchanged.** Skew: the requester's version must be strictly newer
48 (`std.SemanticVersion.order`; `0.0.1-13` parses, `-13` is the
49 prerelease field and orders numerically). The path must be absolute
50 and executable; the old daemon runs it as a child with `--version` and
51 requires the same string the request carried. It then writes the
52 manifest (below) into a `memfd` and runs the candidate once more as
53 `run --resume-fd N --check`, which must parse the manifest to the end
54 and exit 0 without adopting anything. Any failure → `upgrade_reply`
55 with a reason; the memfd is closed; nothing else happened.
56 2. **Old daemon says goodbye loudly where silence would cost a timeout.**
57 Every QUIC connection gets CONNECTION_CLOSE (a new `closeAll` in
58 `quic_server` that drains once — today's `closeConn` is deliberately
59 quiet and would leave WAN clients waiting out `default_idle_ms`).
60 Unix clients need nothing: their fds are close-on-exec and the exec
61 is their EOF.
62 3. **Old daemon replies `upgrade_reply` accepted, clears `FD_CLOEXEC` on
63 exactly the fds it keeps** — unix listener, QUIC UDP socket (if any),
64 each session's pty master, each session's agent listener, the manifest
65 memfd — and `execve`s the candidate as
66 `muxd run --resume-fd N`, same argv[0], same environment.
67 4. **New binary adopts.** It reads the manifest, rebuilds each session
68 (fresh engine, replay the VT dump, adopt the pty fd and child pid,
69 mint a fresh epoch, restore the fields listed below), stands up TLS on
70 the passed UDP fd (`quic_server.Listener.initFromFd`, a constructor
71 that skips `socket`/`bind` and keeps everything from `wolfSSL_Init`
72 on), and starts pumping. Clients that were dropped are redialing on
73 their existing backoff and take the snapshot path because no epoch
74 matches.
75 5. **Rollback is another exec.** If adoption fails at any point before
76 the pump starts, the new binary execs the OLD binary (its path is in
77 the manifest) with the same `--resume-fd`: the manifest is versioned
78 and the old binary wrote it, so it can read it. The old binary
79 readopts and serves as if nothing happened. Only if that exec itself
80 fails does the process exit — the shells then get SIGHUP when the
81 masters close, which is exactly today's `stop`+`run` outcome, not a
82 worse one.
83
84 Nothing in `Server.deinit` runs on the exec path: no unlink, no
85 `deleteTree`, no SIGTERM. The socket path, the shim dir, the agent dir
86 and the agent sockets are never touched, because the process that owns
87 them never exits.
88
89 ## The manifest
90
91 An anonymous `memfd`, written by the old binary, read by the new (or, on
92 rollback, by the old again). Length-prefixed sections with a section tag;
93 an unknown tag is skipped by length. `manifest_version` is its own
94 number, independent of the release version, from day one.
95
96 Daemon section:
97
98 | field | why it crosses |
99 |---|---|
100 | manifest_version, writer version string, writer binary path | skew + rollback target |
101 | `sock_path` | live shells hold it as `MUX_SOCK`; the listener fd is served under this name |
102 | unix listener fd number | passed by inheritance; the number is the handle |
103 | `shellint_dir`, `agent_dir` | live shells reference both; pid-named, cannot be re-minted |
104 | spawn inputs: shell path, shell-integration on/off, extra env | future sessions must be born the same way; the plan's slices are rebuilt from these |
105 | QUIC: arm (`none`/`borrowed`/`owned`), UDP fd, bound address, idle ms, **key bytes** | runtime state, not launch flags: `lazyBindQuic` may own an ephemeral port no flag names. Key as bytes because the memfd is anonymous memory and the path may have moved; it is never written to disk |
106 | `Stats` counters, `agent_refused_*` | cumulative; zeroing them makes an upgrade look like a restart to anything sampling |
107
108 Per session:
109
110 | field | why |
111 |---|---|
112 | name | also the shell's `MUX_SESSION`; cannot change |
113 | pty master fd, child pid | the session |
114 | cols, rows | the size the engine is rebuilt at before replay |
115 | engine VT dump (`Engine.dumpState`, viewport only) | the grid, styles, cursor, modes, palette, tabstops, pwd |
116 | window title bytes | `dumpState` does not emit the title; without this every title goes blank until the shell sets it again |
117 | `cmd.Tracker` (phase, marks_seen, start_row, end_row, exit_code) and `last_return` | `marks_seen` is what keeps `muxa await` on the marks mechanism; `last_return` is the watermark an await answers from. Losing either silently demotes every await to pgid/settle |
118 | agent listener fd, agent path | the shell's `SSH_AUTH_SOCK` |
119
120 Rebuilt, not carried: delta tracker (fresh over the replayed engine — safe
121 only because the epoch is re-minted), `*_sent` latches (no client survives
122 to have been told), `last_pty_ms` (0 is the safe direction), agent
123 channels and awaits (bound to dropped clients), observers.
124
125 ## Exit detection is unchanged
126
127 Same pid, same children: `checkExited`'s `waitpid` works and `exit_status`
128 keeps carrying the shell's real code after an upgrade. `Pty` gains a way
129 to be constructed from an adopted `{master, child}` pair; nothing else in
130 `pty.zig` changes.
131
132 ## Wire
133
134 Two frames, in the free ranges: `upgrade_req` (client→daemon: version
135 string, NUL, absolute path) and `upgrade_reply` (daemon→client: one status
136 byte, then reason text). A pre-feature daemon drops an unknown frame
137 silently, so `muxd upgrade` waits a bounded time and reports "no reply:
138 this daemon predates upgrade — stop and run". Nothing else on the wire
139 changes; a client older than this feature sees a bare close and redials
140 as it does for any tear. The "detach is a goodbye" rule holds: only a
141 client's own detach sets `detach_ack`, so a daemon-side exec is a tear.
142
143 ## Skew policy (unchanged in substance)
144
145 Old → strictly newer only. Same version is refused except under
146 `--allow-same-version`, which exists for the e2e leg and says so in its
147 help text. Downgrade is refused; recovering from a bad release is
148 `stop` + `run` of the old binary, losing sessions, as today — the
149 rollback exec above covers a candidate that fails to ADOPT, not one that
150 adopts and then misbehaves.
151
152 ## Trust
153
154 `upgrade_req` execs a path of the requester's choosing as the daemon's
155 user. That is the same power `stop_req` already grants to anyone who can
156 open the socket, and the socket is the user's own. The validation steps
157 exist for safety against mistakes (wrong path, wrong arch, half-copied
158 binary), not against an adversary who already owns the socket.
159
160 ## Client experience
161
162 Attached clients see a close and take their normal redial: backoff,
163 re-dial, epoch mismatch, one snapshot repaint. QUIC clients see
164 CONNECTION_CLOSE and redial at once instead of after the idle timeout.
165 Prediction overlay resets with the reconnect as it already does. The
166 wall's `[reconnecting]` paints for as long as the exec + adoption takes —
167 tens of milliseconds for a few sessions, bounded by replaying one viewport
168 per session.
169
170 ## Non-goals (v1)
171
172 - **Scrollback does not survive** (collab issue bf612b14). `dumpState` is
173 viewport-only by construction; history is a payload extension behind
174 `manifest_version`. A client observes it as `history_rows` going to 0 and
175 its absolute row space renumbering.
176 - No automatic detection of a newer binary on disk. Manual only.
177 - No live QUIC connection migration; per-connection ngtcp2/TLS state is C
178 state and is dropped.
179 - No config overrides on `upgrade`: the new daemon adopts the old
180 runtime state exactly. Changing the QUIC bind or the shell is still
181 `stop` + `run`.
182
183 ## Testing
184
185 - **e2e same-binary leg** (`--allow-same-version`), the gate for v1: a
186 session with a typed marker and `echo $$` on the grid; `muxd upgrade`;
187 after the wall's reattach the marker and the SAME pid are on the grid;
188 a second marker typed post-upgrade lands; the session's title survives;
189 `muxa await` still answers with `mechanism: marks` and a real exit code;
190 `ssh-add -l` inside an `-A` session still answers; `muxd stats` still
191 shows the pre-upgrade counters plus the new attach.
192 - **Refusal legs**: same version without the flag (refused, reason names
193 the versions); a path that is not executable; a path whose `--version`
194 disagrees with the request. After each, the old daemon still answers
195 `muxa status` and the session is intact.
196 - **Rollback leg**: the candidate is run with a test-only
197 `--resume-fail-at SECTION` that aborts adoption after parsing; the old
198 binary is exec'd back; the session survives with the same pid.
199 - **QUIC leg**: a client attached over QUIC sees CONNECTION_CLOSE and is
200 reattached within one backoff, not after `default_idle_ms`.
201 - **xversion**: the container's PID 1 stays the daemon across the exec
202 (same pid), so the existing rig hosts a handover leg once an old side
203 that carries this feature exists — the first release with it is the
204 floor. Until then the same-binary leg is the gate and the cross-version
205 leg is a documented gap.
206 - **Unit**: manifest encode/decode round-trip; unknown-section skip;
207 version ordering including prerelease; `Pty` adoption; `initFromFd`
208 refusing an fd that is not a bound UDP socket.
docs/superpowers/specs/2026-08-27-wall-of-hosts-design.md
Old New
@@ -1,174 +0,0 @@
1 # The wall is a list of daemons
2
3 **Status:** design, user-shaped 2026-08-27. Supersedes the session-list wall
4 of `2026-08-19-wall-home-screen-design.md` (the zoom lens, the layout
5 tree, the sidecar and the "mux IS the wall" entry all survive; only the
6 SOURCE of tiles changes).
7
8 ## Problem
9
10 There are two lists of "my sessions" and they disagree. The daemon's list
11 is live and is what `Ctrl-\ n`/`p` walk (`sessions_req`). The wall file's
12 list is attach history and is what `mux wall`, the digits and `Ctrl-\ w`
13 show. A session born by a split, a `muxa` or another client is on the
14 daemon and not on the wall — `n` lands the user in shells they did not
15 know existed. The other direction is worse: a wall line for a session the
16 daemon no longer has is re-created on restore (`client.hydratedCreates`),
17 so the file resurrects shells. And the local daemon gets a third path of
18 its own: bare `mux` is attach-or-create `#0`, zoomed, re-recorded on every
19 run, so `Ctrl-\ x` on it is undone by the next `mux`.
20
21 ## Model
22
23 **The wall is a list of daemons, local and remote. Tiles are their live
24 sessions.**
25
26 - The file records hosts, one spelling per line, in wall order:
27 `--sock PATH` | `HOST` | `quic://HOST[:PORT]`. No `#SESSION` — a host line
28 names nothing that can be resurrected.
29 - Every host contributes every session it has live, as tiles, in the
30 daemon's slot order. Births appear, exits disappear. There is one list;
31 `n`/`p`/digits walk it.
32 - The local daemon is a host like any other: the line `--sock <default>`.
33 What is special about it is only that `mux` adds it when the file is
34 empty and auto-starts it when nothing listens.
35 - A host that is down is ONE stripe, `HOST [unreachable]`, that keeps
36 redialling — not one dead tile per session it used to have.
37
38 ## Command line
39
40 ```
41 mux open the wall; an empty file becomes the local host with #0
42 mux HOST add HOST if new; open zoomed on its default session `0` (created if absent)
43 mux --sock P | --via CMD | quic://H same, for the other transports
44 mux --session NAME ... zoom into NAME on that host instead of `0` (created if absent)
45 mux hosts list the hosts, one per line, with their live session count
46 mux hosts add SPELLING put a host on the wall without opening it
47 mux hosts rm SPELLING take one off (its sessions keep running)
48 ```
49
50 `mux wall`, `mux wall add`, `mux wall rm` go. No compatibility spelling —
51 the project is too early for one (decisions.md 2026-08-20). A `#SESSION`
52 tail on a `hosts` spelling is a usage error naming the new rule.
53
54 Entry: `mux` opens on the grid, layout and focus from the sidecar, and a
55 wall of one tile is the whole terminal as before. `mux HOST` opens zoomed
56 on the named session. Every host dials at once, in the background, the
57 zoomed tile first; the user is never held on a slow host's ssh to see the
58 one they asked for. The `Ctrl-\ w` fold — hydrate on first unzoom — has
59 nothing left to hydrate and becomes a plain unzoom.
60
61 ## Chords
62
63 `d` and `x` are the two verbs the user asked for by name.
64
65 - `Ctrl-\ d` **disconnects**: this client leaves every tile; nothing else
66 changes. Other clients on those sessions are not interrupted. This is
67 today's detach, unchanged.
68 - `Ctrl-\ x` **ends** the focused session: the daemon hangs up its shell
69 and every attached client sees the exit. Because it interrupts others,
70 the daemon refuses a first `x` on a session with other clients attached
71 and says how many; the rail shows `N others attached — x again to end`,
72 and a second `x` within the chord window forces it. One client attached
73 (you) ends at once. The tile leaves the wall when the daemon's list no
74 longer has the session, not when the chord is pressed.
75 - Forgetting a host is not a chord: `mux hosts rm`. Dropping a machine is
76 rarer than ending a shell and must not sit one key from `n`.
77 - `n`/`p`/`c`, digits, `h/j/k/l`, `|`/`-`, `f`, `r`: unchanged in meaning;
78 `n`/`p` now walk the wall's tiles across hosts (the daemon ring was only
79 ever the local host's slice of this list).
80
81 ## Wire
82
83 Two additions to `protocol.MsgType`, both observer verbs; nothing existing
84 changes shape (cross-version gate: an old daemon answers neither and the
85 client's `mechanism`-style fallback is "the verb is not there", named).
86
87 - `end_req = 0x11` — payload: `u8 flags` (bit0 force) ++ session-name tail
88 (empty = default). Daemon: unknown session → `end_reply` refused "no such
89 session"; other clients attached and !force → refused with the count;
90 else `Pty` hangup (the bounded SIGHUP → SIGTERM → SIGKILL `deinit` path),
91 session reaped as an exited shell is today, `end_reply` accepted.
92 - `end_reply = 0x94` — payload: `u8 status` (0 accepted, 1 refused) ++
93 `u8 others` ++ reason text.
94
95 Session births: the wall keeps one side observer per host and re-asks
96 `sessions_req` once a second and immediately after any chord that births
97 (`c`, `|`, `-`, `:`). Polling, not a push: a subscription is a new daemon
98 concept, and one small frame a second per host over a link that already
99 carries deltas is not a cost worth designing around. If it is ever
100 measured to matter, `sessions_changed` is the push and this is the line
101 that said so.
102
103 ## The file
104
105 `$XDG_STATE_HOME/mux/hosts`, one spelling per line, atomic rewrite, one
106 owner (`wall.zig`, renamed `hosts.zig`; grammar minus the `#` split). The
107 old `wall` file is not read and not migrated: its lines are attach history
108 of sessions, and the model no longer has that. `hosts.zig` stays strict
109 (a host line is authored intent); the layout sidecar stays lenient.
110
111 The layout sidecar keys leaves by tile spelling today (`HOST#SESSION`);
112 it keeps doing so — a tile's spelling is host + the daemon's session name,
113 so healing per leaf against the live list is the same walk it does now
114 against the file.
115
116 ## The hub (muxweb)
117
118 Reads the same `hosts` file, lists each host's live sessions the same way
119 (one side connection per host, `sessions_req`), and renders tiles from the
120 list. `POST /tiles` becomes `POST /hosts` with a host spelling; `/tiles`
121 GET becomes `/hosts`. The page's `×` on a tile is `end_req` (same
122 two-step); a host's `×` is `hosts rm`. The `ended` latch and the
123 side-connection birth path go: a session the user ended is not on the
124 daemon's list, so nothing can bring it back. Phase 2; phase 1 leaves the
125 hub on the old `wall` file and unaffected, since the CLI no longer writes
126 that file.
127
128 ## What goes
129
130 `wall.zig`'s `#SESSION` split and attach-history semantics;
131 `client.recordOnState`, `client.hydratedCreates`, the "saved local line
132 attaches-or-creates" rule and its dead-tile marker; `mux wall`; `Entry.
133 hydrate`/`hydrated`/`record0`; the fold; the hub's `ended` latch (phase 2).
134 Each has e2e legs that pin it; those legs are deleted with the rule, and
135 the count pin in `test/e2e.sh` drops with them. The README's "the wall is
136 your attach history" section is rewritten as "the wall is your hosts".
137
138 ## Bounds
139
140 `wallview.max_tiles` stays 32. A wall whose hosts hold more than 32 live
141 sessions shows the first 32 in host-then-slot order and the rail says
142 `+N not shown`; `n` does not reach them. Not worth designing past until
143 someone has 33 shells.
144
145 ## Testing
146
147 Assert behaviour, plural by default: two hosts (the local socket and a
148 second daemon on another socket standing in for remote) is the baseline
149 fixture; one host is the extra case.
150
151 - Unit (`wallview`, `hosts`): host grammar refuses `#`; a live list of
152 {a,b} then {a} removes b's tile and keeps a's digit; a list of {a} then
153 {a,c} adds c beside the focus; an unreachable host is one stripe.
154 - Server: `end_req` on a session with two clients is refused with
155 `others=1` and the shell lives (`/proc`); forced, the shell is gone and
156 both clients get `exit_status`; `end_req` on the last client ends at
157 once; an old client's unknown verb is answered by the daemon dropping
158 nothing (pin the frame decode).
159 - e2e: `mux` on an empty file writes `--sock <default>` and lands in `#0`
160 (grid shows the shell's `$$`); a split births a session that appears as
161 a tile on a SECOND `mux` on the same wall without any file write; `x`
162 with a second client attached is refused then forced, and the second
163 client exits with the shell's code; daemon restart leaves the host
164 stripe unreachable then healed with ZERO sessions re-created (`muxd
165 stats` sessions= off the real daemon); `mux hosts rm` of a host with a
166 running session leaves the session running (`ps`).
167 - xversion: new client against old daemon — `x` reports "daemon too old to
168 end a session", the tile stays.
169
170 ## Out of scope
171
172 Naming sessions from the wall; a per-host session cap; `mux ls`
173 (deferred earlier, still deferred — `mux hosts` prints the counts and the
174 wall shows the rest); migration of the old `wall` file.
docs/superpowers/specs/2026-08-28-host-picker-design.md
Old New
@@ -1,99 +0,0 @@
1 # The host picker: the wall shows sessions, a popup shows hosts
2
3 Amends `2026-08-27-wall-of-hosts-design.md`. Decided 2026-08-28 after a hands-on
4 demo of the wall of hosts.
5
6 ## Problem
7
8 The demo put a host with no sessions on the wall as a *stripe* — a bar saying
9 `[unreachable]` — and every reflex was wrong on it: `x` did nothing (a stripe
10 has no session to end), the cursor stayed blinking in the previous tile (no
11 pump paints a stripe), and ending a daemon's last session turned that host
12 into a stripe because a muxd exits with its last shell. The stripe was a
13 placeholder for "a host is here, with nothing to show", and a wall of
14 sessions is the wrong place to say that.
15
16 ## Model
17
18 **The wall shows live sessions and nothing else.** A host with no tiles
19 contributes nothing to the wall. Hosts — all of them, with their state — live
20 in a **picker**: a popup over the wall, opened with `Ctrl-\ s`, from which the
21 user births a session on any host, forgets a host, or adds one.
22
23 **A daemon lives until `muxd stop`.** Ending its last session leaves it up
24 and empty; the picker shows it as `no sessions` and a birth from there brings
25 it back. `x` ends a session, never a box. The local default still auto-starts
26 on a bare `mux` when nothing serves it.
27
28 **A new tile takes the lowest free digit.** Tiles 1 2 3, `x` on 2, a birth →
29 it is 2. Live tiles never renumber. (This also retires the slot high-water:
30 the cap is 32 live tiles, not 32 births.)
31
32 ## The picker
33
34 ```
35 hosts
36 1 --sock /run/user/1000/muxd.sock 3 sessions
37 2 box unreachable
38 3 quic://gate:4433 no sessions
39 Enter/c new session x forget a add Esc
40 ```
41
42 - Rows are the hosts file's daemons in file order, numbered; the state
43 column is the poller's last answer: `N session(s)`, `no sessions`,
44 `unreachable`, `connecting` (no answer yet). The pollers keep running while
45 the picker is open and the rows repaint as answers land.
46 - Keys, in the picker only: `j`/`k` (or arrows) move the selection, `1`-`9`
47 select a row, **Enter or `c` births a session on the selected host** and
48 closes the picker; its tile arrives through the birth path like a
49 chord-born tile and takes focus. `x` forgets the selected host: its line
50 leaves the file, its poller stops for good, its tiles detach (sessions
51 keep running — `hosts rm` ends nothing). `a` opens today's spelling
52 editor (the `:` prompt, moved here); Enter adds the host as `mux hosts add`
53 would, Esc returns to the picker. Esc or `s` closes the picker.
54 - Opening with a focused tile pre-selects that tile's host.
55 - **An empty wall opens the picker by itself** and Esc there leaves the
56 one-line empty-wall text, which now says `Ctrl-\ s to pick a host`.
57 - While the picker is open, tiles do not paint (their replicas stay hot);
58 closing bumps `repaint_gen` so every tile redraws under it. The cursor is
59 hidden while it is open.
60 - The picker is a mode of `interact.PrefixFilter` (like the `:` prompt):
61 every byte is the picker's until it closes, so keys never reach a session,
62 and the key table is unit-testable without a terminal.
63
64 ## Chords
65
66 `Ctrl-\ s` opens the picker. `c`, `|`, `-` on a focused tile stay the fast
67 path — a birth on that tile's host, no picker. `:` goes; `a` in the picker
68 replaces it. `x` is unchanged on a tile.
69
70 ## What goes
71
72 `Tile.stripe`, `stripeRule`, `stripePaints`, the `.unreachable` / `.empty`
73 bar states, the stripe tests and e2e claims, commit `02bd4c5` (`x` on a
74 stripe). The daemon's exit-on-empty in `SessionTable.reap`. The `:` chord.
75
76 ## Bounds
77
78 Picker rows: the hosts table's 32. Width: the terminal's; a spelling longer
79 than the room is cut from the left, keeping its tail. A wall narrower than
80 the picker's minimum (24 cols) or shorter than 4 rows shows the picker's
81 first row only.
82
83 ## Testing
84
85 Unit: the key table (every key above, and that prose is eaten); rows from a
86 host table with each state; lowest-free-slot birth and its "pump still
87 unwinding" skip; `reap` returns null on an empty table. e2e: on a real pty —
88 open the picker on a two-daemon wall, birth on the second daemon by digit
89 and see its tile take focus (the daemon's session count is the oracle);
90 `x` a host in the picker and see its line gone and its shell alive (`kill
91 -0`); end a daemon's last session and see the daemon alive (`kill -0` on its
92 pid, `muxd stats` answers `sessions=0`); an empty wall opens the picker; a
93 birth after `x` on tile 2 reads `2>` on its bar. The old side of
94 `xversion` still exits on empty — those legs accept either, keyed on which
95 binary is old.
96
97 ## Out of scope
98
99 Picker over the hub. Sorting or grouping hosts. Multi-select.
test/ptyclient.zig
Old New
@@ -5,7 +5,6 @@
5 //! files non-tty scenarios produce. Capture convention: the master is read 5 //! files non-tty scenarios produce. Capture convention: the master is read
6 //! only while a verb is running, so a script MUST end with `waitexit` — end 6 //! only while a verb is running, so a script MUST end with `waitexit` — end
7 //! it on an expect and --out holds only what had arrived by that match. 7 //! it on an expect and --out holds only what had arrived by that match.
8 //! Spec: docs/superpowers/specs/2026-08-10-m12-ptyclient-design.md.
9 const std = @import("std"); 8 const std = @import("std");
10 const Pty = @import("pty").Pty; 9 const Pty = @import("pty").Pty;
11 // The script dialect this fixture and wsclient both speak: the escape 10 // The script dialect this fixture and wsclient both speak: the escape
test/wan.sh
Old New
@@ -19,7 +19,7 @@
19 # resume hands-off and be served by a delta (the remote 19 # resume hands-off and be served by a delta (the remote
20 # daemon's snapshots counter must not move) 20 # daemon's snapshots counter must not move)
21 # 21 #
22 # M7 kill criterion (docs/superpowers/plans/2026-08-07-m7-reconnect.md): 22 # M7 kill criterion (the retired plan is in git history):
23 # 10 consecutive transport kills against a live session, every one resumed 23 # 10 consecutive transport kills against a live session, every one resumed
24 # with zero manual action, every one delta-served. The resume wall clock is 24 # with zero manual action, every one delta-served. The resume wall clock is
25 # printed, not gated: it necessarily includes the client's first backoff 25 # printed, not gated: it necessarily includes the client's first backoff
@@ -40,7 +40,7 @@
40 # all ten resumes. That is the substitution, it is weaker than the plan's 40 # all ten resumes. That is the substitution, it is weaker than the plan's
41 # wording, and the full convergence check stays banked. 41 # wording, and the full convergence check stays banked.
42 # 42 #
43 # Kill criterion (docs/superpowers/plans/2026-08-07-m6-transport.md): 43 # M6 kill criterion (the retired plan is in git history):
44 # median echo <= baseline median + 120ms, and reattach <= ~2x the link 44 # median echo <= baseline median + 120ms, and reattach <= ~2x the link
45 # round-trip "of the attach request" — i.e. measured from the attach, so 45 # round-trip "of the attach request" — i.e. measured from the attach, so
46 # the gate rules on the reattach's protocol share, with the measured ssh 46 # the gate rules on the reattach's protocol share, with the measured ssh