a73x

docs/superpowers/specs/2026-09-02-wall-is-the-layout-design.md

Ref:   Size: 11.5 KiB   History

# The wall is the layout: panes are authored, the poll only grades them

2026-09-02. Status: approved design, pre-implementation.

## Problem

Today a wall lists every live session of every daemon in the hosts file.
Tiles come from each daemon's own `sessions_req`, polled once a second, and
the layout sidecar is a derived convenience healed against those lists. So a
bare `mux` on the laptop shows the two sessions this box is viewing, because
they are the laptop daemon's live sessions, and a session born by `mux a`, a
browser, or another device turns up on every wall that lists its daemon.

The user's stated expectation (2026-09-02): "by default wall should just be
the layout. I connect to 2 local and 3 remotes; every time I type `mux` I
expect to view that. If I go to a new machine and type `mux`, I expect to
view only 1 local session because I've never connected before." A session
viewed from another device is a different client's business and should not
appear here until this device adds it.

## Decisions (settled in brainstorm)

- **The layout file is the wall.** `$XDG_STATE_HOME/mux/layout` becomes
  authored intent. Tiles come from it and from nothing else. The
  once-a-second poll grades each pane (`up`, `gone`, `unreachable`) and never
  adds a tile.
- **Sessions get onto a wall through the picker.** `Ctrl-\ s` on a host
  opens that daemon's session list; Enter adds one as a pane. No
  `mux HOST#SESSION` argv form. The user chose this for ergonomics.
- **`x` removes the pane; the session lives on.** Ending a session moves
  into the picker's session list, beside the count of who else holds it,
  where the daemon's two-step still applies. A pane is this wall's; the
  session is the daemon's.
- **A new machine gets one local pane.** No layout and no hosts file:
  `mux` records the local daemon and opens one pane on its session `0`,
  creating it if the daemon is empty.
- **Open item, carried with a default.** Whether that first pane should take
  the next free name when session `0` already exists on the local daemon.
  Default: session `0`, which is what the README promises today. The user
  parked this as a separate question.

## Model

Two files, two meanings:

| File | Meaning | Strictness |
|---|---|---|
| `hosts` | the daemons this device may browse | strict: a bad line refuses `mux` with rc 2 |
| `layout` | the wall: a pane tree whose leaves are `HOST#SESSION` | strict on parse, lenient on absence |

The layout is saved on every change to the pane set or the tree — an add, an
`x`, a split, a resize, a detach — not only on the last detach. Two terminals
on one device then see each other's adds on their next start, and a wall that
crashes loses nothing it had committed. Writes are the existing atomic
rename.

A missing layout is a wall of one pane on the local daemon (section "New
machine"). A layout that fails to parse is reported on stderr with the line,
and `mux` then behaves as if the file were missing. Silently degrading
authored intent to a default cut is how a user loses a wall; a missing file
is recoverable and a printed line says what to fix. This reverses the
2026-08 asymmetry (strict hosts, lenient sidecar), because the sidecar is
no longer derived.

The leaf spelling stays `HOST#SESSION`, where `HOST` is a hosts-file line
verbatim (`--sock PATH` | `HOST` | `quic://HOST[:PORT]`). A leaf whose host
is not in the hosts file is an error at parse: the layout may not name a
daemon the picker cannot reach.

## Where panes come from

Exactly three places add a pane:

1. **The layout, on start.** Every leaf becomes a pane in its saved rect.
2. **The picker.** Enter on a session in a host's list adds it and zooms to
   it. The birth key creates a session on that host at
   `client.nextFreeName` off that host's own list, adds it, and zooms.
3. **A chord split.** Births a session on the focused pane's host, as today,
   and the new pane inherits the focused pane's `-A`.

`mux HOST` on the command line keeps its entry meaning: HOST is added to the
hosts file if new, and the wall opens zoomed on a pane for HOST's session
`0`, added to the layout if it was not there, created on the daemon if the
daemon has no such session. This is the one argv path that adds a pane, and
it is the existing entry dial rather than a new grammar.

Nothing else adds one. A session born by `mux a`, a browser, another device,
or another terminal on this device appears on no wall until a picker adds it.

## Picker

Rows stay the hosts file's daemons in file order, with the poller's last
answer beside each (`N sessions` / `no sessions` / `unreachable` /
`connecting`). Enter on a host opens a second level: that daemon's sessions
from the poller's last answer, one row each, showing the name, whether it is
already a pane on this wall, and how many clients hold it.

The holder count is the one wire addition. `sessions_reply` is names, one
per line, plus the `# mux VERSION` meta line; the count rides beside them
as one `# holds NAME N` line per session. `sessionsIter` already yields
only lines that are valid session names, so an old client skips the new
lines as it skips the meta line, and an old daemon sends none, which the
list shows as no count. Nothing else on the wire changes.

Keys in the session list:

- **Enter** adds the session as a pane and zooms to it. On a session already
  on the wall, Enter just zooms.
- **Birth key** (the existing one) creates a session on this host and adds
  it.
- **End key** runs the daemon's `end_req` on the selected session. The row
  already shows the holder count, so the first press on a session others
  hold shows the daemon's refusal and count, and the second press within
  3 s forces, as today. An ended session's pane, if any, leaves the wall on
  the next poll.
- **`a`** stays the host spelling editor, at the host level.
- **`x`** at the host level forgets a host: the hosts line, its poller, and
  every pane on it (the sessions keep running).
- **Esc** backs out one level: session list to host list, host list to the
  wall.

Every byte is the popup's while it is open; tiles do not paint
(`Shared.picker_open`). An empty wall opens the picker once by itself, as
today, and the birth that a daemon refuses leaves the empty wall rather than
ending `mux` on a terminal.

## Keys on the wall

- **`x`** removes the focused pane from the layout, sends its
  `session_release`, and ends its pump. Nothing is ended on the daemon. The
  layout is saved. A pane that has never come up closes the same way.
- **`n`, `p`, digits** walk the layout's panes in tree order.
- **A shell that exits** takes its pane with it: the user ended it. The
  layout is saved without the pane.
- **A pane whose reachable host answers without its session** wears `gone`.
  Enter re-creates the session in that pane's own rect; `x` removes the
  pane. Every other key at a gone pane is eaten. This is the gone-panes
  design of 2026-09-01 unchanged, and it now applies to every pane, not
  only seeded ones, because every pane is seeded.
- **A dark host's panes** wear `unreachable` and stay, for as long as the
  box is dark. Enter retries the dial.
- The chords for split, resize, focus, zoom, and detach are unchanged.

## New machine

No layout and no hosts file: `mux` writes the local daemon's line
(`--sock <default>`, starting the daemon if nothing listens), opens one pane
on its session `0`, creating it if the daemon is empty, and saves a layout
of that one leaf. A daemon that already has a session `0` — another client
or `mux a` made it — is joined, not duplicated. The open item above is
whether to take the next free name instead.

A hosts file with lines but no layout — the state after this change ships
to a device with an old hosts file — opens the picker on an empty wall,
exactly as an empty wall does today, so the user adds what they want to see.
It does NOT reconstruct the old every-session wall.

## Hub

`mux web` serves the layout of the machine it runs on: `/tiles` lists the
layout's panes in tree order, each with its host, session, and the poller's
grade. The page's `+` births on the focused pane's daemon and adds the pane
to the layout. `mux web HOST ...` still records its argv into the hosts
file. The hub no longer lists every session of every host; a host with no
pane on the layout contributes nothing.

The hub and the terminal wall read and write the same file, so a pane added
in the browser is on the next terminal wall and the reverse. Neither holds
the file open; each write is a read-modify-write over an atomic rename, and
two writers within the same instant lose one update, which the hosts file
already accepts.

## What goes away

- Healing the sidecar per leaf against live lists (`seedLayout`,
  `seedAttempt`, and the default cut for a wall that has a layout).
- The poll-born tile path: `planHostDiff`'s `birth` arm in `wall_host` and
  `applyList`'s in `webhub`. The `keep`, `missed-once grace`, and `vanish`
  arms become the grading of existing panes. This retires the duplicated
  planner filed as collab 68d4700e.
- `Tile.keeps_wall` and the `born_from` null it worked around: a refused
  picker birth leaves the wall as it was, since the wall never depended on
  the birth.
- The aspect rule for a first wall of many sessions: a first wall has one
  pane.
- The README's "tiles are whatever those daemons have live" paragraph and
  the CLAUDE.md invariant "The wall file lists DAEMONS; tiles are their live
  sessions", replaced by "The layout is the wall; the poll grades it".

## Testing

Unit, on the pane set and the layout (no terminal):

- A poll answering sessions not on the layout adds no pane.
- A poll missing a pane's session marks it `gone`; the next answer that
  names it clears the mark.
- `x` writes the layout without the pane and releases the session; the
  session is not ended.
- Enter in the picker's session list on a session already on the wall adds
  nothing.
- A layout naming a host absent from the hosts file is refused at parse
  with the line reported.
- A layout with a parse error is reported and treated as missing; a wall of
  one local pane results.
- Every write of the layout goes through the one save path; a mutation
  that skips it fails the test that counts saves per operation.

E2e, on a real pty (ptyclient), plural by default: two daemons, three
sessions each:

- A wall shows exactly its layout's panes; a session `mux a` births on a
  listed daemon never appears on the running wall.
- A second state dir against the same daemons starts with one local pane,
  adds two sessions through the picker, and shows only those three; the
  first wall is unchanged.
- `x` on a pane, then `mux d stats`: the session still has its other
  client. The layout file no longer names the pane.
- The picker's end key on a session another wall holds: first press shows
  the count, second press within 3 s ends it, and the other wall's pane
  leaves on its next poll.
- A fresh state dir and hosts file: one pane, session `0`, layout written
  with one leaf.
- A hosts file with lines and no layout: the picker opens on an empty wall.
- The hub's `/tiles` matches the layout file, not the daemons' lists, and a
  pane added in the browser is on the next terminal wall.

Mutation checks on every leg's oracle before it is trusted: a leg whose
first assertion is an exit code has masked a wrong pane set before.

## Out of scope

- The parked question: session `0` versus the next free name for a new
  machine's first pane.
- Carrying a layout between devices. Layout stays client-local.
- Any wire change beyond the `# holds` lines above. Adding a pane, removing
  one, and ending a session all use frames that exist.