a73x

2103f4d1

docs: spec — the wall is the layout; the poll grades panes and adds none

a73x   2026-09-03 05:20

Commit message
docs: spec — the wall is the layout; the poll grades panes and adds none

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.