3b301dee
docs: retire the superpowers specs/plans and the original handoff — git history keeps them
a73x 2026-08-28 20:07
Commit message
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 |