a73x

README

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.

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.

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:

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:

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:

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

The supported Ghostty-style settings 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:

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 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:

./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:

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:

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:

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

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:

KeyEffect
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-\ ccreate a session on the focused pane's daemon, add a pane for it, focus it
Ctrl-\ n / Ctrl-\ pfocus the next / previous pane on the wall, wrapping
Ctrl-\ h j k lfocus 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-\ ffullscreen the focused pane (toggle)
Ctrl-\ rresize 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-9focus 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-\ xtake 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-\ Xend 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-\ wzoom out to the wall
Ctrl-\ sthe 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+PageDownscrollback, a screen at a time (any other key returns to live)
mouse wheelscrollback, three rows a notch (arrow keys to a full-screen app)
drag with the left buttonselect 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:

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:

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

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.

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

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 execves 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 reattaches 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.

Open Patches

Open Issues

ID Title Author
0025a395 docscheck: tier-3 span walk stops at a wrapped signature's ) line a73x
02516f97 muxg: slim the shared popover core before shipping it a73x
02e5bd20 log rotation via xdg.logPath a73x
05fc3090 picker x: this wall's own pane counts as an 'other', so ending a session you alone hold takes two presses a73x
063cec67 copying scrollback: the host terminal only ever sees one painted screen a73x
08ff372f muxg: release bundling and install on a fresh box a73x
0b725c82 muxg: end a session (end_req two-step) from pane and picker a73x
0c8680e2 release packaging: everything before v0.0.1-12 is a mix of tarballs and bare binaries a73x
0e7ec481 Dedupe the QUIC key-and-dial recipe across mux d and mux a a73x
0ebe07d8 egress_cap and the transport params: one owner for 256*1024 a73x
124a6059 sessions_reply holds lines: a released v0.0.1-16 client refuses a new daemon's long reply as unreachable a73x
1f6d027a agent forwarding: mux -A HOST (ssh, then the QUIC handoff) has no e2e leg a73x
20288f65 muxg: finish and gate recovery and force-End native popovers a73x
2195870d daemon log lines carry no timestamps a73x
28a9c6a7 e2e: pin mux d run --quic --key P with HOME unset (and MUX_KEY_FILE=P) a73x
2b62dbde no way to answer an ssh prompt: password, passphrase and unknown-host reach only the tty a73x
2fc534d6 keeps_wall: invert to an is_entry mark on tiles[0]; a lone seeded pane whose attach is refused ends mux a73x
2ff6b659 muxg: migrate the legacy session picker to the shared popover path a73x
30c77da1 mux d endpoint announces the default key, not the listener's --key a73x
32d168d1 layout file: guard against a wall that changed underneath (two terminals on one device) a73x
3b1fb324 docs: the Darwin SO_NOSIGPIPE rule is written twice under sendNoSig and sendNoSigNoWait a73x
3b6c9a8d Observers need a total-lifetime bound, not only an idle one a73x
45f73db2 xversion: epoch identity pins refuse any post-M18 'old' side a73x
48847e6d unit-layer mutation sweep a73x
48902711 mux a run --quic died once on the macOS guest with no output (xos leg 6, intermittent) a73x
4986ea6b delta: dumpVtRow allocates and frees per row, 50x per pty read a73x
50ca9ba5 muxg: agent forwarding (-A) for a pane a73x
53e912d8 test harnesses exist and half the suites don't use them (dedup sweep, server + tui) a73x
55ca6e6d wall: a pane that CREATES a session on an auto-started daemon looks identical to one that joined the real one a73x
5ec6164a per-socket daemon logs a73x
686a0668 web/mux.js MSG and KEY tables have no gate against protocol.zig and keymap.zig a73x
6889d4a4 the browser learns the layout tree a73x
68d4700e the session-list→tile diff is written twice: webhub.applyList vs wall_host.planHostDiff a73x
6aba30a0 mux web narrates reconnects only to the browser a73x
743cff83 xversion drive() types with zero settle after dial; cold containers can lose the line a73x
777e346e a recycled session name is undetectable to a client that arrives after the death a73x
77a44eac fold Transport's alloc/qout into the .quic Link payload a73x
7a667b91 keyRefusalBody's e2e coverage is loose (one grep in e2e_04_handoff.sh) a73x
7b04a2ac layout: a late-arriving tile is cut stacked on a wide wall; the aspect rule runs once a73x
7e02b965 a daemon whose socket file is deleted can never be reached by path again, and no admin verb reaches it any other way a73x
86159ab0 No way to see WHY a prediction stalled: capture a profile on a latency spike a73x
862ecafd "Every muxa reply names the mechanism" is overbroad in CLAUDE.md and decisions.md a73x
88901b5e standalone rigs re-implement e2e_lib's wait/counter helpers, drifted and unscaled a73x
8b16e26b scroll viewport: anchored history, selection, and wheel behavior a73x
92d0a0e2 quic: g_key PSK global races concurrent dials with different keys a73x
94ed7dfd retire the terminal wall: mux is strictly one target, no picker; mux web stays a73x
950b4c1d mux web: one --key for all quic tiles a73x
958b50b0 attach refusals are neither counted nor logged: five causes, one byte, no line a73x
9653cd02 e2e: assert_stopped should take a target so the --quic stop leg reuses it a73x
97965f0d e2e legibility: a scenario can die unnamed under set -eu a73x
a2ef48d0 mux d upgrade drops every client at once, so a latent dial bug fires on every wall in the same second a73x
a91002c0 Session switch leaves the previous session's terminal title a73x
af2fba08 muxg: missing ⏵⏵ glyphs and incorrectly rendered Claude icon a73x
b1d57544 xversion: the rig cannot pass client flags per leg, so -A against an old daemon stays a hand check a73x
ba8c7a03 mux d upgrade drops every client at once, so a latent dial bug fires on every wall in the same second a73x
bf612b14 scrollback does not survive muxd upgrade (v1 gap) a73x
c30f805d delta: change detection pays for change encoding (hash of rendered bytes) a73x
c50ed98f wall: a pane that CREATES a session on an auto-started daemon looks identical to one that joined the real one a73x
d61fdc4c muxg: build, run and gate on macOS — the TUI is the only client there a73x
d909b10f first-backoff tuning: reconnect pays 200ms-2s per tear a73x
df3935c1 quic: a multi-homed daemon replies from the wrong source address, so every dial silently falls back to ssh a73x
e3548ab1 wall-is-the-layout follow-ups: parked minors from the branch reviews a73x
eabd1c43 paint: renderRowsClipped is tested only at top=0,left=0 a73x
eac95cf4 mux web -A: the hub offers its own agent (phase 2, consent model decided) a73x
eec5c6e2 agent forwarding: the redial re-offer and the reverse pin are asserted below the wire a73x
f25709a3 initFromManifest: a failed adopt of the socket listener should be non-fatal a73x
f3cf5785 muxg: ssh prompts through an askpass popup a73x
f582f4f8 an unterminated OSC can grow daemon memory without bound a73x
fb4a0ee4 muxg: prediction overlay a73x
fe84d155 the wire cannot say WHY an attach was refused, so the client guesses in the message a73x

Recent Commits

Commit Summary Author Date
d1ab8e37 refactor: merge reusable picker presentation a73x 2026-09-09
df2dc36a refactor: extract reusable picker presentation a73x 2026-09-09
1b15df83 docs: add marketing documentation a73x 2026-09-09
ea607659 docs: align toolchain instructions with mise a73x 2026-09-09
eef028e2 docs: record forwarding landing validation a73x 2026-09-08
8d4f4e41 feat: add native local TCP forwarding a73x 2026-09-08
03661841 docs: require plan-first delegated changes a73x 2026-09-07
cf196f16 feat: fullscreen native panes a73x 2026-09-07
7fa72ef8 test: consolidate native scenarios and fixtures a73x 2026-09-07
a9b271c8 fix: dismiss native command hints on invalid keys a73x 2026-09-07