a73x

ee9d0f38

docs: shorten agent guidance and separate detailed contracts

a73x   2026-09-06 09:23

Commit message
docs: shorten agent guidance and separate detailed contracts

CLAUDE.md
Old New
@@ -1,589 +1,99 @@
1 # mux 1 # mux
2 2
3 Terminal multiplexer: ghostty-vt engine runs authoritatively in `mux d`; the wire 3 Terminal multiplexer in Zig, for Linux and Apple-silicon macOS.
4 carries its grid as cells and the client copies them into a grid of its own. 4 The daemon owns the ghostty-vt engine; clients receive cell snapshots and row
5 Attach = one snapshot, then row deltas. Zig; Linux and Apple-silicon macOS; 5 updates. Read the relevant module header and contract document before edits.
6 prototype. 6
7 7 ## Scope
8 ## Sprint delivery 8
9 9 The terminal wall is frozen: bug fixes only until the native client meets
10 When the user requests sprint delivery, read and follow 10 issue `94ed7dfd`. No new wall features or invariants. `mux web` stays and will
11 [the shared skill](docs/skills/sprint-delivery/SKILL.md). Use this repository copy 11 own the layout file. See the 2026-09-05 entry in [decisions](docs/decisions.md).
12 for mux work; no personal skill installation is required. Ordinary isolated 12
13 edits do not require the sprint workflow. 13 When the user requests sprint delivery, follow the repository
14 14 [sprint-delivery skill](docs/skills/sprint-delivery/SKILL.md). Ordinary isolated
15 ## The wall is frozen (2026-09-05) 15 edits do not require that workflow.
16 16
17 The terminal wall — tiles, rails, chords, the layout tree and file, the 17 ## Toolchain and gates
18 pane grade, the picker — retires once the native client covers it, and 18
19 `mux [TARGET]` becomes one session per invocation with no picker. Until 19 Use `deps/zig/zig` (0.15.2) through Make; the system Zig is incompatible.
20 every gate in issue `94ed7dfd` lands (macOS `muxg` first), the wall takes 20 Make handles the macOS shadow SDK. See [development commands](docs/development.md)
21 BUG FIXES ONLY: no new wall features, no new wall invariants. `mux web` 21 for setup, releases, cross-OS checks and the full command list.
22 stays and will own the layout file. Rationale and the file list are in 22
23 `docs/decisions.md` under that date. 23 - `make build`: development CLI build.
24 24 - `make lint`: fetch and verify pinned zlint, then lint repository Zig sources.
25 ## Toolchain (pinned — system zig will NOT build this) 25 - `make check`: required before commit; lint, formatting, unit tests, shell
26 26 checks, web ABI verification, comment references and source-rule checks.
27 ```sh 27 - `make ci`: required before delivery; check, e2e, agent and throughput gates.
28 ZIG=deps/zig/zig # vendored 0.15.2 (ghostty pin); copy the toolchain here, gitignored 28 - `make daemon-test client-test native-core-test`: focused component tests.
29 # on a Mac that is the aarch64 0.15.2 tarball, in the same place 29 - `make native native-e2e`: GUI build/tests and real-window checks; the full
30 make mac-sdk # Darwin only, and `make build` runs it: a shadow SDK 30 GUI remains outside the default delivery gate.
31 # under deps/mac-sdk, because zig 0.15.2 cannot link 31 - `make mac xos`: explicit macOS and cross-OS journeys; see development setup.
32 # the stub Xcode 26.4+ ships. No-op elsewhere. 32
33 make build test e2e # Makefile already points at it 33 ## Architecture
34 make check # fmt + unit tests + shell syntax + comment-claim refs — pre-commit gate 34
35 make ci # check + e2e + agent + throughput — the delivery gate 35 `build.zig` owns the module definitions, imports and source-boundary rules.
36 make daemon-test client-test # focused component tests; same artifacts as make test 36 Use [component ownership](docs/component-ownership.md) to find the affected
37 make native-core-test # GUI workspace/interaction tests without window/font libraries 37 component and focused tests. Do not maintain another inventory of module files
38 make agent soak bench throughput 38 here. A Zig file belongs to one build module, even when used by several clients.
39 make native native-e2e # opt-in muxg; SDL3/freetype/fontconfig/HarfBuzz + GL headers; e2e also needs python3 39
40 make vm # real user journeys against the mux-e2e VM (test/vm.sh) 40 - `src/engine/`: terminal grid, protocol, replay and authoritative VT engine.
41 make mac xos # macOS journeys (test/mac.sh); the cross-OS gate (test/xos.sh). 41 - `src/server/`: daemon, sessions, PTYs and upgrade handling.
42 # Both take their boxes BY NAME and have no default 42 - `src/client/`: reusable client services and browser hub.
43 # for one: MAC_BOX (macOS, no toolchain, SCRUBBED), 43 - `src/gui/`, `src/tui/`, `web/`: native, terminal and browser frontends.
44 # MAC_BUILDER (a Mac with zig + deps/mac-sdk, builds 44 - `src/os/`: operating-system implementations behind shared interfaces.
45 # only, never scrubbed, defaults to $MAC_BOX), and 45 - `src/cli/`: command entry points. `mux d`, `mux a`, `mux web` select modes;
46 # for xos also LINUX_BOX (x86_64 Linux VM, scrubbed). 46 other first words select a client transport. `muxg` is the native GUI.
47 make provision-mac # clone, boot and key a macOS guest on a tart host and 47
48 # print one line; `eval "$(test/provision-mac.sh)"` 48 Source-rule exceptions must explain the immediately following occurrence.
49 # is how MAC_BOX gets set. --down takes it away. 49 One permitted occurrence must not exempt the rest of a file. Rule definitions
50 make install # mux + muxg -> ~/.local/bin, ReleaseSafe; needs native libraries 50 and their diagnostics live in `build.zig`; `test/bans.sh` tests enforcement.
51 # MUX_TARGET follows the HOST: a Mac installs a Mac 51
52 # binary, because that is the only one it can link 52 ## Contracts
53 make release release-mac # one tarball per OS into dist/, cut on that OS; 53
54 # release-mac runs `make release` on $MAC_BUILDER 54 - The daemon owns VT parsing. CLI, wasm and fixtures use one replay core over
55 make xversion-build xversion # cross-version gate; XVER_OLD_WORKTREE points at the old checkout (default ..) 55 the cell grid. Prediction is an overlay and never enters the replica.
56 ``` 56 - Proxy and QUIC transport carry opaque bytes, independent of terminal frames.
57 57 - Client and daemon plain dumps agree after trimming row-tail whitespace.
58 First build after a clean checkout fetches ~30MB of QUIC deps (minutes, once). 58 - Latest-active client controls session geometry. Agent clients attach at 0×0.
59 `make clean` deliberately spares `deps/quic`. 59 - Read-only discovery never starts a daemon. Start and upgrade behavior belong
60 60 to the daemon; processes are executed as argv without shell interpretation.
61 ## Reading this repo without burning context 61 - Detach leaves sessions alive. Ending a session and stopping a daemon are
62 62 explicit actions. Never steal a socket owned by a live daemon.
63 Files are large and comment-dense (~37% of Zig bytes are whole `//` lines). Reading the repo 63 - Agent forwarding is opt-in. Clipboard reads through OSC 52 are refused.
64 costs ~800k tokens; every token stays in context and is re-billed each turn. 64
65 65 Read the detailed contracts for the area being changed:
66 - **Never `cat` these:** `src/server/server.zig` (3.3k lines, ~31k tok; its tests are 66
67 siblings in `src/server/`, none over 1.8k; the agent relay and the 67 - [Terminal and agent contracts](docs/terminal-contracts.md).
68 session table are `src/server/server_agent.zig` and `src/server/server_sessions.zig`), 68 - [Daemon, transport and upgrade contracts](docs/daemon-contracts.md).
69 `src/tui/interact.zig` (4.9k), `src/engine/protocol.zig` (3.0k), 69 - [Terminal wall and browser layout contracts](docs/wall-contracts.md).
70 `docs/decisions.md` (9.2k). `test/e2e.sh` is a 188-line runner now; the
71 bodies are `test/e2e_NN_*.sh` (`E2E_ONLY=<group>` runs one).
72 Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window.
73 `src/client/client.zig` is 3.3k now — cheap enough to read, and so is
74 every piece of the wall: `wallview.zig` is 2.6k (tiles, focus, births,
75 endings, the keyboard loop) with `wall_pump.zig` (0.9k, one tile's
76 thread), `wall_host.zig` (0.5k), `wall_picker.zig` (0.9k),
77 `wall_layout.zig` (0.5k) and its tests in `src/tui/wall_test_*.zig`
78 (none over 1.4k). The wire side is small: `src/engine/grid.zig` is 0.4k,
79 `replica.zig` 0.6k, `delta.zig` 0.5k, `engine.zig` 1.8k, `paint.zig` 1.3k.
80 Every figure here is `wc -l` on 2026-09-04; re-measure before trusting one.
81 - Every module has a `//!` header stating its contract. `head -12` on it
82 answers most "what is this" questions for ~200 tokens.
83 - Pipe Bash output: `| tail -30`, `2>/dev/null`, `grep -c`. `make test` full
84 output is thousands of tokens of re-billed noise.
85
86 ## Layout
87
88 `src/` is grouped by the thing that owns the code, so an app that wants the
89 engine and a client can link those folders and paint its own way. A module is
90 a COMPONENT, not a file: a table row names the root, and the rest of the
91 folder is child files that root re-exports (`term.replica`, `client.hosts`).
92 A file belongs to exactly one module, so a cross-domain grab is a compiler
93 error about a file in two modules, not a missing table edge. Prose here names
94 a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module.
95
96 | Folder | Row — its child files |
97 |---|---|
98 | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test |
99 | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` |
100 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` `session_pump` `buffered_wire` `open_wait` `resolver` `discovery` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) |
101 | `src/gui/` | `native_core`(`native_core.zig`) — `workspace` `runtime` `picker` `persistence` `interaction` · `native`(`native.zig`) — `config` `theme` `font` `atlas` `quads` `gl` `frame` `bench` |
102 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` |
103 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `muxg`(native viewer) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) |
104 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) |
105 | `src/` | `xdg` `sockpath` `dial` `link` `serve` `proxy` `quic` `testtmp` — what both sides link; `dial` is the client side of a daemon socket and `link` the live connection under it whatever reached it (fd, handoff stdio, QUIC), `serve` the right to bind a socket path and the duty to unlink it, `term` and nothing else under them |
106
107 The grouping itself is a convention now, not a gate: the table wires the
108 build and says who owns what, and an import edit is an ordinary diff. What
109 `build.zig` still ENFORCES is `checkSourceBans`, which reads the PRODUCTION
110 lines of every file under `src/` for bytes no import graph can catch. Rule 4:
111 no line outside a `test` block under `src/engine/` or `src/client/` spells
112 `termios`, `isatty`, `tcgetattr`, `tcsetattr` or an escape byte without a
113 `// folder rule 4 exemption:` line saying why — matched against a lower-cased
114 line, so `\X1B[` is the same needle as `\x1b[`. Rule 5 is the product-wide
115 one: no file under `src/` at all may spell `"/bin/sh"` or `"-c"`, because
116 every program mux runs is exec'd as argv and no shell of ours parses a line
117 we built; its three markers are `flags.zig`, `main.zig` and
118 `server_test_session.zig`, and two of the three cover prose and a fixture
119 rather than a shell the product runs. Rule 6 is the `posix.fork` site, one
120 per OS arm, and it names those files in `except` rather than in the files.
121 The three keep their numbers because the in-file `folder rule N exemption:` lines cite them
122 by number. Rule 7 is the platform ban: `std.os.linux`, `/proc`, `memfd`,
123 `close_range`, `exit_group`, the `SO_PEERCRED` socket option, the two
124 Linux-only pty ioctls and `MSG_NOSIGNAL` may appear only under `src/os/`,
125 comments included —
126 the roots `server_os.zig` and `client_os.zig` are the contract and their
127 per-OS children the spellings (spec 2026-09-03). Rule 4's two remaining
128 debts are the markers in `engine.zig` and `keymap.zig`, each of which
129 produces VT bytes by contract. `protocol.zig`'s marker went with the cells
130 flip: it existed because `composeDelta` stamped CUP and EL2 around a rendered
131 row, and there is no rendered row on the wire any more (2026-09-04).
132 `predict.zig` sits under `src/tui/` with the rest of the wall, so the overlay
133 lives beside the code that paints it and nowhere `term` would find it.
134 `spawn` lives under `src/os/` with the rest of the platform layer: asking
135 the OS for a terminal is platform code, and rule 4 forbids a client module
136 from doing it.
137
138 Rules 5, 6 and 7 also cover `src/gui/`. Rule 8 keeps multi-session policy
139 independent of terminal wall policy and persistence. Rule 9 confines SDL to `src/gui/frame.zig`; the separate
140 entry `src/cli/muxg.zig` may also use it. Everything else in the painter is
141 unit-tested without opening a window.
142
143 The default development build produces `mux`, and the first word
144 picks a mode: `mux d` the daemon,
145 `mux a` the agent surface (JSON verbs), `mux web` the browser hub, and no
146 letter at all the client. There is NO alias: a word that is not a mode is a
147 transport, so `mux run` names a host called "run" (pinned in `mux.zig`'s
148 `modeOf`). The daemon's entrypoint, the client's and the hub's are child
149 files of the `mux` row and keep their old paths (`src/cli/main.zig` is the
150 daemon); `agent` stays a row of its own so muxa's suite can fail on its
151 own. Test fixtures in `test/`:
152 `ptyclient` (real client on a real pty), `wsclient` (browser stand-in),
153 `rawmode`, `delaypipe`, `render` — those stay separate binaries.
154 The second product binary is `muxg`, a dynamically linked window on native
155 session panes. `make install` and `make release` build both binaries in
156 ReleaseSafe; `make native` builds the GUI for development. The GUI remains
157 outside `ci`. On Linux, `mux` stays static musl while `muxg` uses the host's
158 native target and system GUI libraries, which are not bundled in the release.
159
160 ## Invariants — do not break, they are load-bearing
161
162 - **`proxy.zig` and the QUIC modules carry opaque bytes and know nothing of
163 frames.** Keep the wire contract — `term` — out of `proxy.zig`'s imports.
164 - **`predict.zig` output never enters the replica.** It is an overlay.
165 - **One replay core.** CLI, wasm, and test fixtures all go through `replica.zig`,
166 over a `grid`, never an engine — the client parses no VT.
167 Do not hand-roll a second applier.
168 - **A client dump equals a daemon dump byte for byte, modulo trailing spaces
169 per row.** The rule is stated in three places, and all three must keep
170 saying the same thing: the header on `grid.dumpRowsPlain` (which
171 `Grid.dumpPlain` calls), the server harness's `trimRowTails`, and the e2e
172 `converged_quiet` strip. Ghostty's VT formatter
173 trimmed trailing whitespace on the old wire, so no replica ever held a typed
174 trailing space; the cell encoder's default-space cutoff is parity with that.
175 - **Latest wins.** The grid follows the most recently active client.
176 - **Every tile claims its rect.** The layout tree (`client.layout.Tree`) is
177 client-local, owned by the keyboard thread under `paint_mu`; `relayout`
178 is the single flatten point that turns it into tile rects. Attach sends
179 the rect, relayout resends it. Focus is client-local and sends nothing
180 on the wire: the keyboard writes the outgoing tile's `session_release`
181 under `paint_mu`, then doorbells the old pump (release) and the new
182 (claim). ONE interaction loop, a tile pump: `mux TARGET` is the wall
183 zoomed on the tile it just attached to, and a wall of one tile whose rect
184 is the whole terminal only when there is no terminal at all. A chord-born
185 tile inherits the focused tile's target and its `-A`.
186 - **Resize is gain-only.** `client.layout.Tree.resize` never shrinks a pane; the
187 shrink keys grow a neighbor at the focus's expense in `wall_layout.doResize`.
188 - **Rails are painted from `relayout` and tiles cannot reach them.** Every
189 tile clear is span-bounded ECH, never line-wide, so a tile cannot erase
190 a neighbor's cells or a rail.
191 - **`mux a` attaches at 0×0** so an agent never resizes a human's session.
192 - **OSC 133 marks are opt-in** (`MUX_SHELL_INTEGRATION=1`); without them `mux a`
193 falls back to `pgid`/`settle` and there is no real exit code. Every `mux a`
194 reply names the `mechanism` that answered it.
195 - **OSC 52 clipboard READ is refused deliberately.** Not a gap.
196 - **Agent forwarding is opt-in (`-A`) and the daemon pumps blind.** The
197 per-session agent socket always exists and overwrites the daemon's own
198 `SSH_AUTH_SOCK` — unset when there is no socket, never inherited. The
199 latest-active `-A` client answers. `mux a` and browsers never OFFER an
200 agent, though anything they run inside a session uses that session's
201 socket like any other process. Frames, never transport; both ends cap
202 a frame at `agent_data_max`.
203 - **The layout is the wall; the poll grades it and adds nothing.**
204 `$XDG_STATE_HOME/mux/layout` is authored intent: a pane tree whose leaves
205 are `HOST#SESSION`, `HOST` a hosts-file line verbatim. Tiles come from it
206 and from three doors only — the file on start (`wall_layout.seedLayout`),
207 the picker, and a chord split — never from a daemon's `sessions_reply`;
208 `mux HOST`'s entry pane goes through the same seat-then-`wall_layout.persist`
209 path. `wall_host.planHostDiff` is the once-a-second GRADE and the whole of
210 it: it binds a pending pane whose session the list names, marks `gone` one
211 the list does not, and vanishes a live pane whose shell ended after the
212 one-list grace. A name in the list that no pane spells is nobody's
213 business. A pane on a host that answers NOTHING stays, wearing
214 `unreachable`, for as long as the box is dark — an eight-pane setup is not
215 worth one quiet machine — and a `gone` pane (the host said no, not
216 nothing) takes Enter to re-create the session in that pane's own rect or
217 `x` to remove it, every other key eaten. A wall of only gone panes is not
218 empty; a wall with no panes says so and opens the picker. A daemon restart
219 re-creates nothing: its panes wear `gone` until an Enter starts them.
220 `wall_layout.persist` is the ONE save path, gated on `Shared.layout_path`,
221 and every change to the pane set or the tree calls it — birth, split,
222 resize, detach, removal, a poll's vanish of an exited shell, a picker add,
223 birth or forget. Only a wall on a TERMINAL (`Shared.is_tty`) has a path at
224 all: a piped `mux` is a wall of one and neither reads nor writes.
225 `seedLayout` is STRICT — `.refused` with the offending line for a host the
226 hosts file lacks, a leaf without `#SESSION` or with a bad name, a repeated
227 leaf (a second leaf spelling the entry included), more leaves than
228 `layout.max_leaves`, or text `layout.parseReporting` gives up on — and the
229 caller prints `mux: layout ignored (PATH): LINE` and starts as if the file
230 were missing, because seating half a wall is how a user loses one.
231 Whitespace-only is `.none`, not a refusal. A missing file is one local
232 pane on session `0` when there is an entry and an empty wall when there is
233 not; a hosts file with lines and no layout reconstructs NOTHING. A run
234 whose seed was trimmed to fit the terminal, or that dropped a leaf naming
235 this shell's own session, sets `layout_path` null for the WHOLE run and
236 says which on the notice line (`[layout not saved: ...]`) — it never
237 rewrites the file, and a save that fails for any other reason is a notice
238 too, never a stderr print into somebody's pane. A `--via CMD` entry nulls
239 it for the same family of reason: `hosts.zig` writes no `--via` line, so
240 the leaf `tileLabel` spells for that tile names a host no table can hold
241 and the NEXT start refuses the whole file over it. `focus N` is the one line
242 in the file that is not authored and may go stale between tree changes.
243 Both files are strict now: the 2026-08 asymmetry (strict hosts, lenient
244 sidecar) is reversed, because the layout is no longer derived.
245 - **The hosts file lists DAEMONS and nothing else.**
246 `$XDG_STATE_HOME/mux/hosts`, one spelling per line (`--sock PATH` |
247 `HOST` | `quic://HOST[:PORT]`); `#SESSION` is refused, because a host
248 line must name nothing that could be resurrected — naming a session is
249 the layout's job. `hosts.zig` owns it. `--via` and `mux a` record no host.
250 `client.hosts.load` is strict — a bad line refuses `mux` with rc 2 and prints
251 it — while `mux hosts` lists and `mux hosts rm` edits VERBATIM, so the
252 repair tool can read what the loader will not. The BROWSER hub is the
253 same wall: `mux web [HOST ...]` records its argv into the hosts file and
254 then serves the LAYOUT — `webhub.readLeaves`, which refuses a bad file
255 with `mux web: layout ignored (PATH): LINE` and serves an empty wall —
256 one `client.SessionPoll` per daemon, `/tiles` in tree order carrying
257 `id`, `label`, `session` and `state`. Ids are birth order and are never
258 reused. The page authors ONE thing: its `+` (`POST /tiles/<id>`) checks
259 the file for room and for a duplicate under the hub mutex BEFORE it dials
260 (`webhub.roomForLeaf`), births, then `webhub.appendLeaf` writes the leaf
261 beside that pane — 409 `duplicate` for a second `+` inside one poll
262 interval, 502 for `WallFull` or `BadLayout`. Dialling first would leave a
263 live session behind a 502 that no wall names. POST /tiles, PUT and DELETE
264 answer 405. Two writers in one instant lose one update, which the hosts
265 file has always accepted. The old `wall` file is retired.
266 - **Hosts and sessions live in the picker.** `Ctrl-\ s` is a MODE of
267 `interact.PrefixFilter` (`picking`), like the spelling editor it
268 contains: every byte is the popup's, so a key can never reach a session,
269 and both tables are unit-testable without a terminal. It has two levels
270 (`PrefixFilter.pick_level`). HOSTS are the hosts file's daemons in file
271 order with the POLLER's last answer beside each (`N sessions` / `no
272 sessions` / `unreachable` / `connecting`); Enter descends into that
273 daemon's sessions, `x` forgets the host (the file line, its poller, its
274 panes; the sessions keep running), `a` is the old `:` spelling editor.
275 SESSIONS (`wall_picker.sessionRows`) are one row per name the last answer
276 carried, reading `on this wall` when the layout already has it and
277 `K client(s)` from `proto.parseSessionsHolds` — K is EVERY holder, this
278 wall's own pane included, so it is a number to READ and never a verdict;
279 an old daemon sends none and the row shows no count. There Enter adds the
280 session as a pane and zooms to it (`wall_picker.pickAdd`, which JOINS and
281 never creates; a session already on the wall is zoomed to and nothing
282 else), `x` ends it, and Esc backs out one level. `c` births at either
283 level — `wall_picker.pickBirth` CREATES at `client.nextFreeName` off that
284 host's own list, so a host with no pane is still one you can start work
285 on. Tiles do not paint while it is open (`Shared.picker_open`); the close
286 bumps `repaint_gen`. An empty wall opens it ONCE by itself, so an Esc
287 there leaves the one-line text; and on a TERMINAL the last tile's exit
288 leaves that empty wall rather than ending mux, as does a picker birth the
289 daemon REFUSES (`Tile.keeps_wall`, which `born_from` cannot say — the
290 entry tile carries the same null) — a piped `mux` still exits with the
291 shell's code, because scripts read it.
292 - **`Ctrl-\ x` removes a pane and ends nothing; ending has two doors —
293 `Ctrl-\ X` through the focused tile's own pump and the picker's `x`
294 through a side connection — and the daemon owns the two-step behind
295 both.** `wallview.removePane` sets the notice
296 `[pane removed - the session is still on its daemon]` BEFORE the vanish
297 (the vanish hands the focus on, and the incoming pump's claim is what
298 paints the sentence), tells the pump to detach — it writes `.detach` on
299 its way out and the transport close behind it is the fallback — vanishes
300 the tile, relayouts and persists. Every pane removes the same way,
301 including one that never came up. ENDING is `end_req` `0x11` /
302 `end_reply` `0x94`, observer verbs, asked one of two ways. `Ctrl-\ X` on
303 a tile stores `wallview.intentForEnd` into that tile's `ask` and rings
304 its pump, which puts the frame on the connection the pane is already
305 attached to (`wall_pump`), reads the answer back through
306 `wallview.onEndReply` and banners the refusal; the daemon excludes the
307 asking client from the count, so a session only this pane holds ends on
308 the FIRST press. Nothing is vanished on that keypress — the pane leaves
309 when the shell does. `wall_picker.pickEnd` is the other door, over
310 `client.endSession` on a SIDE connection, because the session may have no
311 pane here to ask through — which is also why its count includes this
312 wall's own pane. That connection dials `HostSpec.poll_target` —
313 the poller's batch recipe, never the interactive one, since an end must
314 not start a daemon and must not reach for a terminal. The daemon refuses
315 a first press on a session other clients hold and answers the count; the
316 two doors keep SEPARATE 3 s arms, each keyed to what its own second press
317 lands on — `Tile.end_armed_until` on the tile, `Shared.pick_end` per host
318 AND name — and every OTHER refusal arms nothing, because only "others
319 attached" is a question a second press answers. An accepted end is
320 BOUNDED — SIGKILL past `Pty.term_grace_ms` — so no shell can refuse to
321 die, and `mux d upgrade` is refused while any session is ending rather
322 than carry a masterless pty across the exec. The press is ASKED on the
323 wire whatever the row says: a daemon that answers nothing spends the
324 reply budget and its `error.Timeout` becomes `[daemon too old to end a
325 session]`, which is the only thing the absence of an arm can look like.
326 The missing `# holds` line is NOT that gate — the released v0.0.1-16
327 daemon answers `end_req` and sends no holds line, so refusing on the
328 count refused a daemon that works. An ended session's pane leaves on the
329 next list, not on the keypress.
330 - **A QUIC client says goodbye.** Every QUIC connection takes one of the
331 daemon's `max_clients` slots at the handshake, attached or not, and the
332 wall polls each QUIC host once a second on a connection of its own.
333 `quic.Client.deinit` therefore writes CONNECTION_CLOSE before it closes
334 the socket; a teardown that only dropped the socket left the daemon to
335 learn from its 15 s idle timer, so the polls filled the table at a slot
336 a second and every real attach after that was refused — on a laptop
337 whose only clients were another wall's polls, when the daemon had eight
338 slots and so took eight seconds (2026-09-02). The table is 32 deep now,
339 which buys time and fixes nothing: the goodbye is what bounds it.
340 A slot is spent per ATTACH, not per session, so 32 matches `max_sessions`
341 and `wallview.max_tiles` — one full wall of tiles fits exactly, and a
342 second wall on the same daemon is refused. The listener's `max_conns`
343 stays ABOVE `max_clients` (a connection exists from the handshake and only
344 then asks for a slot, and a peer that finds no connection is dropped
345 silently rather than refused), pinned by a test because a transport file
346 does not read the daemon's tables. And the listener OUTLIVES the slots
347 that close through it: a QUIC sink closes its connection via the listener,
348 so a borrowed listener's `deinit` is registered AFTER the server's.
349 - **A pid-named leftover is reaped by its successor, never by a signal
350 handler.** The daemon's `mux-agent-PID-*` and `mux-shellint-PID-*`
351 directories and a wall's `mux-ask-PID.sock` are unlinked by their owner
352 on the normal way out; a SIGKILL or a closed terminal window runs
353 nothing, so the next daemon or wall to create one in that directory
354 calls `xdg.reapDeadPid` first and removes every entry whose pid the OS
355 no longer has. A live pid's entry stays even when it is no longer a mux.
356 - **A daemon lives until `mux d stop`; emptiness is not an exit.** `x` ends a
357 session, never a box: `reap` and `pumpOnce` answer nothing, a shell's code
358 reaches that shell's own clients as `exit_status`, and an emptied daemon
359 keeps serving so a birth can take the default name back. One idle process
360 per machine is the accepted cost. `mux d start` exits 0 on every shutdown —
361 a supervisor reads nonzero on a clean stop as a crash — and nonzero only
362 for the boot failures `main.run` picks before anything binds.
363 - **A daemon starts only when someone asks; a read never starts one, and
364 the ask is a WORD.** `mux d start -d`, `mux d endpoint --start`, or the
365 local client's own entry ask are the only starters. Bare `mux d endpoint` and
366 `mux d proxy` answer what is on the box and exit 1 when nothing is — the
367 wall polls `endpoint` over ssh once a second per host, and a poll that
368 started daemons undid every remote `mux d stop` within a second (found on
369 a live box). The dial a user ASKED for — the `mux HOST` entry dial, picker
370 Enter — runs `ssh HOST 'mux d endpoint --start'` ONCE, and that run's
371 announce IS the redial: the remote ensures the daemon and announces on the
372 same stdout (`HandoffTarget.asked`, spent after the first attempt). The
373 poll, `mux hosts` and every redial spell the bare verb and read
374 `unreachable` instead, so the rule holds by argv rather than by a client
375 branch. `--start` is `endpoint`'s alone; every other verb refuses it.
376 `asked` DEFAULTS to false: a new dial path that forgets the field starts
377 nothing, because a missing start says so on screen while a spurious one is
378 a daemon on someone else's box that nothing reports.
379 - **The daemon starts itself, and it execs THIS image.** `mux d start` IS
380 the daemon in the foreground; `-d` is the flag that forks one, and
381 `forkDetached` is the only `posix.fork` under `src/` — one spelling per OS
382 arm, `server_os_linux.forkDetached` and `server_os_macos.forkDetached` —
383 and `main.forkDaemon` is its one caller, through the `server_os` root.
384 build.zig's folder rule 6 names those arm files in `except`, so a third
385 file that forks is still caught. Every other starter spells the argv and
386 execs: `endpoint --start` calls in-process, the
387 local client's entry runs `mux d start -d --sock PATH` with its own fd 2
388 lent to it, so the daemon's refusals reach the user in the daemon's own
389 words. The fork execs `spawn.selfExe` — the running image,
390 `/proc/self/exe` read THROUGH to the file it names on Linux and
391 `std.fs.selfExePath` on Darwin, which has no such link — with argv
392 `mux d start …`. No `execvp`, no PATH walk, so an auto-start can only run
393 the binary that is already running. (It used to exec a `muxd` off PATH, and an e2e leg whose daemon
394 had died graded an installed v0.0.1-10 with no agent code in it.) The
395 resolution is not cosmetic: `comm` is the basename of the filename handed
396 to execve, so exec'ing the link itself names every daemon `exe` and hides
397 it from `pgrep mux` and `killall mux`. Both pins are in `e2e_03_side`, and
398 they ask through `test/os_oracle.sh` (`pid_exe` for the image, `pid_comm`
399 for the name, `pid_args` for the argv the attach may say and no more) —
400 which is `readlink /proc/PID/exe` and the two other `/proc` reads on
401 Linux, and `ps` plus `lsof` on Darwin, where no such directory exists.
402 The detached child's log is APPENDED: one xdg log serves every socket on
403 the box, so truncating would zero a daemon still writing to it.
404 Running the daemon IN the fork instead was tried and crashes every Debug
405 build: `std.debug.MemoryAccessor` caches the pid it reads memory through,
406 so the child's first DebugAllocator stack trace calls `process_vm_readv`
407 on the parent and hits `unreachable // own pid is always valid`
408 (decisions.md, 2026-08-28).
409 - **Remote is still ssh, and still by name.** `client.handoff` spells
410 `ssh HOST 'mux d endpoint'` / `'mux d endpoint --start'`, so a remote
411 running an installation of v0.0.1-15 or older reads `[unreachable]` until
412 it is upgraded — ≤15 knows neither the mode word nor the flag, and answers
413 a usage page on stderr with rc 2 and no announce (measured against the
414 released v0.0.1-15 tarball). There is no in-place upgrade across the
415 rename either: a ≤15 daemon probes the candidate for `muxd <version>` and
416 this binary answers `mux <version>`, so that one migration is
417 `mux d stop` then `mux d start -d` — as is 16 to the next release, whose
418 daemon execs its candidate as `d run` and finds no such verb. That ssh's
419 STDERR is a pipe mux reads, never a writer on somebody's screen: its last
420 line is the dial's `client.handoff.Reason` — quoted by the picker row
421 (`unreachable: <reason>`) and by the entry dial's failure line — and only
422 the entry dial (`HandoffTarget.narrate`) relays the bytes onward, to its
423 own fd 2, and only until the wall takes the screen.
424 - **A wall dial's ssh never touches `/dev/tty`.** `SSH_ASKPASS_REQUIRE=force`
425 plus `SSH_ASKPASS` at this image; `mux askpass` — a mode named by
426 `MUX_ASKPASS_SOCK`, since ssh gives its helper no word — carries the
427 prompt to a popup that eats every byte. `wall_pump.askOn` is the ONE
428 arming point, so the exclusions hold by construction: the entry dial keeps
429 ssh's own tty prompts, polls keep `BatchMode`. A decline SIGTERMs that ssh
430 (a refused askpass is an empty password to OpenSSH, asked three times over)
431 and ends the tile with `[prompt declined]`.
432 - **Detach is a goodbye, not a tear.** A pump that has sent its detach frame
433 never redials — redial checks `detach_ack` — so the daemon's close after
434 a detach is final, not a network event to recover from.
435 - **`mux d upgrade` execs the candidate over the RUNNING daemon**: same pid,
436 same children, same fds — so `waitpid`, the pid-named shim and agent dirs,
437 and the socket path all survive untouched, and `Server.deinit` never runs
438 on that path. `mux d upgrade HOST` is the remote spelling: a preflight ssh
439 whose LINE COUNT is the verdict (`uname -m && command -v mux && mux d
440 endpoint`, the bare read verb), the running image streamed into an atomic
441 rename over the installed mux, then that box's own `mux d upgrade` — so
442 the version rule, manifest and serving check stay daemon-side and are
443 never reimplemented client-side. No daemon on the box is install-only
444 success; no mux on the box is a refusal, because a push replaces an
445 install rather than inventing one. The manifest carrier (a memfd on Linux,
446 an unlinked `mkstemp` file at 0600 on Darwin) carries only
447 what cannot be rebuilt (pty fd + child pid, the VT dump and title, the
448 tracker, the QUIC arm's key bytes, the cumulative counters); scrollback,
449 clients, delta trackers, agent channels and per-connection QUIC state are
450 dropped on purpose. A return watermark is RE-STAMPED, never carried — it
451 belongs to the seq space that minted it. Rollback is another exec, of the
452 binary the manifest names, with `MUX_UPGRADE_ROLLBACK` as the loop guard.
453 - **The sun_path bound belongs to the one binder.** `sockpath.max_sun_path`
454 is private and `mux d` is `tooLong`'s only caller: it refuses at parse,
455 before any fork, on the stderr the asking client lent it. Everyone else
456 dials and reads the kernel's `NameTooLong`, which truncates nothing.
457 `sockpath.runtimeDir` is the one place that names the DIRECTORY those
458 paths go in, so the daemon, the client and the askpass listener agree by
459 construction. `$XDG_RUNTIME_DIR` wins on every OS, because that is how
460 every isolated rig keeps its sockets away from the user's. Linux has NO
461 fallback — a guess cannot make two binaries agree on one daemon, so the
462 caller names it with `--sock` — and Darwin falls back to `/tmp/mux-<uid>`,
463 created 0700 and re-checked on every ask for owner, exact mode and no
464 symlink in the last step, as tmux checks `/tmp/tmux-UID`. Not `$TMPDIR`
465 and not `~/Library/Caches`: the longest name mux creates is the directory
466 plus 68 bytes plus the pid's digits, and neither of those directories
467 leaves room for it in 103 (measured 2026-09-03, docs/decisions.md).
468 - No socket stealing: `mux d start` refuses a path another daemon owns.
469 - **A dial the observer table cannot hold waits in the kernel backlog; it
470 is never accepted and closed.** Every new connection lands in one of four
471 observer slots until its attach frame promotes it, and eight tile pumps
472 dial within 150 µs of each other, so the accept loop used to fill the
473 four with peers whose frame was still in flight and close the fifth —
474 one tile of an eight-tile wall lost at birth with nothing painted, one
475 run in four (2026-09-05). `Server.freeObserverSlot` is asked before the
476 accept and `pumpOnce` polls the listener only while it answers. The
477 harness's three-at-a-time fill rule in `test/e2e_lib.sh` was the
478 workaround and is now only a habit.
479 - **A deleted socket path is taken back within a second, and the log says
480 so.** A unix listener outlives its path: delete the file and the daemon
481 keeps every session on an inode nothing can reach by name (2026-09-04, a
482 live box, three sessions stranded and a second daemon auto-started on
483 the path). `Server.watchSockPath` stats the path once a second against
484 the bound inode and re-binds a missing one through the same
485 `serve.bind(.refuse_live)` a start uses — so a successor already holding
486 the path is refused, not stolen from, and its own stop hands the path
487 back on the next tick. Every socket event is one `mux d: socket PATH:`
488 line on the daemon's stderr (claim branch, bind dev+ino, loss, re-bind
489 or its refusal by name, unlink verdict at exit), and the wall appends
490 the errno its dial saw before it auto-starts. For the daemon a successor
491 keeps path-less, `mux d stop|dump|stats --quic HOST[:PORT] [--key]` is
492 the other door — the daemon has always served those verbs on a QUIC
493 client slot. `upgrade` stays `--sock`.
494 70
495 ## Working rules 71 ## Working rules
496 72
497 - **Assert behavior, don't assume it. Mocks are assumptions.** Use the thing 73 - Assert user-visible behavior with real binaries, PTYs and shells. Test
498 the way an end user does — real binaries, a real pty, a real shell. A 74 plural sessions/panes and nonzero origins; single-item fixtures alone miss
499 fixture that stands in for the product tests the fixture. 75 interactions. Ask the OS about process state, rather than trusting the daemon.
500 - **A fixture that holds a dimension constant is blind to that dimension.** 76 - Use `test/os_oracle.sh` for platform queries and the shared e2e helpers for
501 Where a thing can be plural — sessions, tiles, dials, panes — the DEFAULT 77 fixtures. Linux evidence does not establish macOS behavior.
502 fixture is plural; where a thing has an origin, the default is off-origin. 78 - For ad hoc rigs, use `tools/isolated-run.sh PATH_TO_MUX COMMAND [ARG ...]`.
503 N=1 and offset=0 are extra cases, never the baseline. Three escapes in one 79 It isolates XDG state and the default daemon socket. Custom sockets need
504 week were each exactly this, and all three passed a green gate: session 80 their own cleanup. Never run throwaway rigs against the user's daemon/state.
505 names aliased their neighbour's because every test held ONE session; a tile 81 - Capture noisy checks with `tools/run-logged.sh LOG COMMAND [ARG ...]`.
506 painted over the rail because `col_off` was 0 in every test body; the 82 It preserves the command's exit status; piping a check into tail does not.
507 page's own rules went unpinned because no check executed the page. 83 - Unit tests must not write to stdout: it carries the Zig test-runner protocol.
508 - **A harness assumption is Linux-only until it has run on both OSes.** The 84 Inject a writer or capture fd 1 in a test-owned pipe.
509 shell scripts are the part of this repo with no compiler behind them, and 85 - Measure performance with ReleaseSafe (`make throughput`), not Debug builds.
510 the e2e suite's first run on macOS was sixteen groups and ten of these. 86 - Keep rationale in comments, stated plainly. Put historical narratives in
511 Ask questions through `test/os_oracle.sh` and add an arm rather than a 87 decision records. Comment-reference checks enforce valid references, not
512 branch in a group file. Three shapes bit hardest and are worth knowing by 88 prose length; do not compress explanations merely to satisfy a counter.
513 name. A backtick inside an UNQUOTED heredoc is a command substitution 89 - Split code along responsibilities. Keep wrappers only where they preserve
514 wherever it stands, comments included, and macOS ships programs a Linux 90 an existing API; do not create unrelated fragments to reduce a line count.
515 box does not — a comment that quoted `expect` started `/usr/bin/expect`, 91 - Commit subjects are `type: what changed`, without a parenthesized scope.
516 which read the rest of the heredoc as its own script and hung the group 92 Types: feat, fix, refactor, test, docs, build, chore. Use fixups during work
517 for its whole budget. `$TMPDIR` on macOS is a per-user directory that 93 and autosquash before delivery.
518 ends in `/` and is nearly fifty characters long, so a path spelled from 94 - `git-collab issue list` is the live backlog. The commit-msg hook stamps Patch
519 it fails a string compare against the same path normalised, and does not 95 trailers; batch collab writes because each can sync with origin.
520 fit in a label bar. And BSD's `ps` and `wc` answer in a different shape 96
521 from GNU's — a full path where Linux gives a basename, a padded count 97 See [testing rationale](docs/testing-practices.md) for the incidents behind
522 where Linux gives a bare number. 98 these practices. [README](README.md) covers usage; [decisions](docs/decisions.md)
523 - **Ask the OS about the OS, not the daemon.** A claim about a pid, an fd, a 99 records design history. The old roadmap is historical, not the live queue.
524 socket or a process tree is asserted against `/proc`, `ps`, or the shell's
525 own `$$`. A daemon reporting on itself cannot catch itself being wrong —
526 which is why `mux d upgrade`'s gate is a shell pid read off the grid.
527 - Comments say *why*, not *how*. Existing ones are load-bearing — trim noise,
528 keep rationale. `zig build check` gates the REFERENCES: a cited symbol must
529 resolve, and no project-history codenames. Nothing gates length.
530 - **Write the comment plainly and let it be as long as that takes.** A reader
531 who has to decode a metaphor is worse off than one who reads four ordinary
532 sentences. Prefer the concrete noun to the figure of speech, and name the
533 failure the rule prevents. Longer rationale still belongs in
534 `docs/decisions.md` or a test's failure message, where it is dated or run —
535 because that is where a reader can find it, not to hit a budget.
536 (The byte-budget and essay-count ratchets were removed 2026-08-31: holding
537 a comment under a counter bought compression, and compressed English is
538 metaphor. See docs/decisions.md.)
539 - Commit often with `--fixup`/`--squash`, autosquash before delivery. The final
540 history should tell the feature's story, not the development's.
541 - **A commit subject is `type: what changed`.** `type` is one of `feat` `fix`
542 `refactor` `test` `docs` `build` `chore`, and there is no parenthesised
543 scope — this repo has never spelled one. The type is what `git log --oneline`
544 is read THROUGH when a release is written: the reader skips the `test:` and
545 `refactor:` lines and reads the `feat:` and `fix:` ones. Twenty commits on
546 the connection-primitives branch drifted into ad-hoc scopes (`link:`,
547 `serve:`, `harness:`) and bare sentences, because the convention lived only
548 in the history and nothing here stated it; they were reworded 2026-09-01.
549 - `git-collab` tracks issues (`git-collab issue list`); the commit-msg hook
550 stamps `Patch:` trailers. Each collab write costs a ~15s origin sync — batch.
551 - `make check` before commit, `make ci` before delivery; capture `$?` before
552 piping (`make test | tail` reports tail's exit code, not the build's).
553 - **Any hand-run rig exports an isolated `XDG_STATE_HOME`.** Every `mux`
554 that attaches writes its daemon into `$XDG_STATE_HOME/mux/hosts` — a bare
555 `mux` on an empty file included, since that records the local one. The
556 `make` scripts
557 all isolate it, but an ad-hoc repro or perf rig with the real `HOME` leaves
558 a dead `--sock /tmp/...` line on the user's wall per throwaway socket (49
559 of them found 2026-08-22, under the old per-attach file). `export
560 XDG_STATE_HOME=$SCRATCH/state` first, always — and a rig that dials a
561 default socket needs an isolated `XDG_RUNTIME_DIR` too, or a bare `mux`
562 auto-starts against the user's live daemon.
563 - **A unit test that writes to stdout wedges `zig build test` silently.** fd 1
564 of a test binary is the build runner's protocol stream; bytes on it hang the
565 runner at 0 CPU with no output. Code under test takes a writer; a test that
566 must exercise a stdout path captures fd 1 into a test-owned pipe
567 (`flags.exitForTo` is the shape). A `zig build test` that prints nothing
568 for minutes is this before it is anything else.
569 - **Never quote a speed number measured in the dev tree.** `zig build` is
570 Debug, and Debug builds ghostty with its page-integrity check on — a hash
571 map over every cell per mutation, ~600x on output-heavy work. `make test`,
572 `e2e` and `soak` all inherit it. `make throughput` builds its own
573 ReleaseSafe prefix for exactly this reason; so does `make install`.
574
575 ## Where the answers live
576
577 `docs/component-ownership.md` maps the daemon, shared client/wire, and native GUI
578 boundaries to focused gates and proposes ownership responsibilities.
579
580 `docs/roadmap.md` (~660 ln) the ranked queue, but stale past 2026-08-16 —
581 `git-collab issue list` is the live order · `docs/decisions.md` (9.2k ln, grep
582 only) every decision + measurement · `README.md` user-facing usage. Design
583 history (specs, plans, the original handoff) lives in git: `git log -- docs/superpowers`.
584
585 ## Session hygiene
586
587 Context is billed per turn. Compact or start fresh around ~150k rather than
588 riding to the 900k ceiling — a long session pays for its whole history on every
589 turn, including the trivial ones.
docs/daemon-contracts.md
Old New
@@ -0,0 +1,168 @@
1 # Daemon, transport and upgrade contracts
2
3 Read when changing these components. Implementation history retained from
4 CLAUDE.md; the current code and regression tests are authoritative.
5
6 - **A QUIC client says goodbye.** Every QUIC connection takes one of the
7 daemon's `max_clients` slots at the handshake, attached or not, and the
8 wall polls each QUIC host once a second on a connection of its own.
9 `quic.Client.deinit` therefore writes CONNECTION_CLOSE before it closes
10 the socket; a teardown that only dropped the socket left the daemon to
11 learn from its 15 s idle timer, so the polls filled the table at a slot
12 a second and every real attach after that was refused — on a laptop
13 whose only clients were another wall's polls, when the daemon had eight
14 slots and so took eight seconds (2026-09-02). The table is 32 deep now,
15 which buys time and fixes nothing: the goodbye is what bounds it.
16 A slot is spent per ATTACH, not per session, so 32 matches `max_sessions`
17 and `wallview.max_tiles` — one full wall of tiles fits exactly, and a
18 second wall on the same daemon is refused. The listener's `max_conns`
19 stays ABOVE `max_clients` (a connection exists from the handshake and only
20 then asks for a slot, and a peer that finds no connection is dropped
21 silently rather than refused), pinned by a test because a transport file
22 does not read the daemon's tables. And the listener OUTLIVES the slots
23 that close through it: a QUIC sink closes its connection via the listener,
24 so a borrowed listener's `deinit` is registered AFTER the server's.
25
26 - **A pid-named leftover is reaped by its successor, never by a signal
27 handler.** The daemon's `mux-agent-PID-*` and `mux-shellint-PID-*`
28 directories and a wall's `mux-ask-PID.sock` are unlinked by their owner
29 on the normal way out; a SIGKILL or a closed terminal window runs
30 nothing, so the next daemon or wall to create one in that directory
31 calls `xdg.reapDeadPid` first and removes every entry whose pid the OS
32 no longer has. A live pid's entry stays even when it is no longer a mux.
33
34 - **A daemon lives until `mux d stop`; emptiness is not an exit.** `x` ends a
35 session, never a box: `reap` and `pumpOnce` answer nothing, a shell's code
36 reaches that shell's own clients as `exit_status`, and an emptied daemon
37 keeps serving so a birth can take the default name back. One idle process
38 per machine is the accepted cost. `mux d start` exits 0 on every shutdown —
39 a supervisor reads nonzero on a clean stop as a crash — and nonzero only
40 for the boot failures `main.run` picks before anything binds.
41
42 - **A daemon starts only when someone asks; a read never starts one, and
43 the ask is a WORD.** `mux d start -d`, `mux d endpoint --start`, or the
44 local client's own entry ask are the only starters. Bare `mux d endpoint` and
45 `mux d proxy` answer what is on the box and exit 1 when nothing is — the
46 wall polls `endpoint` over ssh once a second per host, and a poll that
47 started daemons undid every remote `mux d stop` within a second (found on
48 a live box). The dial a user ASKED for — the `mux HOST` entry dial, picker
49 Enter — runs `ssh HOST 'mux d endpoint --start'` ONCE, and that run's
50 announce IS the redial: the remote ensures the daemon and announces on the
51 same stdout (`HandoffTarget.asked`, spent after the first attempt). The
52 poll, `mux hosts` and every redial spell the bare verb and read
53 `unreachable` instead, so the rule holds by argv rather than by a client
54 branch. `--start` is `endpoint`'s alone; every other verb refuses it.
55 `asked` DEFAULTS to false: a new dial path that forgets the field starts
56 nothing, because a missing start says so on screen while a spurious one is
57 a daemon on someone else's box that nothing reports.
58
59 - **The daemon starts itself, and it execs THIS image.** `mux d start` IS
60 the daemon in the foreground; `-d` is the flag that forks one, and
61 `forkDetached` is the only `posix.fork` under `src/` — one spelling per OS
62 arm, `server_os_linux.forkDetached` and `server_os_macos.forkDetached` —
63 and `main.forkDaemon` is its one caller, through the `server_os` root.
64 build.zig's folder rule 6 names those arm files in `except`, so a third
65 file that forks is still caught. Every other starter spells the argv and
66 execs: `endpoint --start` calls in-process, the
67 local client's entry runs `mux d start -d --sock PATH` with its own fd 2
68 lent to it, so the daemon's refusals reach the user in the daemon's own
69 words. The fork execs `spawn.selfExe` — the running image,
70 `/proc/self/exe` read THROUGH to the file it names on Linux and
71 `std.fs.selfExePath` on Darwin, which has no such link — with argv
72 `mux d start …`. No `execvp`, no PATH walk, so an auto-start can only run
73 the binary that is already running. (It used to exec a `muxd` off PATH, and an e2e leg whose daemon
74 had died graded an installed v0.0.1-10 with no agent code in it.) The
75 resolution is not cosmetic: `comm` is the basename of the filename handed
76 to execve, so exec'ing the link itself names every daemon `exe` and hides
77 it from `pgrep mux` and `killall mux`. Both pins are in `e2e_03_side`, and
78 they ask through `test/os_oracle.sh` (`pid_exe` for the image, `pid_comm`
79 for the name, `pid_args` for the argv the attach may say and no more) —
80 which is `readlink /proc/PID/exe` and the two other `/proc` reads on
81 Linux, and `ps` plus `lsof` on Darwin, where no such directory exists.
82 The detached child's log is APPENDED: one xdg log serves every socket on
83 the box, so truncating would zero a daemon still writing to it.
84 Running the daemon IN the fork instead was tried and crashes every Debug
85 build: `std.debug.MemoryAccessor` caches the pid it reads memory through,
86 so the child's first DebugAllocator stack trace calls `process_vm_readv`
87 on the parent and hits `unreachable // own pid is always valid`
88 (decisions.md, 2026-08-28).
89
90 - **Remote is still ssh, and still by name.** `client.handoff` spells
91 `ssh HOST 'mux d endpoint'` / `'mux d endpoint --start'`, so a remote
92 running an installation of v0.0.1-15 or older reads `[unreachable]` until
93 it is upgraded — ≤15 knows neither the mode word nor the flag, and answers
94 a usage page on stderr with rc 2 and no announce (measured against the
95 released v0.0.1-15 tarball). There is no in-place upgrade across the
96 rename either: a ≤15 daemon probes the candidate for `muxd <version>` and
97 this binary answers `mux <version>`, so that one migration is
98 `mux d stop` then `mux d start -d` — as is 16 to the next release, whose
99 daemon execs its candidate as `d run` and finds no such verb. That ssh's
100 STDERR is a pipe mux reads, never a writer on somebody's screen: its last
101 line is the dial's `client.handoff.Reason` — quoted by the picker row
102 (`unreachable: <reason>`) and by the entry dial's failure line — and only
103 the entry dial (`HandoffTarget.narrate`) relays the bytes onward, to its
104 own fd 2, and only until the wall takes the screen.
105
106 - **`mux d upgrade` execs the candidate over the RUNNING daemon**: same pid,
107 same children, same fds — so `waitpid`, the pid-named shim and agent dirs,
108 and the socket path all survive untouched, and `Server.deinit` never runs
109 on that path. `mux d upgrade HOST` is the remote spelling: a preflight ssh
110 whose LINE COUNT is the verdict (`uname -m && command -v mux && mux d
111 endpoint`, the bare read verb), the running image streamed into an atomic
112 rename over the installed mux, then that box's own `mux d upgrade` — so
113 the version rule, manifest and serving check stay daemon-side and are
114 never reimplemented client-side. No daemon on the box is install-only
115 success; no mux on the box is a refusal, because a push replaces an
116 install rather than inventing one. The manifest carrier (a memfd on Linux,
117 an unlinked `mkstemp` file at 0600 on Darwin) carries only
118 what cannot be rebuilt (pty fd + child pid, the VT dump and title, the
119 tracker, the QUIC arm's key bytes, the cumulative counters); scrollback,
120 clients, delta trackers, agent channels and per-connection QUIC state are
121 dropped on purpose. A return watermark is RE-STAMPED, never carried — it
122 belongs to the seq space that minted it. Rollback is another exec, of the
123 binary the manifest names, with `MUX_UPGRADE_ROLLBACK` as the loop guard.
124
125 - **The sun_path bound belongs to the one binder.** `sockpath.max_sun_path`
126 is private and `mux d` is `tooLong`'s only caller: it refuses at parse,
127 before any fork, on the stderr the asking client lent it. Everyone else
128 dials and reads the kernel's `NameTooLong`, which truncates nothing.
129 `sockpath.runtimeDir` is the one place that names the DIRECTORY those
130 paths go in, so the daemon, the client and the askpass listener agree by
131 construction. `$XDG_RUNTIME_DIR` wins on every OS, because that is how
132 every isolated rig keeps its sockets away from the user's. Linux has NO
133 fallback — a guess cannot make two binaries agree on one daemon, so the
134 caller names it with `--sock` — and Darwin falls back to `/tmp/mux-<uid>`,
135 created 0700 and re-checked on every ask for owner, exact mode and no
136 symlink in the last step, as tmux checks `/tmp/tmux-UID`. Not `$TMPDIR`
137 and not `~/Library/Caches`: the longest name mux creates is the directory
138 plus 68 bytes plus the pid's digits, and neither of those directories
139 leaves room for it in 103 (measured 2026-09-03, docs/decisions.md).
140
141 - No socket stealing: `mux d start` refuses a path another daemon owns.
142
143 - **A dial the observer table cannot hold waits in the kernel backlog; it
144 is never accepted and closed.** Every new connection lands in one of four
145 observer slots until its attach frame promotes it, and eight tile pumps
146 dial within 150 µs of each other, so the accept loop used to fill the
147 four with peers whose frame was still in flight and close the fifth —
148 one tile of an eight-tile wall lost at birth with nothing painted, one
149 run in four (2026-09-05). `Server.freeObserverSlot` is asked before the
150 accept and `pumpOnce` polls the listener only while it answers. The
151 harness's three-at-a-time fill rule in `test/e2e_lib.sh` was the
152 workaround and is now only a habit.
153
154 - **A deleted socket path is taken back within a second, and the log says
155 so.** A unix listener outlives its path: delete the file and the daemon
156 keeps every session on an inode nothing can reach by name (2026-09-04, a
157 live box, three sessions stranded and a second daemon auto-started on
158 the path). `Server.watchSockPath` stats the path once a second against
159 the bound inode and re-binds a missing one through the same
160 `serve.bind(.refuse_live)` a start uses — so a successor already holding
161 the path is refused, not stolen from, and its own stop hands the path
162 back on the next tick. Every socket event is one `mux d: socket PATH:`
163 line on the daemon's stderr (claim branch, bind dev+ino, loss, re-bind
164 or its refusal by name, unlink verdict at exit), and the wall appends
165 the errno its dial saw before it auto-starts. For the daemon a successor
166 keeps path-less, `mux d stop|dump|stats --quic HOST[:PORT] [--key]` is
167 the other door — the daemon has always served those verbs on a QUIC
168 client slot. `upgrade` stays `--sock`.
docs/development.md
Old New
@@ -0,0 +1,70 @@
1 # Development commands
2
3 ```sh
4 ZIG=deps/zig/zig # vendored 0.15.2 (ghostty pin); copy the toolchain here, gitignored
5 # on a Mac that is the aarch64 0.15.2 tarball, in the same place
6 make mac-sdk # Darwin only, and `make build` runs it: a shadow SDK
7 # under deps/mac-sdk, because zig 0.15.2 cannot link
8 # the stub Xcode 26.4+ ships. No-op elsewhere.
9 make build test e2e # Makefile already points at it
10 make lint # fetch/verify pinned zlint and lint Zig sources
11 make check # lint + fmt + tests + shell/web ABI/source checks — pre-commit gate
12 make ci # check + e2e + agent + throughput — the delivery gate
13 make daemon-test client-test # focused component tests; same artifacts as make test
14 make native-core-test # GUI workspace/interaction tests without window/font libraries
15 make agent soak bench throughput
16 make native native-e2e # opt-in muxg; SDL3/freetype/fontconfig/HarfBuzz + GL headers; e2e also needs python3
17 make vm # real user journeys against the mux-e2e VM (test/vm.sh)
18 make mac xos # macOS journeys (test/mac.sh); the cross-OS gate (test/xos.sh).
19 # Both take their boxes BY NAME and have no default
20 # for one: MAC_BOX (macOS, no toolchain, SCRUBBED),
21 # MAC_BUILDER (a Mac with zig + deps/mac-sdk, builds
22 # only, never scrubbed, defaults to $MAC_BOX), and
23 # for xos also LINUX_BOX (x86_64 Linux VM, scrubbed).
24 make provision-mac # clone, boot and key a macOS guest on a tart host and
25 # print one line; `eval "$(test/provision-mac.sh)"`
26 # is how MAC_BOX gets set. --down takes it away.
27 make install # mux + muxg -> ~/.local/bin, ReleaseSafe; needs native libraries
28 # MUX_TARGET follows the HOST: a Mac installs a Mac
29 # binary, because that is the only one it can link
30 make release release-mac # one tarball per OS into dist/, cut on that OS;
31 # release-mac runs `make release` on $MAC_BUILDER
32 make xversion-build xversion # cross-version gate; XVER_OLD_WORKTREE points at the old checkout (default ..)
33 ```
34
35 First build after a clean checkout fetches ~30MB of QUIC deps (minutes, once).
36 `make clean` deliberately spares `deps/quic`.
37
38 ## Lint dependencies
39
40 `make lint` fetches zlint v0.9.1 from the upstream release into `deps/zlint/`,
41 verifies the host asset against its pinned SHA-256 checksum, and runs it over
42 all repository Zig sources, including tests and build tools. Cached copies
43 are checked too. Downloads are staged and renamed only after verification.
44 Linux x86-64/ARM64 and macOS Intel/Apple Silicon are supported.
45 The bootstrap requires curl and either sha256sum or shasum.
46
47 `zlint.json` enables rules that can fail the build. We deliberately leave
48 `no-print` off: terminal programs and test fixtures have legitimate output.
49 `unsafe-undefined` and `suppressed-errors` need an ownership/error-handling
50 review before adoption; enabling them globally would encourage blanket
51 suppressions. `no-catch-return` in v0.9.1 incorrectly flags our optional-error
52 return values. `duplicate-case` flags intentionally separate switch arms with
53 identical bodies. These are not enabled to avoid unrelated style churn.
54
55 `make check` requires ShellCheck and Node.js. Install them with the host's
56 package manager (for example, `apt install shellcheck nodejs` on Debian/Ubuntu
57 or `brew install shellcheck node` on macOS). Missing tools fail their checks;
58 a fresh machine cannot silently skip shell lint or web ABI verification.
59 The compiler and linter versions are pinned; ShellCheck currently gates errors
60 only, so its installed version should be recorded when investigating a failure.
61
62 ## Ad hoc reproductions and logs
63
64 Use `tools/isolated-run.sh PATH_TO_MUX COMMAND [ARG ...]` for a reproduction.
65 It exports isolated XDG directories and `MUX`, and stops the daemon on its
66 isolated default socket before removing the directories. A script that creates
67 additional custom sockets remains responsible for stopping those daemons.
68
69 Use `tools/run-logged.sh LOG COMMAND [ARG ...]` to capture a noisy check. It
70 prints the final 30 lines and returns the command's original exit status.
docs/terminal-contracts.md
Old New
@@ -0,0 +1,39 @@
1 # Terminal and agent contracts
2
3 Read when changing these components. Implementation history retained from
4 CLAUDE.md; the current code and regression tests are authoritative.
5
6 - **`proxy.zig` and the QUIC modules carry opaque bytes and know nothing of
7 frames.** Keep the wire contract — `term` — out of `proxy.zig`'s imports.
8
9 - **`predict.zig` output never enters the replica.** It is an overlay.
10
11 - **One replay core.** CLI, wasm, and test fixtures all go through `replica.zig`,
12 over a `grid`, never an engine — the client parses no VT.
13 Do not hand-roll a second applier.
14
15 - **A client dump equals a daemon dump byte for byte, modulo trailing spaces
16 per row.** The rule is stated in three places, and all three must keep
17 saying the same thing: the header on `grid.dumpRowsPlain` (which
18 `Grid.dumpPlain` calls), the server harness's `trimRowTails`, and the e2e
19 `converged_quiet` strip. Ghostty's VT formatter
20 trimmed trailing whitespace on the old wire, so no replica ever held a typed
21 trailing space; the cell encoder's default-space cutoff is parity with that.
22
23 - **Latest wins.** The grid follows the most recently active client.
24
25 - **`mux a` attaches at 0×0** so an agent never resizes a human's session.
26
27 - **OSC 133 marks are opt-in** (`MUX_SHELL_INTEGRATION=1`); without them `mux a`
28 falls back to `pgid`/`settle` and there is no real exit code. Every `mux a`
29 reply names the `mechanism` that answered it.
30
31 - **OSC 52 clipboard READ is refused deliberately.** Not a gap.
32
33 - **Agent forwarding is opt-in (`-A`) and the daemon pumps blind.** The
34 per-session agent socket always exists and overwrites the daemon's own
35 `SSH_AUTH_SOCK` — unset when there is no socket, never inherited. The
36 latest-active `-A` client answers. `mux a` and browsers never OFFER an
37 agent, though anything they run inside a session uses that session's
38 socket like any other process. Frames, never transport; both ends cap
39 a frame at `agent_data_max`.
docs/testing-practices.md
Old New
@@ -0,0 +1,45 @@
1 # Testing rationale
2
3 The prescriptive development rules live in [CLAUDE.md](../CLAUDE.md); this
4 page records the failures that explain the testing rules there.
5
6 - **Assert behavior, don't assume it. Mocks are assumptions.** Real binaries,
7 PTYs and shells have caught failures that fixtures standing in for the
8 product could never see.
9 - **A fixture that holds a dimension constant is blind to that dimension.**
10 Where a thing can be plural — sessions, tiles, dials, panes — the DEFAULT
11 fixture is plural; where a thing has an origin, the default is off-origin.
12 N=1 and offset=0 are extra cases, never the baseline. Three escapes in one
13 week were each exactly this, and all three passed a green gate: session
14 names aliased their neighbour's because every test held ONE session; a tile
15 painted over the rail because `col_off` was 0 in every test body; the
16 page's own rules went unpinned because no check executed the page.
17 - **A harness assumption is Linux-only until it has run on both OSes.** The
18 shell scripts are the part of this repo with no compiler behind them, and
19 the e2e suite's first run on macOS was sixteen groups and ten of these.
20 Ask questions through `test/os_oracle.sh` and add an arm rather than a
21 branch in a group file. Three shapes bit hardest and are worth knowing by
22 name. A backtick inside an UNQUOTED heredoc is a command substitution
23 wherever it stands, comments included, and macOS ships programs a Linux
24 box does not — a comment that quoted `expect` started `/usr/bin/expect`,
25 which read the rest of the heredoc as its own script and hung the group
26 for its whole budget. `$TMPDIR` on macOS is a per-user directory that
27 ends in `/` and is nearly fifty characters long, so a path spelled from
28 it fails a string compare against the same path normalised, and does not
29 fit in a label bar. And BSD's `ps` and `wc` answer in a different shape
30 from GNU's — a full path where Linux gives a basename, a padded count
31 where Linux gives a bare number.
32 - **Ask the OS about the OS, not the daemon.** A claim about a pid, an fd, a
33 socket or a process tree is asserted against `/proc`, `ps`, or the shell's
34 own `$$`. A daemon reporting on itself cannot catch itself being wrong —
35 which is why `mux d upgrade`'s gate is a shell pid read off the grid.
36 - A unit test that writes to stdout once wedged `zig build test` at 0 CPU:
37 fd 1 is the test-runner protocol stream. The authoritative rule and the
38 supported capture pattern are in [CLAUDE.md](../CLAUDE.md#working-rules).
39 - A hand-run rig once left dozens of dead host entries because it used the
40 user's XDG state and runtime directories. Use the wrapper described in
41 [development commands](development.md#ad-hoc-reproductions-and-logs), or
42 isolate custom sockets and state yourself.
43 - Debug timings are dominated by ghostty's page-integrity checks. Performance
44 claims belong to the ReleaseSafe throughput path documented in
45 [CLAUDE.md](../CLAUDE.md#working-rules).
docs/wall-contracts.md
Old New
@@ -0,0 +1,166 @@
1 # Terminal wall and browser layout contracts
2
3 Read when changing these components. Implementation history retained from
4 CLAUDE.md; the current code and regression tests are authoritative.
5
6 - **Every tile claims its rect.** The layout tree (`client.layout.Tree`) is
7 client-local, owned by the keyboard thread under `paint_mu`; `relayout`
8 is the single flatten point that turns it into tile rects. Attach sends
9 the rect, relayout resends it. Focus is client-local and sends nothing
10 on the wire: the keyboard writes the outgoing tile's `session_release`
11 under `paint_mu`, then doorbells the old pump (release) and the new
12 (claim). ONE interaction loop, a tile pump: `mux TARGET` is the wall
13 zoomed on the tile it just attached to, and a wall of one tile whose rect
14 is the whole terminal only when there is no terminal at all. A chord-born
15 tile inherits the focused tile's target and its `-A`.
16
17 - **Resize is gain-only.** `client.layout.Tree.resize` never shrinks a pane; the
18 shrink keys grow a neighbor at the focus's expense in `wall_layout.doResize`.
19
20 - **Rails are painted from `relayout` and tiles cannot reach them.** Every
21 tile clear is span-bounded ECH, never line-wide, so a tile cannot erase
22 a neighbor's cells or a rail.
23
24 - **The layout is the wall; the poll grades it and adds nothing.**
25 `$XDG_STATE_HOME/mux/layout` is authored intent: a pane tree whose leaves
26 are `HOST#SESSION`, `HOST` a hosts-file line verbatim. Tiles come from it
27 and from three doors only — the file on start (`wall_layout.seedLayout`),
28 the picker, and a chord split — never from a daemon's `sessions_reply`;
29 `mux HOST`'s entry pane goes through the same seat-then-`wall_layout.persist`
30 path. `wall_host.planHostDiff` is the once-a-second GRADE and the whole of
31 it: it binds a pending pane whose session the list names, marks `gone` one
32 the list does not, and vanishes a live pane whose shell ended after the
33 one-list grace. A name in the list that no pane spells is nobody's
34 business. A pane on a host that answers NOTHING stays, wearing
35 `unreachable`, for as long as the box is dark — an eight-pane setup is not
36 worth one quiet machine — and a `gone` pane (the host said no, not
37 nothing) takes Enter to re-create the session in that pane's own rect or
38 `x` to remove it, every other key eaten. A wall of only gone panes is not
39 empty; a wall with no panes says so and opens the picker. A daemon restart
40 re-creates nothing: its panes wear `gone` until an Enter starts them.
41 `wall_layout.persist` is the ONE save path, gated on `Shared.layout_path`,
42 and every change to the pane set or the tree calls it — birth, split,
43 resize, detach, removal, a poll's vanish of an exited shell, a picker add,
44 birth or forget. Only a wall on a TERMINAL (`Shared.is_tty`) has a path at
45 all: a piped `mux` is a wall of one and neither reads nor writes.
46 `seedLayout` is STRICT — `.refused` with the offending line for a host the
47 hosts file lacks, a leaf without `#SESSION` or with a bad name, a repeated
48 leaf (a second leaf spelling the entry included), more leaves than
49 `layout.max_leaves`, or text `layout.parseReporting` gives up on — and the
50 caller prints `mux: layout ignored (PATH): LINE` and starts as if the file
51 were missing, because seating half a wall is how a user loses one.
52 Whitespace-only is `.none`, not a refusal. A missing file is one local
53 pane on session `0` when there is an entry and an empty wall when there is
54 not; a hosts file with lines and no layout reconstructs NOTHING. A run
55 whose seed was trimmed to fit the terminal, or that dropped a leaf naming
56 this shell's own session, sets `layout_path` null for the WHOLE run and
57 says which on the notice line (`[layout not saved: ...]`) — it never
58 rewrites the file, and a save that fails for any other reason is a notice
59 too, never a stderr print into somebody's pane. A `--via CMD` entry nulls
60 it for the same family of reason: `hosts.zig` writes no `--via` line, so
61 the leaf `tileLabel` spells for that tile names a host no table can hold
62 and the NEXT start refuses the whole file over it. `focus N` is the one line
63 in the file that is not authored and may go stale between tree changes.
64 Both files are strict now: the 2026-08 asymmetry (strict hosts, lenient
65 sidecar) is reversed, because the layout is no longer derived.
66
67 - **The hosts file lists DAEMONS and nothing else.**
68 `$XDG_STATE_HOME/mux/hosts`, one spelling per line (`--sock PATH` |
69 `HOST` | `quic://HOST[:PORT]`); `#SESSION` is refused, because a host
70 line must name nothing that could be resurrected — naming a session is
71 the layout's job. `hosts.zig` owns it. `--via` and `mux a` record no host.
72 `client.hosts.load` is strict — a bad line refuses `mux` with rc 2 and prints
73 it — while `mux hosts` lists and `mux hosts rm` edits VERBATIM, so the
74 repair tool can read what the loader will not. The BROWSER hub is the
75 same wall: `mux web [HOST ...]` records its argv into the hosts file and
76 then serves the LAYOUT — `webhub.readLeaves`, which refuses a bad file
77 with `mux web: layout ignored (PATH): LINE` and serves an empty wall —
78 one `client.SessionPoll` per daemon, `/tiles` in tree order carrying
79 `id`, `label`, `session` and `state`. Ids are birth order and are never
80 reused. The page authors ONE thing: its `+` (`POST /tiles/<id>`) checks
81 the file for room and for a duplicate under the hub mutex BEFORE it dials
82 (`webhub.roomForLeaf`), births, then `webhub.appendLeaf` writes the leaf
83 beside that pane — 409 `duplicate` for a second `+` inside one poll
84 interval, 502 for `WallFull` or `BadLayout`. Dialling first would leave a
85 live session behind a 502 that no wall names. POST /tiles, PUT and DELETE
86 answer 405. Two writers in one instant lose one update, which the hosts
87 file has always accepted. The old `wall` file is retired.
88
89 - **Hosts and sessions live in the picker.** `Ctrl-\ s` is a MODE of
90 `interact.PrefixFilter` (`picking`), like the spelling editor it
91 contains: every byte is the popup's, so a key can never reach a session,
92 and both tables are unit-testable without a terminal. It has two levels
93 (`PrefixFilter.pick_level`). HOSTS are the hosts file's daemons in file
94 order with the POLLER's last answer beside each (`N sessions` / `no
95 sessions` / `unreachable` / `connecting`); Enter descends into that
96 daemon's sessions, `x` forgets the host (the file line, its poller, its
97 panes; the sessions keep running), `a` is the old `:` spelling editor.
98 SESSIONS (`wall_picker.sessionRows`) are one row per name the last answer
99 carried, reading `on this wall` when the layout already has it and
100 `K client(s)` from `proto.parseSessionsHolds` — K is EVERY holder, this
101 wall's own pane included, so it is a number to READ and never a verdict;
102 an old daemon sends none and the row shows no count. There Enter adds the
103 session as a pane and zooms to it (`wall_picker.pickAdd`, which JOINS and
104 never creates; a session already on the wall is zoomed to and nothing
105 else), `x` ends it, and Esc backs out one level. `c` births at either
106 level — `wall_picker.pickBirth` CREATES at `client.nextFreeName` off that
107 host's own list, so a host with no pane is still one you can start work
108 on. Tiles do not paint while it is open (`Shared.picker_open`); the close
109 bumps `repaint_gen`. An empty wall opens it ONCE by itself, so an Esc
110 there leaves the one-line text; and on a TERMINAL the last tile's exit
111 leaves that empty wall rather than ending mux, as does a picker birth the
112 daemon REFUSES (`Tile.keeps_wall`, which `born_from` cannot say — the
113 entry tile carries the same null) — a piped `mux` still exits with the
114 shell's code, because scripts read it.
115
116 - **`Ctrl-\ x` removes a pane and ends nothing; ending has two doors —
117 `Ctrl-\ X` through the focused tile's own pump and the picker's `x`
118 through a side connection — and the daemon owns the two-step behind
119 both.** `wallview.removePane` sets the notice
120 `[pane removed - the session is still on its daemon]` BEFORE the vanish
121 (the vanish hands the focus on, and the incoming pump's claim is what
122 paints the sentence), tells the pump to detach — it writes `.detach` on
123 its way out and the transport close behind it is the fallback — vanishes
124 the tile, relayouts and persists. Every pane removes the same way,
125 including one that never came up. ENDING is `end_req` `0x11` /
126 `end_reply` `0x94`, observer verbs, asked one of two ways. `Ctrl-\ X` on
127 a tile stores `wallview.intentForEnd` into that tile's `ask` and rings
128 its pump, which puts the frame on the connection the pane is already
129 attached to (`wall_pump`), reads the answer back through
130 `wallview.onEndReply` and banners the refusal; the daemon excludes the
131 asking client from the count, so a session only this pane holds ends on
132 the FIRST press. Nothing is vanished on that keypress — the pane leaves
133 when the shell does. `wall_picker.pickEnd` is the other door, over
134 `client.endSession` on a SIDE connection, because the session may have no
135 pane here to ask through — which is also why its count includes this
136 wall's own pane. That connection dials `HostSpec.poll_target` —
137 the poller's batch recipe, never the interactive one, since an end must
138 not start a daemon and must not reach for a terminal. The daemon refuses
139 a first press on a session other clients hold and answers the count; the
140 two doors keep SEPARATE 3 s arms, each keyed to what its own second press
141 lands on — `Tile.end_armed_until` on the tile, `Shared.pick_end` per host
142 AND name — and every OTHER refusal arms nothing, because only "others
143 attached" is a question a second press answers. An accepted end is
144 BOUNDED — SIGKILL past `Pty.term_grace_ms` — so no shell can refuse to
145 die, and `mux d upgrade` is refused while any session is ending rather
146 than carry a masterless pty across the exec. The press is ASKED on the
147 wire whatever the row says: a daemon that answers nothing spends the
148 reply budget and its `error.Timeout` becomes `[daemon too old to end a
149 session]`, which is the only thing the absence of an arm can look like.
150 The missing `# holds` line is NOT that gate — the released v0.0.1-16
151 daemon answers `end_req` and sends no holds line, so refusing on the
152 count refused a daemon that works. An ended session's pane leaves on the
153 next list, not on the keypress.
154
155 - **A wall dial's ssh never touches `/dev/tty`.** `SSH_ASKPASS_REQUIRE=force`
156 plus `SSH_ASKPASS` at this image; `mux askpass` — a mode named by
157 `MUX_ASKPASS_SOCK`, since ssh gives its helper no word — carries the
158 prompt to a popup that eats every byte. `wall_pump.askOn` is the ONE
159 arming point, so the exclusions hold by construction: the entry dial keeps
160 ssh's own tty prompts, polls keep `BatchMode`. A decline SIGTERMs that ssh
161 (a refused askpass is an empty password to OpenSSH, asked three times over)
162 and ends the tile with `[prompt declined]`.
163
164 - **Detach is a goodbye, not a tear.** A pump that has sent its detach frame
165 never redials — redial checks `detach_ack` — so the daemon's close after
166 a detach is final, not a network event to recover from.