a73x

README.md

Ref:   Size: 53.1 KiB   History

# mux

Terminal multiplexer where detach/reattach is **state sync, not replay**:
the terminal engine (ghostty-vt) runs authoritatively in a daemon (`mux d`),
and the wire carries its grid as cells that the client (`mux`) copies into a
grid of its own. Attach costs one snapshot; everything after is row deltas;
a dropped connection is a non-event. Mux publicly supports Linux x86_64 only.

- [Ethos](docs/Ethos.md)
- [Architecture](docs/Architecture.md)
- [Features](docs/Features.md)

## Build

Requires mise and Zig **0.15.2** (the ghostty pin — a newer Zig will not build
the dependency). The repository's `mise.toml` declares the version and the
Makefile resolves it through mise. Override with `make ZIG=/path/to/zig` only
when deliberately using another installation.

```sh
make build            # first build fetches + compiles the QUIC deps: ~30MB, a few minutes, once
make test && make e2e # verify
make install          # mux + muxg in ReleaseSafe to ~/.local/bin (override BINDIR)
```

The native client is a dynamically linked second
binary. It needs SDL3, freetype2, fontconfig, HarfBuzz and OpenGL development
packages; OpenGL functions are loaded through SDL, with no direct libGL
link. `make install` and `make release` build it alongside `mux`; it is outside
the default development build and CI gates:

```sh
make native             # Debug development build and no-window unit tests
make native-e2e         # One ReleaseSafe GUI user journey; needs python3
make native-stress      # Linux: raw cat /dev/random, shared and separate daemon neighbours
./zig-out/bin/muxg [TARGET] [--forward LOCAL_PORT:REMOTE_PORT]... [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N]
```

For interactive use and performance measurements, build **both** binaries in
ReleaseSafe. Debug enables expensive terminal integrity checks in the daemon;
a release GUI alone does not avoid that cost. A separate output directory keeps
subsequent development checks from replacing these binaries:

```sh
deps/zig/zig build install native -Doptimize=ReleaseSafe -p dist/native-release
./dist/native-release/bin/mux d start -d
./dist/native-release/bin/muxg
```

The daemon command starts a new daemon; an already running daemon retains its
existing build until it is upgraded or restarted.

`muxg` displays daemon sessions in native terminal panes. With no arguments it
restores the saved native workspace; first launch opens the host/session picker.
For local sessions, start the daemon with `mux d start -d`. It never starts a local
daemon itself, and an unavailable target is shown in its pane. A remote `HOST` uses mux's SSH
handoff, including that handoff's own remote-start behavior. `--via` uses a
command's stdio, and `quic://HOST[:PORT]` uses `--key` or `MUX_KEY_FILE`.
Closing the window detaches all panes; their sessions stay on their daemons. When
a shell exits, its pane shows the exit status and the window remains open.
`muxg` reads `$XDG_CONFIG_HOME/mux/config`, or `~/.config/mux/config` when
that variable is unset or empty. For example, with these fonts installed:

```ini
font-family = "CommitMono Nerd Font Mono"
font-family = "Adwaita Mono"
font-size = 12.4
```

The supported [Ghostty-style settings](https://ghostty.org/docs/config/reference#font-family)
are an ordered list of installed monospace `font-family` values and a
`font-size` from 1–192 points. The first family supplies grid metrics; each
later family is tried when an entire cell grapheme is unavailable in the
families before it.
Fractional sizes are preserved until rasterization: points × 96/72 on Linux,
then display scale, rounded to a pixel. Blank lines and
full-line `#` comments are accepted; a family may be double quoted. Duplicate
singleton keys, unknown mux config keys, malformed values and unavailable
families produce diagnostics. Per-style families, escapes and inline comments
are not supported. This is a supported subset of Ghostty appearance settings.

`--font-family NAME` may be repeated to supply an ordered CLI family list; any
CLI family replaces the complete config list. `--font-size POINTS` overrides
the config size. The existing
`--font-px N` (1–256 pixels at 100% scale) also overrides config sizing; choose
one size flag. Without config the original font (monospace, 16 logical pixels)
and colors remain. Close and reopen the client to apply config edits; daemon
sessions survive. Glyphs refresh automatically when display scale changes,
retaining the family list. Explicit fallback families are supported; automatic
system fallback, colour emoji and cross-cell programming ligatures are not.

In the native GUI, a left drag selects and copies text unless the application
has requested mouse reporting. Applications such as tmux then receive ordinary
clicks and drags; hold Shift when starting a drag to select locally instead.
That choice remains fixed until release, even if Shift changes or the pointer
crosses another pane. Ctrl+Shift+C copies the current local selection.
Application OSC 52 writes update the desktop clipboard (`c`) or primary
selection (`p`/`s`); clipboard queries remain refused. Ctrl+Shift+V pastes
into the focused pane or an editable picker field. A terminal that enables
bracketed paste receives one bracket pair around the complete clipboard text,
including when the payload is chunked.

Choose a Ghostty theme file by name in `$XDG_CONFIG_HOME/mux/themes/` (or
`~/.config/mux/themes/`), or by absolute path:

```ini
font-family = "CommitMono Nerd Font Mono"
font-size = 12.4
theme = Catppuccin Mocha
# Optional override, independent of its position before/after theme:
background = #202030
```

Copy a theme file into that directory yourself; mux does not bundle a catalogue.
A theme name is a single filename; relative paths containing directories are refused.
`--theme NAME_OR_ABSOLUTE_PATH` overrides the configured theme choice for one launch.
Color precedence is original defaults → selected theme → explicit mux config →
CLI. Repeated CLI flags use the last value (per index for `--palette`). Use `--background RRGGBB`, `--foreground RRGGBB`, `--cursor-color RRGGBB`,
and repeatable `--palette N=RRGGBB` for temporary color overrides.

Supported theme keys are `background`, `foreground`, `cursor-color`, and
`palette = N=RRGGBB`. Colors require six hex digits with an optional `#`;
indices are decimal 0–255. Distinct palette indices may repeat the key, but
repeating the same index or a singleton key in a file is an error. Missing theme
files and malformed supported settings prevent startup with file/line diagnostics.
Unsupported keys in a theme file warn and are ignored; unsupported keys in mux's
own config remain errors. In particular, imported `selection-background`,
`selection-foreground`, and `cursor-text` are not implemented. Theme files cannot
set fonts or include other files. Named X11 colors, dynamic color expressions,
automatic light/dark selection, and palette-generation options are not supported.
See [Ghostty's option reference](https://ghostty.org/docs/config/reference) for the
full format that this subset follows.

Headers, dividers, menus, and bell feedback derive from the final colors.
Application-supplied truecolor remains exact. Unspecified palette entries retain
the original ANSI/xterm defaults. Restart to apply theme edits; fonts and sessions
behave as above.

Linux
prefers native Wayland, with X11 as a fallback. An explicit `SDL_VIDEO_DRIVER`
or `SDL_VIDEODRIVER` setting overrides that preference.
`kill -USR1 PID` prints the frame timing table to stderr, as does exit.
The end-to-end leg checks the real build mode, reads rendered pixels back
from OpenGL, and measures a 20 ms p99 window-side budget under concurrent
output; pump apply time is reported separately.

Open the saved workspace, or an explicit target in a temporary workspace:

```sh
./zig-out/bin/muxg
./zig-out/bin/muxg alpha --session work
./zig-out/bin/muxg --forward 8080:80 alpha
```

`--forward` binds the local IPv4 loopback address (`127.0.0.1`) and carries
TCP to the same loopback address on the target host. It may be repeated; exact
duplicates are ignored. Forwarding requires an explicit target for this
invocation and is not saved in the workspace. The listeners remain reserved
across daemon reconnects, while live streams are reset; removing the final pane
for that target releases them. Hostname destinations, wildcard binds, reverse
forwarding and UDP are not part of this first slice.

Press `Ctrl+\`, then **v** for a pane below or **b** for a pane beside it.
The new pane uses the focused pane's connection and gets the next free numeric
session name automatically. It opens in the background; you can keep typing or
change focus while it connects. Esc cancels a pending opening. With no focused
pane, these bindings open the picker.

Press the prefix, then **Enter**, to choose a different host or an existing or
named session. Arrows or **j/k** select a row; Enter chooses it. **v/b** change
the split direction in the host and session lists. Select a host,
then an existing session or **New session...**, which asks for a name. **Add host...**
accepts an SSH host, `quic://HOST[:PORT]`, or `--sock PATH` and saves it to the shared
host catalogue. You can also click picker rows. The split stays on the pane where
it was armed, even if focus moves before insertion. Prefix + Enter without an
armed direction defaults to side by side.

Esc in a name editor returns without submitting; Esc in sessions returns to hosts;
Esc in hosts closes the picker while preserving the pending split. Cancelling
before submitting a new session creates nothing. An explicit name collision is
refused; automatic naming refreshes the list and retries a confirmed collision.
If creation was submitted but its reply was lost, the picker reports an unknown
outcome; refresh the session list before retrying. Existing-session selection
never recreates a vanished shell. Older daemons can serve existing sessions but
may leave the new creation request unanswered; upgrade the daemon to enable it.

Prefix followed by **h/j/k/l** or an arrow moves focus; clicking a pane also focuses
it. Prefix then **Esc** cancels a pending split. Press the prefix twice to send its
normal terminal encoding through once. Window resizing updates each visible pane's PTY.
An exited or unavailable pane leaves other panes usable.

Prefix then **f** toggles the focused pane fullscreen within the window. Press it
again to restore the splits. Focus movement keeps the newly focused pane fullscreen;
hidden sessions stay attached at their last size. Opening or detaching a pane, or
entering resize mode, returns to the split view. Fullscreen is not saved on exit.

Drag a divider to resize its panes, or press the prefix then **r** for resize mode.
**h/j/k/l** or arrows move the nearest divider on that axis in the indicated
direction by one terminal cell; **Enter** or **Esc** exits. Pane minimum sizes
limit movement. Window resizing preserves the chosen proportions.

Prefix then **d** detaches and removes the focused pane, preserving its session.
Prefix then **x** asks the daemon to end that session. If other clients are
attached, a confirmation offers **End for all clients**, with **Cancel** selected.
Prefix then **p** offers **Retry**, **Choose session** in the same pane, and **Detach**.
Missing and exited sessions stay visible until an explicit action; restore never
creates a replacement shell. Unreachable hosts retry with backoff while other
panes remain usable. Removing the last pane leaves an Add pane surface.

The default workspace saves to `$XDG_STATE_HOME/mux/native-workspace.json`
(or `~/.local/state/mux/native-workspace.json`). It remembers pane identities,
targets, focus, split directions, and relative sizes. Explicit target/session
launches are temporary and leave that saved workspace alone. One window may own
the default workspace at a time. Save failures appear in the GUI; unreadable or
malformed saved state is preserved with saving disabled for that run.

Tabs are represented in the saved model but have no UI yet. Native layout choices
are independent of terminal mux; both clients use the same daemon sessions.

Binaries land in `zig-out/bin/`. For remote machines, build a static binary
that runs on any x86_64 Linux:

```sh
mise exec -- zig build -Dtarget=x86_64-linux-musl
```

`make release` builds both `mux` and `muxg` in `ReleaseSafe`, strips them,
checks both against `build.zig`'s version, and packages them in `dist/`. On
Linux, `mux` remains static musl; `muxg` uses the build host's native target
and needs compatible CPU, libc, SDL3, FreeType, fontconfig, and HarfBuzz
libraries on the destination, plus an OpenGL-capable display. These libraries
are not bundled. The tarball carries the executable bit for installation on
another machine:

```sh
make release
ssh HOST 'mkdir -p ~/.local/bin && tar xzf - -C ~/.local/bin' < dist/mux-vVERSION-x86_64-linux-musl.tar.gz
```

Linux x86_64 releases are published as
`mux-vVERSION-x86_64-linux-musl.tar.gz` under
`https://git.a73x.sh/mux/releases/vVERSION/`. Install a published release:

```sh
mkdir -p ~/.local/bin
curl -fsSL https://git.a73x.sh/mux/releases/vVERSION/mux-vVERSION-x86_64-linux-musl.tar.gz | tar xzf - -C ~/.local/bin
```

## Quick start, local

```sh
mux d start &        # the daemon: hosts your shell and the authoritative screen
mux               # attach
```

**`mux` IS the wall, and the wall is your layout.** The file
`~/.local/state/mux/layout` names the panes you have opened — each one a
`HOST#SESSION` leaf, in the tree you arranged them in — and every `mux` on
this machine opens exactly that. `~/.local/state/mux/hosts` is the other
file: the daemons this machine may browse, your own and every box you have
attached to, one spelling per line. There is one list and one navigation
axis: `Ctrl-\` `1`-`9` focuses a pane, `Ctrl-\` `n`/`p` walk them all,
`Ctrl-\` `d` leaves. Every pane's replica stays hot whether you are looking
at it or not, so moving focus is a repaint and not a re-attach — no child
process, no second screen.

Sessions live on daemons, and a daemon may have more of them than your wall
shows: a session born by `mux a`, by a browser, or by another machine is on
no wall until you put it there. `mux` still asks every listed daemon once a
second what it is hosting, but that answer only GRADES the panes you
authored — it wakes one whose session is there, marks `gone` one whose
session the daemon no longer lists, and takes away one whose shell exited.
It never adds a pane. `Ctrl-\` `s` lists your daemons, Enter on one lists
its sessions with who else holds each, Enter on a session adds it as a
pane, `c` starts a new one there, `x` ends one (asking first when someone
else holds it), Esc backs out a level. On the wall, `Ctrl-\` `x` takes the
focused pane off this wall and ends nothing, while `Ctrl-\` `X` ends the
focused pane's session on its daemon — the shift is the whole difference
between the key you can undo and the key that kills a shell.

`mux` on a machine that has never run it records your own daemon (the line
`--sock <default>`, starting it if nothing is listening) and opens one pane
on its session `0` — a first run is still just a shell. After that, bare
`mux` opens the layout you left, and `mux HOST` opens zoomed on HOST's
session `0`, adding HOST to the hosts file and the pane to your layout if
they were not there. Every host dials at once, in the background, so a slow ssh
somewhere else never holds up the one you asked for; `Ctrl-\` `w` zooms back
out to see them. A wall of one tile still feels like a plain client, because
a wall of one tile is what a plain client always was — except that on a
terminal every tile wears its label bar, one tile or many, so the zoomed
view keeps the session's name and state in sight. Only a piped `mux` is
bare bytes.

A split births a session: not a window onto an existing one, but its own
shell on the focused pane's daemon, laid out beside or below the focus. A
wall with no layout to open places its panes side by side when the terminal
is at least twice as wide as it is tall, and stacks them otherwise — after
that the tree is yours. Fullscreen is a layout where the focused pane takes
the whole terminal; resize mode trades cells between the focus and its
neighbors, one per keystroke. Every change to the panes or the tree — a
birth, a split, a resize, an `x` — writes the layout file, so a wall that
is killed loses nothing it had committed and the next `mux` opens what you
last arranged. Each terminal writes on its OWN changes and the last writer
wins: two walls open at once on one machine do not merge, and the one that
changed something last is the one the next start sees.

The focused tile has the terminal, and only the prefix is held back:

| Key | Effect |
|---|---|
| `Ctrl-\` | command prefix — press it, then a command key |
| `Ctrl-\` `d` (or `Ctrl-\` `Ctrl-\`) | disconnect: leave every tile and leave mux, interrupt nobody (every session keeps running) |
| `Ctrl-\` `c` | create a session on the focused pane's daemon, add a pane for it, focus it |
| `Ctrl-\` `n` / `Ctrl-\` `p` | focus the next / previous pane on the wall, wrapping |
| `Ctrl-\` `h` `j` `k` `l` | focus the pane left / down / up / right |
| `Ctrl-\` `\|` (or `\`) | split right: new session in a pane to the right |
| `Ctrl-\` `-` | split below: new session in a pane below |
| `Ctrl-\` `f` | fullscreen the focused pane (toggle) |
| `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 |
| `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 |
| `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 |
| `Ctrl-\` `X` | end the focused pane's session on its daemon, asked over the pane's own connection. A session someone else holds is refused with the count — `[1 other attached - X again to end]` — and a second `X` within three seconds forces it; a session only this pane holds ends on the first press. The pane leaves when the shell does |
| `Ctrl-\` `w` | zoom out to the wall |
| `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. `s` or `Ctrl-C` closes the whole popup from either level, while Esc backs out one level at a time — and an arrow key split across two reads reads as a bare Escape, so at level one 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 |
| `Shift+PageUp` / `Shift+PageDown` | scrollback, a screen at a time (any other key returns to live) |
| mouse wheel | scrollback, three rows a notch (arrow keys to a full-screen app) |
| drag with the left button | select what it crosses, copy on release |
| any key not behind `Ctrl-\` | goes to the focused tile |

`n`, `p` and the digits walk the same list, because there is only one: your
layout's panes, in tree order, whatever daemons they came from. `c`, `|`
and `-` ask the FOCUSED pane's daemon for a new session, so a split on a
remote pane births a shell on that machine and not on yours.

Three doors put a pane on the wall and nothing else does: the layout file
on start, the picker, and a chord split — plus `mux HOST` on the command
line, which is the entry pane and takes the same path. A session born
anywhere else is real and running and on no wall of yours until you add it.

`x` on the wall is a wall edit and only that: the pane goes, the layout is
written without it, the client says goodbye to that session, and the shell
behind it keeps running for whoever else holds it — the rail reads
`[pane removed - the session is still on its daemon]`.

ENDING a session has two doors, and both go through the same two-step on
the daemon. `Ctrl-\` `X` ends the FOCUSED pane's session, asked over that
pane's own connection. The picker's `x` at the session level ends any
session the daemon has, pane or no pane, which is what you want when you
can see who else holds it first. Because ending interrupts anyone else
watching, the daemon refuses a first press on a session with other clients
attached and says how many — `[work: 1 other attached - x again to end]` —
and a second press on the same tile or the same row within three seconds
forces it. A shell that ignores the hangup is killed half a second later;
there is no session that cannot be ended.

The two doors count holders differently, because the daemon does not count
the client that is asking. The picker asks over a connection of its own, so
the count on the row is EVERY holder including this wall's own pane, and
ending a session you alone have open there is two presses. `Ctrl-\` `X`
asks over the pane itself, so that pane is not one of the others and a
session nobody else holds ends on the first press.

Dropping a whole MACHINE is not a chord either: from inside the wall it is
the picker's `x` at the host level (`Ctrl-\` `s`, then `x`), and from the
command line `mux hosts rm`.

The picker's `x` ends a session, never a box. A daemon whose last session
ends stays up and empty, and the next `mux HOST` is born into it — so
emptying a machine costs you nothing but the shell you meant to end. Ending
the daemon itself is `mux d stop`.

A session whose shell exits takes its pane off the wall — you ended it, so
the layout is written without it. On a terminal the LAST pane's exit leaves
you on the empty wall with the picker over it, not back in your shell:
`Ctrl-\` `d` is how you leave. A piped `mux` has no wall
to leave you on, so it exits with that shell's code — `mux` stays something
you can put in a script.

The wheel goes to whatever is in front of it. At a shell prompt it moves
mux's scrollback, three rows a notch. In a full-screen application that did
not ask for the mouse — `less`, `man`, most pagers — it becomes arrow keys,
so the application scrolls the way it always does. And an application that
DID ask the terminal for mouse reporting — vim with `set mouse=a`, htop —
gets every notch, click and drag forwarded to it untouched, with
`Shift+PageUp` still reaching mux's own scrollback.

A drag with the left button is mux's own. It highlights what it crosses and
copies it to your terminal's clipboard when you let go — no key to press,
the way tmux does it. That copy comes from the daemon's grid, so it is
right across soft-wrapped lines, and it works from a remote box over QUIC,
where your terminal's own selection can only see the one screenful mux
painted. On the wall a drag stays inside the stripe it started in, and a
plain click moves the selection to that stripe.

Only the visible screen: a drag while scrolled back selects nothing, and a
drag in an application that asked for the mouse belongs to the application.
Shift+drag is still your terminal's own selection for both of those — it
splits soft-wrapped lines at the window edge, which is the one thing it
does worse.

`mux` again reattaches — full TUI screens included. Kill the client with
`kill -9` if you like; the session doesn't care.

One daemon hosts up to thirty-two named sessions; the first is called `0`.
`Ctrl-\` `c` creates one named after the lowest free number and focuses it,
and `mux --session NAME` attaches to any of them by name (creating it if
there is room). One wall holds up to thirty-two panes however many daemons
they came from, and a layout file with more leaves than that is refused
rather than half-seated. Not worth designing past until someone has
thirty-three shells.

To see which sessions are alive without attaching to one: `mux hosts` prints
every daemon in your hosts file with its live session count, `Ctrl-\` `s`
then Enter lists one daemon's sessions by name, and `mux d stats` names
every session of one daemon with the number of clients watching it. `stats`
is the question to ask when `mux` will not let you in — thirty-two sessions
is the table, and the thirty-third name has nowhere to go.

Every session shell is told where it lives: `$MUX_SOCK` is the daemon's
socket path and `$MUX_SESSION` is the session's name. Scripts and prompts
can read them, and `mux` reads them to refuse attaching to the very session
it is running in — that attach would feed the session's own paint back into
itself, take the alternate screen and swallow every keystroke, with no
outer keyboard able to reach it. Attaching from one session to *another* on
the same daemon is fine and unaffected, and a wall opened from inside a
session simply never births that one tile — the rest of the daemon's
sessions are there, the one you are standing in is not. Unset `MUX_SESSION`
if you really want the loop.

## Quick start, remote over ssh

Works anywhere ssh works. On the remote host: put `mux` on PATH (`make
install` puts it in `~/.local/bin`), then:

```sh
ssh HOST 'mux d start -d'
mux HOST          # attach; Ctrl-\ d detaches, running it again reattaches
```

`mux d start -d` spawns the daemon detached and waits until it answers, so it
is safe to run every time — if one is already up it says so and exits 0.

`mux HOST` runs `mux d endpoint --start` on HOST over ssh, ONE run
(finding a `mux` in `~/.local/bin` even though ssh's non-login shell leaves
it off PATH). That verb ensures a daemon and then announces the
coordinates on the same stdout, so a cold attach costs one ssh login and
the `ssh HOST 'mux d start -d'` line above is optional after the first time.
Nothing else starts a daemon, because nothing else spells the flag: the
wall's per-host poll, `mux hosts`, every reconnect and `mux --via 'ssh HOST
mux d proxy'` run the bare `mux d endpoint`, report the box unreachable and
leave it alone. When that poll fails, the picker row says why in ssh's own
words — `unreachable: ssh: connect to host box port 22: No route to host` —
because mux reads that ssh's stderr rather than letting it print over the
wall; `mux HOST` relays the same bytes to your terminal as they come, being
the one dial you are sitting in front of. Your
ssh config (aliases, ports,
ProxyJump) all keeps working, since `mux` never parses HOST. That ssh
fetches the daemon's QUIC port and key once and the session moves onto
QUIC, leaving no ssh process behind; the coordinates are cached under
`~/.cache/mux/hosts/`, so later attaches dial QUIC straight away and skip
ssh entirely. Where inbound UDP is blocked, the attach lands over ssh one
deadline (~2s) later — ONE, whether the coordinates came from the cache or
from that same ssh — saying so in a single line, and the session is the
same session either way. HOST is one argv word handed to `ssh`, never a
shell line: `mux` execs ssh itself, so nothing local expands or splits it.

When a host wants a password, a key passphrase or a host-key `yes`, the
wall asks it in a popup: ssh hands the question to `mux` itself, the box
paints it over the tiles with the answer starred (the host-key question is
not — you are comparing a fingerprint), and Enter sends it back. A security
key's "touch now" notice appears the same way and goes away by itself. Esc
declines, which ends both that dial and the tile rather than asking again;
birth on the host again from `Ctrl-\ s` when you want another go. `mux HOST`
typed at a bare prompt still uses ssh's own prompts, and the wall's
once-a-second poll never asks anything.

`--via CMD` accepts any command that exposes the daemon's socket on stdio,
for custom socket paths or transports. CMD is split on whitespace into
argv and exec'd directly — no shell, so no quoting, no variables, no
pipelines, and a command that needs any of those is not supported: put it
in a script and name the script.

If the link drops, the client reconnects and resumes by itself; a bare
`Ctrl-\` gives up waiting — while reconnecting there is no session to
command, so it is not a prefix there.

**Your keys on the remote:** `mux -A HOST` forwards this client's ssh-agent
into the session, mirroring `ssh -A` — and mirroring its threat model, which
is why it is the same opt-in flag: while you are attached, root on the remote
can *use* your keys, though not read them. What differs is who owns the
socket. Every session is born with an agent socket the **daemon** owns, so
the `SSH_AUTH_SOCK` a shell was started with stays valid for that session's
whole life; the tmux disease — a socket that goes stale on every reconnect,
patched over with `update-environment` and symlinks — cannot happen here,
because the socket outlives the connections that answer for it. Who signs is
whoever typed last among the attached `-A` clients, decided afresh for each
connection the agent makes, so `git push` on the remote uses the keys of the
person driving. Detach and nothing dangles: with no `-A` client attached the
daemon accepts the dial and closes it, and ssh reports the agent refused it
straight away instead of hanging. The daemon never reads the traffic it
carries — it pumps bytes between the session and your machine's agent — so
policy stays in the agent, `ssh-add -c` confirmations included.

**What this changes for local sessions:** the daemon's socket *replaces*
whatever `SSH_AUTH_SOCK` the daemon itself was started with, in every session it
spawns. Before agent forwarding, a locally auto-started daemon passed its own
inherited agent straight through, and `git push` inside a local session
worked with no flag at all; now it finds an agent that answers only while an
`-A` client is attached, and refuses otherwise. This is deliberate — a
session pointed at the daemon's agent reaches past the client watching it,
and every client would share one identity — but it does mean local sessions
need `mux -A --sock PATH` (or `mux -A` on the auto-started daemon) where
they previously needed nothing.

`-A` with no agent running is a usage error, not a silent no-op: `mux` asks
`$SSH_AUTH_SOCK` to list its identities before it attaches — the request
`ssh-add -l` sends — and exits 2 if nothing answers, including the common
case of a variable left behind by an agent that has died. Without that
check the flag was kept silently — the offer is a declaration, not a
capability — and the first sign was `permission denied (publickey)` from a
git remote inside the session.

It is a request and not a bare connect because of nesting. Run `mux -A`
from *inside* a mux session and `SSH_AUTH_SOCK` is the daemon's own socket,
which accepts every connection and only afterwards looks for a client to
route it to; a dial succeeds there whether or not anyone can answer. Asking
tells the two apart: with an `-A` client on the outer session the request is
forwarded out and the real agent replies, so the nested `-A` is allowed;
with nobody offering, the daemon hangs up and the nested `-A` is refused.
An agent that is merely slow still passes — the refusal is immediate, so
being slow is not what separates them.

`-A` is offered by the tile you named and by nothing else. `mux -A HOST`
(or `mux -A quic://HOST`, `mux -A --sock PATH`, or a bare `mux -A` on your
own daemon) forwards on that tile, and a sibling grown from it by chord
(`Ctrl-\ c`, `|`, `-`) inherits it — same daemon, same host, nothing newly
exposed. Every OTHER pane on the wall arrives from your layout file or from
the picker rather than from your command line, and those never offer an
agent: a machine you have never typed the flag for must not get your keys
because a pane of yours happens to sit on it. `mux a` never forwards, under
any flag.

## Quick start, remote over QUIC

No ssh in the path; needs inbound UDP to the host. Authentication is a
shared 32-byte key (TLS 1.3 external PSK — no certificates), so the
handshake is one round trip.

Put `mux` on the host (`scp` a static binary), then:

```sh
mux d keygen                                   # once
ssh HOST 'mkdir -p -m 700 ~/.config/mux && cat > ~/.config/mux/key \
  && chmod 600 ~/.config/mux/key' < ~/.config/mux/key       # once per host
ssh HOST 'mux d start -d --quic 0.0.0.0'          # once per host boot
mux quic://HOST                               # every attach
```

The port defaults to 4433 on both ends, and both sides look for the key
at `~/.config/mux/key` — `--key FILE` or `MUX_KEY_FILE` override it, in
that order.

**Trust model, in one sentence:** anyone holding that key file can attach
to that session, so it is exactly as sensitive as an ssh private key —
`mux` refuses one that is readable by group or other, and
there is no unauthenticated mode to fall back to.

## Quick start, a wall of sessions in a browser

```sh
mux web                      # serves your layout on 127.0.0.1:7681
mux web HOST quic://OTHER --sock /tmp/s.sock   # ...adds those daemons first, then serves
```

**The wall is your layout, in the browser exactly as in the terminal.**
The hub reads the same `$XDG_STATE_HOME/mux/layout` a terminal wall does
and serves its panes in tree order, each labelled with its daemon — `HOST`,
`quic://HOST[:PORT]` or `--sock PATH`, a line of `$XDG_STATE_HOME/mux/hosts`
verbatim — and its session. A session started anywhere else appears on no
tile; a pane whose daemon stops listing its session reads `gone` and stays.
A `HOST` on the command line is added to the hosts file (the same thing
`mux HOST` does) and then the layout is the wall; `#SESSION` on a HOST
argument is refused, because a *host* line may not name something that
could be resurrected — sessions are named by the layout file instead. A
layout the hub cannot parse is reported as `mux web: layout ignored (PATH):
LINE` and it serves an empty wall rather than a guess.

Click a tile to focus it and type in it; click the shade to come back. `+`
on a tile starts a new session on that tile's daemon, writes the new leaf
into the layout beside that pane, and drops you into it — so a birth in the
browser is on the next terminal wall and the reverse. It checks the file
for room and for a duplicate before it dials, so a full wall or a repeat
never leaves a session nobody asked for behind a 502; a second `+` inside
one poll interval is a 409. That is the whole of what the page can do to
the wall: adding and removing DAEMONS is `mux hosts add|rm` or the CLI
picker's `a`/`x`, removing a pane is `Ctrl-\ x`, and ending a session is
the CLI picker's `x` or `exit` in the shell.

Two writers in the same instant lose one update — the hub and a terminal
wall each read, modify and atomically rename the file, exactly as the hosts
file has always worked.

Localhost only, deliberately: to see it from another machine, forward it —
`ssh -L 7681:127.0.0.1:7681 HOST`.

An older `~/.local/state/mux/wall` is ignored and can be deleted; the hub
kept its own list of sessions there until this version.

## Two files: your daemons, and your wall

`$XDG_STATE_HOME/mux/hosts` is the daemons this machine may browse.
`$XDG_STATE_HOME/mux/layout` is the wall itself — the panes you opened, in
the tree you put them in. The first says what you *can* reach; the second
says what you *see*.

```sh
mux hosts                    # the daemons you can browse, one per line, with live session counts
mux hosts add HOST           # record a daemon without opening it
mux hosts add --sock /tmp/s.sock
mux hosts add quic://box:4433
mux hosts rm HOST            # take one off (its sessions keep running)
```

The file is `~/.local/state/mux/hosts` (`$XDG_STATE_HOME/mux/hosts`), one
daemon per line, in picker order:

```
--sock /run/user/1000/muxd.sock
box
quic://gate.example:4433
```

A line names a **machine**, never a session: `#NAME` on a spelling is
refused, because naming sessions is the layout file's job and a host line
must name nothing that anything could resurrect a shell from. Lines are
deduped byte for byte, so the same box written `box` and `quic://box` is
two hosts, deliberately: they are two different ways in.

Every `mux` that names a target records that target — `mux HOST`,
`mux quic://HOST`, `mux --sock PATH`, and a bare `mux`, which records your
own daemon. `--via CMD` never records one, because an arbitrary command is
not an address and the grammar has no line to write for it; the session is
real, it is just not a machine the hosts file can name. A `--via` wall
therefore records nothing at all, layout included: a leaf naming a `--via`
command would be a leaf no hosts file can ever match, and the next start
would refuse the whole layout over it. Such a run says
`[layout not saved: a --via wall is not recorded]` and leaves the file you
have alone. `mux a` records nothing
either (it attaches at 0×0 and claims nothing). Writing the file is best
effort — if it cannot be written you get one warning line and the attach
happens anyway.

Reading it is not best effort. A line the grammar refuses stops `mux` with
exit 2 and prints the line, because a wall silently missing one of the
machines you wrote down is worse than no wall. `mux hosts` is the exception
and lists everything verbatim, marking the bad one:

```
--sock /run/user/1000/muxd.sock	3
box	[unreachable]
#build	[bad host: names a session after '#': a host line names a daemon; the layout names sessions]
```

— the line, a tab, then its live session count or what went wrong. That
listing dials the hosts one at a time and waits up to two seconds for each,
so the cost is the SUM and not the slowest: two silent hosts is four
seconds. Only a host that accepts the dial and then says nothing costs the
full wait — a socket with nothing behind it is refused instantly (measured
2026-08-28, `docs/decisions.md`: 2002 ms for one silent daemon, 4002 ms for
two, ~1 ms for any number of
absent sockets). `mux hosts rm` reads the file verbatim too, so a line
you hand-edited into something no longer legal can be typed back byte for
byte and removed instead of wedging every command that touches the wall.

A daemon that is not answering keeps **every pane you put on it**, wearing
`unreachable`, for as long as the box is dark — an eight-pane setup is not
worth one quiet machine. Its line stays in the file and its poller keeps
redialling, forever and on purpose; a wall is a thing you leave up, and a
laptop that wakes an hour later should find its panes back on it. The cost
is that a daemon that is never coming back looks exactly like one whose
network is merely down. Nothing times it out; `mux hosts rm SPELLING`, or
the picker's `x` at the host level, is how a line leaves. Your own daemon is
the exception that is started rather than waited for: a listed local
`--sock <default>` that nothing is serving is started for you, because it
dies on every reboot while its line lives on.

A host that is REACHABLE and answers without one of your sessions is a
different sentence: that pane wears `gone`, because the daemon said no
rather than nothing. Enter re-creates the session in that pane's own rect,
`x` takes the pane off the wall, and every other key at it is eaten. A wall
of only gone panes is not an empty wall.

A daemon with no sessions at all is an ordinary state rather than a remote
curiosity: a daemon lives until `mux d stop`, so one whose last session you
ended sits there with nothing to show until someone is born into it again.
It contributes no pane, and a host with no pane of yours contributes nothing
either — the wall is what you authored, not what the daemons happen to have.

A wall with no panes says so on one line rather than showing a blank screen
that reads as hung, and opens the **picker** over it by itself, because a
blank screen is no place to act from; Esc leaves the one-line text standing.
That is the state you get on a machine whose hosts file has lines but whose
layout file does not exist yet — nothing reconstructs a wall for you.

`Ctrl-\` `x` on the last pane of a terminal wall therefore leaves you on
that empty wall with the picker open, not back in your shell — `x` edits
the wall, never mux. A piped `mux` is unchanged: it is a wall of one, it
neither saves nor restores a layout, and it still exits with the shell's
exit code.

**A daemon too old to be asked shows nothing.** Listing a daemon's live
sessions is a question a daemon from before that change answers with the
silence it answers every unknown frame with. It is up, `mux --sock PATH`
still attaches to it, and the picker cannot show you its sessions to add,
so `mux d upgrade` (or a restart) on every box you browse is what fills it
in. Counting who ELSE holds a session is newer still: a daemon that does
not send the count shows none, and its sessions cannot be ended from the
picker at all (`[daemon too old to end a session]`).

Restarting a daemon re-creates **nothing**. Your panes on it stay, wearing
`gone` once it answers without their sessions; Enter on one starts that
session again, in the pane's own rect. Nothing does that for you, because a
shell that came back empty is not the shell you left.

### The layout file

`~/.local/state/mux/layout` (`$XDG_STATE_HOME/mux/layout`) is the wall.
Every leaf is `HOST#SESSION`, where `HOST` is a line of the hosts file
spelled exactly as it appears there:

```
mux-layout 1
beside 0
 leaf 40 --sock /run/user/1000/muxd.sock#0
 stacked 40
  leaf 12 box#work
  leaf 12 quic://gate.example:4433#2
focus 0
```

A first run on a new machine writes the smallest one there is — a hosts
file of `--sock <default>` and a layout of `leaf 0 --sock <default>#0` with
`focus 0`.

It is written by any wall on a terminal, on every change — a birth, a
split, a resize, an `x`, a detach — so two terminals on this machine see
each other's adds on their next start. `focus N` is the one line in it that
is not authored: it records where the focus was, and it can go stale between
changes to the tree.

Reading it is strict, the way the hosts file is strict: a leaf naming a host
the hosts file does not have, a leaf with no `#SESSION` or a bad session
name, a repeated leaf, more leaves than a wall holds, or text that is not a
layout REFUSES the whole file. `mux` says `mux: layout ignored (PATH): LINE`
with the line it gave up on and then starts as if the file were missing — it
does not seat half a wall, because silently dropping panes you authored is
how you lose one. An empty file is not a bad file; it is simply no wall.

There is one thing a wall will not save: a run whose panes it could not all
seat. A terminal too small for the tree, or a layout naming the very session
this `mux` is running inside, drops those leaves for the run and says
`[layout not saved: terminal too small for 2 of its panes]` (or
`[layout not saved: it names this shell's own session]`) on the notice line.
That run then writes NOTHING — the file you have is the file you keep, and
the next start in a window that fits saves again. A save that fails for any
other reason says `[layout not saved: <reason>]` in the same place.

`mux hosts add`/`rm` are file operations only: neither dials, and `rm` never
touches the session — the shells on that machine go on running and `mux
HOST` reaches them again. A wall that is already up does not notice the
edit; the running wall's own way to add a machine is the picker's `a`
(`Ctrl-\` `s`, then `a`), and its own way to drop one is the picker's `x`.

There is no `mux wall` any more — the wall has no separate door to open, so
`mux wall` is read as a HOST called `wall` like any other bare word, and ssh
says what it thinks of that. `hosts` is the one name a subcommand claims:
`mux hosts` lists, so a machine called `hosts` is attached to as
`quic://hosts` or `--via ssh hosts mux d proxy`, though `mux hosts add hosts`
puts it on the wall by that name quite happily.

Every tile on the wall is a real attach at its own rectangle — label bar plus
the rows its rect gives it — not a thumbnail and not a read-only view. What
the unfocused ones do NOT do is claim the session: only the focused tile's
title, mouse modes and clipboard reach your terminal, so a shell painting
away in the corner cannot take the screen off you.

The keys are the ones listed under "Quick start, local" — the zoomed tile
and the wall are one program entered through different doors, so there is
one key table and not two. Every typed key goes to the focused tile; only
the `Ctrl-\` prefix is held back. `Ctrl-\` `1`-`9` focuses a tile, `Ctrl-\`
`n`/`p` walk them, `Ctrl-\` `c` creates a session on the focused tile's
daemon, `Ctrl-\` `h`/`j`/`k`/`l` move focus between panes, `Ctrl-\` `|`/`-`
split right or below, `Ctrl-\` `f` fullscreen, `Ctrl-\` `r` resize mode,
`Ctrl-\` `s` opens the picker, `Ctrl-\` `x` removes the focused pane,
`Ctrl-\` `w` zooms out, `Ctrl-\` `d` leaves. Any other command key after the prefix
is swallowed along with it, as in a plain client.

Every tile's label bar opens with its chord digit, and the focused tile's
carries a `>` beside it — `1> ` has the focus, `2  ` does not. The digit is
the order the tiles joined the wall — which is what `Ctrl-\` `1`-`9` indexes
— not their left-to-right position and not the session name. A pane splits
beside the focus while its digit is appended, so on a wall you have split or
restored the digits need not run in screen order. A digit stays with its
tile for as long as the tile is there: a session that ends leaves a hole
rather than renumbering its neighbours, and the next tile born fills the
lowest hole — end 2 of 1 2 3 and the session after it is 2, not 4. Past 9
the bar still prints the number, though no chord reaches it.

Focus is client-local: moving it sends nothing on the wire, so `Ctrl-\` `n`
and `Ctrl-\` `h` move between sessions instantly — no reconnect, no
snapshot, no flash. Every tile keeps replicating at its own rect whether
you are looking at it or not, so nothing reflows when focus moves away.

Typed input at the focused tile is predicted locally, and the wheel and the
mouse work exactly as they do in a plain client, because a focused tile *is*
a plain client's loop. The focused tile's Core owns the mouse modes; a tile
that is not focused holds none, so nothing it armed can report while you
are looking elsewhere.

The panes, where the cuts were and which pane had the focus live in
`$XDG_STATE_HOME/mux/layout` (above), written by any wall on a terminal on
every change and read back verbatim. It is authored intent, not derived
convenience: a file that will not parse is refused with its line rather than
degraded to a default cut, and the default cut — panes side by side on a
wide terminal, stacked on a tall one — is what you get only when there is no
file to open.

A scripted `mux TARGET` — stdout on a pipe — is still a wall of one pane
whose rect is the whole terminal, writing exactly what the plain client
wrote, with no other host dialled and no layout read or saved. A wall of MANY needs
a terminal to cut stripes from and says `mux: wall needs a terminal` if it
has none.

## Everything else

```sh
mux d start [-d]    # the daemon: in this shell, or -d to fork one and wait for it
mux d dump [--vt]   # debug: print the authoritative grid (what the screen *should* be)
mux d stats         # live sessions by name + clients each; wire stats (deltas vs snapshot bytes)
mux d stop          # ask the daemon to exit; dump, stats and stop also take
                    #   --quic HOST[:PORT] [--key FILE], for a daemon whose socket file is gone
mux d upgrade       # exec a newly installed binary in place; every session keeps running
mux d upgrade HOST  # push THIS binary onto HOST over ssh, then upgrade its daemon the same way
make bench         # typing-workload bytes-per-keystroke measurement
```

`mux a` is the agent-facing mode — verbs `status`, `capture`, `send`,
`run`, `await`, one JSON object each, against a local socket or
`--quic HOST`. `mux a run "make test"` sends the command line, waits for the
shell to return it, and prints the exit code with the output rows; no
polling and no sleeps, because the daemon holds the wait. It attaches at
0×0 so it never resizes the session a human is using — which also means it
can only join a session that already exists, and `--session NAME` for a name
no daemon holds prints `{"error":"attach refused"}` and exits 1 rather than
creating one. (`capture` is the exception: it reads by name without
attaching, so it answers `{"grid":"mux d: no such session: NAME"}` and exits
0.) Every reply names the `mechanism` that answered — an exit code is real
only under `marks` (OSC 133 shell integration, injected at spawn), absent
under the `pgid` and `settle` fallbacks.

Marks are **opt-in**: start the daemon with `MUX_SHELL_INTEGRATION=1` in its
environment, or `mux a` gets the fallbacks and no exit code. The injection is
off by default because it is not free on a shell you actually live in — under
zsh it points `ZDOTDIR` at a shim directory that has no `.zshenv`, and under
bash its `DEBUG` trap displaces yours (atuin, bash-preexec, iTerm2). A shell
`shellint` has no scripts for is unaffected either way.

Multiple clients may attach to one session; the grid follows the most
recently active client — typing, attaching, or resizing claims it (latest
wins). A session survives logout (this assumes systemd-logind's default
`KillUserProcesses=no`; a box configured to kill user processes at logout
kills the daemon with them), though not a reboot.
`mux d start` refuses a socket another daemon already owns; there is no
socket-stealing. It runs until something stops it — no session's exit ends
it — and exits 0 when `mux d stop` or a supervisor's SIGTERM asks; a nonzero
exit means it never got as far as serving.

Rolling a new daemon out does **not** mean killing the sessions. Install the
new binary and run `mux d upgrade` — the new binary asks the running daemon
to become it, and the daemon `execve`s it in place: same pid, same shells,
same socket, same ssh-agent sockets. Attached clients see one reconnect and
one repaint. It refuses anything that is not strictly newer, naming both
versions (`--allow-same-version` exists for the test suite and says so in
the usage text), and it refuses on any doubt — it runs the candidate first
with `--version` and then over the manifest as a dry run, and a refusal
changes nothing at all. If the new binary cannot adopt what it read, it
execs the old one back and the old one carries on serving. Scrollback does
not survive the handover; the visible grid, the titles, the command marks
and the exit codes do. A daemon too old to know the request answers nothing,
and `mux d upgrade` says so: `no reply: this daemon predates upgrade — stop
and run`. A session in the middle of being ended (the picker's `x`, up to
half a second while its shell is being hung up) refuses the upgrade with `session
ending, retry` rather than carrying a shell with no master across the exec.

The remote spelling is `mux d upgrade HOST`: one binary means the machine
you build on already holds the image every box needs, so this pushes the
running binary itself over ssh — it checks the arches match and finds the
installed `mux` first (`command -v`, with `~/.local/bin` as the fallback),
streams the image into a temp file and renames it over the install so a
dropped connection can never truncate a binary, then runs that box's own
`mux d upgrade`, which is everything above, there. A box with no daemon
running just gets the install (exit 0, and it says so); a box with no mux
at all is refused — a push replaces an install, it does not invent one.

Older daemons may not support in-place upgrade or newer session-discovery
requests. Stop and start those daemons with the current binary instead.


Copy and paste work through the session: a mux drag and an application's own
OSC 52 write both reach your terminal's clipboard (including from a remote box over QUIC,
where nothing else can), a paste arrives bracketed when the application
asked for bracketed paste, the window title follows the session, and a
bell rings. The clipboard READ direction (`OSC 52` query) is refused
deliberately — answering it would let anything in any session read
whatever you last copied.

## How it works

One real terminal engine runs, and it is the daemon's: shell output mutates
it, and clients receive a sequence-numbered stream of row deltas. A delta row
is the daemon's own cells — the glyph, the style, the wide flag — which the
client copies into a grid and paints from. No client parses a terminal escape
sequence of its own. A client that attaches — or *re*attaches after a kill,
a network tear, or a laptop sleep — tells the daemon what sequence number it
already holds, and the daemon answers with a delta instead of a repaint
whenever it can. Transport is deliberately dumb: the same frames ride a unix
socket, an ssh pipe (`mux d proxy` is a byte pump with zero protocol
knowledge), or a QUIC stream, and none of the three has ever needed a frame of
its own. The frames themselves do change: the move to cells renumbered the
replay frames and replaced their payloads, and no transport noticed.

Every decision and measurement: `docs/decisions.md`. What's next: `git-collab
issue list` (`docs/roadmap.md` is the older ranked queue).

## Status

Prototype. Mux publicly supports Linux x86_64 only. See the feature and
architecture documentation above for the current product scope.