2103f4d1
docs: spec — the wall is the layout; the poll grades panes and adds none
a73x 2026-09-03 05:20
Commit message
docs/superpowers/specs/2026-09-02-wall-is-the-layout-design.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,236 @@ | |||
| 1 | # The wall is the layout: panes are authored, the poll only grades them | ||
| 2 | |||
| 3 | 2026-09-02. Status: approved design, pre-implementation. | ||
| 4 | |||
| 5 | ## Problem | ||
| 6 | |||
| 7 | Today a wall lists every live session of every daemon in the hosts file. | ||
| 8 | Tiles come from each daemon's own `sessions_req`, polled once a second, and | ||
| 9 | the layout sidecar is a derived convenience healed against those lists. So a | ||
| 10 | bare `mux` on the laptop shows the two sessions this box is viewing, because | ||
| 11 | they are the laptop daemon's live sessions, and a session born by `mux a`, a | ||
| 12 | browser, or another device turns up on every wall that lists its daemon. | ||
| 13 | |||
| 14 | The user's stated expectation (2026-09-02): "by default wall should just be | ||
| 15 | the layout. I connect to 2 local and 3 remotes; every time I type `mux` I | ||
| 16 | expect to view that. If I go to a new machine and type `mux`, I expect to | ||
| 17 | view only 1 local session because I've never connected before." A session | ||
| 18 | viewed from another device is a different client's business and should not | ||
| 19 | appear here until this device adds it. | ||
| 20 | |||
| 21 | ## Decisions (settled in brainstorm) | ||
| 22 | |||
| 23 | - **The layout file is the wall.** `$XDG_STATE_HOME/mux/layout` becomes | ||
| 24 | authored intent. Tiles come from it and from nothing else. The | ||
| 25 | once-a-second poll grades each pane (`up`, `gone`, `unreachable`) and never | ||
| 26 | adds a tile. | ||
| 27 | - **Sessions get onto a wall through the picker.** `Ctrl-\ s` on a host | ||
| 28 | opens that daemon's session list; Enter adds one as a pane. No | ||
| 29 | `mux HOST#SESSION` argv form. The user chose this for ergonomics. | ||
| 30 | - **`x` removes the pane; the session lives on.** Ending a session moves | ||
| 31 | into the picker's session list, beside the count of who else holds it, | ||
| 32 | where the daemon's two-step still applies. A pane is this wall's; the | ||
| 33 | session is the daemon's. | ||
| 34 | - **A new machine gets one local pane.** No layout and no hosts file: | ||
| 35 | `mux` records the local daemon and opens one pane on its session `0`, | ||
| 36 | creating it if the daemon is empty. | ||
| 37 | - **Open item, carried with a default.** Whether that first pane should take | ||
| 38 | the next free name when session `0` already exists on the local daemon. | ||
| 39 | Default: session `0`, which is what the README promises today. The user | ||
| 40 | parked this as a separate question. | ||
| 41 | |||
| 42 | ## Model | ||
| 43 | |||
| 44 | Two files, two meanings: | ||
| 45 | |||
| 46 | | File | Meaning | Strictness | | ||
| 47 | |---|---|---| | ||
| 48 | | `hosts` | the daemons this device may browse | strict: a bad line refuses `mux` with rc 2 | | ||
| 49 | | `layout` | the wall: a pane tree whose leaves are `HOST#SESSION` | strict on parse, lenient on absence | | ||
| 50 | |||
| 51 | The layout is saved on every change to the pane set or the tree — an add, an | ||
| 52 | `x`, a split, a resize, a detach — not only on the last detach. Two terminals | ||
| 53 | on one device then see each other's adds on their next start, and a wall that | ||
| 54 | crashes loses nothing it had committed. Writes are the existing atomic | ||
| 55 | rename. | ||
| 56 | |||
| 57 | A missing layout is a wall of one pane on the local daemon (section "New | ||
| 58 | machine"). A layout that fails to parse is reported on stderr with the line, | ||
| 59 | and `mux` then behaves as if the file were missing. Silently degrading | ||
| 60 | authored intent to a default cut is how a user loses a wall; a missing file | ||
| 61 | is recoverable and a printed line says what to fix. This reverses the | ||
| 62 | 2026-08 asymmetry (strict hosts, lenient sidecar), because the sidecar is | ||
| 63 | no longer derived. | ||
| 64 | |||
| 65 | The leaf spelling stays `HOST#SESSION`, where `HOST` is a hosts-file line | ||
| 66 | verbatim (`--sock PATH` | `HOST` | `quic://HOST[:PORT]`). A leaf whose host | ||
| 67 | is not in the hosts file is an error at parse: the layout may not name a | ||
| 68 | daemon the picker cannot reach. | ||
| 69 | |||
| 70 | ## Where panes come from | ||
| 71 | |||
| 72 | Exactly three places add a pane: | ||
| 73 | |||
| 74 | 1. **The layout, on start.** Every leaf becomes a pane in its saved rect. | ||
| 75 | 2. **The picker.** Enter on a session in a host's list adds it and zooms to | ||
| 76 | it. The birth key creates a session on that host at | ||
| 77 | `client.nextFreeName` off that host's own list, adds it, and zooms. | ||
| 78 | 3. **A chord split.** Births a session on the focused pane's host, as today, | ||
| 79 | and the new pane inherits the focused pane's `-A`. | ||
| 80 | |||
| 81 | `mux HOST` on the command line keeps its entry meaning: HOST is added to the | ||
| 82 | hosts file if new, and the wall opens zoomed on a pane for HOST's session | ||
| 83 | `0`, added to the layout if it was not there, created on the daemon if the | ||
| 84 | daemon has no such session. This is the one argv path that adds a pane, and | ||
| 85 | it is the existing entry dial rather than a new grammar. | ||
| 86 | |||
| 87 | Nothing else adds one. A session born by `mux a`, a browser, another device, | ||
| 88 | or another terminal on this device appears on no wall until a picker adds it. | ||
| 89 | |||
| 90 | ## Picker | ||
| 91 | |||
| 92 | Rows stay the hosts file's daemons in file order, with the poller's last | ||
| 93 | answer beside each (`N sessions` / `no sessions` / `unreachable` / | ||
| 94 | `connecting`). Enter on a host opens a second level: that daemon's sessions | ||
| 95 | from the poller's last answer, one row each, showing the name, whether it is | ||
| 96 | already a pane on this wall, and how many clients hold it. | ||
| 97 | |||
| 98 | The holder count is the one wire addition. `sessions_reply` is names, one | ||
| 99 | per line, plus the `# mux VERSION` meta line; the count rides beside them | ||
| 100 | as one `# holds NAME N` line per session. `sessionsIter` already yields | ||
| 101 | only lines that are valid session names, so an old client skips the new | ||
| 102 | lines as it skips the meta line, and an old daemon sends none, which the | ||
| 103 | list shows as no count. Nothing else on the wire changes. | ||
| 104 | |||
| 105 | Keys in the session list: | ||
| 106 | |||
| 107 | - **Enter** adds the session as a pane and zooms to it. On a session already | ||
| 108 | on the wall, Enter just zooms. | ||
| 109 | - **Birth key** (the existing one) creates a session on this host and adds | ||
| 110 | it. | ||
| 111 | - **End key** runs the daemon's `end_req` on the selected session. The row | ||
| 112 | already shows the holder count, so the first press on a session others | ||
| 113 | hold shows the daemon's refusal and count, and the second press within | ||
| 114 | 3 s forces, as today. An ended session's pane, if any, leaves the wall on | ||
| 115 | the next poll. | ||
| 116 | - **`a`** stays the host spelling editor, at the host level. | ||
| 117 | - **`x`** at the host level forgets a host: the hosts line, its poller, and | ||
| 118 | every pane on it (the sessions keep running). | ||
| 119 | - **Esc** backs out one level: session list to host list, host list to the | ||
| 120 | wall. | ||
| 121 | |||
| 122 | Every byte is the popup's while it is open; tiles do not paint | ||
| 123 | (`Shared.picker_open`). An empty wall opens the picker once by itself, as | ||
| 124 | today, and the birth that a daemon refuses leaves the empty wall rather than | ||
| 125 | ending `mux` on a terminal. | ||
| 126 | |||
| 127 | ## Keys on the wall | ||
| 128 | |||
| 129 | - **`x`** removes the focused pane from the layout, sends its | ||
| 130 | `session_release`, and ends its pump. Nothing is ended on the daemon. The | ||
| 131 | layout is saved. A pane that has never come up closes the same way. | ||
| 132 | - **`n`, `p`, digits** walk the layout's panes in tree order. | ||
| 133 | - **A shell that exits** takes its pane with it: the user ended it. The | ||
| 134 | layout is saved without the pane. | ||
| 135 | - **A pane whose reachable host answers without its session** wears `gone`. | ||
| 136 | Enter re-creates the session in that pane's own rect; `x` removes the | ||
| 137 | pane. Every other key at a gone pane is eaten. This is the gone-panes | ||
| 138 | design of 2026-09-01 unchanged, and it now applies to every pane, not | ||
| 139 | only seeded ones, because every pane is seeded. | ||
| 140 | - **A dark host's panes** wear `unreachable` and stay, for as long as the | ||
| 141 | box is dark. Enter retries the dial. | ||
| 142 | - The chords for split, resize, focus, zoom, and detach are unchanged. | ||
| 143 | |||
| 144 | ## New machine | ||
| 145 | |||
| 146 | No layout and no hosts file: `mux` writes the local daemon's line | ||
| 147 | (`--sock <default>`, starting the daemon if nothing listens), opens one pane | ||
| 148 | on its session `0`, creating it if the daemon is empty, and saves a layout | ||
| 149 | of that one leaf. A daemon that already has a session `0` — another client | ||
| 150 | or `mux a` made it — is joined, not duplicated. The open item above is | ||
| 151 | whether to take the next free name instead. | ||
| 152 | |||
| 153 | A hosts file with lines but no layout — the state after this change ships | ||
| 154 | to a device with an old hosts file — opens the picker on an empty wall, | ||
| 155 | exactly as an empty wall does today, so the user adds what they want to see. | ||
| 156 | It does NOT reconstruct the old every-session wall. | ||
| 157 | |||
| 158 | ## Hub | ||
| 159 | |||
| 160 | `mux web` serves the layout of the machine it runs on: `/tiles` lists the | ||
| 161 | layout's panes in tree order, each with its host, session, and the poller's | ||
| 162 | grade. The page's `+` births on the focused pane's daemon and adds the pane | ||
| 163 | to the layout. `mux web HOST ...` still records its argv into the hosts | ||
| 164 | file. The hub no longer lists every session of every host; a host with no | ||
| 165 | pane on the layout contributes nothing. | ||
| 166 | |||
| 167 | The hub and the terminal wall read and write the same file, so a pane added | ||
| 168 | in the browser is on the next terminal wall and the reverse. Neither holds | ||
| 169 | the file open; each write is a read-modify-write over an atomic rename, and | ||
| 170 | two writers within the same instant lose one update, which the hosts file | ||
| 171 | already accepts. | ||
| 172 | |||
| 173 | ## What goes away | ||
| 174 | |||
| 175 | - Healing the sidecar per leaf against live lists (`seedLayout`, | ||
| 176 | `seedAttempt`, and the default cut for a wall that has a layout). | ||
| 177 | - The poll-born tile path: `planHostDiff`'s `birth` arm in `wall_host` and | ||
| 178 | `applyList`'s in `webhub`. The `keep`, `missed-once grace`, and `vanish` | ||
| 179 | arms become the grading of existing panes. This retires the duplicated | ||
| 180 | planner filed as collab 68d4700e. | ||
| 181 | - `Tile.keeps_wall` and the `born_from` null it worked around: a refused | ||
| 182 | picker birth leaves the wall as it was, since the wall never depended on | ||
| 183 | the birth. | ||
| 184 | - The aspect rule for a first wall of many sessions: a first wall has one | ||
| 185 | pane. | ||
| 186 | - The README's "tiles are whatever those daemons have live" paragraph and | ||
| 187 | the CLAUDE.md invariant "The wall file lists DAEMONS; tiles are their live | ||
| 188 | sessions", replaced by "The layout is the wall; the poll grades it". | ||
| 189 | |||
| 190 | ## Testing | ||
| 191 | |||
| 192 | Unit, on the pane set and the layout (no terminal): | ||
| 193 | |||
| 194 | - A poll answering sessions not on the layout adds no pane. | ||
| 195 | - A poll missing a pane's session marks it `gone`; the next answer that | ||
| 196 | names it clears the mark. | ||
| 197 | - `x` writes the layout without the pane and releases the session; the | ||
| 198 | session is not ended. | ||
| 199 | - Enter in the picker's session list on a session already on the wall adds | ||
| 200 | nothing. | ||
| 201 | - A layout naming a host absent from the hosts file is refused at parse | ||
| 202 | with the line reported. | ||
| 203 | - A layout with a parse error is reported and treated as missing; a wall of | ||
| 204 | one local pane results. | ||
| 205 | - Every write of the layout goes through the one save path; a mutation | ||
| 206 | that skips it fails the test that counts saves per operation. | ||
| 207 | |||
| 208 | E2e, on a real pty (ptyclient), plural by default: two daemons, three | ||
| 209 | sessions each: | ||
| 210 | |||
| 211 | - A wall shows exactly its layout's panes; a session `mux a` births on a | ||
| 212 | listed daemon never appears on the running wall. | ||
| 213 | - A second state dir against the same daemons starts with one local pane, | ||
| 214 | adds two sessions through the picker, and shows only those three; the | ||
| 215 | first wall is unchanged. | ||
| 216 | - `x` on a pane, then `mux d stats`: the session still has its other | ||
| 217 | client. The layout file no longer names the pane. | ||
| 218 | - The picker's end key on a session another wall holds: first press shows | ||
| 219 | the count, second press within 3 s ends it, and the other wall's pane | ||
| 220 | leaves on its next poll. | ||
| 221 | - A fresh state dir and hosts file: one pane, session `0`, layout written | ||
| 222 | with one leaf. | ||
| 223 | - A hosts file with lines and no layout: the picker opens on an empty wall. | ||
| 224 | - The hub's `/tiles` matches the layout file, not the daemons' lists, and a | ||
| 225 | pane added in the browser is on the next terminal wall. | ||
| 226 | |||
| 227 | Mutation checks on every leg's oracle before it is trusted: a leg whose | ||
| 228 | first assertion is an exit code has masked a wrong pane set before. | ||
| 229 | |||
| 230 | ## Out of scope | ||
| 231 | |||
| 232 | - The parked question: session `0` versus the next free name for a new | ||
| 233 | machine's first pane. | ||
| 234 | - Carrying a layout between devices. Layout stays client-local. | ||
| 235 | - Any wire change beyond the `# holds` lines above. Adding a pane, removing | ||
| 236 | one, and ending a session all use frames that exist. | ||