docs/superpowers/specs/2026-09-02-wall-is-the-layout-design.md
Ref: Size: 11.5 KiB History
# The wall is the layout: panes are authored, the poll only grades them 2026-09-02. Status: approved design, pre-implementation. ## Problem Today a wall lists every live session of every daemon in the hosts file. Tiles come from each daemon's own `sessions_req`, polled once a second, and the layout sidecar is a derived convenience healed against those lists. So a bare `mux` on the laptop shows the two sessions this box is viewing, because they are the laptop daemon's live sessions, and a session born by `mux a`, a browser, or another device turns up on every wall that lists its daemon. The user's stated expectation (2026-09-02): "by default wall should just be the layout. I connect to 2 local and 3 remotes; every time I type `mux` I expect to view that. If I go to a new machine and type `mux`, I expect to view only 1 local session because I've never connected before." A session viewed from another device is a different client's business and should not appear here until this device adds it. ## Decisions (settled in brainstorm) - **The layout file is the wall.** `$XDG_STATE_HOME/mux/layout` becomes authored intent. Tiles come from it and from nothing else. The once-a-second poll grades each pane (`up`, `gone`, `unreachable`) and never adds a tile. - **Sessions get onto a wall through the picker.** `Ctrl-\ s` on a host opens that daemon's session list; Enter adds one as a pane. No `mux HOST#SESSION` argv form. The user chose this for ergonomics. - **`x` removes the pane; the session lives on.** Ending a session moves into the picker's session list, beside the count of who else holds it, where the daemon's two-step still applies. A pane is this wall's; the session is the daemon's. - **A new machine gets one local pane.** No layout and no hosts file: `mux` records the local daemon and opens one pane on its session `0`, creating it if the daemon is empty. - **Open item, carried with a default.** Whether that first pane should take the next free name when session `0` already exists on the local daemon. Default: session `0`, which is what the README promises today. The user parked this as a separate question. ## Model Two files, two meanings: | File | Meaning | Strictness | |---|---|---| | `hosts` | the daemons this device may browse | strict: a bad line refuses `mux` with rc 2 | | `layout` | the wall: a pane tree whose leaves are `HOST#SESSION` | strict on parse, lenient on absence | The layout is saved on every change to the pane set or the tree — an add, an `x`, a split, a resize, a detach — not only on the last detach. Two terminals on one device then see each other's adds on their next start, and a wall that crashes loses nothing it had committed. Writes are the existing atomic rename. A missing layout is a wall of one pane on the local daemon (section "New machine"). A layout that fails to parse is reported on stderr with the line, and `mux` then behaves as if the file were missing. Silently degrading authored intent to a default cut is how a user loses a wall; a missing file is recoverable and a printed line says what to fix. This reverses the 2026-08 asymmetry (strict hosts, lenient sidecar), because the sidecar is no longer derived. The leaf spelling stays `HOST#SESSION`, where `HOST` is a hosts-file line verbatim (`--sock PATH` | `HOST` | `quic://HOST[:PORT]`). A leaf whose host is not in the hosts file is an error at parse: the layout may not name a daemon the picker cannot reach. ## Where panes come from Exactly three places add a pane: 1. **The layout, on start.** Every leaf becomes a pane in its saved rect. 2. **The picker.** Enter on a session in a host's list adds it and zooms to it. The birth key creates a session on that host at `client.nextFreeName` off that host's own list, adds it, and zooms. 3. **A chord split.** Births a session on the focused pane's host, as today, and the new pane inherits the focused pane's `-A`. `mux HOST` on the command line keeps its entry meaning: HOST is added to the hosts file if new, and the wall opens zoomed on a pane for HOST's session `0`, added to the layout if it was not there, created on the daemon if the daemon has no such session. This is the one argv path that adds a pane, and it is the existing entry dial rather than a new grammar. Nothing else adds one. A session born by `mux a`, a browser, another device, or another terminal on this device appears on no wall until a picker adds it. ## Picker Rows stay the hosts file's daemons in file order, with the poller's last answer beside each (`N sessions` / `no sessions` / `unreachable` / `connecting`). Enter on a host opens a second level: that daemon's sessions from the poller's last answer, one row each, showing the name, whether it is already a pane on this wall, and how many clients hold it. The holder count is the one wire addition. `sessions_reply` is names, one per line, plus the `# mux VERSION` meta line; the count rides beside them as one `# holds NAME N` line per session. `sessionsIter` already yields only lines that are valid session names, so an old client skips the new lines as it skips the meta line, and an old daemon sends none, which the list shows as no count. Nothing else on the wire changes. Keys in the session list: - **Enter** adds the session as a pane and zooms to it. On a session already on the wall, Enter just zooms. - **Birth key** (the existing one) creates a session on this host and adds it. - **End key** runs the daemon's `end_req` on the selected session. The row already shows the holder count, so the first press on a session others hold shows the daemon's refusal and count, and the second press within 3 s forces, as today. An ended session's pane, if any, leaves the wall on the next poll. - **`a`** stays the host spelling editor, at the host level. - **`x`** at the host level forgets a host: the hosts line, its poller, and every pane on it (the sessions keep running). - **Esc** backs out one level: session list to host list, host list to the wall. Every byte is the popup's while it is open; tiles do not paint (`Shared.picker_open`). An empty wall opens the picker once by itself, as today, and the birth that a daemon refuses leaves the empty wall rather than ending `mux` on a terminal. ## Keys on the wall - **`x`** removes the focused pane from the layout, sends its `session_release`, and ends its pump. Nothing is ended on the daemon. The layout is saved. A pane that has never come up closes the same way. - **`n`, `p`, digits** walk the layout's panes in tree order. - **A shell that exits** takes its pane with it: the user ended it. The layout is saved without the pane. - **A pane whose reachable host answers without its session** wears `gone`. Enter re-creates the session in that pane's own rect; `x` removes the pane. Every other key at a gone pane is eaten. This is the gone-panes design of 2026-09-01 unchanged, and it now applies to every pane, not only seeded ones, because every pane is seeded. - **A dark host's panes** wear `unreachable` and stay, for as long as the box is dark. Enter retries the dial. - The chords for split, resize, focus, zoom, and detach are unchanged. ## New machine No layout and no hosts file: `mux` writes the local daemon's line (`--sock <default>`, starting the daemon if nothing listens), opens one pane on its session `0`, creating it if the daemon is empty, and saves a layout of that one leaf. A daemon that already has a session `0` — another client or `mux a` made it — is joined, not duplicated. The open item above is whether to take the next free name instead. A hosts file with lines but no layout — the state after this change ships to a device with an old hosts file — opens the picker on an empty wall, exactly as an empty wall does today, so the user adds what they want to see. It does NOT reconstruct the old every-session wall. ## Hub `mux web` serves the layout of the machine it runs on: `/tiles` lists the layout's panes in tree order, each with its host, session, and the poller's grade. The page's `+` births on the focused pane's daemon and adds the pane to the layout. `mux web HOST ...` still records its argv into the hosts file. The hub no longer lists every session of every host; a host with no pane on the layout contributes nothing. The hub and the terminal wall read and write the same file, so a pane added in the browser is on the next terminal wall and the reverse. Neither holds the file open; each write is a read-modify-write over an atomic rename, and two writers within the same instant lose one update, which the hosts file already accepts. ## What goes away - Healing the sidecar per leaf against live lists (`seedLayout`, `seedAttempt`, and the default cut for a wall that has a layout). - The poll-born tile path: `planHostDiff`'s `birth` arm in `wall_host` and `applyList`'s in `webhub`. The `keep`, `missed-once grace`, and `vanish` arms become the grading of existing panes. This retires the duplicated planner filed as collab 68d4700e. - `Tile.keeps_wall` and the `born_from` null it worked around: a refused picker birth leaves the wall as it was, since the wall never depended on the birth. - The aspect rule for a first wall of many sessions: a first wall has one pane. - The README's "tiles are whatever those daemons have live" paragraph and the CLAUDE.md invariant "The wall file lists DAEMONS; tiles are their live sessions", replaced by "The layout is the wall; the poll grades it". ## Testing Unit, on the pane set and the layout (no terminal): - A poll answering sessions not on the layout adds no pane. - A poll missing a pane's session marks it `gone`; the next answer that names it clears the mark. - `x` writes the layout without the pane and releases the session; the session is not ended. - Enter in the picker's session list on a session already on the wall adds nothing. - A layout naming a host absent from the hosts file is refused at parse with the line reported. - A layout with a parse error is reported and treated as missing; a wall of one local pane results. - Every write of the layout goes through the one save path; a mutation that skips it fails the test that counts saves per operation. E2e, on a real pty (ptyclient), plural by default: two daemons, three sessions each: - A wall shows exactly its layout's panes; a session `mux a` births on a listed daemon never appears on the running wall. - A second state dir against the same daemons starts with one local pane, adds two sessions through the picker, and shows only those three; the first wall is unchanged. - `x` on a pane, then `mux d stats`: the session still has its other client. The layout file no longer names the pane. - The picker's end key on a session another wall holds: first press shows the count, second press within 3 s ends it, and the other wall's pane leaves on its next poll. - A fresh state dir and hosts file: one pane, session `0`, layout written with one leaf. - A hosts file with lines and no layout: the picker opens on an empty wall. - The hub's `/tiles` matches the layout file, not the daemons' lists, and a pane added in the browser is on the next terminal wall. Mutation checks on every leg's oracle before it is trusted: a leg whose first assertion is an exit code has masked a wrong pane set before. ## Out of scope - The parked question: session `0` versus the next free name for a new machine's first pane. - Carrying a layout between devices. Layout stays client-local. - Any wire change beyond the `# holds` lines above. Adding a pane, removing one, and ending a session all use frames that exist.