646ac14b
docs: the wall is the layout
a73x 2026-09-03 05:20
Commit message
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -130,63 +130,116 @@ own. Test fixtures in `test/`: | |||
| 130 | agent, though anything they run inside a session uses that session's | 130 | agent, though anything they run inside a session uses that session's |
| 131 | socket like any other process. Frames, never transport; both ends cap | 131 | socket like any other process. Frames, never transport; both ends cap |
| 132 | a frame at `agent_data_max`. | 132 | a frame at `agent_data_max`. |
| 133 | - **The wall file lists DAEMONS; tiles are their live sessions.** | 133 | - **The layout is the wall; the poll grades it and adds nothing.** |
| 134 | `$XDG_STATE_HOME/mux/layout` is authored intent: a pane tree whose leaves | ||
| 135 | are `HOST#SESSION`, `HOST` a hosts-file line verbatim. Tiles come from it | ||
| 136 | and from three doors only — the file on start (`wall_layout.seedLayout`), | ||
| 137 | the picker, and a chord split — never from a daemon's `sessions_reply`; | ||
| 138 | `mux HOST`'s entry pane goes through the same seat-then-`wall_layout.persist` | ||
| 139 | path. `wall_host.planHostDiff` is the once-a-second GRADE and the whole of | ||
| 140 | it: it binds a pending pane whose session the list names, marks `gone` one | ||
| 141 | the list does not, and vanishes a live pane whose shell ended after the | ||
| 142 | one-list grace. A name in the list that no pane spells is nobody's | ||
| 143 | business. A pane on a host that answers NOTHING stays, wearing | ||
| 144 | `unreachable`, for as long as the box is dark — an eight-pane setup is not | ||
| 145 | worth one quiet machine — and a `gone` pane (the host said no, not | ||
| 146 | nothing) takes Enter to re-create the session in that pane's own rect or | ||
| 147 | `x` to remove it, every other key eaten. A wall of only gone panes is not | ||
| 148 | empty; a wall with no panes says so and opens the picker. A daemon restart | ||
| 149 | re-creates nothing: its panes wear `gone` until an Enter starts them. | ||
| 150 | `wall_layout.persist` is the ONE save path, gated on `Shared.layout_path`, | ||
| 151 | and every change to the pane set or the tree calls it — birth, split, | ||
| 152 | resize, detach, removal, a poll's vanish of an exited shell, a picker add, | ||
| 153 | birth or forget. Only a wall on a TERMINAL (`Shared.is_tty`) has a path at | ||
| 154 | all: a piped `mux` is a wall of one and neither reads nor writes. | ||
| 155 | `seedLayout` is STRICT — `.refused` with the offending line for a host the | ||
| 156 | hosts file lacks, a leaf without `#SESSION` or with a bad name, a repeated | ||
| 157 | leaf (a second leaf spelling the entry included), more leaves than | ||
| 158 | `layout.max_leaves`, or text `layout.parseReporting` gives up on — and the | ||
| 159 | caller prints `mux: layout ignored (PATH): LINE` and starts as if the file | ||
| 160 | were missing, because seating half a wall is how a user loses one. | ||
| 161 | Whitespace-only is `.none`, not a refusal. A missing file is one local | ||
| 162 | pane on session `0` when there is an entry and an empty wall when there is | ||
| 163 | not; a hosts file with lines and no layout reconstructs NOTHING. A run | ||
| 164 | whose seed was trimmed to fit the terminal, or that dropped a leaf naming | ||
| 165 | this shell's own session, sets `layout_path` null for the WHOLE run and | ||
| 166 | says which on the notice line (`[layout not saved: ...]`) — it never | ||
| 167 | rewrites the file, and a save that fails for any other reason is a notice | ||
| 168 | too, never a stderr print into somebody's pane. `focus N` is the one line | ||
| 169 | in the file that is not authored and may go stale between tree changes. | ||
| 170 | Both files are strict now: the 2026-08 asymmetry (strict hosts, lenient | ||
| 171 | sidecar) is reversed, because the layout is no longer derived. | ||
| 172 | - **The hosts file lists DAEMONS and nothing else.** | ||
| 134 | `$XDG_STATE_HOME/mux/hosts`, one spelling per line (`--sock PATH` | | 173 | `$XDG_STATE_HOME/mux/hosts`, one spelling per line (`--sock PATH` | |
| 135 | `HOST` | `quic://HOST[:PORT]`); `#SESSION` is refused, because a host | 174 | `HOST` | `quic://HOST[:PORT]`); `#SESSION` is refused, because a host |
| 136 | line must name nothing that could be resurrected. `hosts.zig` owns it. | 175 | line must name nothing that could be resurrected — naming a session is |
| 137 | Tiles come from each host's own `sessions_req` on a fresh side | 176 | the layout's job. `hosts.zig` owns it. `--via` and `mux a` record no host. |
| 138 | connection, polled once a second — never from the file — so a birth by | ||
| 139 | anyone appears and an exit disappears, and `n`/`p`/digits walk the one | ||
| 140 | list. A host with no live session contributes NOTHING — the wall shows | ||
| 141 | sessions and nothing else, and a wall with none says so; tiles | ||
| 142 | ride out a blip rather than vanishing. Only a host's OWN list may take a | ||
| 143 | pane: a saved pane on a host that answers nothing stays, wearing | ||
| 144 | `unreachable`, for as long as the box is dark — an eight-pane setup is | ||
| 145 | not worth one quiet machine. A saved pane whose REACHABLE host answers | ||
| 146 | without its session stays too, wearing `gone` — the host said no, not | ||
| 147 | nothing — until Enter re-creates the session in that pane's own rect or | ||
| 148 | `x` dismisses it; every other key at a gone pane is eaten. A wall of | ||
| 149 | only gone panes is not empty. `--via` and `mux a` record no host. | ||
| 150 | Nothing re-creates a session: a daemon restart heals to what that daemon | ||
| 151 | now has, which for a fresh daemon is session `0`. | ||
| 152 | `client.hosts.load` is strict — a bad line refuses `mux` with rc 2 and prints | 177 | `client.hosts.load` is strict — a bad line refuses `mux` with rc 2 and prints |
| 153 | it — while `mux hosts` lists and `mux hosts rm` edits VERBATIM, so the | 178 | it — while `mux hosts` lists and `mux hosts rm` edits VERBATIM, so the |
| 154 | repair tool can read what the loader will not. The BROWSER hub is the | 179 | repair tool can read what the loader will not. The BROWSER hub is the |
| 155 | same wall: `mux web [HOST ...]` records its argv into the file and then | 180 | same wall: `mux web [HOST ...]` records its argv into the hosts file and |
| 156 | serves the file, one `client.SessionPoll` per daemon, `/tiles` in host | 181 | then serves the LAYOUT — `webhub.readLeaves`, which refuses a bad file |
| 157 | order then daemon order. Ids are birth order and are never reused. The | 182 | with `mux web: layout ignored (PATH): LINE` and serves an empty wall — |
| 158 | page authors nothing — its `+` births on that tile's daemon, and POST | 183 | one `client.SessionPoll` per daemon, `/tiles` in tree order carrying |
| 159 | /tiles, PUT and DELETE answer 405. The old `wall` file is retired. | 184 | `id`, `label`, `session` and `state`. Ids are birth order and are never |
| 160 | - **Hosts live in the picker, not on the wall.** `Ctrl-\ s` is a MODE of | 185 | reused. The page authors ONE thing: its `+` (`POST /tiles/<id>`) checks |
| 161 | `interact.PrefixFilter` (`picking`), like the spelling editor it now | 186 | the file for room and for a duplicate under the hub mutex BEFORE it dials |
| 187 | (`webhub.roomForLeaf`), births, then `webhub.appendLeaf` writes the leaf | ||
| 188 | beside that pane — 409 `duplicate` for a second `+` inside one poll | ||
| 189 | interval, 502 for `WallFull` or `BadLayout`. Dialling first would leave a | ||
| 190 | live session behind a 502 that no wall names. POST /tiles, PUT and DELETE | ||
| 191 | answer 405. Two writers in one instant lose one update, which the hosts | ||
| 192 | file has always accepted. The old `wall` file is retired. | ||
| 193 | - **Hosts and sessions live in the picker.** `Ctrl-\ s` is a MODE of | ||
| 194 | `interact.PrefixFilter` (`picking`), like the spelling editor it | ||
| 162 | contains: every byte is the popup's, so a key can never reach a session, | 195 | contains: every byte is the popup's, so a key can never reach a session, |
| 163 | and the table is unit-testable without a terminal. Rows are the hosts | 196 | and both tables are unit-testable without a terminal. It has two levels |
| 164 | file's daemons in file order with the POLLER's last answer beside each | 197 | (`PrefixFilter.pick_level`). HOSTS are the hosts file's daemons in file |
| 165 | (`N sessions` / `no sessions` / `unreachable` / `connecting`); Enter | 198 | order with the POLLER's last answer beside each (`N sessions` / `no |
| 166 | births on the selected host — a tile that CREATES at `client.nextFreeName` | 199 | sessions` / `unreachable` / `connecting`); Enter descends into that |
| 167 | off that host's own list, so a host with no tile is still one you can | 200 | daemon's sessions, `x` forgets the host (the file line, its poller, its |
| 168 | start work on — `x` forgets a host (the file line, its poller, its tiles; | 201 | panes; the sessions keep running), `a` is the old `:` spelling editor. |
| 169 | the sessions keep running), `a` is the old `:` editor. Tiles do not paint | 202 | SESSIONS (`wall_picker.sessionRows`) are one row per name the last answer |
| 170 | while it is open (`Shared.picker_open`); the close bumps `repaint_gen`. | 203 | carried, reading `on this wall` when the layout already has it and |
| 171 | An empty wall opens it ONCE by itself, so an Esc there leaves the | 204 | `K client(s)` from `proto.parseSessionsHolds` — K is EVERY holder, this |
| 172 | one-line text; and on a TERMINAL the last tile's exit leaves that empty | 205 | wall's own pane included, so it is a number to READ and never a verdict; |
| 173 | wall rather than ending mux, as does a picker birth the daemon REFUSES | 206 | an old daemon sends none and the row shows no count. There Enter adds the |
| 174 | (`Tile.keeps_wall`, which `born_from` cannot say — the entry tile carries | 207 | session as a pane and zooms to it (`wall_picker.pickAdd`, which JOINS and |
| 175 | the same null) — a piped `mux` still exits with the shell's code, because | 208 | never creates; a session already on the wall is zoomed to and nothing |
| 176 | scripts read it. | 209 | else), `x` ends it, and Esc backs out one level. `c` births at either |
| 177 | - **`Ctrl-\ x` ends a session; the daemon owns the two-step.** `end_req` | 210 | level — `wall_picker.pickBirth` CREATES at `client.nextFreeName` off that |
| 178 | `0x11` / `end_reply` `0x94`, observer verbs. The daemon refuses a first | 211 | host's own list, so a host with no pane is still one you can start work |
| 179 | `x` on a session other clients hold and answers the count; the client | 212 | on. Tiles do not paint while it is open (`Shared.picker_open`); the close |
| 180 | arms 3 s for the forcing second press. An accepted end is BOUNDED — | 213 | bumps `repaint_gen`. An empty wall opens it ONCE by itself, so an Esc |
| 181 | SIGKILL past `Pty.term_grace_ms` — so no shell can refuse to die, and | 214 | there leaves the one-line text; and on a TERMINAL the last tile's exit |
| 182 | `mux d upgrade` is refused while any session is ending rather than carry | 215 | leaves that empty wall rather than ending mux, as does a picker birth the |
| 183 | a masterless pty across the exec. A daemon with no arm answers nothing | 216 | daemon REFUSES (`Tile.keeps_wall`, which `born_from` cannot say — the |
| 184 | and the client banners `[daemon too old to end a session]`. The tile | 217 | entry tile carries the same null) — a piped `mux` still exits with the |
| 185 | leaves on the next list, not on the keypress — EXCEPT a tile that has | 218 | shell's code, because scripts read it. |
| 186 | never come up, a saved pane on a dark host included: `x` there closes the | 219 | - **`Ctrl-\ x` removes a pane and ends nothing; ending is the picker's, and |
| 187 | tile locally, because a pump parked in `dial` reads no ask, a pending pane | 220 | the daemon owns its two-step.** `wallview.removePane` sets the notice |
| 188 | has no pump at all, and birthing onto an `unreachable` row is a designed | 221 | `[pane removed - the session is still on its daemon]` BEFORE the vanish |
| 189 | path. | 222 | (the vanish hands the focus on, and the incoming pump's claim is what |
| 223 | paints the sentence), tells the pump to detach — it writes `.detach` on | ||
| 224 | its way out and the transport close behind it is the fallback — vanishes | ||
| 225 | the tile, relayouts and persists. Every pane removes the same way, | ||
| 226 | including one that never came up. ENDING is `wall_picker.pickEnd`: | ||
| 227 | `end_req` `0x11` / `end_reply` `0x94`, observer verbs, over | ||
| 228 | `client.endSession` on a SIDE connection, because the session may have no | ||
| 229 | pane here to ask through. That connection dials `HostSpec.poll_target` — | ||
| 230 | the poller's batch recipe, never the interactive one, since an end must | ||
| 231 | not start a daemon and must not reach for a terminal. The daemon refuses | ||
| 232 | a first `x` on a session other clients hold and answers the count; | ||
| 233 | `Shared.pick_end` arms 3 s per host AND name for the forcing second | ||
| 234 | press, and every OTHER refusal arms nothing, because only "others | ||
| 235 | attached" is a question a second press answers. An accepted end is | ||
| 236 | BOUNDED — SIGKILL past `Pty.term_grace_ms` — so no shell can refuse to | ||
| 237 | die, and `mux d upgrade` is refused while any session is ending rather | ||
| 238 | than carry a masterless pty across the exec. A daemon that sends no holds | ||
| 239 | line is refused up front with `[daemon too old to end a session]`: it | ||
| 240 | answers an unknown frame with silence, so the press would spend the whole | ||
| 241 | reply budget with the wall frozen. An ended session's pane leaves on the | ||
| 242 | next list, not on the keypress. | ||
| 190 | - **A QUIC client says goodbye.** Every QUIC connection takes one of the | 243 | - **A QUIC client says goodbye.** Every QUIC connection takes one of the |
| 191 | daemon's eight client slots at the handshake, attached or not, and the | 244 | daemon's eight client slots at the handshake, attached or not, and the |
| 192 | wall polls each QUIC host once a second on a connection of its own. | 245 | wall polls each QUIC host once a second on a connection of its own. |
| @@ -272,15 +325,6 @@ own. Test fixtures in `test/`: | |||
| 272 | ssh's own tty prompts, polls keep `BatchMode`. A decline SIGTERMs that ssh | 325 | ssh's own tty prompts, polls keep `BatchMode`. A decline SIGTERMs that ssh |
| 273 | (a refused askpass is an empty password to OpenSSH, asked three times over) | 326 | (a refused askpass is an empty password to OpenSSH, asked three times over) |
| 274 | and ends the tile with `[prompt declined]`. | 327 | and ends the tile with `[prompt declined]`. |
| 275 | - **The layout sidecar is derived convenience, not authored intent.** | ||
| 276 | `$XDG_STATE_HOME/mux/layout` stores the pane tree on last detach, from | ||
| 277 | every wall left on a TERMINAL (`shared.is_tty`) — a piped `mux` is a wall | ||
| 278 | of one and neither saves nor restores. Restore is verbatim; the aspect | ||
| 279 | rule applies only when no saved tree exists. Healing is per-leaf against | ||
| 280 | the hosts' live lists. `client.hosts.load` stays strict (a host line is authored | ||
| 281 | intent) while the sidecar is lenient — every failure degrades silently to | ||
| 282 | the default cut. The asymmetry is deliberate: a bad host line is a user's | ||
| 283 | mistake to fix; a stale layout is a convenience to forget. | ||
| 284 | - **Detach is a goodbye, not a tear.** A pump that has sent its detach frame | 328 | - **Detach is a goodbye, not a tear.** A pump that has sent its detach frame |
| 285 | never redials — redial checks `detach_ack` — so the daemon's close after | 329 | never redials — redial checks `detach_ack` — so the daemon's close after |
| 286 | a detach is final, not a network event to recover from. | 330 | a detach is final, not a network event to recover from. |
README.md
| Old | New | ||
|---|---|---|---|
| @@ -42,27 +42,35 @@ mux d start & # the daemon: hosts your shell and the authoritative screen | |||
| 42 | mux # attach | 42 | mux # attach |
| 43 | ``` | 43 | ``` |
| 44 | 44 | ||
| 45 | **`mux` IS the wall, and the wall is a list of daemons.** The file | 45 | **`mux` IS the wall, and the wall is your layout.** The file |
| 46 | `~/.local/state/mux/hosts` names machines — your own and every box you have | 46 | `~/.local/state/mux/layout` names the panes you have opened — each one a |
| 47 | attached to — one spelling per line, and the tiles are the sessions those | 47 | `HOST#SESSION` leaf, in the tree you arranged them in — and every `mux` on |
| 48 | daemons have live *right now*. There is one list and one navigation axis: | 48 | this machine opens exactly that. `~/.local/state/mux/hosts` is the other |
| 49 | `Ctrl-\` `1`-`9` focuses a tile, `Ctrl-\` `n`/`p` walk them all, `Ctrl-\` | 49 | file: the daemons this machine may browse, your own and every box you have |
| 50 | `d` leaves. Every tile's replica stays hot whether you are looking at it or | 50 | attached to, one spelling per line. There is one list and one navigation |
| 51 | not, so moving focus is a repaint and not a re-attach — no child process, | 51 | axis: `Ctrl-\` `1`-`9` focuses a pane, `Ctrl-\` `n`/`p` walk them all, |
| 52 | no second screen. | 52 | `Ctrl-\` `d` leaves. Every pane's replica stays hot whether you are looking |
| 53 | 53 | at it or not, so moving focus is a repaint and not a re-attach — no child | |
| 54 | The list is the daemons' answer, not your history: `mux` asks every listed | 54 | process, no second screen. |
| 55 | daemon once a second what it is hosting, so a session a split, a `mux a` or | 55 | |
| 56 | somebody else's client births turns up as a tile by itself, and a session | 56 | Sessions live on daemons, and a daemon may have more of them than your wall |
| 57 | that ends takes its tile with it. Nothing on the wall can outlive the shell | 57 | shows: a session born by `mux a`, by a browser, or by another machine is on |
| 58 | behind it, and nothing in the file can bring one back. | 58 | no wall until you put it there. `mux` still asks every listed daemon once a |
| 59 | second what it is hosting, but that answer only GRADES the panes you | ||
| 60 | authored — it wakes one whose session is there, marks `gone` one whose | ||
| 61 | session the daemon no longer lists, and takes away one whose shell exited. | ||
| 62 | It never adds a pane. `Ctrl-\` `s` lists your daemons, Enter on one lists | ||
| 63 | its sessions with who else holds each, Enter on a session adds it as a | ||
| 64 | pane, `c` starts a new one there, `x` ends one (asking first when someone | ||
| 65 | else holds it), Esc backs out a level. On the wall, `Ctrl-\` `x` takes the | ||
| 66 | focused pane off this wall and ends nothing. | ||
| 59 | 67 | ||
| 60 | `mux` on a machine that has never run it records your own daemon (the line | 68 | `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 | 69 | `--sock <default>`, starting it if nothing is listening) and opens one pane |
| 62 | its session `0` — a first run is still just a shell. After that, bare `mux` | 70 | on its session `0` — a first run is still just a shell. After that, bare |
| 63 | opens on the wall itself, and `mux HOST` opens zoomed on HOST's session `0`, | 71 | `mux` opens the layout you left, and `mux HOST` opens zoomed on HOST's |
| 64 | adding HOST to the file if it is new, with the rest of the list on the wall | 72 | session `0`, adding HOST to the hosts file and the pane to your layout if |
| 65 | behind it. Every host dials at once, in the background, so a slow ssh | 73 | they were not there. 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 | 74 | 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 | 75 | 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 — except that on a | 76 | a wall of one tile is what a plain client always was — except that on a |
| @@ -71,12 +79,15 @@ view keeps the session's name and state in sight. Only a piped `mux` is | |||
| 71 | bare bytes. | 79 | bare bytes. |
| 72 | 80 | ||
| 73 | A split births a session: not a window onto an existing one, but its own | 81 | A split births a session: not a window onto an existing one, but its own |
| 74 | shell on the focused tile's daemon, laid out beside or below the focus. The | 82 | shell on the focused pane's daemon, laid out beside or below the focus. A |
| 75 | wall places tiles side by side when the terminal is at least twice as wide | 83 | wall with no layout to open places its panes side by side when the terminal |
| 76 | as it is tall, and stacks them otherwise. Fullscreen is a layout where the | 84 | is at least twice as wide as it is tall, and stacks them otherwise — after |
| 77 | focused pane takes the whole terminal; resize mode trades cells between the | 85 | that the tree is yours. Fullscreen is a layout where the focused pane takes |
| 78 | focus and its neighbors, one per keystroke. The layout survives detach and | 86 | the whole terminal; resize mode trades cells between the focus and its |
| 79 | comes back the next time you open a wall on a terminal. | 87 | neighbors, one per keystroke. Every change to the panes or the tree — a |
| 88 | birth, a split, a resize, an `x`, a detach — writes the layout file, so a | ||
| 89 | second terminal on this machine opens what you last arranged and a wall | ||
| 90 | that is killed loses nothing it had committed. | ||
| 80 | 91 | ||
| 81 | The focused tile has the terminal, and only the prefix is held back: | 92 | The focused tile has the terminal, and only the prefix is held back: |
| 82 | 93 | ||
| @@ -84,48 +95,58 @@ The focused tile has the terminal, and only the prefix is held back: | |||
| 84 | |---|---| | 95 | |---|---| |
| 85 | | `Ctrl-\` | command prefix — press it, then a command key | | 96 | | `Ctrl-\` | command prefix — press it, then a command key | |
| 86 | | `Ctrl-\` `d` (or `Ctrl-\` `Ctrl-\`) | disconnect: leave every tile and leave mux, interrupt nobody (every session keeps running) | | 97 | | `Ctrl-\` `d` (or `Ctrl-\` `Ctrl-\`) | disconnect: leave every tile and leave mux, interrupt nobody (every session keeps running) | |
| 87 | | `Ctrl-\` `c` | create a session on the focused tile's daemon, add a tile, focus it | | 98 | | `Ctrl-\` `c` | create a session on the focused pane's daemon, add a pane for it, focus it | |
| 88 | | `Ctrl-\` `n` / `Ctrl-\` `p` | focus the next / previous tile on the wall, wrapping | | 99 | | `Ctrl-\` `n` / `Ctrl-\` `p` | focus the next / previous pane on the wall, wrapping | |
| 89 | | `Ctrl-\` `h` `j` `k` `l` | focus the pane left / down / up / right | | 100 | | `Ctrl-\` `h` `j` `k` `l` | focus the pane left / down / up / right | |
| 90 | | `Ctrl-\` `\|` (or `\`) | split right: new session in a pane to the right | | 101 | | `Ctrl-\` `\|` (or `\`) | split right: new session in a pane to the right | |
| 91 | | `Ctrl-\` `-` | split below: new session in a pane below | | 102 | | `Ctrl-\` `-` | split below: new session in a pane below | |
| 92 | | `Ctrl-\` `f` | fullscreen the focused pane (toggle) | | 103 | | `Ctrl-\` `f` | fullscreen the focused pane (toggle) | |
| 93 | | `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 | | 104 | | `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 | |
| 94 | | `Ctrl-\` `1`-`9` | focus tile N — the digit on that tile's label bar. A new tile takes the lowest free digit, so end 2 of 1 2 3 and the next session is 2 again | | 105 | | `Ctrl-\` `1`-`9` | focus pane N — the digit on that pane's label bar. A new pane takes the lowest free digit, so remove 2 of 1 2 3 and the next pane is 2 again | |
| 95 | | `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 | | 106 | | `Ctrl-\` `x` | take the focused pane off this wall — the layout is written without it and the session keeps running on its daemon for whoever else holds it. Ending a session is the picker's `x` | |
| 96 | | `Ctrl-\` `w` | zoom out to the wall | | 107 | | `Ctrl-\` `w` | zoom out to the wall | |
| 97 | | `Ctrl-\` `s` | the host picker: a popup listing every daemon on the wall with what its last poll said — `N sessions`, `no sessions`, `unreachable`, `connecting`. `j`/`k` or the arrows move, `1`-`9` pick a row, Enter or `c` starts a session on the selected machine and closes, `x` forgets it (the file line goes, the sessions keep running), `a` adds one by spelling — `HOST`, `quic://HOST[:PORT]` or `--sock PATH`, Enter adds and Esc returns to the rows. Esc or `s` closes — and an arrow key split across two reads reads as a bare Escape, so it closes too. The spelling 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. While the picker is open every key is its own: nothing reaches a session | | 108 | | `Ctrl-\` `s` | the picker, and the only door a session comes onto the wall through. Level one is every daemon in your hosts file with what its last poll said — `N sessions`, `no sessions`, `unreachable`, `connecting`. `j`/`k` or the arrows move, `1`-`9` pick a row, Enter opens that daemon's SESSIONS, `c` starts a new session there and adds it, `x` forgets the host (the file line, its poller and its panes go; the sessions keep running), `a` adds a host by spelling — `HOST`, `quic://HOST[:PORT]` or `--sock PATH`, Enter adds and Esc returns to the rows. Level two is one row per session that daemon has, reading `on this wall` when it is already a pane and `K client(s)` for everyone holding it, this wall's own pane counted: Enter adds it as a pane (or just zooms to it), `c` still births, `x` ends the session with the daemon's two-step, Esc backs out to the hosts. Esc or `s` at level one closes — and an arrow key split across two reads reads as a bare Escape, so it closes too. The spelling 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. While the picker is open every key is its own: nothing reaches a session | |
| 98 | | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) | | 109 | | `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) | |
| 99 | | mouse wheel | scrollback, three rows a notch (arrow keys to a full-screen app) | | 110 | | mouse wheel | scrollback, three rows a notch (arrow keys to a full-screen app) | |
| 100 | | drag with the left button | select what it crosses, copy on release | | 111 | | drag with the left button | select what it crosses, copy on release | |
| 101 | | any key not behind `Ctrl-\` | goes to the focused tile | | 112 | | any key not behind `Ctrl-\` | goes to the focused tile | |
| 102 | 113 | ||
| 103 | `n`, `p` and the digits walk the same list, because there is only one: | 114 | `n`, `p` and the digits walk the same list, because there is only one: your |
| 104 | the wall's tiles, across every daemon on it, in host order and then in each | 115 | layout's panes, in tree order, whatever daemons they came from. `c`, `|` |
| 105 | daemon's own slot order. `c`, `|` and `-` ask the FOCUSED tile's daemon for | 116 | and `-` ask the FOCUSED pane's daemon for a new session, so a split on a |
| 106 | a new session, so a split on a remote tile births a shell on that machine | 117 | remote pane births a shell on that machine and not on yours. |
| 107 | and not on yours. | 118 | |
| 108 | 119 | Three doors put a pane on the wall and nothing else does: the layout file | |
| 109 | `x` is the other half of `c`, and it is not a wall edit: it ends the | 120 | on start, the picker, and a chord split — plus `mux HOST` on the command |
| 110 | session. The tile leaves when the daemon's list no longer has it, not when | 121 | line, which is the entry pane and takes the same path. A session born |
| 111 | the key is pressed, so what you see is always what the daemons have. | 122 | anywhere else is real and running and on no wall of yours until you add it. |
| 112 | Because ending interrupts anyone else watching, the daemon refuses a first | 123 | |
| 113 | `x` on a session with other clients attached and says how many — the rail | 124 | `x` on the wall is a wall edit and only that: the pane goes, the layout is |
| 114 | reads `[1 other attached - x again to end]` — and a second `x` within three | 125 | written without it, the client says goodbye to that session, and the shell |
| 115 | seconds forces it. A shell that ignores the hangup is killed half a second | 126 | behind it keeps running for whoever else holds it — the rail reads |
| 116 | later; there is no session that cannot be ended. Dropping a whole MACHINE | 127 | `[pane removed - the session is still on its daemon]`. ENDING a session is |
| 117 | is not a chord: it is rarer than ending a shell and must not sit one key | 128 | the picker's `x` at the session level, which is where you can see who else |
| 118 | from `n`. From inside the wall it is the picker's `x` (`Ctrl-\` `s`, then | 129 | holds it first. Because ending interrupts anyone else watching, the daemon |
| 119 | `x`); from the command line it is `mux hosts rm`. | 130 | refuses a first `x` on a session with other clients attached and says how |
| 120 | 131 | many — `[work: 1 other attached - x again to end]` — and a second `x` on | |
| 121 | `x` ends a session, never a box. A daemon whose last session ends stays up | 132 | the same row within three seconds forces it. A shell that ignores the |
| 122 | and empty, and the next `mux HOST` is born into it — so emptying a machine | 133 | hangup is killed half a second later; there is no session that cannot be |
| 123 | costs you nothing but the shell you meant to end. Ending the daemon itself | 134 | ended. That count on the row is EVERY holder including this wall's own |
| 124 | is `mux d stop`. | 135 | pane, so ending a session you alone have open is two presses. |
| 125 | 136 | ||
| 126 | A session whose shell exits takes its tile off the wall. On a terminal the | 137 | Dropping a whole MACHINE is not a chord either: from inside the wall it is |
| 127 | LAST tile's exit leaves you on the empty wall with the picker over it, not | 138 | the picker's `x` at the host level (`Ctrl-\` `s`, then `x`), and from the |
| 128 | back in your shell: `Ctrl-\` `d` is how you leave. A piped `mux` has no wall | 139 | command line `mux hosts rm`. |
| 140 | |||
| 141 | The picker's `x` ends a session, never a box. A daemon whose last session | ||
| 142 | ends stays up and empty, and the next `mux HOST` is born into it — so | ||
| 143 | emptying a machine costs you nothing but the shell you meant to end. Ending | ||
| 144 | the daemon itself is `mux d stop`. | ||
| 145 | |||
| 146 | A session whose shell exits takes its pane off the wall — you ended it, so | ||
| 147 | the layout is written without it. On a terminal the LAST pane's exit leaves | ||
| 148 | you on the empty wall with the picker over it, not back in your shell: | ||
| 149 | `Ctrl-\` `d` is how you leave. A piped `mux` has no wall | ||
| 129 | to leave you on, so it exits with that shell's code — `mux` stays something | 150 | to leave you on, so it exits with that shell's code — `mux` stays something |
| 130 | you can put in a script. | 151 | you can put in a script. |
| 131 | 152 | ||
| @@ -157,13 +178,14 @@ does worse. | |||
| 157 | One daemon hosts up to thirty-two named sessions; the first is called `0`. | 178 | One daemon hosts up to thirty-two named sessions; the first is called `0`. |
| 158 | `Ctrl-\` `c` creates one named after the lowest free number and focuses it, | 179 | `Ctrl-\` `c` creates one named after the lowest free number and focuses it, |
| 159 | and `mux --session NAME` attaches to any of them by name (creating it if | 180 | and `mux --session NAME` attaches to any of them by name (creating it if |
| 160 | there is room). One wall holds up to thirty-two tiles however many daemons | 181 | there is room). One wall holds up to thirty-two panes however many daemons |
| 161 | they came from; past that the rail says `[+N not shown]` and the digits do | 182 | they came from, and a layout file with more leaves than that is refused |
| 162 | not reach them. Not worth designing past until someone has thirty-three | 183 | rather than half-seated. Not worth designing past until someone has |
| 163 | shells. | 184 | thirty-three shells. |
| 164 | 185 | ||
| 165 | To see which sessions are alive without attaching to one: `mux hosts` prints | 186 | To see which sessions are alive without attaching to one: `mux hosts` prints |
| 166 | every daemon on the wall with its live session count, and `mux d stats` names | 187 | every daemon in your hosts file with its live session count, `Ctrl-\` `s` |
| 188 | then Enter lists one daemon's sessions by name, and `mux d stats` names | ||
| 167 | every session of one daemon with the number of clients watching it. `stats` | 189 | every session of one daemon with the number of clients watching it. `stats` |
| 168 | is the question to ask when `mux` will not let you in — thirty-two sessions | 190 | is the question to ask when `mux` will not let you in — thirty-two sessions |
| 169 | is the table, and the thirty-third name has nowhere to go. | 191 | is the table, and the thirty-third name has nowhere to go. |
| @@ -286,11 +308,11 @@ being slow is not what separates them. | |||
| 286 | (or `mux -A quic://HOST`, `mux -A --sock PATH`, or a bare `mux -A` on your | 308 | (or `mux -A quic://HOST`, `mux -A --sock PATH`, or a bare `mux -A` on your |
| 287 | own daemon) forwards on that tile, and a sibling grown from it by chord | 309 | own daemon) forwards on that tile, and a sibling grown from it by chord |
| 288 | (`Ctrl-\ c`, `|`, `-`) inherits it — same daemon, same host, nothing newly | 310 | (`Ctrl-\ c`, `|`, `-`) inherits it — same daemon, same host, nothing newly |
| 289 | exposed. Every OTHER tile on the wall arrives from a daemon's session list | 311 | exposed. Every OTHER pane on the wall arrives from your layout file or from |
| 290 | rather than from your command line, and those never offer an agent: the | 312 | the picker rather than from your command line, and those never offer an |
| 291 | wall is a list of machines, and a machine you have never typed the flag for | 313 | agent: a machine you have never typed the flag for must not get your keys |
| 292 | must not get your keys because it happens to be on it. `mux a` never | 314 | because a pane of yours happens to sit on it. `mux a` never forwards, under |
| 293 | forwards, under any flag. | 315 | any flag. |
| 294 | 316 | ||
| 295 | ## Quick start, remote over QUIC | 317 | ## Quick start, remote over QUIC |
| 296 | 318 | ||
| @@ -320,24 +342,37 @@ there is no unauthenticated mode to fall back to. | |||
| 320 | ## Quick start, a wall of sessions in a browser | 342 | ## Quick start, a wall of sessions in a browser |
| 321 | 343 | ||
| 322 | ```sh | 344 | ```sh |
| 323 | mux web # serves your hosts file on 127.0.0.1:7681 | 345 | mux web # serves your layout on 127.0.0.1:7681 |
| 324 | mux web HOST quic://OTHER --sock /tmp/s.sock # ...adds those daemons first, then serves | 346 | mux web HOST quic://OTHER --sock /tmp/s.sock # ...adds those daemons first, then serves |
| 325 | ``` | 347 | ``` |
| 326 | 348 | ||
| 327 | **The wall is your hosts file, in the browser exactly as in the terminal.** | 349 | **The wall is your layout, in the browser exactly as in the terminal.** |
| 328 | Each line of `$XDG_STATE_HOME/mux/hosts` is a DAEMON — `HOST`, | 350 | The hub reads the same `$XDG_STATE_HOME/mux/layout` a terminal wall does |
| 329 | `quic://HOST[:PORT]` or `--sock PATH` — and the tiles are whatever those | 351 | and serves its panes in tree order, each labelled with its daemon — `HOST`, |
| 330 | daemons have live, polled once a second. A session started anywhere, by | 352 | `quic://HOST[:PORT]` or `--sock PATH`, a line of `$XDG_STATE_HOME/mux/hosts` |
| 331 | anyone, appears; one that ends disappears. A `HOST` on the command line is | 353 | verbatim — and its session. A session started anywhere else appears on no |
| 332 | added to the file (the same thing `mux HOST` does) and then the file is the | 354 | tile; a pane whose daemon stops listing its session reads `gone` and stays. |
| 333 | wall; `#SESSION` is refused, because a host line may not name something that | 355 | A `HOST` on the command line is added to the hosts file (the same thing |
| 334 | could be resurrected. | 356 | `mux HOST` does) and then the layout is the wall; `#SESSION` on a HOST |
| 357 | argument is refused, because a *host* line may not name something that | ||
| 358 | could be resurrected — sessions are named by the layout file instead. A | ||
| 359 | layout the hub cannot parse is reported as `mux web: layout ignored (PATH): | ||
| 360 | LINE` and it serves an empty wall rather than a guess. | ||
| 335 | 361 | ||
| 336 | Click a tile to focus it and type in it; click the shade to come back. `+` | 362 | Click a tile to focus it and type in it; click the shade to come back. `+` |
| 337 | on a tile starts a new session on that tile's daemon and drops you into it. | 363 | on a tile starts a new session on that tile's daemon, writes the new leaf |
| 338 | That is the whole of what the page can do to the wall: adding and removing | 364 | into the layout beside that pane, and drops you into it — so a birth in the |
| 339 | DAEMONS is `mux hosts add|rm` or the CLI picker's `a`/`x`, and ending a | 365 | browser is on the next terminal wall and the reverse. It checks the file |
| 340 | session is `Ctrl-\ x` in the terminal or `exit` in the shell. | 366 | for room and for a duplicate before it dials, so a full wall or a repeat |
| 367 | never leaves a session nobody asked for behind a 502; a second `+` inside | ||
| 368 | one poll interval is a 409. That is the whole of what the page can do to | ||
| 369 | the wall: adding and removing DAEMONS is `mux hosts add|rm` or the CLI | ||
| 370 | picker's `a`/`x`, removing a pane is `Ctrl-\ x`, and ending a session is | ||
| 371 | the CLI picker's `x` or `exit` in the shell. | ||
| 372 | |||
| 373 | Two writers in the same instant lose one update — the hub and a terminal | ||
| 374 | wall each read, modify and atomically rename the file, exactly as the hosts | ||
| 375 | file has always worked. | ||
| 341 | 376 | ||
| 342 | Localhost only, deliberately: to see it from another machine, forward it — | 377 | Localhost only, deliberately: to see it from another machine, forward it — |
| 343 | `ssh -L 7681:127.0.0.1:7681 HOST`. | 378 | `ssh -L 7681:127.0.0.1:7681 HOST`. |
| @@ -345,18 +380,23 @@ Localhost only, deliberately: to see it from another machine, forward it — | |||
| 345 | An older `~/.local/state/mux/wall` is ignored and can be deleted; the hub | 380 | An older `~/.local/state/mux/wall` is ignored and can be deleted; the hub |
| 346 | kept its own list of sessions there until this version. | 381 | kept its own list of sessions there until this version. |
| 347 | 382 | ||
| 348 | ## The wall is your hosts | 383 | ## Two files: your daemons, and your wall |
| 384 | |||
| 385 | `$XDG_STATE_HOME/mux/hosts` is the daemons this machine may browse. | ||
| 386 | `$XDG_STATE_HOME/mux/layout` is the wall itself — the panes you opened, in | ||
| 387 | the tree you put them in. The first says what you *can* reach; the second | ||
| 388 | says what you *see*. | ||
| 349 | 389 | ||
| 350 | ```sh | 390 | ```sh |
| 351 | mux hosts # the daemons on the wall, one per line, with live session counts | 391 | mux hosts # the daemons you can browse, one per line, with live session counts |
| 352 | mux hosts add HOST # put a daemon on the wall without opening it | 392 | mux hosts add HOST # record a daemon without opening it |
| 353 | mux hosts add --sock /tmp/s.sock | 393 | mux hosts add --sock /tmp/s.sock |
| 354 | mux hosts add quic://box:4433 | 394 | mux hosts add quic://box:4433 |
| 355 | mux hosts rm HOST # take one off (its sessions keep running) | 395 | mux hosts rm HOST # take one off (its sessions keep running) |
| 356 | ``` | 396 | ``` |
| 357 | 397 | ||
| 358 | The file is `~/.local/state/mux/hosts` (`$XDG_STATE_HOME/mux/hosts`), one | 398 | The file is `~/.local/state/mux/hosts` (`$XDG_STATE_HOME/mux/hosts`), one |
| 359 | daemon per line, in wall order: | 399 | daemon per line, in picker order: |
| 360 | 400 | ||
| 361 | ``` | 401 | ``` |
| 362 | --sock /run/user/1000/muxd.sock | 402 | --sock /run/user/1000/muxd.sock |
| @@ -365,17 +405,17 @@ quic://gate.example:4433 | |||
| 365 | ``` | 405 | ``` |
| 366 | 406 | ||
| 367 | A line names a **machine**, never a session: `#NAME` on a spelling is | 407 | A line names a **machine**, never a session: `#NAME` on a spelling is |
| 368 | refused, because the wall shows every session a daemon has and there is | 408 | refused, because naming sessions is the layout file's job and a host line |
| 369 | nothing left for a `#` to pick. That is also what makes the file safe to | 409 | must name nothing that anything could resurrect a shell from. Lines are |
| 370 | keep — there is no line in it that anything could resurrect a shell from. | 410 | deduped byte for byte, so the same box written `box` and `quic://box` is |
| 371 | Lines are deduped byte for byte, so the same box written `box` and | 411 | two hosts, deliberately: they are two different ways in. |
| 372 | `quic://box` is two hosts, deliberately: they are two different ways in. | ||
| 373 | 412 | ||
| 374 | Every `mux` that names a target records that target — `mux HOST`, | 413 | Every `mux` that names a target records that target — `mux HOST`, |
| 375 | `mux quic://HOST`, `mux --sock PATH`, and a bare `mux`, which records your | 414 | `mux quic://HOST`, `mux --sock PATH`, and a bare `mux`, which records your |
| 376 | own daemon. `--via CMD` never records one, because an arbitrary command is | 415 | own daemon. `--via CMD` never records one, because an arbitrary command is |
| 377 | not an address and the grammar has no line to write for it; the session is | 416 | not an address and the grammar has no line to write for it; the session is |
| 378 | real, it is just not a machine the wall can name. `mux a` records nothing | 417 | real, it is just not a machine the hosts file can name — which also means a |
| 418 | `--via` session is on no layout and no wall. `mux a` records nothing | ||
| 379 | either (it attaches at 0×0 and claims nothing). Writing the file is best | 419 | either (it attaches at 0×0 and claims nothing). Writing the file is best |
| 380 | effort — if it cannot be written you get one warning line and the attach | 420 | effort — if it cannot be written you get one warning line and the attach |
| 381 | happens anyway. | 421 | happens anyway. |
| @@ -402,51 +442,98 @@ absent sockets). `mux hosts rm` reads the file verbatim too, so a line | |||
| 402 | you hand-edited into something no longer legal can be typed back byte for | 442 | you hand-edited into something no longer legal can be typed back byte for |
| 403 | byte and removed instead of wedging every command that touches the wall. | 443 | byte and removed instead of wedging every command that touches the wall. |
| 404 | 444 | ||
| 405 | A daemon that is not answering contributes **nothing** to the wall: not one | 445 | A daemon that is not answering keeps **every pane you put on it**, wearing |
| 406 | dead tile per session it used to have, and no placeholder of its own either. | 446 | `unreachable`, for as long as the box is dark — an eight-pane setup is not |
| 407 | Its line stays in the file and its poller keeps redialling, forever and on | 447 | worth one quiet machine. Its line stays in the file and its poller keeps |
| 408 | purpose — a wall is a thing you leave up, and a laptop that wakes an hour | 448 | redialling, forever and on purpose; a wall is a thing you leave up, and a |
| 409 | later should find its sessions back on it. The cost is that a daemon that is | 449 | laptop that wakes an hour later should find its panes back on it. The cost |
| 410 | never coming back looks exactly like one whose network is merely down. | 450 | is that a daemon that is never coming back looks exactly like one whose |
| 411 | Nothing times it out; `mux hosts rm SPELLING` is how a line leaves. Your own | 451 | network is merely down. Nothing times it out; `mux hosts rm SPELLING`, or |
| 412 | daemon is the exception that is started rather than waited for: a listed | 452 | the picker's `x` at the host level, is how a line leaves. Your own daemon is |
| 413 | local `--sock <default>` that nothing is serving is started for you, because | 453 | the exception that is started rather than waited for: a listed local |
| 414 | it dies on every reboot while its line lives on. | 454 | `--sock <default>` that nothing is serving is started for you, because it |
| 415 | 455 | dies on every reboot while its line lives on. | |
| 416 | A host that answers with an EMPTY list contributes nothing either, and that | 456 | |
| 417 | is an ordinary state rather than a remote curiosity: a daemon lives until | 457 | A host that is REACHABLE and answers without one of your sessions is a |
| 418 | `mux d stop`, so one whose last session you ended sits there with nothing to | 458 | different sentence: that pane wears `gone`, because the daemon said no |
| 419 | show until someone is born into it again. A wall on which no host has a live | 459 | rather than nothing. Enter re-creates the session in that pane's own rect, |
| 420 | session is empty, and says so on one line rather than showing a blank screen | 460 | `x` takes the pane off the wall, and every other key at it is eaten. A wall |
| 421 | that reads as hung. | 461 | of only gone panes is not an empty wall. |
| 422 | 462 | ||
| 423 | That is what the **host picker** is for. The wall shows live sessions and | 463 | A daemon with no sessions at all is an ordinary state rather than a remote |
| 424 | nothing else, so the machines behind them live in a popup over it: `Ctrl-\` | 464 | curiosity: a daemon lives until `mux d stop`, so one whose last session you |
| 425 | `s` lists every daemon in the hosts file with what its last poll said, and | 465 | ended sits there with nothing to show until someone is born into it again. |
| 426 | Enter starts a session on the one you select — including a machine that | 466 | It contributes no pane, and a host with no pane of yours contributes nothing |
| 427 | currently has none, which is the whole point of `no sessions` being a row | 467 | either — the wall is what you authored, not what the daemons happen to have. |
| 428 | you can act on. `x` there forgets a host (the same edit `mux hosts rm` | 468 | |
| 429 | makes, and it ends nothing), and `a` adds one. An empty wall opens the | 469 | A wall with no panes says so on one line rather than showing a blank screen |
| 430 | picker by itself, because a blank screen is no place to act from; Esc | 470 | that reads as hung, and opens the **picker** over it by itself, because a |
| 431 | leaves the one-line text standing. | 471 | blank screen is no place to act from; Esc leaves the one-line text standing. |
| 432 | 472 | That is the state you get on a machine whose hosts file has lines but whose | |
| 433 | `Ctrl-\` `x` on the last tile of a terminal wall therefore leaves you on | 473 | layout file does not exist yet — nothing reconstructs a wall for you. |
| 434 | that empty wall with the picker open, not back in your shell — `x` ends a | 474 | |
| 435 | session, never mux. A piped `mux` is unchanged: it is a wall of one and it | 475 | `Ctrl-\` `x` on the last pane of a terminal wall therefore leaves you on |
| 436 | still exits with the shell's exit code. | 476 | that empty wall with the picker open, not back in your shell — `x` edits |
| 437 | 477 | the wall, never mux. A piped `mux` is unchanged: it is a wall of one, it | |
| 438 | **A daemon too old to be asked shows nothing too.** Listing a daemon's live | 478 | neither saves nor restores a layout, and it still exits with the shell's |
| 439 | sessions is a new question on the wire, and a daemon from before this change | 479 | exit code. |
| 440 | answers it with the silence it answers every unknown frame with. It is up, | 480 | |
| 441 | `mux --sock PATH` still attaches to it, and none of its sessions reach the | 481 | **A daemon too old to be asked shows nothing.** Listing a daemon's live |
| 442 | wall, so today `mux d upgrade` (or a restart) on every box you put on the | 482 | sessions is a question a daemon from before that change answers with the |
| 443 | wall is what fills it in. | 483 | silence it answers every unknown frame with. It is up, `mux --sock PATH` |
| 444 | 484 | still attaches to it, and the picker cannot show you its sessions to add, | |
| 445 | Restarting a daemon re-creates **nothing**. The old wall would have brought | 485 | so `mux d upgrade` (or a restart) on every box you browse is what fills it |
| 446 | your sessions back from the file; this one cannot, because the file has no | 486 | in. Counting who ELSE holds a session is newer still: a daemon that does |
| 447 | session in it to bring back. A host's tiles leave with it and the wall heals | 487 | not send the count shows none, and its sessions cannot be ended from the |
| 448 | to whatever that daemon has when it returns — which, for a daemon someone | 488 | picker at all (`[daemon too old to end a session]`). |
| 449 | just started, is one session called `0`. | 489 | |
| 490 | Restarting a daemon re-creates **nothing**. Your panes on it stay, wearing | ||
| 491 | `gone` once it answers without their sessions; Enter on one starts that | ||
| 492 | session again, in the pane's own rect. Nothing does that for you, because a | ||
| 493 | shell that came back empty is not the shell you left. | ||
| 494 | |||
| 495 | ### The layout file | ||
| 496 | |||
| 497 | `~/.local/state/mux/layout` (`$XDG_STATE_HOME/mux/layout`) is the wall. | ||
| 498 | Every leaf is `HOST#SESSION`, where `HOST` is a line of the hosts file | ||
| 499 | spelled exactly as it appears there: | ||
| 500 | |||
| 501 | ``` | ||
| 502 | mux-layout 1 | ||
| 503 | beside 0 | ||
| 504 | leaf 40 --sock /run/user/1000/muxd.sock#0 | ||
| 505 | stacked 40 | ||
| 506 | leaf 12 box#work | ||
| 507 | leaf 12 quic://gate.example:4433#2 | ||
| 508 | focus 0 | ||
| 509 | ``` | ||
| 510 | |||
| 511 | A first run on a new machine writes the smallest one there is — a hosts | ||
| 512 | file of `--sock <default>` and a layout of `leaf 0 --sock <default>#0` with | ||
| 513 | `focus 0`. | ||
| 514 | |||
| 515 | It is written by any wall on a terminal, on every change — a birth, a | ||
| 516 | split, a resize, an `x`, a detach — so two terminals on this machine see | ||
| 517 | each other's adds on their next start. `focus N` is the one line in it that | ||
| 518 | is not authored: it records where the focus was, and it can go stale between | ||
| 519 | changes to the tree. | ||
| 520 | |||
| 521 | Reading it is strict, the way the hosts file is strict: a leaf naming a host | ||
| 522 | the hosts file does not have, a leaf with no `#SESSION` or a bad session | ||
| 523 | name, a repeated leaf, more leaves than a wall holds, or text that is not a | ||
| 524 | layout REFUSES the whole file. `mux` says `mux: layout ignored (PATH): LINE` | ||
| 525 | with the line it gave up on and then starts as if the file were missing — it | ||
| 526 | does not seat half a wall, because silently dropping panes you authored is | ||
| 527 | how you lose one. An empty file is not a bad file; it is simply no wall. | ||
| 528 | |||
| 529 | There is one thing a wall will not save: a run whose panes it could not all | ||
| 530 | seat. A terminal too small for the tree, or a layout naming the very session | ||
| 531 | this `mux` is running inside, drops those leaves for the run and says | ||
| 532 | `[layout not saved: terminal too small for 2 of its panes]` (or | ||
| 533 | `[layout not saved: it names this shell's own session]`) on the notice line. | ||
| 534 | That run then writes NOTHING — the file you have is the file you keep, and | ||
| 535 | the next start in a window that fits saves again. A save that fails for any | ||
| 536 | other reason says `[layout not saved: <reason>]` in the same place. | ||
| 450 | 537 | ||
| 451 | `mux hosts add`/`rm` are file operations only: neither dials, and `rm` never | 538 | `mux hosts add`/`rm` are file operations only: neither dials, and `rm` never |
| 452 | touches the session — the shells on that machine go on running and `mux | 539 | touches the session — the shells on that machine go on running and `mux |
| @@ -474,7 +561,7 @@ the `Ctrl-\` prefix is held back. `Ctrl-\` `1`-`9` focuses a tile, `Ctrl-\` | |||
| 474 | `n`/`p` walk them, `Ctrl-\` `c` creates a session on the focused tile's | 561 | `n`/`p` walk them, `Ctrl-\` `c` creates a session on the focused tile's |
| 475 | daemon, `Ctrl-\` `h`/`j`/`k`/`l` move focus between panes, `Ctrl-\` `|`/`-` | 562 | daemon, `Ctrl-\` `h`/`j`/`k`/`l` move focus between panes, `Ctrl-\` `|`/`-` |
| 476 | split right or below, `Ctrl-\` `f` fullscreen, `Ctrl-\` `r` resize mode, | 563 | split right or below, `Ctrl-\` `f` fullscreen, `Ctrl-\` `r` resize mode, |
| 477 | `Ctrl-\` `s` opens the host picker, `Ctrl-\` `x` ends the focused session, | 564 | `Ctrl-\` `s` opens the picker, `Ctrl-\` `x` removes the focused pane, |
| 478 | `Ctrl-\` `w` zooms out, `Ctrl-\` `d` leaves. Any other command key after the prefix | 565 | `Ctrl-\` `w` zooms out, `Ctrl-\` `d` leaves. Any other command key after the prefix |
| 479 | is swallowed along with it, as in a plain client. | 566 | is swallowed along with it, as in a plain client. |
| 480 | 567 | ||
| @@ -500,17 +587,17 @@ a plain client's loop. The focused tile's Core owns the mouse modes; a tile | |||
| 500 | that is not focused holds none, so nothing it armed can report while you | 587 | that is not focused holds none, so nothing it armed can report while you |
| 501 | are looking elsewhere. | 588 | are looking elsewhere. |
| 502 | 589 | ||
| 503 | The pane layout is remembered — where the cuts were, which tile had the | 590 | The panes, where the cuts were and which pane had the focus live in |
| 504 | focus — under `$XDG_STATE_HOME/mux/layout`, saved by any wall you leave from | 591 | `$XDG_STATE_HOME/mux/layout` (above), written by any wall on a terminal on |
| 505 | a terminal and restored over whatever sessions the daemons turn out to have | 592 | every change and read back verbatim. It is authored intent, not derived |
| 506 | when you come back. It is derived convenience and not something you authored: | 593 | convenience: a file that will not parse is refused with its line rather than |
| 507 | every way it can fail (a missing file, a stale tree, a session that is no | 594 | degraded to a default cut, and the default cut — panes side by side on a |
| 508 | longer there) degrades quietly to the default cut, where the wall places | 595 | wide terminal, stacked on a tall one — is what you get only when there is no |
| 509 | tiles side by side on a wide terminal and stacks them on a tall one. | 596 | file to open. |
| 510 | 597 | ||
| 511 | A scripted `mux TARGET` — stdout on a pipe — is still a wall of one tile | 598 | A scripted `mux TARGET` — stdout on a pipe — is still a wall of one pane |
| 512 | whose rect is the whole terminal, writing exactly what the plain client | 599 | whose rect is the whole terminal, writing exactly what the plain client |
| 513 | wrote, with no other host dialled and no layout saved. A wall of MANY needs | 600 | wrote, with no other host dialled and no layout read or saved. A wall of MANY needs |
| 514 | a terminal to cut stripes from and says `mux: wall needs a terminal` if it | 601 | a terminal to cut stripes from and says `mux: wall needs a terminal` if it |
| 515 | has none. | 602 | has none. |
| 516 | 603 | ||
| @@ -569,8 +656,8 @@ execs the old one back and the old one carries on serving. Scrollback does | |||
| 569 | not survive the handover; the visible grid, the titles, the command marks | 656 | not survive the handover; the visible grid, the titles, the command marks |
| 570 | and the exit codes do. A daemon too old to know the request answers nothing, | 657 | and the exit codes do. A daemon too old to know the request answers nothing, |
| 571 | and `mux d upgrade` says so: `no reply: this daemon predates upgrade — stop | 658 | and `mux d upgrade` says so: `no reply: this daemon predates upgrade — stop |
| 572 | and run`. A session in the middle of being ended (`Ctrl-\` `x`, up to half a | 659 | and run`. A session in the middle of being ended (the picker's `x`, up to |
| 573 | second while its shell is being hung up) refuses the upgrade with `session | 660 | half a second while its shell is being hung up) refuses the upgrade with `session |
| 574 | ending, retry` rather than carrying a shell with no master across the exec. | 661 | ending, retry` rather than carrying a shell with no master across the exec. |
| 575 | 662 | ||
| 576 | The remote spelling is `mux d upgrade HOST`: one binary means the machine | 663 | The remote spelling is `mux d upgrade HOST`: one binary means the machine |
| @@ -591,6 +678,15 @@ execs its candidate as `mux d run`, and the verb is `start` now. `make install` | |||
| 591 | install directory on its way in — they are modes of `mux` now, and a | 678 | install directory on its way in — they are modes of `mux` now, and a |
| 592 | `muxd` left on PATH is a second daemon waiting for someone to start it. | 679 | `muxd` left on PATH is a second daemon waiting for someone to start it. |
| 593 | 680 | ||
| 681 | One more mixed-version caveat runs the other way, daemon newer than client. | ||
| 682 | A daemon now answers `sessions_req` with a `# holds NAME N` line per session | ||
| 683 | beside the names, which an older client skips as it skips the version line — | ||
| 684 | but a client at v0.0.1-17 or older sized its receive buffer for the names | ||
| 685 | alone (1101 bytes) and reads anything longer as a transport error. A new | ||
| 686 | daemon holding roughly fourteen or more sessions with 32-character names | ||
| 687 | therefore reads `unreachable` on such a client until that side is upgraded. | ||
| 688 | Upgrading the client end is the fix; there is nothing to do on the daemon. | ||
| 689 | |||
| 594 | Copy and paste work through the session: a mux drag and an application's own | 690 | Copy and paste work through the session: a mux drag and an application's own |
| 595 | OSC 52 write both reach your terminal's clipboard (including from a remote box over QUIC, | 691 | OSC 52 write both reach your terminal's clipboard (including from a remote box over QUIC, |
| 596 | where nothing else can), a paste arrives bracketed when the application | 692 | where nothing else can), a paste arrives bracketed when the application |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -7854,3 +7854,174 @@ whose first draft passed against the bug, because `mux d stats` puts | |||
| 7854 | `session 0 clients=1` on the SAME line and a greedy match read that | 7854 | `session 0 clients=1` on the SAME line and a greedy match read that |
| 7855 | instead of `clients=N attaches=`. The mutant's series, 1 2 3 4 5 6 7 8, | 7855 | instead of `clients=N attaches=`. The mutant's series, 1 2 3 4 5 6 7 8, |
| 7856 | is what the leg now prints when it fails. | 7856 | is what the leg now prints when it fails. |
| 7857 | |||
| 7858 | ## 2026-09-02 — the wall is the layout: panes are authored, the poll only grades | ||
| 7859 | |||
| 7860 | **The report.** "by default wall should just be the layout. I connect to 2 | ||
| 7861 | local and 3 remotes; every time I type `mux` I expect to view that. If I go | ||
| 7862 | to a new machine and type `mux`, I expect to view only 1 local session | ||
| 7863 | because I've never connected before." What the product did instead: a wall | ||
| 7864 | listed every live session of every daemon in the hosts file, tiles came from | ||
| 7865 | each daemon's own `sessions_req` once a second, and the layout sidecar was a | ||
| 7866 | derived convenience healed against those lists. A bare `mux` on the laptop | ||
| 7867 | therefore showed the two sessions this box was VIEWING, because they were | ||
| 7868 | the laptop daemon's live sessions, and a session born by `mux a`, a browser | ||
| 7869 | or another device turned up on every wall that listed its daemon. | ||
| 7870 | |||
| 7871 | **The shapes on the table.** Three. (1) Leave the live-list wall and add a | ||
| 7872 | per-device hide list — rejected: it makes the default wrong and asks the | ||
| 7873 | user to keep subtracting from it forever, and a new session on a shared | ||
| 7874 | daemon still arrives unannounced. (2) Keep the live-list wall and add a | ||
| 7875 | `mux HOST#SESSION` argv grammar for the panes you want — rejected on | ||
| 7876 | ergonomics by the user: naming a session on the command line is not how | ||
| 7877 | anyone finds one, and it leaves the default wall untouched anyway. (3) Flip | ||
| 7878 | the layout file from derived to AUTHORED and make it the only source of | ||
| 7879 | tiles. The flip won because it removes a source of tiles rather than adding | ||
| 7880 | a filter over one: with the file authoritative there is nothing to subtract, | ||
| 7881 | and the poll has exactly one job left, which is to grade the panes that are | ||
| 7882 | already there. It also deleted the duplicated poll-planner filed as collab | ||
| 7883 | 68d4700e — `planHostDiff`'s `birth` arm and `applyList`'s in the hub both | ||
| 7884 | went, leaving one grading rule per front instead of two birthing ones. | ||
| 7885 | |||
| 7886 | **The strictness reversal.** Until now `hosts` was strict (a bad line | ||
| 7887 | refuses `mux` with rc 2) and the layout sidecar was lenient (every failure | ||
| 7888 | degraded silently to the default cut). The reason was that a host line is | ||
| 7889 | authored intent and a layout was not. The layout is authored intent now, so | ||
| 7890 | it is strict too: `seedLayout` returns `.plan`, `.refused LINE` or `.none`, | ||
| 7891 | and a refusal prints `mux: layout ignored (PATH): LINE` and starts as if the | ||
| 7892 | file were missing. Refused: a leaf naming a host the hosts file does not | ||
| 7893 | have, a leaf with no `#SESSION` or a bad session name, a repeated leaf (a | ||
| 7894 | second leaf spelling the entry counts), more leaves than `layout.max_leaves` | ||
| 7895 | (32, shared with `wallview.max_tiles`), or text `layout.parseReporting` | ||
| 7896 | gives up on — which reports the file line it stopped at, not the header. | ||
| 7897 | Whitespace-only is `.none`: nothing was authored, so there is nothing to | ||
| 7898 | name in a refusal. Seating PART of a wall is the one thing that must not | ||
| 7899 | happen, because a user who loses four of six panes has no undo and no line | ||
| 7900 | to fix. | ||
| 7901 | |||
| 7902 | **The trimmed-seed no-save ruling.** A seed that could not seat everything | ||
| 7903 | the file named — a terminal too small for the tree, or a leaf naming the | ||
| 7904 | very session this `mux` runs inside — sets `Shared.layout_path` null for the | ||
| 7905 | WHOLE run and says which on the notice line (`[layout not saved: terminal | ||
| 7906 | too small for N of its panes]` / `[layout not saved: it names this shell's | ||
| 7907 | own session]`). It does not write a trimmed file. `persist` serializes the | ||
| 7908 | tree it HAS, so the first save of such a run would drop the leaves the wall | ||
| 7909 | left out: the user's other panes gone before a key was pressed. Stopping the | ||
| 7910 | whole run's saving is the conservative half of that trade — a resize you | ||
| 7911 | made in a too-small window is forgotten, and the panes you authored survive. | ||
| 7912 | The two sentences are counted apart (`SeedPlan.dropped` vs `dropped_self`) | ||
| 7913 | because a wall that blamed the terminal's size for a shell's own stripe | ||
| 7914 | would send the user resizing a window that was never the problem. In the | ||
| 7915 | same spirit a save FAILURE is a notice, not a `std.debug.print`: every save | ||
| 7916 | runs under the alternate screen, so a stderr line would land in the middle | ||
| 7917 | of whichever pane the cursor was in and stay there until a repaint. | ||
| 7918 | |||
| 7919 | **`# holds NAME N`, and why a `#` line.** The picker's session rows need to | ||
| 7920 | say who else is on a session before you end it, and that is the only wire | ||
| 7921 | addition in the change. `sessions_reply` was names one per line plus a | ||
| 7922 | `# mux VERSION` meta line; it now carries one `# holds NAME N` line per | ||
| 7923 | session under the SAME version gate as the meta line, so a daemon with no | ||
| 7924 | version to state sends a byte-identical old payload. A `#` line rather than | ||
| 7925 | a new frame or a new field because `sessionsIter` already yields only lines | ||
| 7926 | that are valid session names: an old client skips the holds lines exactly as | ||
| 7927 | it skips the meta line, an old daemon sends none, and `parseSessionsHolds` | ||
| 7928 | reads that as unknown rather than as zero — a zero would read as "safe to | ||
| 7929 | end". The count is EVERY holder, the asker included. | ||
| 7930 | |||
| 7931 | **The count includes this wall's own pane (ruling).** It is a number to | ||
| 7932 | READ, never a verdict. Whether an end goes through is judged by the DAEMON | ||
| 7933 | against the OTHERS — the holders that are not the connection asking — so a | ||
| 7934 | client that decided from this number would refuse to end a session only it | ||
| 7935 | was in. The visible cost: ending, from the picker, a session whose one pane | ||
| 7936 | is on this very wall takes two presses, because the daemon counts that pane | ||
| 7937 | as another client of that session. The alternative considered was | ||
| 7938 | auto-forcing when the only other holder is this wall's own pane; it needs | ||
| 7939 | the client to match a holder to a pane, which is exactly the verdict-making | ||
| 7940 | the number is not for. Deferred to a collab issue as a UX change rather than | ||
| 7941 | smuggled in here. | ||
| 7942 | |||
| 7943 | **`x` moved, and what each one means now.** `Ctrl-\ x` on the wall is | ||
| 7944 | `removePane`: the notice `[pane removed - the session is still on its | ||
| 7945 | daemon]`, the pump told to detach (it writes `.detach` on its way out; the | ||
| 7946 | transport close is the fallback), the tile vanished, the layout persisted. | ||
| 7947 | Nothing is ended, and every pane goes the same way including one that never | ||
| 7948 | came up. ENDING is the picker's `x` at the session level, where the row | ||
| 7949 | already shows the holder count — the question "who else is in here" is | ||
| 7950 | answered before the key is pressed rather than by the refusal after it. That | ||
| 7951 | end runs `client.endSession` on a SIDE connection, because the session may | ||
| 7952 | have no pane on this wall to ask through, and it dials `HostSpec.poll_target` | ||
| 7953 | — the poller's batch recipe, with ssh's `BatchMode=yes`, a connect timeout | ||
| 7954 | and `asked` false. Never the interactive target: an end must not start a | ||
| 7955 | daemon, and a password prompt going to `/dev/tty` under the popup would park | ||
| 7956 | the keyboard thread in the TCP retry schedule. A daemon that sends no holds | ||
| 7957 | line is refused up front (`[daemon too old to end a session]`) rather than | ||
| 7958 | on the wire, because such a box answers an unknown frame with silence and | ||
| 7959 | the press would spend the whole reply budget with the wall frozen. Only | ||
| 7960 | "others attached" arms the 3 s force window (`Shared.pick_end`, per host and | ||
| 7961 | name); every other refusal shows the daemon's own reason and arms nothing, | ||
| 7962 | since arming there would leave the next `x` forcing an end nobody said was | ||
| 7963 | blocked. | ||
| 7964 | |||
| 7965 | **The hub reads and writes the same file.** `mux web` serves the layout of | ||
| 7966 | the machine it runs on: `webhub.readLeaves`, refusing a bad file with | ||
| 7967 | `mux web: layout ignored (PATH): LINE` and serving an empty wall rather than | ||
| 7968 | a guess, `/tiles` in tree order carrying `id`, `label`, `session` and | ||
| 7969 | `state`. The page's `+` (`POST /tiles/<id>`) checks the file for ROOM and | ||
| 7970 | for a DUPLICATE under the hub mutex BEFORE it dials (`roomForLeaf`), births, | ||
| 7971 | then `appendLeaf` writes the leaf beside that pane. Pre-dial because | ||
| 7972 | dialling first and finding the wall full afterwards leaves a live session | ||
| 7973 | nobody asked for, on no wall, that only `mux a` or a terminal could find | ||
| 7974 | again. A second `+` on one tile inside a single poll interval is a 409 | ||
| 7975 | `duplicate`, not a repeated leaf; `WallFull` and `BadLayout` are 502. Two | ||
| 7976 | hubs or walls writing in one instant lose one update — each write is a | ||
| 7977 | read-modify-write over an atomic rename, which is what the hosts file has | ||
| 7978 | always done, and the alternative is a lock file nobody would clean up. | ||
| 7979 | Known and accepted: a zoomed `gone` pane in the browser can resurrect its | ||
| 7980 | session across a page RELOAD, because the page latches `exited` only until | ||
| 7981 | the reload throws that state away; the terminal wall's `gone` pane is Enter | ||
| 7982 | to restart by design, and the browser reaching the same place by F5 is the | ||
| 7983 | same door with no keystroke on it. | ||
| 7984 | |||
| 7985 | **Duplicate leaves are refused on both fronts.** The loader refuses a | ||
| 7986 | repeated leaf (including a second leaf spelling the entry — only the first | ||
| 7987 | can be the tile the user is typing in), and the hub refuses one before it | ||
| 7988 | dials. Two panes on one session would each attach, each claim a rect, and | ||
| 7989 | each grade off the same list; the first one to lose its session would take | ||
| 7990 | the other's name with it. | ||
| 7991 | |||
| 7992 | **`keeps_wall` was NOT deleted.** The plan called for it: with the wall no | ||
| 7993 | longer built from births, a refused picker birth should leave the wall as it | ||
| 7994 | was without a flag saying so. It cannot go. `endAction`'s tail is reached | ||
| 7995 | only when the ended tile is the ONLY present pane on a terminal with stdin | ||
| 7996 | open and the reason is not a clean exit, and two panes arrive there with | ||
| 7997 | byte-identical fields and opposite right answers — a picker birth onto an | ||
| 7998 | empty wall must vanish and leave the wall standing, and the ENTRY tile of | ||
| 7999 | `mux TARGET` must finish with rc 1 and the refusal sentence, because that | ||
| 8000 | refusal is the program's. `born_from` is null for both and `creates` is true | ||
| 8001 | for both. The entry tile IS also the one tile with `retry_cold` false, so | ||
| 8002 | that field could distinguish them today — but it answers a different | ||
| 8003 | question (is a link that died before any state arrived worth redialling), | ||
| 8004 | and hanging the wall's survival off it would mean one edit to the retry rule | ||
| 8005 | silently changes what a refused `mux TARGET` does. The flag stays, its doc | ||
| 8006 | comment now says exactly that, and the test that would fail without it | ||
| 8007 | carries the argument. Recorded follow-up, not done and wanting its own | ||
| 8008 | decision: INVERTING the flag — marking the one tile whose ending is the | ||
| 8009 | program's, where `retry_cold = false` is already set — carries the same | ||
| 8010 | information with the safer default and fixes an edge the current polarity | ||
| 8011 | gets wrong, since a SEEDED pane alone on a terminal wall, refused, has | ||
| 8012 | `keeps_wall` false and so ends mux, when under this model a saved pane's | ||
| 8013 | refusal is a pane's and not the program's. | ||
| 8014 | |||
| 8015 | **Cross-version caveat, daemon newer than client.** A client at v0.0.1-17 or | ||
| 8016 | older sizes its `sessions_reply` buffer for the names alone (1101 bytes) and | ||
| 8017 | reads a longer reply as a transport error. A NEW daemon holding roughly | ||
| 8018 | fourteen or more sessions with 32-character names therefore reads | ||
| 8019 | `unreachable` on such a wall until that wall is upgraded. `sessions_reply_max` | ||
| 8020 | is now names + one holds line per session + the meta line for exactly this | ||
| 8021 | reason: a receiver sized to the names alone reads a full daemon that states | ||
| 8022 | its version as an unreachable box. | ||
| 8023 | |||
| 8024 | **The pin.** The e2e suite grades 110 scenario checkpoints and 38 | ||
| 8025 | convergence points, up from what the old model needed, and the leg that | ||
| 8026 | states the whole change in one sentence is "two walls on the same daemons | ||
| 8027 | are two layouts; neither learns of the other's panes" in the hosts group. | ||