e5302436
docs: README restructured as a quick start
a73x 2026-08-08 14:17
Commit message
README.md
| Old | New | ||
|---|---|---|---|
| @@ -1,102 +1,121 @@ | |||
| 1 | # mux | 1 | # mux |
| 2 | 2 | ||
| 3 | Prototype terminal multiplexer: the terminal engine (ghostty-vt) runs | 3 | Terminal multiplexer where detach/reattach is **state sync, not replay**: |
| 4 | authoritatively in a daemon and replicated in the client — state sync | 4 | the terminal engine (ghostty-vt) runs authoritatively in a daemon (`muxd`) |
| 5 | instead of escape-sequence replay. See `docs/handoff.md` for the design | 5 | and replicated in the client (`mux`). Attach costs one snapshot; everything |
| 6 | and `docs/decisions.md` for decisions made. | 6 | after is row deltas; a dropped connection is a non-event. Linux only, |
| 7 | 7 | prototype (see [Status](#status)). | |
| 8 | Status: **M8 — QUIC.** A third transport arm: `mux quic://HOST:PORT --key | 8 | |
| 9 | FILE` attaches straight to a `muxd run --quic`, with no ssh in the path. | 9 | ## Build |
| 10 | Authentication is TLS 1.3 external PSK — both ends hold the same 32-byte | 10 | |
| 11 | key and nobody holds a certificate. Measured on a LAN box against ssh-via | 11 | Requires Zig **0.15.x** (ghostty pin — a newer Zig will not build the |
| 12 | in the same run: cold attach 6.9ms vs 9.8ms, and 234.1ms vs 235.7ms with | 12 | dependency). The Makefile points at a pinned toolchain path; override with |
| 13 | 75ms of emulated delay on the real path. Ten consecutive network tears (a | 13 | `make ZIG=/path/to/zig` if yours lives elsewhere. |
| 14 | real UDP blackhole) all resumed hands-off and all delta-served. The wire | 14 | |
| 15 | protocol did not change: `git diff` on `src/protocol.zig` is empty across | 15 | ```sh |
| 16 | the whole milestone, which is the third time transport has turned out to | 16 | make build # first build fetches + compiles the QUIC deps: ~30MB, a few minutes, once |
| 17 | be a swap rather than a redesign. | 17 | make test && make e2e # verify |
| 18 | 18 | ``` | |
| 19 | ### QUIC quickstart | 19 | |
| 20 | Binaries land in `zig-out/bin/`. For remote machines, build a static binary | ||
| 21 | that runs on any x86_64 Linux: | ||
| 22 | |||
| 23 | ```sh | ||
| 24 | ~/Downloads/zig-x86_64-linux-0.15.2/zig build -Dtarget=x86_64-linux-musl | ||
| 25 | ``` | ||
| 26 | |||
| 27 | ## Quick start, local | ||
| 28 | |||
| 29 | ```sh | ||
| 30 | muxd run & # the daemon: hosts your shell and the authoritative screen | ||
| 31 | mux # attach | ||
| 32 | ``` | ||
| 33 | |||
| 34 | Inside a session: | ||
| 35 | |||
| 36 | | Key | Effect | | ||
| 37 | |---|---| | ||
| 38 | | `Ctrl-\` | detach (session keeps running) | | ||
| 39 | | `Shift+PageUp` / `Shift+PageDown` | scrollback (any other key returns to live) | | ||
| 40 | |||
| 41 | `mux` again reattaches — full TUI screens included. Kill the client with | ||
| 42 | `kill -9` if you like; the session doesn't care. | ||
| 43 | |||
| 44 | ## Quick start, remote over ssh | ||
| 45 | |||
| 46 | Works anywhere ssh works. On the remote host: put `muxd` on PATH and start | ||
| 47 | the daemon once (`loginctl enable-linger $USER` keeps it alive after you | ||
| 48 | log out; systemd user units are in `contrib/`). | ||
| 49 | |||
| 50 | ```sh | ||
| 51 | ssh HOST 'setsid nohup muxd run >/dev/null 2>&1 &' | ||
| 52 | mux HOST # attach; Ctrl-\ detaches, running it again reattaches | ||
| 53 | ``` | ||
| 54 | |||
| 55 | `mux HOST` is sugar for `mux --via "ssh HOST muxd proxy"` — ssh config | ||
| 56 | (aliases, ports, ProxyJump) all keeps working, since `mux` never parses | ||
| 57 | HOST. It **is** interpolated into a shell command, so HOST must be trusted: | ||
| 58 | feeding it unreviewed from an inventory file or a cloud API is handing that | ||
| 59 | source a shell. `--via` accepts any command that exposes the daemon's | ||
| 60 | socket on stdio, for custom socket paths or transports. | ||
| 61 | |||
| 62 | If the link drops, the client reconnects and resumes by itself; `Ctrl-\` | ||
| 63 | gives up waiting. | ||
| 64 | |||
| 65 | ## Quick start, remote over QUIC | ||
| 66 | |||
| 67 | No ssh in the path; needs inbound UDP to the host. Authentication is a | ||
| 68 | shared 32-byte key (TLS 1.3 external PSK — no certificates), so the | ||
| 69 | handshake is one round trip. | ||
| 20 | 70 | ||
| 21 | ```sh | 71 | ```sh |
| 22 | head -c 32 /dev/urandom > ~/.mux.key && chmod 600 ~/.mux.key # 32 raw bytes or 64 hex chars | 72 | head -c 32 /dev/urandom > ~/.mux.key && chmod 600 ~/.mux.key # 32 raw bytes or 64 hex chars |
| 23 | scp ~/.mux.key muxd HOST: # the key and a static binary | 73 | scp ~/.mux.key muxd HOST: # the key and a static binary |
| 24 | ssh HOST 'chmod 600 ~/.mux.key; muxd run --quic 0.0.0.0:4433 --key ~/.mux.key' | 74 | ssh HOST 'chmod 600 ~/.mux.key; setsid nohup muxd run --quic 0.0.0.0:4433 --key ~/.mux.key >/dev/null 2>&1 &' |
| 25 | mux quic://HOST:4433 --key ~/.mux.key # or set MUX_KEY_FILE | 75 | mux quic://HOST:4433 --key ~/.mux.key # or set MUX_KEY_FILE |
| 76 | ``` | ||
| 77 | |||
| 78 | **Trust model, in one sentence:** anyone holding that key file can attach | ||
| 79 | to that session, so it is exactly as sensitive as an ssh private key — | ||
| 80 | `muxd` and `mux` both refuse one that is readable by group or other, and | ||
| 81 | there is no unauthenticated mode to fall back to. | ||
| 82 | |||
| 83 | ## Everything else | ||
| 84 | |||
| 85 | ```sh | ||
| 86 | muxd dump [--vt] # debug: print the authoritative grid (what the screen *should* be) | ||
| 87 | muxd stats # wire stats: deltas vs snapshot bytes, attached clients | ||
| 88 | make bench # typing-workload byte-ratio measurement | ||
| 26 | ``` | 89 | ``` |
| 27 | 90 | ||
| 28 | **Trust model, in one sentence:** anyone holding that key file can attach to | 91 | Multiple clients may attach to one session; the grid follows the most |
| 29 | that session, so it is exactly as sensitive as an ssh private key — `muxd` | ||
| 30 | and `mux` both refuse one that is readable by group or other, and there is | ||
| 31 | no unauthenticated mode to fall back to. | ||
| 32 | |||
| 33 | Previously, **M7 — reconnect.** A dropped link is a non-event: the client | ||
| 34 | keeps its replica, rebuilds the transport, and re-attaches quoting what it | ||
| 35 | already holds, so the daemon can answer with a delta instead of a repaint. | ||
| 36 | Measured over a real WAN link: 10 consecutive transport kills against a | ||
| 37 | live session, all 10 resumed hands-off, all 10 delta-served (the daemon's | ||
| 38 | snapshot counter never moved), first frame back in ~254 ms — most of which | ||
| 39 | is the client's own 200 ms first retry. `mux HOST` attaches over ssh in | ||
| 40 | one word. | ||
| 41 | |||
| 42 | Before that, **M6 — transport.** All handoff milestones complete; all kill | ||
| 43 | criteria cleared. The prototype's two founding questions are both | ||
| 44 | answered yes: ghostty-vt serves as an authoritative headless grid in a | ||
| 45 | daemon without forking it, and detach/reattach as state sync is correct | ||
| 46 | and fast — a killed client reattaches into a live full-screen `nvim` | ||
| 47 | session in ~5 ms from one snapshot, and steady-state delta traffic is 1% | ||
| 48 | of the snapshot-equivalent cost. M6 answered the third: transport is a | ||
| 49 | swap, not a redesign. Measured over a real WAN link, the protocol adds | ||
| 50 | ~4 ms to a raw ssh byte round-trip (constant, not a multiple of it), and | ||
| 51 | reattach-after-kill costs ~1.3× the round trip in protocol time on top | ||
| 52 | of ssh's own channel setup. | ||
| 53 | |||
| 54 | Requires Zig 0.15.x (ghostty pin); the Makefile points at the pinned | ||
| 55 | toolchain, override with `make ZIG=...`. | ||
| 56 | |||
| 57 | The first build also builds the vendored QUIC stack (ngtcp2 + wolfSSL, in | ||
| 58 | `deps/quic`), which **downloads ~30MB and takes a few minutes once**; every | ||
| 59 | build after that skips it. `make deps` does it on its own if you would | ||
| 60 | rather pay that cost deliberately, and `make clean` deliberately does *not* | ||
| 61 | throw it away — `make clean-deps` is the one that does. The sources are | ||
| 62 | pinned by version and sha256 and built by the same pinned Zig as everything | ||
| 63 | else; there is no prebuilt binary in the repo on purpose. | ||
| 64 | |||
| 65 | make test && make e2e # verify | ||
| 66 | make build | ||
| 67 | ./zig-out/bin/muxd run & # daemon | ||
| 68 | ./zig-out/bin/mux # attach (Ctrl-\ detach, Shift+PgUp scroll) | ||
| 69 | ./zig-out/bin/muxd dump [--vt] # debug: print the authoritative grid | ||
| 70 | ./zig-out/bin/muxd stats # wire stats: deltas vs snapshot bytes | ||
| 71 | make bench # typing-workload byte-ratio measurement | ||
| 72 | |||
| 73 | Attach over a network: | ||
| 74 | |||
| 75 | ./zig-out/bin/mux HOST # sugar for the --via line below | ||
| 76 | ./zig-out/bin/mux --via "ssh host /path/muxd proxy --sock /path/muxd.sock" | ||
| 77 | |||
| 78 | `mux HOST` runs `ssh HOST muxd proxy`, so muxd must be on HOST's PATH; | ||
| 79 | anything more specific (a custom socket, a non-default path) is what | ||
| 80 | `--via` is for. `mux` itself does not parse HOST, so ssh's own config — | ||
| 81 | host aliases, ports, ProxyJump — keeps working. It is interpolated into a | ||
| 82 | shell command, though, exactly as the `--via` string is: **HOST must be | ||
| 83 | trusted**. Feeding it unreviewed from an inventory file or a cloud API is | ||
| 84 | handing that source a shell. If the link drops, the client reconnects on | ||
| 85 | its own and resumes the session; Ctrl-\ gives up waiting. | ||
| 86 | |||
| 87 | `--via` runs the protocol over any command that exposes the daemon's | ||
| 88 | socket on its stdio — `muxd proxy` is one such command, and it is a | ||
| 89 | frame-agnostic byte pump that contains no protocol knowledge at all. | ||
| 90 | `test/wan.sh` is the env-driven harness that measured this over a real | ||
| 91 | WAN link (see the script header for the variables it needs). | ||
| 92 | |||
| 93 | Multiple mux clients may attach to one session; the grid follows the most | ||
| 94 | recently active client — typing, attaching, or resizing claims it (latest | 92 | recently active client — typing, attaching, or resizing claims it (latest |
| 95 | wins). | 93 | wins). With linger enabled a session survives logout, though not a reboot. |
| 96 | 94 | `muxd run` refuses a socket another daemon already owns; there is no | |
| 97 | Detach, or kill the client outright — the session survives and `mux` | 95 | socket-stealing. |
| 98 | resumes it from a state snapshot, including TUI screens and the primary | 96 | |
| 99 | screen behind them. Scrollback is fetched lazily (Shift+PageUp/PageDown | 97 | ## How it works |
| 100 | pages; any other key returns to live). systemd user units (socket | 98 | |
| 101 | activation) live in `contrib/`; with `loginctl enable-linger` a session | 99 | Both ends run a real terminal engine. The daemon's is authoritative: shell |
| 102 | also survives logout, though not a reboot. | 100 | output mutates it, and clients receive a sequence-numbered stream of row |
| 101 | deltas. A client that attaches — or *re*attaches after a kill, a network | ||
| 102 | tear, or a laptop sleep — tells the daemon what sequence number it already | ||
| 103 | holds, and the daemon answers with a delta instead of a repaint whenever it | ||
| 104 | can. Transport is deliberately dumb: the same frames ride a unix socket, an | ||
| 105 | ssh pipe (`muxd proxy` is a byte pump with zero protocol knowledge), or a | ||
| 106 | QUIC stream, and the wire protocol has survived all three without changing. | ||
| 107 | |||
| 108 | Design: `docs/handoff.md`. Every decision and measurement: `docs/decisions.md`. | ||
| 109 | |||
| 110 | ## Status | ||
| 111 | |||
| 112 | Prototype, eight milestones in, first trial cut tagged `v0.0.1-1`. All | ||
| 113 | founding kill criteria cleared, measured on real networks rather than | ||
| 114 | loopback: reattach into a live full-screen `nvim` in ~5ms from one | ||
| 115 | snapshot; steady-state delta traffic ~1% of snapshot-equivalent cost; the | ||
| 116 | protocol adds ~4ms to a raw ssh round trip (constant, not a multiple of | ||
| 117 | RTT); ten consecutive real network tears resumed hands-off, all | ||
| 118 | delta-served, on both ssh and QUIC transports; QUIC cold attach beats | ||
| 119 | ssh-via (6.9ms vs 9.8ms clean, 234.1ms vs 235.7ms with 75ms emulated | ||
| 120 | delay on a real path). Not yet here: daemon auto-start on attach, | ||
| 121 | predictive local echo for high-RTT links, reconnect backoff tuning. | ||