ee9d0f38
docs: shorten agent guidance and separate detailed contracts
a73x 2026-09-06 09:23
Commit message
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. | ||