a73x

411b6c25

docs: the wall file is attach history now

a73x   2026-08-20 00:50

Commit message
docs: the wall file is attach history now

decisions.md records the calls phase 2 had to make concretely: why the
write seam sits inside client.attach's loop rather than in mux_main, why
the rule is enforced by placement instead of a size or tty check, why the
tile is written after the dial and taken back on a refused switch, what
"atomic" does and does not claim, and the staleness gap left open on
purpose (a running wall or hub does not see another process's add until
it restarts). README's wall section and both key tables gain `x`.

README.md
Old New
@@ -40,6 +40,12 @@ Inside a session:
40 | `Ctrl-\` `c` | create a session and switch to it (the old one keeps running) | 40 | `Ctrl-\` `c` | create a session and switch to it (the old one keeps running) |
41 | `Ctrl-\` `n` / `Ctrl-\` `p` | switch to the next / previous session, wrapping | 41 | `Ctrl-\` `n` / `Ctrl-\` `p` | switch to the next / previous session, wrapping |
42 | `Ctrl-\` `w` | show every session as a wall (`j`/`k` select, `Enter` zooms, `q` comes back) | 42 | `Ctrl-\` `w` | show every session as a wall (`j`/`k` select, `Enter` zooms, `q` comes back) |
43
44 The wall `Ctrl-\` `w` opens is a real wall, so `x` in it edits the saved
45 wall file like any other — the tiles it lists are spelled exactly as the
46 recorded lines, so forgetting one there forgets it everywhere. The session
47 keeps running either way.
48
43 | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) | 49 | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) |
44 | mouse wheel | scrollback, three rows a notch (arrow keys to a full-screen app) | 50 | mouse wheel | scrollback, three rows a notch (arrow keys to a full-screen app) |
45 51
@@ -135,7 +141,7 @@ there is no unauthenticated mode to fall back to.
135 141
136 ```sh 142 ```sh
137 muxweb # restores the wall you left; opens on 127.0.0.1:7681 143 muxweb # restores the wall you left; opens on 127.0.0.1:7681
138 muxweb HOST quic://OTHER --sock /tmp/s.sock#b # ...or state it, which replaces the saved wall 144 muxweb HOST quic://OTHER --sock /tmp/s.sock#b # ...or state it: shows these, ADDS them to the saved wall
139 ``` 145 ```
140 146
141 Each tile is a live session, dialed the way `mux` dials it — the same 147 Each tile is a live session, dialed the way `mux` dials it — the same
@@ -159,8 +165,38 @@ The same wall, without a browser:
159 mux wall # the saved wall, as stripes in this terminal 165 mux wall # the saved wall, as stripes in this terminal
160 mux wall HOST '--sock /tmp/s.sock#b' # ...or state it (one spelling per argument) 166 mux wall HOST '--sock /tmp/s.sock#b' # ...or state it (one spelling per argument)
161 mux wall HOST --sock /tmp/s.sock#b # ...`--sock PATH` unquoted works too, as in muxweb 167 mux wall HOST --sock /tmp/s.sock#b # ...`--sock PATH` unquoted works too, as in muxweb
168 mux wall add HOST'#build' # put a tile on the saved wall without attaching
169 mux wall rm HOST'#build' # take one off (the session keeps running)
162 ``` 170 ```
163 171
172 `add` and `rm` are subcommands, so they claim the first argument: a tile
173 spelled literally `add` or `rm` has to reach the wall some other way (the
174 `Ctrl-\` chords, `muxweb`, or an editor on the file). Naming neither a
175 target nor a spelling is a usage error, not an empty edit.
176
177 **The wall is your attach history.** Every `mux` attach writes its own tile
178 to that file, so a bare `mux` leaves `--sock <default>#0` behind and
179 `mux HOST --session build` leaves `HOST#build` — as does every session you
180 reach with `Ctrl-\` `c`/`n`/`p`, because visiting a session is attaching to
181 it. Tiles are deduped by spelling, byte for byte: the same session reached
182 as `HOST#build` and as `quic://HOST#build` is two tiles, deliberately.
183 `muxa` never adds one (it attaches at 0×0 and claims nothing), and neither
184 do the wall's own stripes. Writing the file is best effort — if it cannot be
185 written you get one warning line and the attach happens anyway.
186
187 `mux wall add`/`rm` are the same edit without an attach: file operations
188 only, so neither dials, and `rm` never touches the session. A wall or hub
189 that is already running does not notice the change until you reopen it.
190 `rm` is also the repair tool: it is the one command that reads the file
191 leniently, so a line you hand-edited into something no longer legal can be
192 removed (and every other line survives it byte for byte) instead of
193 wedging every command that touches the wall.
194
195 `muxweb TILE...` ADDS its tiles to that file rather than replacing it —
196 naming a wall on the command line changes what this run shows, not what
197 you have recorded. Forgetting stays explicit: the page's `×`, the wall's
198 `x`, `mux wall rm`.
199
164 Watching is read-only: each stripe is a live session (label bar + the rows 200 Watching is read-only: each stripe is a live session (label bar + the rows
165 around its cursor), attached the way `muxa` attaches — no size claim, so 201 around its cursor), attached the way `muxa` attaches — no size claim, so
166 watching never resizes anyone. The stripes are cut once, from the terminal's 202 watching never resizes anyone. The stripes are cut once, from the terminal's
@@ -174,6 +210,7 @@ Zoomed out (the wall):
174 | `j` / `k` (or `n` / `p`) | select the next / previous stripe, wrapping | 210 | `j` / `k` (or `n` / `p`) | select the next / previous stripe, wrapping |
175 | `1`-`9` | select that stripe, counting from the top | 211 | `1`-`9` | select that stripe, counting from the top |
176 | `Enter` | zoom the selected stripe — type into that session | 212 | `Enter` | zoom the selected stripe — type into that session |
213 | `x` | forget the selected tile (off the wall file; the session keeps running) |
177 | `q` (or `Ctrl-\`) | leave the wall | 214 | `q` (or `Ctrl-\`) | leave the wall |
178 215
179 Zoomed in (one session, full screen): 216 Zoomed in (one session, full screen):
@@ -201,6 +238,11 @@ reflows when you look away.
201 Typed input at a zoomed tile is predicted locally, as in a plain client. 238 Typed input at a zoomed tile is predicted locally, as in a plain client.
202 The mouse is not: a zoomed tile is keyboard-only for now. 239 The mouse is not: a zoomed tile is keyboard-only for now.
203 240
241 `x` forgets the selected tile: its line leaves the wall file, its stripe
242 leaves the screen, and the remaining stripes are re-cut over the space.
243 Forgetting is never killing — the session goes on running and `mux` reaches
244 it again by name. Forget them all and the wall says so.
245
204 The same wall, from inside a session: `Ctrl-\` `w` shows every session on 246 The same wall, from inside a session: `Ctrl-\` `w` shows every session on
205 the daemon you are attached to, and `q` puts you back where you were. 247 the daemon you are attached to, and `q` puts you back where you were.
206 248
docs/decisions.md
Old New
@@ -4375,3 +4375,182 @@ consumes as selection keys, so what a leak would actually deliver is `wallne`
4375 and the grep could never have matched. Renamed to `wallmute`, every letter of 4375 and the grep could never have matched. Renamed to `wallmute`, every letter of
4376 which the wall swallows, and re-checked under the mutation before being 4376 which the wall swallows, and re-checked under the mutation before being
4377 believed. 4377 believed.
4378
4379 ## 2026-08-20 (the wall is the home screen, phase 2: attach adds, `x` forgets)
4380
4381 Spec: `docs/superpowers/specs/2026-08-19-wall-home-screen-design.md`. Phase 2 of
4382 three: the wall file becomes attach history. Phase 3 converges `client.zig`'s
4383 session loop with the wall.
4384
4385 **The write seam is inside `client.attach`'s loop, not in `mux_main`.** The
4386 spec's rule is mechanical — any `mux` attach that CLAIMS the grid (attaches at
4387 nonzero size) writes its tile — and the argv layer cannot honour it, because
4388 `Ctrl-\ c`/`n`/`p` re-dial full-size from inside `attach`'s own `while (true)`
4389 and never come back through `main`. A seam up there would record the session
4390 the user started at and none of the ones they actually visited, which is the
4391 opposite of "the wall grows by the truth". So `client.zig` grew a `wall`
4392 import (layer 2 ← layer 1) and one call per turn of the loop.
4393
4394 **The rule is enforced by WHERE the call sits, not by a size check.** There is
4395 no `if (cols > 0)` and deliberately no tty check. `client.attach` is the only
4396 grid-claiming attach path in the tree — it attaches at the tty's size, or at
4397 80×24 when stdin is a pipe, never at zero — while the two passive attachers
4398 never reach it: `muxa` does not link the module at all, and `wallview`'s tile
4399 pumps build their own `Transport`. A tty check would additionally have excluded
4400 the `ptyclient` fixture, which the spec explicitly wants included (tests run
4401 under an isolated `XDG_STATE_HOME`). "muxa attaches at 0×0" stops being a
4402 sentence in CLAUDE.md and becomes an e2e leg: `muxa send` drives the same
4403 session and the wall file's sha256 does not move, anchored on a marker the
4404 agent actually landed so a muxa that did nothing cannot pass by doing nothing.
4405
4406 **Recorded when the first STATE arrives, not when the dial succeeds.** The
4407 first version wrote the tile straight after `Transport.open` and argued that a
4408 transport coming up is the strongest "this attach is real" signal available
4409 before the first snapshot. It is not a signal at all: the daemon can still
4410 refuse (its session table is full at four), and the case analysis that followed
4411 from the wrong seam was wrong in the way case analyses are — it enumerated the
4412 refusal it could see. A SWITCH's refusal returns `.refused`, which has somewhere
4413 to fall back to and so a place to undo the write; a FIRST attach refused
4414 identically returns `.{ .exit = 1 }` and left the line stranded, a tile naming a
4415 session that never existed. Review reproduced it: a daemon at `max_sessions`,
4416 `mux --session PHANTOM`, rc 1 and a `#PHANTOM` line on the wall. The
4417 `session(...) catch |err|` path had the same hole.
4418
4419 So the seam moved inside `session()`, keyed on `rep.state_since_attach` — the
4420 daemon's own answer to "did this attach land", and the very flag BOTH refusal
4421 paths read to decide there was none. A refusal now cannot record, on any path,
4422 present or future, without anyone having to keep an enumeration exhaustive; and
4423 with nothing recorded there is nothing to take back, so `unrecordTile` is
4424 deleted rather than fixed. The reason the old seam existed survives untouched:
4425 the snapshot is milliseconds behind the dial, so a `mux wall` in another
4426 terminal still shows the session you are attached to WHILE you are attached to
4427 it. A reconnect clears the flag and it turns true again, so the write is latched
4428 per `session()` run — dedup would make the second one a no-op, but a resync
4429 should not pay a read-modify-write to find that out.
4430
4431 The general lesson, third time in this file: **when a write has a "was this
4432 real" question attached to it, key it on the flag the code already uses to
4433 answer that question, rather than on a proxy plus a list of exceptions.** The
4434 proxy is what needs the list.
4435
4436 **Dedup is byte-exact on the spelling, never on identity.** The same session
4437 reached as `HOST#S` and as `quic://…#S` is two tiles, deliberately: identity
4438 dedup would need an endpoint handshake the wall does not have and the spec does
4439 not want. The default session records as `#0`, the RESOLVED name, not the empty
4440 wire name the attach frame carries — a wall line must be a spelling a user
4441 could type back.
4442
4443 **Best effort, one warning, never a failure.** An unwritable wall file (a
4444 directory in its place, a read-only state home) costs one line on stderr and
4445 nothing else: the attach happens, the session is usable, the exit code is
4446 untouched. The warning is latched per `attach()` call rather than per write,
4447 because a chord loop visits N sessions and a wall file that cannot be written
4448 cannot be written for any of them — repeating the sentence would scroll a
4449 working session for a record nobody is reading. `--via` records nothing at all
4450 and says nothing: the wall grammar has no form for "an arbitrary command's
4451 stdio", the same refusal `Ctrl-\ w` already makes.
4452
4453 **`muxweb TILE...` adds; it no longer overwrites.** Argv used to be saved with
4454 `wall.save`, replacing the file with whatever this run was told to show — which
4455 was defensible while the file meant "the last wall muxweb was given". It stopped
4456 meaning that the moment attaches started writing to it: one `muxweb HOST` would
4457 have silently erased every tile every `mux` had recorded, and the erasure would
4458 look like a feature ("argv overrides"). Argv still overrides the VIEW — the run
4459 shows the tiles it named and only those — but each is now `wall.record`ed,
4460 deduped, and nothing is removed. Forgetting stays explicit: the page's `×`, the
4461 wall's `x`, `mux wall rm`. The e2e assertion INVERTED rather than moving: it
4462 used to fail on `argv appended to the wall instead of replacing it`, and now
4463 fails if the pre-existing line does not survive. Both halves are asserted,
4464 because the "argv reached the file" grep alone passes on an overwrite.
4465
4466 **Removal reads the file leniently; growing it does not.** `load` refuses a wall
4467 holding a line that no longer parses, which is right — silently dropping a tile
4468 the user wrote down is worse than making them fix it — but when every path went
4469 through `load`, one hand-edited line made the file unrepairable by the tool that
4470 owns it, including the command whose entire job is removing a line. So `forget`
4471 (and therefore `mux wall rm` and `x`) reads with `loadLines`, which applies no
4472 grammar: it can delete the broken line, and it writes every line it did not
4473 match back byte for byte. `record` and `Wall.add` keep refusing — growing a wall
4474 whose existing content is not understood would re-save garbage as though it had
4475 been read.
4476
4477 **`mux wall add`/`rm` build the whole edit in memory and save once.** The doc
4478 comment promised validate-all-before-write, and the per-spelling `record`/`forget`
4479 loop made that a half-truth: a bad line was caught before anything moved, but an
4480 IO error on the third of four arguments left the first two applied. One
4481 in-memory pass, one atomic rename. `rm` still reports each absent spelling and
4482 exits non-zero while removing the ones that were there — the report is about the
4483 argument, not a reason to abandon the others.
4484
4485 **Atomicity is writer-vs-reader, and the staleness gap is left open.** Every
4486 mutation goes through `wall.save`'s temp-file-plus-rename in the same directory,
4487 so the browser hub reading concurrently never sees half a file. Two writers
4488 resolve as last-rename-wins, which is what a single user's state file deserves.
4489 What is NOT solved, deliberately: a `mux wall` or `muxweb` that is ALREADY
4490 RUNNING reads the file once at startup and does not notice a line another
4491 process adds. The file is state, not a channel; a change feed (inotify, a hub
4492 verb, a poll) is real work for a case the model does not need yet — you see the
4493 new tile the next time you open the wall.
4494
4495 **`x` is an ordered removal that never speaks to the daemon.** The line leaves
4496 the file (`orderedRemove`, so the survivors keep the positions `1`-`9` jump to),
4497 the tile's pump returns — which closes its transport and frees the daemon slot —
4498 and the stripes are re-cut over what is left. Nothing else is sent: "remove is
4499 detach", the dynamic-wall doctrine, now with a leg that proves it (`muxa
4500 status` still answers for the forgotten session, and its marker is still in the
4501 grid). A tile that came from `mux wall`'s own command line and was never in the
4502 file is forgotten from the VIEW just the same, silently — the file had nothing
4503 to remove and the screen is the answer either way.
4504
4505 **Forgotten tiles become holes; nothing is compacted.** The pump threads hold
4506 `*Tile` pointers for the wall's whole life, so the array cannot shrink. The two
4507 pure chord tables therefore take a `present: []const bool` instead of a count:
4508 `j`/`k` step over holes, the digits renumber with the BARS the eye can actually
4509 see, and `Ctrl-\ l` aimed at a forgotten tile unzooms — which is exactly the
4510 fallback phase 1's comment predicted for "a tile that has since been forgotten".
4511 The `gone` flag is also read inside `paintModeLocked`, under `paint_mu`: without
4512 it a pump already past its own check could paint a stale stripe onto rows that
4513 have just changed owner.
4514
4515 **An empty wall says so.** Forget the last tile and the terminal would
4516 otherwise be blank with no cursor, which reads as hung — phase 1's dead-zoom
4517 lesson, second occurrence. One line, cursor shown, naming the state and the way
4518 out.
4519
4520 **`mux wall add`/`rm` are file operations only.** They do not dial, resolve a
4521 key or spawn ssh, which is what makes them safe in a script. `add` validates
4522 every spelling through the one grammar BEFORE writing any of them — a refused
4523 line should not leave the earlier ones half-applied — and adds the one refusal
4524 that belongs to the transport rather than the grammar (a `sun_path` too long to
4525 bind), because add time is the only moment the user is still looking at what
4526 they typed. `rm` of a spelling that is not on the wall says so and exits
4527 non-zero: a script that thinks it cleaned up a tile should learn it was spelled
4528 differently. The price of the subcommand is that a tile spelled literally `add`
4529 can no longer be `mux wall`'s first argument.
4530
4531 **Two independent pumps, two consecutive expects, one coin flip.** The `x` leg
4532 first expected BOTH tiles' markers in a row. Tiles are threads; `expect` consumes
4533 forward; whichever stripe painted second left the other's marker behind the
4534 cursor, and verb 2 spent its whole 20s budget waiting for bytes that had already
4535 gone past. It passed twice here and failed twice on the reviewer's box, which is
4536 the only interesting fact about it — a green run proved the scheduler, not the
4537 code. Every other two-tile ptyclient leg in this suite expects exactly ONE
4538 marker and then `settle`s, and that is not a style: it is the only pattern that
4539 is a fact about the program rather than about thread order. Fixed to match.
4540
4541 Three mutations, to check the new legs have teeth. Making `wall.record` always
4542 append fails the dedup leg at `a second attach to the same session made 2
4543 lines`. Putting the seam back at dial time fails the phantom-tile leg, which is
4544 the regression test for the case review found. Making `muxa`'s `attachZero`
4545 record a tile like a human attach fails the byte-identical leg at `muxa attached
4546 at 0x0 and still wrote a tile`. That one is worth a note on how it was read: on
4547 its first run the same mutation's extra file I/O also pushed the CLI-wall
4548 block's 5s injection expect past its budget, and the suite stopped THERE, before
4549 reaching the leg under test — a kill that proves the mutation is detectable
4550 without proving which assertion detects it. Re-run after the review fixes it
4551 died on its own leg. A mutation that fails the suite somewhere is not a mutation
4552 that fails the leg you aimed it at; when the two differ, say so and go get the
4553 targeted answer (here, `muxa send` against a hashed wall file, standalone). No
4554 mutation was needed for `x`:
4555 the leg asserts a line count, a surviving spelling and a session that still
4556 answers, and no two of those can be satisfied by the same accident.