a73x

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.