docs/wall-contracts.md
Ref: Size: 11.0 KiB History
# Terminal wall and browser layout contracts Read when changing these components. Implementation history retained from CLAUDE.md; the current code and regression tests are authoritative. - **Every tile claims its rect.** The layout tree (`client.layout.Tree`) is client-local, owned by the keyboard thread under `paint_mu`; `relayout` is the single flatten point that turns it into tile rects. Attach sends the rect, relayout resends it. Focus is client-local and sends nothing on the wire: the keyboard writes the outgoing tile's `session_release` under `paint_mu`, then doorbells the old pump (release) and the new (claim). ONE interaction loop, a tile pump: `mux TARGET` is the wall zoomed on the tile it just attached to, and a wall of one tile whose rect is the whole terminal only when there is no terminal at all. A chord-born tile inherits the focused tile's target and its `-A`. - **Resize is gain-only.** `client.layout.Tree.resize` never shrinks a pane; the shrink keys grow a neighbor at the focus's expense in `wall_layout.doResize`. - **Rails are painted from `relayout` and tiles cannot reach them.** Every tile clear is span-bounded ECH, never line-wide, so a tile cannot erase a neighbor's cells or a rail. - **The layout is the wall; the poll grades it and adds nothing.** `$XDG_STATE_HOME/mux/layout` is authored intent: a pane tree whose leaves are `HOST#SESSION`, `HOST` a hosts-file line verbatim. Tiles come from it and from three doors only — the file on start (`wall_layout.seedLayout`), the picker, and a chord split — never from a daemon's `sessions_reply`; `mux HOST`'s entry pane goes through the same seat-then-`wall_layout.persist` path. `wall_host.planHostDiff` is the once-a-second GRADE and the whole of it: it binds a pending pane whose session the list names, marks `gone` one the list does not, and vanishes a live pane whose shell ended after the one-list grace. A name in the list that no pane spells is nobody's business. A pane on a host that answers NOTHING stays, wearing `unreachable`, for as long as the box is dark — an eight-pane setup is not worth one quiet machine — and a `gone` pane (the host said no, not nothing) takes Enter to re-create the session in that pane's own rect or `x` to remove it, every other key eaten. A wall of only gone panes is not empty; a wall with no panes says so and opens the picker. A daemon restart re-creates nothing: its panes wear `gone` until an Enter starts them. `wall_layout.persist` is the ONE save path, gated on `Shared.layout_path`, and every change to the pane set or the tree calls it — birth, split, resize, detach, removal, a poll's vanish of an exited shell, a picker add, birth or forget. Only a wall on a TERMINAL (`Shared.is_tty`) has a path at all: a piped `mux` is a wall of one and neither reads nor writes. `seedLayout` is STRICT — `.refused` with the offending line for a host the hosts file lacks, a leaf without `#SESSION` or with a bad name, a repeated leaf (a second leaf spelling the entry included), more leaves than `layout.max_leaves`, or text `layout.parseReporting` gives up on — and the caller prints `mux: layout ignored (PATH): LINE` and starts as if the file were missing, because seating half a wall is how a user loses one. Whitespace-only is `.none`, not a refusal. A missing file is one local pane on session `0` when there is an entry and an empty wall when there is not; a hosts file with lines and no layout reconstructs NOTHING. A run whose seed was trimmed to fit the terminal, or that dropped a leaf naming this shell's own session, sets `layout_path` null for the WHOLE run and says which on the notice line (`[layout not saved: ...]`) — it never rewrites the file, and a save that fails for any other reason is a notice too, never a stderr print into somebody's pane. A `--via CMD` entry nulls it for the same family of reason: `hosts.zig` writes no `--via` line, so the leaf `tileLabel` spells for that tile names a host no table can hold and the NEXT start refuses the whole file over it. `focus N` is the one line in the file that is not authored and may go stale between tree changes. Both files are strict now: the 2026-08 asymmetry (strict hosts, lenient sidecar) is reversed, because the layout is no longer derived. - **The hosts file lists DAEMONS and nothing else.** `$XDG_STATE_HOME/mux/hosts`, one spelling per line (`--sock PATH` | `HOST` | `quic://HOST[:PORT]`); `#SESSION` is refused, because a host line must name nothing that could be resurrected — naming a session is the layout's job. `hosts.zig` owns it. `--via` and `mux a` record no host. `client.hosts.load` is strict — a bad line refuses `mux` with rc 2 and prints it — while `mux hosts` lists and `mux hosts rm` edits VERBATIM, so the repair tool can read what the loader will not. The BROWSER hub is the same wall: `mux web [HOST ...]` records its argv into the hosts file and then serves the LAYOUT — `webhub.readLeaves`, which refuses a bad file with `mux web: layout ignored (PATH): LINE` and serves an empty wall — one `client.SessionPoll` per daemon, `/tiles` in tree order carrying `id`, `label`, `session` and `state`. Ids are birth order and are never reused. The page authors ONE thing: its `+` (`POST /tiles/<id>`) checks the file for room and for a duplicate under the hub mutex BEFORE it dials (`webhub.roomForLeaf`), births, then `webhub.appendLeaf` writes the leaf beside that pane — 409 `duplicate` for a second `+` inside one poll interval, 502 for `WallFull` or `BadLayout`. Dialling first would leave a live session behind a 502 that no wall names. POST /tiles, PUT and DELETE answer 405. Two writers in one instant lose one update, which the hosts file has always accepted. The old `wall` file is retired. - **Hosts and sessions live in the picker.** `Ctrl-\ s` is a MODE of `interact.PrefixFilter` (`picking`), like the spelling editor it contains: every byte is the popup's, so a key can never reach a session, and both tables are unit-testable without a terminal. It has two levels (`PrefixFilter.pick_level`). HOSTS are the hosts file's daemons in file order with the POLLER's last answer beside each (`N sessions` / `no sessions` / `unreachable` / `connecting`); Enter descends into that daemon's sessions, `x` forgets the host (the file line, its poller, its panes; the sessions keep running), `a` is the old `:` spelling editor. SESSIONS (`wall_picker.sessionRows`) are one row per name the last answer carried, reading `on this wall` when the layout already has it and `K client(s)` from `proto.parseSessionsHolds` — K is EVERY holder, this wall's own pane included, so it is a number to READ and never a verdict; an old daemon sends none and the row shows no count. There Enter adds the session as a pane and zooms to it (`wall_picker.pickAdd`, which JOINS and never creates; a session already on the wall is zoomed to and nothing else), `x` ends it, and Esc backs out one level. `c` births at either level — `wall_picker.pickBirth` CREATES at `client.nextFreeName` off that host's own list, so a host with no pane is still one you can start work on. Tiles do not paint while it is open (`Shared.picker_open`); the close bumps `repaint_gen`. An empty wall opens it ONCE by itself, so an Esc there leaves the one-line text; and on a TERMINAL the last tile's exit leaves that empty wall rather than ending mux, as does a picker birth the daemon REFUSES (`Tile.keeps_wall`, which `born_from` cannot say — the entry tile carries the same null) — a piped `mux` still exits with the shell's code, because scripts read it. - **`Ctrl-\ x` removes a pane and ends nothing; ending has two doors — `Ctrl-\ X` through the focused tile's own pump and the picker's `x` through a side connection — and the daemon owns the two-step behind both.** `wallview.removePane` sets the notice `[pane removed - the session is still on its daemon]` BEFORE the vanish (the vanish hands the focus on, and the incoming pump's claim is what paints the sentence), tells the pump to detach — it writes `.detach` on its way out and the transport close behind it is the fallback — vanishes the tile, relayouts and persists. Every pane removes the same way, including one that never came up. ENDING is `end_req` `0x11` / `end_reply` `0x94`, observer verbs, asked one of two ways. `Ctrl-\ X` on a tile stores `wallview.intentForEnd` into that tile's `ask` and rings its pump, which puts the frame on the connection the pane is already attached to (`wall_pump`), reads the answer back through `wallview.onEndReply` and banners the refusal; the daemon excludes the asking client from the count, so a session only this pane holds ends on the FIRST press. Nothing is vanished on that keypress — the pane leaves when the shell does. `wall_picker.pickEnd` is the other door, over `client.endSession` on a SIDE connection, because the session may have no pane here to ask through — which is also why its count includes this wall's own pane. That connection dials `HostSpec.poll_target` — the poller's batch recipe, never the interactive one, since an end must not start a daemon and must not reach for a terminal. The daemon refuses a first press on a session other clients hold and answers the count; the two doors keep SEPARATE 3 s arms, each keyed to what its own second press lands on — `Tile.end_armed_until` on the tile, `Shared.pick_end` per host AND name — and every OTHER refusal arms nothing, because only "others attached" is a question a second press answers. An accepted end is BOUNDED — SIGKILL past `Pty.term_grace_ms` — so no shell can refuse to die, and `mux d upgrade` is refused while any session is ending rather than carry a masterless pty across the exec. The press is ASKED on the wire whatever the row says: a daemon that answers nothing spends the reply budget and its `error.Timeout` becomes `[daemon too old to end a session]`, which is the only thing the absence of an arm can look like. The missing `# holds` line is NOT that gate — the released v0.0.1-16 daemon answers `end_req` and sends no holds line, so refusing on the count refused a daemon that works. An ended session's pane leaves on the next list, not on the keypress. - **A wall dial's ssh never touches `/dev/tty`.** `SSH_ASKPASS_REQUIRE=force` plus `SSH_ASKPASS` at this image; `mux askpass` — a mode named by `MUX_ASKPASS_SOCK`, since ssh gives its helper no word — carries the prompt to a popup that eats every byte. `wall_pump.askOn` is the ONE arming point, so the exclusions hold by construction: the entry dial keeps ssh's own tty prompts, polls keep `BatchMode`. A decline SIGTERMs that ssh (a refused askpass is an empty password to OpenSSH, asked three times over) and ends the tile with `[prompt declined]`. - **Detach is a goodbye, not a tear.** A pump that has sent its detach frame never redials — redial checks `detach_ack` — so the daemon's close after a detach is final, not a network event to recover from.