0a0fefea
docs: the wall lists daemons, and the tiles are their live sessions
a73x 2026-08-28 19:53
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -26,8 +26,9 @@ costs ~800k tokens; every token stays in context and is re-billed each turn. | |||
| 26 | - **Never `cat` these:** `src/server.zig` (3.5k lines, ~35k tok; its tests are | 26 | - **Never `cat` these:** `src/server.zig` (3.5k lines, ~35k tok; its tests are |
| 27 | siblings, `src/server_test_*.zig`, none over 1.6k; the agent relay and the | 27 | siblings, `src/server_test_*.zig`, none over 1.6k; the agent relay and the |
| 28 | session table are `src/server_agent.zig` and `src/server_sessions.zig`), | 28 | session table are `src/server_agent.zig` and `src/server_sessions.zig`), |
| 29 | `test/e2e.sh` (9.7k), `docs/decisions.md` (7.0k), `src/wallview.zig` (5.5k), | 29 | `src/wallview.zig` (6.6k), `src/interact.zig` (4.5k), |
| 30 | `src/interact.zig` (4.5k). | 30 | `docs/decisions.md` (7.1k). `test/e2e.sh` is a 189-line runner now; the |
| 31 | bodies are `test/e2e_NN_*.sh` (`E2E_ONLY=<group>` runs one). | ||
| 31 | Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window. | 32 | Use `grep -n` for the symbol, then `sed -n 'A,Bp'` for a window. |
| 32 | `src/client.zig` is 1.9k now — cheap enough to read. | 33 | `src/client.zig` is 1.9k now — cheap enough to read. |
| 33 | - Every module has a `//!` header stating its contract. `head -12 src/X.zig` | 34 | - Every module has a `//!` header stating its contract. `head -12 src/X.zig` |
| @@ -42,9 +43,9 @@ Layers are enforced in `build.zig`'s module table (grep `.layer =` for the graph | |||
| 42 | 43 | ||
| 43 | | Layer | Modules | | 44 | | Layer | Modules | |
| 44 | |---|---| | 45 | |---|---| |
| 45 | | 0 | `protocol` `engine` `pty` `quic` `keymap` `xdg` `sockpath` `proxy` `testtmp` | | 46 | | 0 | `protocol` `engine` `pty` `quic` `keymap` `xdg` `sockpath` `proxy` `cliflags` `testtmp` | |
| 46 | | 1 | `client_core` `quic_server` `quic_client` `predict` `spawn` `handoff` `delta` `cmd` `wall` `shellint` `replica` `paint` `layout` | | 47 | | 1 | `client_core` `quic_server` `quic_client` `predict` `spawn` `handoff` `delta` `cmd` `wall` `shellint` `replica` `paint` `layout` `select` `upgrade` | |
| 47 | | 2 | `server` `muxa` `interact` | | 48 | | 2 | `server` `muxa` `interact` `hosts` | |
| 48 | | 3 | `client` `main`(muxd) | | 49 | | 3 | `client` `main`(muxd) | |
| 49 | | 4 | `webhub` `wallview` | | 50 | | 4 | `webhub` `wallview` | |
| 50 | | 5 | `mux_main` `webhub_main`(muxweb) | | 51 | | 5 | `mux_main` `webhub_main`(muxweb) | |
| @@ -67,11 +68,12 @@ real pty), `wsclient` (browser stand-in), `rawmode`, `delaypipe`, `render`. | |||
| 67 | the rect, relayout resends it. Focus is client-local and sends nothing | 68 | the rect, relayout resends it. Focus is client-local and sends nothing |
| 68 | on the wire: the keyboard writes the outgoing tile's `session_release` | 69 | on the wire: the keyboard writes the outgoing tile's `session_release` |
| 69 | under `paint_mu`, then doorbells the old pump (release) and the new | 70 | under `paint_mu`, then doorbells the old pump (release) and the new |
| 70 | (claim). `mux [TARGET]` is a wall of one tile whose rect is the whole | 71 | (claim). ONE interaction loop, a tile pump: `mux TARGET` is the wall |
| 71 | terminal — ONE interaction loop, a tile pump. A chord-born tile | 72 | zoomed on the tile it just attached to, and a wall of one tile whose rect |
| 72 | inherits the focused tile's target; `Ctrl-\ :` is the one chord that | 73 | is the whole terminal only when there is no terminal at all. A chord-born |
| 73 | takes a spelling, born with argv's row of the birth table (creates, | 74 | tile inherits the focused tile's target and its `-A`; `Ctrl-\ :` takes a |
| 74 | records, no `-A`) through the same `birthTile`. | 75 | spelling but adds a HOST, not a tile — its sessions arrive on that |
| 76 | host's first poll like any other host's. | ||
| 75 | - **Resize is gain-only.** `layout.Tree.resize` never shrinks a pane; the | 77 | - **Resize is gain-only.** `layout.Tree.resize` never shrinks a pane; the |
| 76 | shrink keys grow a neighbor at the focus's expense in `wallview.doResize`. | 78 | shrink keys grow a neighbor at the focus's expense in `wallview.doResize`. |
| 77 | - **Rails are painted from `relayout` and tiles cannot reach them.** Every | 79 | - **Rails are painted from `relayout` and tiles cannot reach them.** Every |
| @@ -89,30 +91,40 @@ real pty), `wsclient` (browser stand-in), `rawmode`, `delaypipe`, `render`. | |||
| 89 | agent, though anything they run inside a session uses that session's | 91 | agent, though anything they run inside a session uses that session's |
| 90 | socket like any other process. Frames, never transport; both ends cap | 92 | socket like any other process. Frames, never transport; both ends cap |
| 91 | a frame at `agent_data_max`. | 93 | a frame at `agent_data_max`. |
| 92 | - **The wall file is attach history, written on first state.** | 94 | - **The wall file lists DAEMONS; tiles are their live sessions.** |
| 93 | `client.recordOnState` fires when the daemon's first state arrives and never | 95 | `$XDG_STATE_HOME/mux/hosts`, one spelling per line (`--sock PATH` | |
| 94 | on a successful dial, so a refused attach cannot strand a line — which is why | 96 | `HOST` | `quic://HOST[:PORT]`); `#SESSION` is refused, because a host |
| 95 | there is no `unrecordTile`. `--via` and `muxa` record nothing; `mux wall`'s | 97 | line must name nothing that could be resurrected. `hosts.zig` owns it. |
| 96 | argv is a view, not an attach. A RESTORED line attaches-or-creates when it | 98 | Tiles come from each host's own `sessions_req` on a fresh side |
| 97 | names the local socket and joins only when it is remote | 99 | connection, polled once a second — never from the file — so a birth by |
| 98 | (`client.hydratedCreates`, one owner for the CLI and the hub): a daemon | 100 | anyone appears and an exit disappears, and `n`/`p`/digits walk the one |
| 99 | restart must not leave the user's own wall a grid of `[refused]`, and a | 101 | list. A host contributes ONE stripe only when it has no tiles; tiles ride |
| 100 | saved file must not spawn a shell on another host. The hub reaches the | 102 | out a blip rather than vanishing. `--via` and `muxa` record no host. |
| 101 | same rule from the other side — it cannot claim a size, so it births the | 103 | Nothing re-creates a session: a daemon restart heals to what that daemon |
| 102 | session on a side connection when an attach is refused before any grid. | 104 | now has, which for a fresh `muxd` is session `0`. |
| 103 | "A session the user ENDED stays ended" needs BOTH halves: the hub's | 105 | `hosts.load` is strict — a bad line refuses `mux` with rc 2 and prints |
| 104 | `ended` latch, and `sendAttach` declining to attach an `exited` tile at | 106 | it — while `mux hosts` lists and `mux hosts rm` edits VERBATIM, so the |
| 105 | all. A zoomed tile's attach carries a size, and a sized attach creates — | 107 | repair tool can read what the loader will not. The hub is phase 2: it |
| 106 | so the page can resurrect a shell without the hub involved. | 108 | still reads the old `wall` file (`client.hydratedCreates`, the `ended` |
| 109 | latch), which nothing on the CLI writes any more. | ||
| 110 | - **`Ctrl-\ x` ends a session; the daemon owns the two-step.** `end_req` | ||
| 111 | `0x11` / `end_reply` `0x94`, observer verbs. The daemon refuses a first | ||
| 112 | `x` on a session other clients hold and answers the count; the client | ||
| 113 | arms 3 s for the forcing second press. An accepted end is BOUNDED — | ||
| 114 | SIGKILL past `Pty.term_grace_ms` — so no shell can refuse to die, and | ||
| 115 | `muxd upgrade` is refused while any session is ending rather than carry | ||
| 116 | a masterless pty across the exec. A daemon with no arm answers nothing | ||
| 117 | and the client banners `[daemon too old to end a session]`. The tile | ||
| 118 | leaves on the next list, not on the keypress. | ||
| 107 | - **The layout sidecar is derived convenience, not authored intent.** | 119 | - **The layout sidecar is derived convenience, not authored intent.** |
| 108 | `$XDG_STATE_HOME/mux/layout` stores the pane tree on last detach, hydrated | 120 | `$XDG_STATE_HOME/mux/layout` stores the pane tree on last detach, from |
| 109 | walls only (no-argv `mux wall` or post-fold; argv walls and `mux TARGET` | 121 | every wall left on a TERMINAL (`shared.is_tty`) — a piped `mux` is a wall |
| 110 | never write). Restore is verbatim; the aspect rule applies only when no | 122 | of one and neither saves nor restores. Restore is verbatim; the aspect |
| 111 | saved tree exists. Healing is per-leaf against wall drift. `wall.load` | 123 | rule applies only when no saved tree exists. Healing is per-leaf against |
| 112 | stays strict (a wall line is authored intent) while the sidecar is | 124 | the hosts' live lists. `hosts.load` stays strict (a host line is authored |
| 113 | lenient — every failure degrades silently to the default cut. The | 125 | intent) while the sidecar is lenient — every failure degrades silently to |
| 114 | asymmetry is deliberate: a bad wall line is a user's mistake to fix; a | 126 | the default cut. The asymmetry is deliberate: a bad host line is a user's |
| 115 | stale layout is a convenience to forget. | 127 | mistake to fix; a stale layout is a convenience to forget. |
| 116 | - **Detach is a goodbye, not a tear.** A pump that has sent its detach frame | 128 | - **Detach is a goodbye, not a tear.** A pump that has sent its detach frame |
| 117 | never redials — redial checks `detach_ack` — so the daemon's close after | 129 | never redials — redial checks `detach_ack` — so the daemon's close after |
| 118 | a detach is final, not a network event to recover from. | 130 | a detach is final, not a network event to recover from. |
| @@ -157,11 +169,14 @@ real pty), `wsclient` (browser stand-in), `rawmode`, `delaypipe`, `render`. | |||
| 157 | stamps `Patch:` trailers. Each collab write costs a ~15s origin sync — batch. | 169 | stamps `Patch:` trailers. Each collab write costs a ~15s origin sync — batch. |
| 158 | - `make check` before commit, `make ci` before delivery; capture `$?` before | 170 | - `make check` before commit, `make ci` before delivery; capture `$?` before |
| 159 | piping (`make test | tail` reports tail's exit code, not the build's). | 171 | piping (`make test | tail` reports tail's exit code, not the build's). |
| 160 | - **Any hand-run rig exports an isolated `XDG_STATE_HOME`.** Every attach | 172 | - **Any hand-run rig exports an isolated `XDG_STATE_HOME`.** Every named |
| 161 | writes a tile into `$XDG_STATE_HOME/mux/wall`; the `make` scripts all | 173 | attach writes a host into `$XDG_STATE_HOME/mux/hosts`; the `make` scripts |
| 162 | isolate it, but an ad-hoc repro or perf rig with the real `HOME` leaves a | 174 | all isolate it, but an ad-hoc repro or perf rig with the real `HOME` leaves |
| 163 | dead `--sock /tmp/...` stripe on the user's wall per attach (49 of them | 175 | a dead `--sock /tmp/...` line on the user's wall per throwaway socket (49 |
| 164 | found 2026-08-22). `export XDG_STATE_HOME=$SCRATCH/state` first, always. | 176 | of them found 2026-08-22, under the old per-attach file). `export |
| 177 | XDG_STATE_HOME=$SCRATCH/state` first, always — and a rig that dials a | ||
| 178 | default socket needs an isolated `XDG_RUNTIME_DIR` too, or a bare `mux` | ||
| 179 | auto-starts against the user's live daemon. | ||
| 165 | - **Never quote a speed number measured in the dev tree.** `zig build` is | 180 | - **Never quote a speed number measured in the dev tree.** `zig build` is |
| 166 | Debug, and Debug builds ghostty with its page-integrity check on — a hash | 181 | Debug, and Debug builds ghostty with its page-integrity check on — a hash |
| 167 | map over every cell per mutation, ~600x on output-heavy work. `make test`, | 182 | map over every cell per mutation, ~600x on output-heavy work. `make test`, |
README.md
| Old | New | ||
|---|---|---|---|
| @@ -42,49 +42,77 @@ muxd run & # the daemon: hosts your shell and the authoritative screen | |||
| 42 | mux # attach | 42 | mux # attach |
| 43 | ``` | 43 | ``` |
| 44 | 44 | ||
| 45 | **`mux` IS the wall.** There is one navigation axis and one program behind | 45 | **`mux` IS the wall, and the wall is a list of daemons.** The file |
| 46 | it: every tile is a live session on its own rectangle, and one tile is | 46 | `~/.local/state/mux/hosts` names machines — your own and every box you have |
| 47 | focused — it receives your keystrokes. `mux` and `mux HOST` open on a wall | 47 | attached to — one spelling per line, and the tiles are the sessions those |
| 48 | of one tile whose rect is the whole terminal; `mux wall` opens on the saved | 48 | daemons have live *right now*. There is one list and one navigation axis: |
| 49 | wall. `Ctrl-\` `1`-`9` focuses a tile, `Ctrl-\` `d` leaves — no child | 49 | `Ctrl-\` `1`-`9` focuses a tile, `Ctrl-\` `n`/`p` walk them all, `Ctrl-\` |
| 50 | process, no second screen, and every tile's replica stays hot, so moving | 50 | `d` leaves. Every tile's replica stays hot whether you are looking at it or |
| 51 | focus is a repaint and not a re-attach. Bare `mux` still feels like a plain | 51 | not, so moving focus is a repaint and not a re-attach — no child process, |
| 52 | client, because a wall of one tile is what a plain client always was. | 52 | no second screen. |
| 53 | |||
| 54 | The list is the daemons' answer, not your history: `mux` asks every listed | ||
| 55 | daemon once a second what it is hosting, so a session a split, a `muxa` or | ||
| 56 | somebody else's client births turns up as a tile by itself, and a session | ||
| 57 | that ends takes its tile with it. Nothing on the wall can outlive the shell | ||
| 58 | behind it, and nothing in the file can bring one back. | ||
| 59 | |||
| 60 | `mux` on a machine that has never run it records your own daemon (the line | ||
| 61 | `--sock <default>`, starting it if nothing is listening) and opens zoomed on | ||
| 62 | its session `0` — a first run is still just a shell. After that, bare `mux` | ||
| 63 | opens on the wall itself, and `mux HOST` opens zoomed on HOST's session `0`, | ||
| 64 | adding HOST to the file if it is new, with the rest of the list on the wall | ||
| 65 | behind it. Every host dials at once, in the background, so a slow ssh | ||
| 66 | somewhere else never holds up the one you asked for; `Ctrl-\` `w` zooms back | ||
| 67 | out to see them. A wall of one tile still feels like a plain client, because | ||
| 68 | a wall of one tile is what a plain client always was. | ||
| 53 | 69 | ||
| 54 | A split births a session: not a window onto an existing one, but its own | 70 | A split births a session: not a window onto an existing one, but its own |
| 55 | shell, laid out beside or below the focus. The wall places tiles side by | 71 | shell on the focused tile's daemon, laid out beside or below the focus. The |
| 56 | side when the terminal is at least twice as wide as it is tall, and stacks | 72 | wall places tiles side by side when the terminal is at least twice as wide |
| 57 | them otherwise. Fullscreen is a layout where the focused pane takes the | 73 | as it is tall, and stacks them otherwise. Fullscreen is a layout where the |
| 58 | whole terminal; resize mode trades cells between the focus and its | 74 | focused pane takes the whole terminal; resize mode trades cells between the |
| 59 | neighbors, one per keystroke. The layout survives detach and comes back | 75 | focus and its neighbors, one per keystroke. The layout survives detach and |
| 60 | the next time the wall does — `mux wall`, or `Ctrl-\ w` from a plain `mux`. | 76 | comes back the next time you open a wall on a terminal. |
| 61 | 77 | ||
| 62 | The focused tile has the terminal, and only the prefix is held back: | 78 | The focused tile has the terminal, and only the prefix is held back: |
| 63 | 79 | ||
| 64 | | Key | Effect | | 80 | | Key | Effect | |
| 65 | |---|---| | 81 | |---|---| |
| 66 | | `Ctrl-\` | command prefix — press it, then a command key | | 82 | | `Ctrl-\` | command prefix — press it, then a command key | |
| 67 | | `Ctrl-\` `d` (or `Ctrl-\` `Ctrl-\`) | detach and leave mux (session keeps running) | | 83 | | `Ctrl-\` `d` (or `Ctrl-\` `Ctrl-\`) | disconnect: leave every tile and leave mux, interrupt nobody (every session keeps running) | |
| 68 | | `Ctrl-\` `c` | create a session, add a tile, focus it | | 84 | | `Ctrl-\` `c` | create a session on the focused tile's daemon, add a tile, focus it | |
| 69 | | `Ctrl-\` `n` / `Ctrl-\` `p` | focus the next / previous session of this daemon, wrapping | | 85 | | `Ctrl-\` `n` / `Ctrl-\` `p` | focus the next / previous tile on the wall, wrapping | |
| 70 | | `Ctrl-\` `h` `j` `k` `l` | focus the pane left / down / up / right | | 86 | | `Ctrl-\` `h` `j` `k` `l` | focus the pane left / down / up / right | |
| 71 | | `Ctrl-\` `\|` (or `\`) | split right: new session in a pane to the right | | 87 | | `Ctrl-\` `\|` (or `\`) | split right: new session in a pane to the right | |
| 72 | | `Ctrl-\` `-` | split below: new session in a pane below | | 88 | | `Ctrl-\` `-` | split below: new session in a pane below | |
| 73 | | `Ctrl-\` `f` | fullscreen the focused pane (toggle) | | 89 | | `Ctrl-\` `f` | fullscreen the focused pane (toggle) | |
| 74 | | `Ctrl-\` `r` | resize mode: `h`/`l` shrink/grow the focus's width, `k`/`j` its height, one cell per press; Esc leaves silently, any other key leaves and types | | 90 | | `Ctrl-\` `r` | resize mode: `h`/`l` shrink/grow the focus's width, `k`/`j` its height, one cell per press; Esc leaves silently, any other key leaves and types | |
| 75 | | `Ctrl-\` `1`-`9` | focus tile N — the digit on that tile's label bar | | 91 | | `Ctrl-\` `1`-`9` | focus tile N — the digit on that tile's label bar | |
| 76 | | `Ctrl-\` `x` | forget the focused tile (off the wall file; the session keeps running) | | 92 | | `Ctrl-\` `x` | end the focused session — the daemon hangs up its shell and every attached client sees the exit. With other clients on it the daemon refuses the first press and says how many; a second `x` within 3 seconds forces it | |
| 77 | | `Ctrl-\` `:` | add a tile by spelling: type `HOST#SESSION`, `quic://HOST#SESSION` or `--sock PATH#SESSION`, Enter adds it beside the focus (creating the session if needed) and records it; Esc or Ctrl-C cancels. The line editor takes printable ASCII only, so a path or hostname with any other byte in it goes in the wall file or on argv instead | | 93 | | `Ctrl-\` `w` | zoom out to the wall | |
| 94 | | `Ctrl-\` `:` | add a daemon by spelling: type `HOST`, `quic://HOST[:PORT]` or `--sock PATH`, Enter puts it on the wall and its sessions arrive as tiles; Esc or Ctrl-C cancels. The line editor takes printable ASCII only, so a path or hostname with any other byte in it goes in the hosts file or on argv instead | | ||
| 78 | | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) | | 95 | | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) | |
| 79 | | mouse wheel | scrollback, three rows a notch (arrow keys to a full-screen app) | | 96 | | mouse wheel | scrollback, three rows a notch (arrow keys to a full-screen app) | |
| 80 | | drag with the left button | select what it crosses, copy on release | | 97 | | drag with the left button | select what it crosses, copy on release | |
| 81 | | any key not behind `Ctrl-\` | goes to the focused tile | | 98 | | any key not behind `Ctrl-\` | goes to the focused tile | |
| 82 | 99 | ||
| 83 | Behind the `Ctrl-\` prefix, `n`, `p` and `c` are about SESSIONS, not tiles: | 100 | `n`, `p` and the digits walk the same list, because there is only one: |
| 84 | they walk the daemon's own session ring. A session that already has a tile | 101 | the wall's tiles, across every daemon on it, in host order and then in each |
| 85 | is an instant focus move; one that does not gets a tile, because visiting it | 102 | daemon's own slot order. `c`, `|` and `-` ask the FOCUSED tile's daemon for |
| 86 | is attaching to it and every attach is recorded (see "the wall is your | 103 | a new session, so a split on a remote tile births a shell on that machine |
| 87 | attach history" below). The digits are what walk tiles. | 104 | and not on yours. |
| 105 | |||
| 106 | `x` is the other half of `c`, and it is not a wall edit: it ends the | ||
| 107 | session. The tile leaves when the daemon's list no longer has it, not when | ||
| 108 | the key is pressed, so what you see is always what the daemons have. | ||
| 109 | Because ending interrupts anyone else watching, the daemon refuses a first | ||
| 110 | `x` on a session with other clients attached and says how many — the rail | ||
| 111 | reads `[1 other attached - x again to end]` — and a second `x` within three | ||
| 112 | seconds forces it. A shell that ignores the hangup is killed half a second | ||
| 113 | later; there is no session that cannot be ended. Dropping a whole MACHINE | ||
| 114 | is `mux hosts rm`, not a chord: it is rarer than ending a shell and must not | ||
| 115 | sit one key from `n`. | ||
| 88 | 116 | ||
| 89 | A session whose shell exits ends `mux` with that shell's exit code when it | 117 | A session whose shell exits ends `mux` with that shell's exit code when it |
| 90 | was the only tile on the wall — so `mux` stays something you can put in a | 118 | was the only tile on the wall — so `mux` stays something you can put in a |
| @@ -118,15 +146,17 @@ does worse. | |||
| 118 | 146 | ||
| 119 | One daemon hosts up to thirty-two named sessions; the first is called `0`. | 147 | One daemon hosts up to thirty-two named sessions; the first is called `0`. |
| 120 | `Ctrl-\` `c` creates one named after the lowest free number and focuses it, | 148 | `Ctrl-\` `c` creates one named after the lowest free number and focuses it, |
| 121 | `Ctrl-\` `n` and `Ctrl-\` `p` step through them in the daemon's order and | 149 | and `mux --session NAME` attaches to any of them by name (creating it if |
| 122 | wrap at the ends, and `mux --session NAME` attaches to any of them by name | 150 | there is room). One wall holds up to thirty-two tiles however many daemons |
| 123 | (creating it if there is room). With only one session open `n` and `p` do | 151 | they came from; past that the rail says `[+N not shown]` and the digits do |
| 124 | nothing — there is nowhere to step to. | 152 | not reach them. Not worth designing past until someone has thirty-three |
| 125 | 153 | shells. | |
| 126 | To see which sessions are alive without attaching to one, ask the daemon: | 154 | |
| 127 | `muxd stats` names every live session with the number of clients watching it. | 155 | To see which sessions are alive without attaching to one: `mux hosts` prints |
| 128 | That is the question to ask when `mux` will not let you in — thirty-two | 156 | every daemon on the wall with its live session count, and `muxd stats` names |
| 129 | sessions is the table, and the thirty-third name has nowhere to go. | 157 | every session of one daemon with the number of clients watching it. `stats` |
| 158 | is the question to ask when `mux` will not let you in — thirty-two sessions | ||
| 159 | is the table, and the thirty-third name has nowhere to go. | ||
| 130 | 160 | ||
| 131 | Every session shell is told where it lives: `$MUX_SOCK` is the daemon's | 161 | Every session shell is told where it lives: `$MUX_SOCK` is the daemon's |
| 132 | socket path and `$MUX_SESSION` is the session's name. Scripts and prompts | 162 | socket path and `$MUX_SESSION` is the session's name. Scripts and prompts |
| @@ -134,9 +164,10 @@ can read them, and `mux` reads them to refuse attaching to the very session | |||
| 134 | it is running in — that attach would feed the session's own paint back into | 164 | it is running in — that attach would feed the session's own paint back into |
| 135 | itself, take the alternate screen and swallow every keystroke, with no | 165 | itself, take the alternate screen and swallow every keystroke, with no |
| 136 | outer keyboard able to reach it. Attaching from one session to *another* on | 166 | outer keyboard able to reach it. Attaching from one session to *another* on |
| 137 | the same daemon is fine and unaffected; so is `mux wall`, unless one of its | 167 | the same daemon is fine and unaffected, and a wall opened from inside a |
| 138 | tiles is the session it was launched from. Unset `MUX_SESSION` if you | 168 | session simply never births that one tile — the rest of the daemon's |
| 139 | really want the loop. | 169 | sessions are there, the one you are standing in is not. Unset `MUX_SESSION` |
| 170 | if you really want the loop. | ||
| 140 | 171 | ||
| 141 | ## Quick start, remote over ssh | 172 | ## Quick start, remote over ssh |
| 142 | 173 | ||
| @@ -216,12 +247,15 @@ with nobody offering, the daemon hangs up and the nested `-A` is refused. | |||
| 216 | An agent that is merely slow still passes — the refusal is immediate, so | 247 | An agent that is merely slow still passes — the refusal is immediate, so |
| 217 | being slow is not what separates them. | 248 | being slow is not what separates them. |
| 218 | 249 | ||
| 219 | `-A` goes on the attach form (`mux -A HOST`, `mux -A quic://HOST`, | 250 | `-A` is offered by the tile you named and by nothing else. `mux -A HOST` |
| 220 | `mux -A --sock PATH`), not on `mux wall`, which refuses a flag where a | 251 | (or `mux -A quic://HOST`, `mux -A --sock PATH`, or a bare `mux -A` on your |
| 221 | target belongs. Sibling tiles grown from an `-A` attach by chord (`Ctrl-\ c`, | 252 | own daemon) forwards on that tile, and a sibling grown from it by chord |
| 222 | `n`, `p`) inherit it — same daemon, same host, nothing newly exposed — while tiles | 253 | (`Ctrl-\ c`, `|`, `-`) inherits it — same daemon, same host, nothing newly |
| 223 | restored from the saved wall never do. `muxa` never forwards, under any | 254 | exposed. Every OTHER tile on the wall arrives from a daemon's session list |
| 224 | flag. | 255 | rather than from your command line, and those never offer an agent: the |
| 256 | wall is a list of machines, and a machine you have never typed the flag for | ||
| 257 | must not get your keys because it happens to be on it. `muxa` never | ||
| 258 | forwards, under any flag. | ||
| 225 | 259 | ||
| 226 | ## Quick start, remote over QUIC | 260 | ## Quick start, remote over QUIC |
| 227 | 261 | ||
| @@ -267,89 +301,126 @@ line, so the next bare `muxweb` comes back to the same wall. That saved | |||
| 267 | spelling — `--sock PATH` in one piece — is also accepted on the command | 301 | spelling — `--sock PATH` in one piece — is also accepted on the command |
| 268 | line, so a line from the file can be pasted back verbatim. | 302 | line, so a line from the file can be pasted back verbatim. |
| 269 | 303 | ||
| 270 | A daemon restart is survived here the same way it is on the CLI wall: a | 304 | A daemon restart is survived here, and this is where the hub still differs |
| 271 | saved line on a local socket (`--sock PATH#NAME`) comes back as a fresh | 305 | from the terminal: a saved line on a local socket (`--sock PATH#NAME`) comes |
| 272 | session, a remote one (`HOST#NAME`, `quic://HOST#NAME`) is joined only and | 306 | back as a fresh session, a remote one (`HOST#NAME`, `quic://HOST#NAME`) is |
| 273 | its tile reads `refused`. | 307 | joined only and its tile reads `refused`. The CLI wall re-creates nothing at |
| 308 | all, because its file names no session to re-create. | ||
| 274 | 309 | ||
| 275 | Localhost only, deliberately: to see it from another machine, forward it — | 310 | Localhost only, deliberately: to see it from another machine, forward it — |
| 276 | `ssh -L 7681:127.0.0.1:7681 HOST`. | 311 | `ssh -L 7681:127.0.0.1:7681 HOST`. |
| 277 | 312 | ||
| 278 | The same wall, without a browser: | 313 | **The hub and the CLI wall have parted company for now.** `muxweb` still |
| 314 | keeps its own list of *sessions* in `~/.local/state/mux/wall`, and nothing | ||
| 315 | on the command line writes that file any more — so on a fresh machine the | ||
| 316 | hub opens empty until you name tiles on its command line or in the page, and | ||
| 317 | a host you add with `mux hosts add` does not appear there. Moving the hub | ||
| 318 | onto the `hosts` file below is the next piece of work, not a gap you can | ||
| 319 | configure around. | ||
| 320 | |||
| 321 | ## The wall is your hosts | ||
| 279 | 322 | ||
| 280 | ```sh | 323 | ```sh |
| 281 | mux wall # the saved wall, as stripes in this terminal | 324 | mux hosts # the daemons on the wall, one per line, with live session counts |
| 282 | mux wall HOST '--sock /tmp/s.sock#b' # ...or state it (one spelling per argument) | 325 | mux hosts add HOST # put a daemon on the wall without opening it |
| 283 | mux wall HOST --sock /tmp/s.sock#b # ...`--sock PATH` unquoted works too, as in muxweb | 326 | mux hosts add --sock /tmp/s.sock |
| 284 | mux wall add HOST'#build' # put a tile on the saved wall without attaching | 327 | mux hosts add quic://box:4433 |
| 285 | mux wall rm HOST'#build' # take one off (the session keeps running) | 328 | mux hosts rm HOST # take one off (its sessions keep running) |
| 329 | ``` | ||
| 330 | |||
| 331 | The file is `~/.local/state/mux/hosts` (`$XDG_STATE_HOME/mux/hosts`), one | ||
| 332 | daemon per line, in wall order: | ||
| 333 | |||
| 334 | ``` | ||
| 335 | --sock /run/user/1000/muxd.sock | ||
| 336 | box | ||
| 337 | quic://gate.example:4433 | ||
| 286 | ``` | 338 | ``` |
| 287 | 339 | ||
| 288 | `add` and `rm` are subcommands, so they claim the first argument: a tile | 340 | A line names a **machine**, never a session: `#NAME` on a spelling is |
| 289 | spelled literally `add` or `rm` has to reach the wall some other way (the | 341 | refused, because the wall shows every session a daemon has and there is |
| 290 | `Ctrl-\` chords, `muxweb`, or an editor on the file). Naming neither a | 342 | nothing left for a `#` to pick. That is also what makes the file safe to |
| 291 | target nor a spelling is a usage error, not an empty edit. | 343 | keep — there is no line in it that anything could resurrect a shell from. |
| 292 | 344 | Lines are deduped byte for byte, so the same box written `box` and | |
| 293 | Unlike `muxweb`, `mux wall TILE...` does not add what you name to the file: | 345 | `quic://box` is two hosts, deliberately: they are two different ways in. |
| 294 | spellings on this command line are a view of some tiles for this run, not an | 346 | |
| 295 | attach of yours, so they are gone when you leave. `mux wall add` is how you | 347 | Every `mux` that names a target records that target — `mux HOST`, |
| 296 | make one stick. | 348 | `mux quic://HOST`, `mux --sock PATH`, and a bare `mux`, which records your |
| 297 | 349 | own daemon. `--via CMD` never records one, because an arbitrary command is | |
| 298 | **The wall is your attach history**, kept in `~/.local/state/mux/wall`. | 350 | not an address and the grammar has no line to write for it; the session is |
| 299 | Every `mux` attach writes its own tile | 351 | real, it is just not a machine the wall can name. `muxa` records nothing |
| 300 | to that file, so a bare `mux` leaves `--sock <default>#0` behind and | 352 | either (it attaches at 0×0 and claims nothing). Writing the file is best |
| 301 | `mux HOST --session build` leaves `HOST#build` — as does every session you | 353 | effort — if it cannot be written you get one warning line and the attach |
| 302 | reach with `Ctrl-\` `c`/`n`/`p`, because visiting a session is attaching to | 354 | happens anyway. |
| 303 | it. Tiles are deduped by spelling, byte for byte: the same session reached | 355 | |
| 304 | as `HOST#build` and as `quic://HOST#build` is two tiles, deliberately. | 356 | Reading it is not best effort. A line the grammar refuses stops `mux` with |
| 305 | `muxa` never adds one (it attaches at 0×0 and claims nothing); `--via` never | 357 | exit 2 and prints the line, because a wall silently missing one of the |
| 306 | adds one either, because an arbitrary command is not an address and the wall | 358 | machines you wrote down is worse than no wall. `mux hosts` is the exception |
| 307 | grammar has no line to write for it — the session is real, it is just not | 359 | and lists everything verbatim, marking the bad one: |
| 308 | addressable as a tile, and `Ctrl-\` `w` labels it `--via CMD#NAME` on a bar | 360 | |
| 309 | that `Ctrl-\` `x` will find nothing in the file to forget; and neither do the wall's | 361 | ``` |
| 310 | own stripes. Writing the file is best effort — if it cannot be | 362 | --sock /run/user/1000/muxd.sock 3 |
| 311 | written you get one warning line and the attach happens anyway. | 363 | box [unreachable] |
| 312 | 364 | #build [bad host: names a session after '#': the wall lists daemons and shows every session they have] | |
| 313 | That file is what `mux wall` opens on, and what `Ctrl-\` `w` folds in | 365 | ``` |
| 314 | one-shot — the saved wall with the tile you are standing on among them. | 366 | |
| 315 | There is one wall, and no way to end up looking at a second one by accident. | 367 | — the line, a tab, then its live session count or what went wrong. That |
| 316 | 368 | listing dials every host, so it takes as long as the slowest one answers (up | |
| 317 | A daemon restart takes every session the file names with it. A line on a | 369 | to two seconds each). `mux hosts rm` reads the file verbatim too, so a line |
| 318 | local socket (`--sock PATH#NAME`) comes back as a fresh session rather than | 370 | you hand-edited into something no longer legal can be typed back byte for |
| 319 | `[refused]`: recreating a shell on your own box is cheap, and a wall you have | 371 | byte and removed instead of wedging every command that touches the wall. |
| 320 | to forget line by line after every reboot is not what attach history is for. | 372 | |
| 321 | A remote line (`HOST#NAME`, `quic://HOST#NAME`) is joined only, and a refused | 373 | A daemon that is not answering is **one** stripe — `--sock PATH |
| 322 | tile stays refused for as long as the wall is up: it says `[refused]` until you | 374 | [unreachable]` — and not one dead tile per session it used to have. It keeps |
| 323 | reopen the wall with that session already there — a saved file must not spawn | 375 | redialling, forever and on purpose: a wall is a thing you leave up, and a |
| 324 | shells on a machine you are not looking at. Spellings on `mux wall`'s own command line | 376 | laptop that wakes an hour later should find its sessions. The cost is that a |
| 325 | are a view, so they join and never create. | 377 | daemon that is never coming back looks exactly like one whose network is |
| 326 | 378 | merely down. Nothing times it out; `mux hosts rm SPELLING` is how a line | |
| 327 | `mux wall add`/`rm` are the same edit without an attach: file operations | 379 | leaves. Your own daemon is the exception that needs no stripe: a listed |
| 328 | only, so neither dials, and `rm` never touches the session. A wall or hub | 380 | local `--sock <default>` that nothing is serving is started for you, because |
| 329 | that is already running does not notice the change until you reopen it. | 381 | it dies on every reboot while its line lives on. |
| 330 | `rm` is also the repair tool: it is the one command that reads the file | 382 | |
| 331 | leniently, so a line you hand-edited into something no longer legal can be | 383 | **A daemon too old to be asked reads as `[unreachable]` too.** Listing a |
| 332 | removed (and every other line survives it byte for byte) instead of | 384 | daemon's live sessions is a new question on the wire, and a daemon from |
| 333 | wedging every command that touches the wall. | 385 | before this change answers it with the silence it answers every unknown |
| 334 | 386 | frame with. It is up, `mux --sock PATH` still attaches to it, and the wall | |
| 335 | `muxweb TILE...` ADDS its tiles to that file rather than replacing it — | 387 | still shows it as a stripe that never heals — so `muxd upgrade` (or a |
| 336 | naming a wall on the command line changes what this run shows, not what | 388 | restart) on every box you put on the wall is part of taking this version. |
| 337 | you have recorded. Forgetting stays explicit: the page's `×`, `Ctrl-\` `x`, | 389 | |
| 338 | `mux wall rm`. | 390 | Restarting a daemon re-creates **nothing**. The old wall would have brought |
| 339 | 391 | your sessions back from the file; this one cannot, because the file has no | |
| 340 | Watching is read-only: each stripe is a live session (label bar + the rows | 392 | session in it to bring back. The stripe goes `[unreachable]`, then heals to |
| 341 | around its cursor), attached the way `muxa` attaches — no size claim, so | 393 | whatever the daemon has when it returns — which, for a daemon someone just |
| 342 | watching never resizes anyone. | 394 | started, is one session called `0`. |
| 343 | 395 | ||
| 344 | The keys are the ones listed under "Quick start, local" — `mux wall` and | 396 | `mux hosts add`/`rm` are file operations only: neither dials, and `rm` never |
| 345 | `mux` are the same program, entered through different doors, so there is | 397 | touches the session — the shells on that machine go on running and `mux |
| 398 | HOST` reaches them again. A wall that is already up does not notice the | ||
| 399 | edit; the running wall's own way to add a machine is `Ctrl-\` `:`. | ||
| 400 | |||
| 401 | There is no `mux wall` any more — the wall has no separate door to open, so | ||
| 402 | `mux wall` is read as a HOST called `wall` like any other bare word, and ssh | ||
| 403 | says what it thinks of that. `hosts` is the one name a subcommand claims: | ||
| 404 | `mux hosts` lists, so a machine called `hosts` is attached to as | ||
| 405 | `quic://hosts` or `--via ssh hosts muxd proxy`, though `mux hosts add hosts` | ||
| 406 | puts it on the wall by that name quite happily. | ||
| 407 | |||
| 408 | Every tile on the wall is a real attach at its own rectangle — label bar plus | ||
| 409 | the rows its rect gives it — not a thumbnail and not a read-only view. What | ||
| 410 | the unfocused ones do NOT do is claim the session: only the focused tile's | ||
| 411 | title, mouse modes and clipboard reach your terminal, so a shell painting | ||
| 412 | away in the corner cannot take the screen off you. | ||
| 413 | |||
| 414 | The keys are the ones listed under "Quick start, local" — the zoomed tile | ||
| 415 | and the wall are one program entered through different doors, so there is | ||
| 346 | one key table and not two. Every typed key goes to the focused tile; only | 416 | one key table and not two. Every typed key goes to the focused tile; only |
| 347 | the `Ctrl-\` prefix is held back. `Ctrl-\` `1`-`9` focuses a tile (as does | 417 | the `Ctrl-\` prefix is held back. `Ctrl-\` `1`-`9` focuses a tile, `Ctrl-\` |
| 348 | clicking one), `Ctrl-\` `n`/`p`/`c` walk the daemon's session ring, `Ctrl-\` | 418 | `n`/`p` walk them, `Ctrl-\` `c` creates a session on the focused tile's |
| 349 | `h`/`j`/`k`/`l` move focus between panes, `Ctrl-\` `|`/`-` split right or | 419 | daemon, `Ctrl-\` `h`/`j`/`k`/`l` move focus between panes, `Ctrl-\` `|`/`-` |
| 350 | below, `Ctrl-\` `f` fullscreen, `Ctrl-\` `r` resize mode, `Ctrl-\` `x` | 420 | split right or below, `Ctrl-\` `f` fullscreen, `Ctrl-\` `r` resize mode, |
| 351 | forgets the focused tile, `Ctrl-\` `d` leaves. Any other command key after | 421 | `Ctrl-\` `:` adds a daemon, `Ctrl-\` `x` ends the focused session, `Ctrl-\` |
| 352 | the prefix is swallowed along with it, as in a plain client. | 422 | `w` zooms out, `Ctrl-\` `d` leaves. Any other command key after the prefix |
| 423 | is swallowed along with it, as in a plain client. | ||
| 353 | 424 | ||
| 354 | Every tile's label bar opens with its chord digit, and the focused tile's | 425 | Every tile's label bar opens with its chord digit, and the focused tile's |
| 355 | carries a `>` beside it — `1> ` has the focus, `2 ` does not. The digit is | 426 | carries a `>` beside it — `1> ` has the focus, `2 ` does not. The digit is |
| @@ -357,9 +428,9 @@ the order the tiles joined the wall — which is what `Ctrl-\` `1`-`9` indexes | |||
| 357 | — not their left-to-right position and not the session name. A pane splits | 428 | — not their left-to-right position and not the session name. A pane splits |
| 358 | beside the focus while its digit is appended, so on a wall you have split or | 429 | beside the focus while its digit is appended, so on a wall you have split or |
| 359 | restored the digits need not run in screen order. Each stays with its tile | 430 | restored the digits need not run in screen order. Each stays with its tile |
| 360 | for the whole run: forgetting one leaves a hole in the numbering rather than | 431 | for the whole run: a session that ends leaves a hole in the numbering rather |
| 361 | renumbering its neighbours. Past 9 the bar still prints the number, though | 432 | than renumbering its neighbours. Past 9 the bar still prints the number, |
| 362 | no chord reaches it. | 433 | though no chord reaches it. |
| 363 | 434 | ||
| 364 | Focus is client-local: moving it sends nothing on the wire, so `Ctrl-\` `n` | 435 | Focus is client-local: moving it sends nothing on the wire, so `Ctrl-\` `n` |
| 365 | and `Ctrl-\` `h` move between sessions instantly — no reconnect, no | 436 | and `Ctrl-\` `h` move between sessions instantly — no reconnect, no |
| @@ -372,20 +443,19 @@ a plain client's loop. The focused tile's Core owns the mouse modes; a tile | |||
| 372 | that is not focused holds none, so nothing it armed can report while you | 443 | that is not focused holds none, so nothing it armed can report while you |
| 373 | are looking elsewhere. | 444 | are looking elsewhere. |
| 374 | 445 | ||
| 375 | A tile whose host is not answering says `reconnecting` and keeps retrying, | 446 | The pane layout is remembered — where the cuts were, which tile had the |
| 376 | forever and on purpose — a wall is a thing you leave up, and a laptop that | 447 | focus — under `$XDG_STATE_HOME/mux/layout`, saved by any wall you leave from |
| 377 | wakes an hour later should find its sessions. The cost is that a tile | 448 | a terminal and restored over whatever sessions the daemons turn out to have |
| 378 | pointing at a daemon that is never coming back looks identical to one whose | 449 | when you come back. It is derived convenience and not something you authored: |
| 379 | network is merely down. Nothing times it out; `Ctrl-\` `x`, or `mux wall rm | 450 | every way it can fail (a missing file, a stale tree, a session that is no |
| 380 | SPELLING`, is how a line leaves. | 451 | longer there) degrades quietly to the default cut, where the wall places |
| 381 | 452 | tiles side by side on a wide terminal and stacks them on a tall one. | |
| 382 | `Ctrl-\` `x` forgets the focused tile: its line leaves the wall file, its stripe | ||
| 383 | leaves the screen, and the remaining stripes are re-cut over the space. | ||
| 384 | Forgetting is never killing — the session goes on running and `mux` reaches | ||
| 385 | it again by name. Forget them all and the wall says so. | ||
| 386 | 453 | ||
| 387 | `Ctrl-\` `w` folds in the saved wall's tiles one-shot — the wall you are | 454 | A scripted `mux TARGET` — stdout on a pipe — is still a wall of one tile |
| 388 | already on, plus every session recorded in the file. `Ctrl-\` `d` leaves. | 455 | whose rect is the whole terminal, writing exactly what the plain client |
| 456 | wrote, with no other host dialled and no layout saved. A wall of MANY needs | ||
| 457 | a terminal to cut stripes from and says `mux: wall needs a terminal` if it | ||
| 458 | has none. | ||
| 389 | 459 | ||
| 390 | ## Everything else | 460 | ## Everything else |
| 391 | 461 | ||
| @@ -438,7 +508,9 @@ execs the old one back and the old one carries on serving. Scrollback does | |||
| 438 | not survive the handover; the visible grid, the titles, the command marks | 508 | not survive the handover; the visible grid, the titles, the command marks |
| 439 | and the exit codes do. A daemon too old to know the request answers nothing, | 509 | and the exit codes do. A daemon too old to know the request answers nothing, |
| 440 | and `muxd upgrade` says so: `no reply: this daemon predates upgrade — stop | 510 | and `muxd upgrade` says so: `no reply: this daemon predates upgrade — stop |
| 441 | and run`. | 511 | and run`. A session in the middle of being ended (`Ctrl-\` `x`, up to half a |
| 512 | second while its shell is being hung up) refuses the upgrade with `session | ||
| 513 | ending, retry` rather than carrying a shell with no master across the exec. | ||
| 442 | 514 | ||
| 443 | Copy and paste work through the session: a mux drag and an application's own | 515 | Copy and paste work through the session: a mux drag and an application's own |
| 444 | OSC 52 write both reach your terminal's clipboard (including from a remote box over QUIC, | 516 | OSC 52 write both reach your terminal's clipboard (including from a remote box over QUIC, |
| @@ -464,7 +536,7 @@ Design: `docs/handoff.md`. Every decision and measurement: | |||
| 464 | 536 | ||
| 465 | ## Status | 537 | ## Status |
| 466 | 538 | ||
| 467 | Prototype, eighteen milestones in, current cut tagged `v0.0.1-10`. All | 539 | Prototype, eighteen milestones in, current cut tagged `v0.0.1-15`. All |
| 468 | founding kill criteria cleared, measured on real networks rather than | 540 | founding kill criteria cleared, measured on real networks rather than |
| 469 | loopback: reattach into a live full-screen `nvim` in ~5ms from one | 541 | loopback: reattach into a live full-screen `nvim` in ~5ms from one |
| 470 | snapshot; steady-state delta traffic ~1% of snapshot-equivalent cost; the | 542 | snapshot; steady-state delta traffic ~1% of snapshot-equivalent cost; the |
| @@ -476,5 +548,8 @@ delay on a real path); and a predicted keystroke paints in **0.1ms** on a | |||
| 476 | 150ms path where the same keystroke's authoritative echo takes **154ms** — | 548 | 150ms path where the same keystroke's authoritative echo takes **154ms** — |
| 477 | typing stops waiting for the round trip, and the two numbers come from one | 549 | typing stops waiting for the round trip, and the two numbers come from one |
| 478 | keystroke on one connection so the gap is not an artefact of comparing | 550 | keystroke on one connection so the gap is not an artefact of comparing |
| 479 | runs. Not yet here: daemon auto-start on attach, reconnect backoff tuning, | 551 | runs. Auto-start is here for local unix-socket attaches — `mux` and `mux |
| 480 | prediction for backspace and for multi-byte input. | 552 | --sock PATH` start a daemon when nothing is serving that path — and the wall |
| 553 | starts the one on the default socket for the same reason. Not yet here: | ||
| 554 | auto-start on a remote host, the browser hub on the `hosts` file, reconnect | ||
| 555 | backoff tuning, prediction for backspace and for multi-byte input. | ||
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -7120,3 +7120,172 @@ leg's `timeout` scales with `E2E_TIME_SCALE`, against the lib's own | |||
| 7120 | "budgets only" wording: a wedged daemon never answers, so waiting longer | 7120 | "budgets only" wording: a wedged daemon never answers, so waiting longer |
| 7121 | changes nothing about the fact demanded, and a kcov-slowed answer must not | 7121 | changes nothing about the fact demanded, and a kcov-slowed answer must not |
| 7122 | read as the wedge. | 7122 | read as the wedge. |
| 7123 | |||
| 7124 | ## 2026-08-28 — the wall lists daemons; tiles are their live sessions | ||
| 7125 | |||
| 7126 | **There were two lists of "my sessions" and they disagreed.** The daemon's | ||
| 7127 | list is live and is what `Ctrl-\ n`/`p` walked (`sessions_req`); the wall | ||
| 7128 | file's list was attach history and is what `mux wall`, the digits and | ||
| 7129 | `Ctrl-\ w` showed. A session born by a split, by `muxa` or by another | ||
| 7130 | client was on one and not the other, so `n` landed the user in shells they | ||
| 7131 | did not know existed. The other direction was worse: a wall line for a | ||
| 7132 | session the daemon no longer had was re-created on restore | ||
| 7133 | (`client.hydratedCreates`), so the file resurrected shells — and bare `mux` | ||
| 7134 | had a third path of its own, attach-or-create `#0` re-recorded on every | ||
| 7135 | run, which meant `Ctrl-\ x` on it was undone by the next `mux`. One list | ||
| 7136 | was the fix, and the only list that cannot lie is the daemons'. | ||
| 7137 | |||
| 7138 | **So the file records HOSTS and nothing else.** `$XDG_STATE_HOME/mux/hosts`, | ||
| 7139 | one spelling per line — `--sock PATH` | `HOST` | `quic://HOST[:PORT]` — and | ||
| 7140 | `#SESSION` is a refusal with the rule in the message. That is the whole | ||
| 7141 | guarantee: a line that names no session is a line nothing can resurrect | ||
| 7142 | from, so "a session the user ENDED stays ended" stopped needing a latch to | ||
| 7143 | enforce it and became a property of the grammar. Tiles are each host's live | ||
| 7144 | sessions, in host order then the daemon's own slot order; births appear, | ||
| 7145 | exits disappear, and `n`/`p`/digits walk the one list. The old `wall` file | ||
| 7146 | is not read by the CLI and not migrated: its lines are attach history of | ||
| 7147 | sessions, and the model no longer has that. | ||
| 7148 | |||
| 7149 | **Poll, not push, and a fresh connection per poll.** Each host gets one | ||
| 7150 | `sessions_req` a second on a side connection that is opened, asked, read and | ||
| 7151 | closed. A subscription would be a new daemon concept — state per watcher, | ||
| 7152 | an invalidation path, a reconnect story — for a frame that is forty bytes | ||
| 7153 | over a link already carrying deltas. The measurement that would justify | ||
| 7154 | changing it is the one to take first: `muxd stats` delta_bytes against | ||
| 7155 | poll bytes on a wall of N hosts over an hour. If polling is ever the cost, | ||
| 7156 | `sessions_changed` is the push and this is the line that said so. A fresh | ||
| 7157 | connection each time is the same argument one level down — a held observer | ||
| 7158 | is a slot the daemon must reap, an idle deadline to tune, and a thing to | ||
| 7159 | re-establish across an upgrade, and the daemon already accepts a connection | ||
| 7160 | per `muxd stats`. | ||
| 7161 | |||
| 7162 | **A host is one stripe only when it has no tiles.** The naive rule — down | ||
| 7163 | host, dead tiles — turns a thirty-second network blip into a wall of | ||
| 7164 | `[refused]` and then a re-attach storm. Tiles ride out a blip on their own | ||
| 7165 | pumps (`reconnecting`, unchanged); the stripe exists for the case where | ||
| 7166 | there is nothing else to draw, and it keeps redialling. The one exception | ||
| 7167 | is the user's own machine: a listed local `--sock <default>` that nothing is | ||
| 7168 | serving is auto-started, because it dies on every reboot while its line | ||
| 7169 | lives on, and a wall that paints your own box `[unreachable]` until you find | ||
| 7170 | some other shell to start a daemon in is the empty-file case with one line | ||
| 7171 | in front of it. | ||
| 7172 | |||
| 7173 | **`[no sessions]` is a word the local path cannot reach.** `muxd` exits when | ||
| 7174 | its last session ends and `muxd run` creates `0` at start, so every daemon | ||
| 7175 | that answers at all contributes at least one tile. The word is kept because | ||
| 7176 | a remote daemon held open by something else could reach it, but phase 1 | ||
| 7177 | pins `[unreachable]` only. Whether an empty daemon should STAY alive is a | ||
| 7178 | spec question for the user — it changes daemon lifetime, and it is the | ||
| 7179 | difference between "the host is gone" and "the host is idle" being | ||
| 7180 | distinguishable on the wall. Filed, not decided. | ||
| 7181 | |||
| 7182 | **`Ctrl-\ x` ends the session, and the DAEMON owns the two-step.** New | ||
| 7183 | observer verbs `end_req 0x11` / `end_reply 0x94`; nothing existing changed | ||
| 7184 | shape. The daemon refuses a first `x` on a session other clients hold and | ||
| 7185 | answers with the count, the client arms three seconds for the forcing second | ||
| 7186 | press, and the rail says `[1 other attached - x again to end]`. The count | ||
| 7187 | had to come from the daemon rather than from the client's own view: a client | ||
| 7188 | knows how many clients it can see, which is one, and a client-guessed | ||
| 7189 | "nobody else is here" is exactly the wrong answer to give about interrupting | ||
| 7190 | someone. An accepted end is BOUNDED — SIGHUP, SIGTERM, then SIGKILL past | ||
| 7191 | `Pty.term_grace_ms` (500 ms) — because a shell that traps both would | ||
| 7192 | otherwise be a session with no master that nothing can end, which is the bug | ||
| 7193 | the user asked to fix. The tile leaves when the daemon's list no longer has | ||
| 7194 | the session, not when the key is pressed, so the screen never claims | ||
| 7195 | something the daemon has not done. | ||
| 7196 | |||
| 7197 | **An upgrade is refused while any session is ending.** The 500 ms hangup | ||
| 7198 | window has a session whose pty master is already closed, and `execUpgrade` | ||
| 7199 | writes that master's fd into the manifest. Rather than teach the manifest a | ||
| 7200 | "masterless" case, `muxd upgrade` answers `session ending, retry` and the | ||
| 7201 | operator retries half a second later. Bounded refusal beats an unbounded | ||
| 7202 | number of adopt-time special cases. | ||
| 7203 | |||
| 7204 | **A vanished tile's wake pipe is never closed.** Pollers now open a socket a | ||
| 7205 | second, so fd recycling is real: closing a vanished tile's wake fd and | ||
| 7206 | having the number handed straight back to a live socket makes a stray | ||
| 7207 | doorbell byte land in someone's session. Up to `max_tiles` fds held for a | ||
| 7208 | wall's life is the cheaper side of that trade. | ||
| 7209 | |||
| 7210 | **A `mux` with no terminal is a wall of ONE.** The standing invariant is | ||
| 7211 | that a scripted `mux TARGET` writes byte-identically to what the plain | ||
| 7212 | client wrote, and there is nowhere to paint a second tile on a pipe. So a | ||
| 7213 | headless run stops at the entry host: no other hosts resolved, no pollers, | ||
| 7214 | no sidecar. This was found the hard way — removing the old `hydrated` flag | ||
| 7215 | made every wall save and restore the layout sidecar, piped ones included, a | ||
| 7216 | restored focus CLAIMED, the claim forced a redundant resize, and | ||
| 7217 | `01_boot`'s "quic delta resume" leg started wanting three snapshots where it | ||
| 7218 | wants one. Bisected to the commit, not guessed. A wall of MANY does need a | ||
| 7219 | terminal and says `mux: wall needs a terminal`. | ||
| 7220 | |||
| 7221 | **The sidecar is saved by every wall left on a terminal, including `mux | ||
| 7222 | TARGET`.** Under the old model `mux TARGET` was an argv wall and argv walls | ||
| 7223 | were views, so they never wrote. Under this one `mux TARGET` IS the wall, | ||
| 7224 | zoomed on one tile, and its layout is as much the user's as any other's. | ||
| 7225 | The sidecar stays derived convenience: restore is verbatim, healing is per | ||
| 7226 | leaf against the hosts' live lists, and every failure degrades silently to | ||
| 7227 | the default cut. Restore moved to "the first list arrives" rather than | ||
| 7228 | "the wall opens", because at open time the wall has one tile and the tree | ||
| 7229 | it is being asked to restore describes several. | ||
| 7230 | |||
| 7231 | **Births from one list keep the list's order.** Each birth anchored beside | ||
| 7232 | the FOCUS, so a wall entered on `a` that learns `{b, c}` in one answer laid | ||
| 7233 | them out `a, c, b`. Each birth now anchors beside the previously born tile | ||
| 7234 | of that same apply, the first beside the focus. A wall's reading order is | ||
| 7235 | the daemon's order, which is the only order the user has been given a reason | ||
| 7236 | to expect. | ||
| 7237 | |||
| 7238 | **Measured, and filed: a TTY wall pays one snapshot per tile per DISTINCT | ||
| 7239 | relayout.** A three-tile wall costs six snapshots to open, with or without a | ||
| 7240 | sidecar. The cause is not the sidecar: `relayout` flags `resize_pending` on | ||
| 7241 | every present tile, the pump turns that into a `.resize`, and | ||
| 7242 | `Server.onResize` calls `resyncSnapshot` even when `applySize` returned | ||
| 7243 | false — a tile whose rect did not change still buys a repaint. Two candidate | ||
| 7244 | fixes, both one-line and both outside this spec's contract: flag | ||
| 7245 | `resize_pending` only on a rect change, or skip the resync when `applySize` | ||
| 7246 | says nothing moved. Follow-up, with the sites named here so the next person | ||
| 7247 | does not re-derive them. | ||
| 7248 | |||
| 7249 | **Two defects named rather than papered over, in `e2e_12`'s own comment.** | ||
| 7250 | A heal that collapses a container loses that container's weight and | ||
| 7251 | orientation, so a restored wall can come back correctly cut but wrongly | ||
| 7252 | proportioned; and the focus after a healed restore lands on the newcomer | ||
| 7253 | rather than on the tile the sidecar named. The healing leg pins what it can | ||
| 7254 | actually hold — no vertical rail at 80x24 after restore — instead of a | ||
| 7255 | geometry assertion that would pass with the heal reverted. An assertion | ||
| 7256 | weakened without saying why is worse than an absent one. | ||
| 7257 | |||
| 7258 | **A daemon that predates this change reads as `[unreachable]`, and that is | ||
| 7259 | the cross-version cost.** `sessions_req` existed, but only on the attached | ||
| 7260 | client's path; answering it on a bare observer connection is new here. So a | ||
| 7261 | v0.0.1-15 daemon is up, `mux --sock PATH` attaches to it, and the wall still | ||
| 7262 | paints it a stripe that never heals. Measured side by side: one hosts file | ||
| 7263 | naming a tree-built daemon and an installed v0.0.1-15 daemon, both alive on | ||
| 7264 | their own sockets, both answering `muxd stats` with `sessions=1` — `mux | ||
| 7265 | hosts` printed `1` for the first and `[unreachable]` for the second, after | ||
| 7266 | the full two-second wait. `muxd upgrade` on every box on the wall is part | ||
| 7267 | of taking this version, and the xversion gate pins both halves of the story | ||
| 7268 | (the stripe, and `Ctrl-\ x`'s `[daemon too old to end a session]`). | ||
| 7269 | |||
| 7270 | **What went, and the legs that went with it.** `wall.zig`'s `#SESSION` | ||
| 7271 | split and attach-history semantics; `client.recordOnState`; the CLI's use of | ||
| 7272 | `client.hydratedCreates` and the saved-local-line attach-or-create rule with | ||
| 7273 | its dead-tile marker; `mux wall`, `mux wall add`, `mux wall rm`; | ||
| 7274 | `Entry.hydrate`/`hydrated`/`record0`; the `Ctrl-\ w` fold, now a plain | ||
| 7275 | unzoom because there is nothing left to fold in; the daemon session ring | ||
| 7276 | behind `n`/`p` and `client.ringNeighbour` with it. Their e2e legs were | ||
| 7277 | deleted with the rules they pinned and the suite's count pin moved 83 → 81, | ||
| 7278 | with a new group `09_hosts` carrying the model's own legs. No compatibility | ||
| 7279 | spelling for `mux wall` — the project is too early for one (2026-08-20), and | ||
| 7280 | `mux wall` now parses as a HOST called `wall`, which ssh says out loud. A | ||
| 7281 | machine literally named `wall` or `hosts` needs a `--via` or `quic://` | ||
| 7282 | spelling. | ||
| 7283 | |||
| 7284 | **The hub is phase 2 and was left strictly alone.** `muxweb` still reads and | ||
| 7285 | writes the old `wall` file, still lists sessions rather than hosts, and its | ||
| 7286 | e2e legs pass untouched — which is the evidence that this change is | ||
| 7287 | contained. The cost is a real divergence for a real user: nothing on the CLI | ||
| 7288 | writes that file any more, so a fresh machine's hub wall is empty until | ||
| 7289 | someone names tiles on its command line or in the page. Stated in the README | ||
| 7290 | rather than smoothed over, because a hub that quietly shows a different wall | ||
| 7291 | from the terminal is the two-lists bug again, one layer out. | ||
docs/roadmap.md
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,9 @@ | |||
| 1 | # Roadmap | 1 | # Roadmap |
| 2 | 2 | ||
| 3 | > The wall became a list of DAEMONS on 2026-08-28 (decisions.md, same date): | ||
| 4 | > every wall-file item below describes the old attach-history file and is | ||
| 5 | > closed. The hub's move onto `hosts` is phase 2 and is the live item. | ||
| 6 | |||
| 3 | > This file is the ranked queue as of 2026-08-16 and has not moved since; | 7 | > This file is the ranked queue as of 2026-08-16 and has not moved since; |
| 4 | > the tracker is the live order and `decisions.md` is current. Sections | 8 | > the tracker is the live order and `decisions.md` is current. Sections |
| 5 | > below marked complete are accurate; the ranking is not. | 9 | > below marked complete are accurate; the ranking is not. |
docs/superpowers/specs/2026-08-19-wall-home-screen-design.md
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,10 @@ | |||
| 1 | # The wall is the home screen; zoom is a lens | 1 | # The wall is the home screen; zoom is a lens |
| 2 | 2 | ||
| 3 | > **Superseded by `2026-08-27-wall-of-hosts-design.md`** for the SOURCE of | ||
| 4 | > tiles: the file lists daemons and tiles are their live sessions. The zoom | ||
| 5 | > lens, the layout tree, the sidecar and the "mux IS the wall" entry below | ||
| 6 | > all still hold. | ||
| 7 | |||
| 3 | Date: 2026-08-19. Status: model agreed in discussion; not yet commissioned. | 8 | Date: 2026-08-19. Status: model agreed in discussion; not yet commissioned. |
| 4 | Companion spec: `2026-08-19-muxd-upgrade-design.md` (independent work). | 9 | Companion spec: `2026-08-19-muxd-upgrade-design.md` (independent work). |
| 5 | 10 | ||
src/cli/mux_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -52,9 +52,12 @@ const usage = | |||
| 52 | \\ is refused, because the wall shows every session a daemon has. | 52 | \\ is refused, because the wall shows every session a daemon has. |
| 53 | \\ | 53 | \\ |
| 54 | \\ On the wall, `Ctrl-\ 1-9` focuses a tile and types into it, | 54 | \\ On the wall, `Ctrl-\ 1-9` focuses a tile and types into it, |
| 55 | \\ `Ctrl-\ h/j/k/l` moves between panes, `Ctrl-\ |/-` split right/below, | 55 | \\ `Ctrl-\ n/p` walk the tiles, `Ctrl-\ h/j/k/l` moves between panes, |
| 56 | \\ `Ctrl-\ f` fullscreen, `Ctrl-\ r` resize mode, `Ctrl-\ :` adds a tile | 56 | \\ `Ctrl-\ c` and `Ctrl-\ |/-` create a session on the focused tile's |
| 57 | \\ by spelling (Enter adds, Esc cancels), `Ctrl-\ d` leaves. | 57 | \\ daemon, `Ctrl-\ x` ends the focused session (twice when others are |
| 58 | \\ attached), `Ctrl-\ f` fullscreen, `Ctrl-\ r` resize mode, | ||
| 59 | \\ `Ctrl-\ w` zooms out, `Ctrl-\ :` adds a DAEMON by spelling | ||
| 60 | \\ (Enter adds, Esc cancels), `Ctrl-\ d` leaves. | ||
| 58 | \\ | 61 | \\ |
| 59 | ; | 62 | ; |
| 60 | 63 | ||