411b6c25
docs: the wall file is attach history now
a73x 2026-08-20 00:50
Commit message
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. | ||