docs/roadmap.md
Ref: Size: 39.2 KiB History
# Roadmap
Entries before 2026-08-28 predate the one-binary move and name `muxd`,
`muxa` and `muxweb`, which are now the `d`, `a` and `web` modes of `mux`;
paths they cite as `src/X.zig` live under a domain folder. They are the
record of what was decided and are left as written.
> The wall became a list of DAEMONS on 2026-08-28 (decisions.md, same date):
> every wall-file item below describes the old attach-history file and is
> closed. The hub's move onto `hosts` is phase 2 and is the live item.
> This file is the ranked queue as of 2026-08-16 and has not moved since;
> the tracker is the live order and `decisions.md` is current. Sections
> below marked complete are accurate; the ranking is not.
The forward view, one item per line, ranked. History and evidence live in
`decisions.md` — every item here points back at its entry there. Update
this file at each milestone close and whenever the queue reorders; the
queue's order is set by the user, not by this file.
**Now:** M1–M18 complete, plus the agent surface and the wall; published as
a Linux tarball, tagged through `v0.0.1-10`; in
field trial on real VMs, and the trial is now producing the queue.
Trial feedback outranks everything below — what actually hurts in use is
better data than any of this ranking, and the proof is that the three
items which headed this list a week ago got there by hurting, not by
being predicted, and shipped as M13.
**Target, stated 2026-08-15:** replace tmux as a daily driver. An agent
driving a session is a nice-to-have behind that, not a peer. The first
consequence was immediate — shell integration became opt-in, because its
two known costs land on the human's shell and only `muxa` reads what they
buy (decisions.md, 2026-08-15). The queue below is ranked against the old
framing and is being re-ranked against this one; the tracker
(`git-collab issue list`) carries the live order.
## M9 — prediction (speculative local echo) — complete
**Verdict:** cleared. A predicted keystroke paints in **0.1ms** while the
same keystroke's authoritative echo — and unpredicted input on the same
connection in the same run — takes **154ms** over a 150ms path. Echo
latency has stopped being a function of the round trip where prediction
applies, and is provably absent where it must not (decisions.md, M9).
Prediction is an *overlay* on the replica, never fed into it; the daemon
ships the pty's mode bits so disabled contexts are known rather than
guessed. The blocking pre-requisite — the RTT-multiple criterion form — was
discharged: thresholds are now stated in absolute units with an explicit
validity floor, and a harness prints NOT EXERCISED below it rather than
PASS.
Two findings outlived the milestone. The everyday shell prompt is
icanon=0/echo=0 (readline echoes for itself), so it is the adaptive tier
and not the always-predict one. And the hunt for a one-in-hundreds test
failure found ngtcp2 being re-entered on **every** QUIC exchange —
constant, with a rare consequence — which is fixed and, unusually, was
proven so by a mutation that fired immediately.
## M10 — QUIC ergonomics — complete
**Verdict: cleared on the real box.** The whole QUIC story is four
commands — `muxd keygen`, one ssh line placing the key, `ssh HOST 'muxd
start --quic 0.0.0.0'`, `mux quic://HOST` — no port or key path typed
anywhere, trust posture unchanged. Kill criterion held on the LAN box:
first-attach success from a clean slate, a marker surviving full ssh
logout, ten attach/detach cycles with exactly one daemon counted by
tracked pid (decisions.md, M10). systemd is deleted, not deferred: the
contrib/ units and `LISTEN_FDS` activation are gone, with the removal
commit as the resurrection reference. En route: a panic on `muxd
start`'s own first-run-mistake path, two plan mutation gates that could
not fail, and a third cross-binary constant drift — all fixed, all with
the rule recorded ("assert the literal, never the constant the code
under test reads").
## M11 — e2e hardening — complete
**Verdict: cleared, both legs.** The suite has stopped asserting that a
marker string appeared and started asserting that the screen the client
painted equals the screen the daemon holds: **22 convergence points over
10 scenario checkpoints**, both pinned as literals, every scenario ending
in a convergence check with the declined exceptions named where they are
declined — and `SOAK_N=10 make soak` at **10/10 on the shipping code**,
~116s a run. Then the harness was graded the only honest way, by breaking
the product 18 times and running each break against both assertion sets
out of the same binaries: old suite **7 of 18**, all via counters and
markers; new suite **13 of 18**; the six-row delta is all paint, overlay
and clip — the byte-blind class, a wrong picture drawn from right bytes
(decisions.md, M11, which carries the table in full). Zero rows ended
undecided. En route the campaign found a real bug in unmutated code — a
reconnect taken while scrolled left prediction silently off until the
user happened to press Shift+PageDown — and a computed timing knife-edge in the burst scenario
that had been sitting on the expiry bound exactly, whose formula also
explains the M9-era `DELAY_MS=400` collapse retroactively. The rule
extracted: **a timing margin is a number you compute, not a sentence you
write**.
## M12 — ptyclient — complete
**Verdict: cleared, both legs.** The tty gate is open: the e2e client
now runs on a real pty, so the branches that were unreachable for the
whole of M11 — raw mode, the alternate screen, true dimensions,
SIGWINCH, scroll mode — are driven and graded. Leg 1 held at **13
scenario checkpoints over 25 convergence points**, both pinned as
literals, every new scenario ending in a convergence check, with
`SOAK_N=10 make soak` at **10/10 on the code that ships**. Leg 2 is
three for three: campaign rows 7 and 18, which M11 had to bank as
ungradeable because the mutated code never ran, and the 412f38f revert,
which had no e2e pin of any kind, were each resurrected and each caught
at exactly the check that predicted them (decisions.md, M12, which
carries the regrade table and the failure lines). En route, three
scenario shapes died under a loaded gate — one of them a clean build
failing with the mutation's own signature — and the rule extracted is
**an assertion about order must be an assertion the bytes can actually
carry**.
## M13 — trial friction: auto-start, `muxd stop`, error audit — complete
**Verdict: cleared, both legs.** The three field findings that headed
this list are closed. `mux user@host` and `muxd proxy` against a box
with no daemon now start one — one helper, `spawn.ensureForAttach`, at
both call sites, spawning a **bare** `run --sock` (never `--quic`), no
opt-out, and the warm path stays silent by contract. `muxd stop` exists
as a protocol verb (`stop_req = 0x07`), armed on both dispatches because
a connection is an *observer* until it attaches; it is idempotent when
nothing is listening (exit 0) and honest when a daemon is wedged —
still-answering, exit 1, and it names the log if the daemon was started
detached. The audit reworded six messages and added two refusals, the
`sun_path` one replacing a 2s timeout story with an instant named no:
**2.006s → 0.0014s**. Leg 1 held at **15 scenario checkpoints over 28
convergence points**, both pinned as literals, with `SOAK_N=10 make
soak` at **10/10 on the code that ships** (414d282). Leg 2 is three for
three: the proxy call site, both `stop_req` arms, and the `--via` death
message were each resurrected and each caught at exactly the check that
predicted them, printing the predicted text (decisions.md, M13, which
carries the table and the failure lines). En route: auto-start turns "a
second daemon on another socket" from an explicit act into an ssh side
effect, which is why the daemon log is now opened **append-only** and
truncation is reserved for `muxd start`; and three review prescriptions
were rejected with evidence, which is the adversarial flow running in
the direction it is easier to forget.
## M14 — ssh→QUIC handoff — complete
**Verdict: cleared, both legs.** `mux HOST` no longer chooses a
transport; it earns one. An ssh to `muxd endpoint` fetches the daemon's
QUIC port and key as a mandatory one-line announce, the session moves
onto QUIC and the coordination ssh is reaped, and the coordinates are
cached so every later attach dials direct with no ssh process at all.
Leg 1 held at **20 scenario checkpoints over 33 convergence points**,
both pinned as literals, with `SOAK_N=10 make soak` at **10/10 on the
code that ships** (d9b17d5). Leg 2 is three for three, each catch
legible at the check that predicted it. The kill criterion held on the
real LAN box with the one allowed manual step: cold attach **244ms**
with the ssh child observed dead while the session went on answering,
warm attach **13ms** with zero ssh spawned, and a blackholed UDP port
still landing the session over ssh, one deadline and one line later
(decisions.md, M14, which carries the numbers and the regrade table).
En route: the announce had to become **mandatory in both directions**
because silence and a slow ssh are indistinguishable at the reading end
— `endpoint none` is now an explicit negative — and the deadline was
pinned from measurement showing there was **no fast-failure case** at
all, not even a refused loopback port *(refusal now fails fast as of
M15: 1ms, local and on the LAN box)*.
## M15 — refactor: typed invariants, single owners, module seams — complete
**Verdict: cleared.** Twelve tasks, each run as implementer + spec
reviewer + quality reviewer, landed in **37 commits without moving the
product**: the suite held at **20 scenario checkpoints over 33
convergence points** from first commit to last, `SOAK_N=10 make soak` was
**10/10 on the code that ships**, and the unit layer grew **245 → 255**
tests. Four modules were carved out with their tests — `paint.zig`,
`quic.zig`, `delta.zig`, `sockpath.zig` — and `server.zig` fell **4708 →
4474**, `quic_server.zig` **2412 → 1972**.
Exactly **two behaviours changed**, both in QUIC dialling and both the
same omission seen from two sides: nothing distinguished a refusal from
silence. A refused dial went **15001ms → 1ms**, and `quic://` got an
attach budget of its own instead of inheriting the 15000ms idle timeout.
The 2000ms deadline stands and silence is still what sets it — a
blackholed address costs 2002ms, a wrong PSK 2012ms. Confirmed on the
LAN box against a deliberately **M14-era daemon**, which made the same
run a cross-version compatibility check: refusal 1ms, blackhole 2002ms,
and an M15 client attaching, painting and detaching cleanly on the old
daemon. The named cost is that `--quic-idle-ms` no longer raises the
attach budget by accident; that escape hatch is gone, and irrelevant
below ~645ms RTT.
Leg 2 is three for three, and **all three were caught at the unit
layer** — `make e2e` was never needed, which is a third argument for the
unit-layer mutation sweep still banked below, at seconds a row instead of
two minutes. En route, the surveys that fed the plan were found to
**locate structure correctly and undercount instances every time** (17
union edit sites were 33, 5 path
literals were 6, 8 message classes were 12), which forced **nine plan
amendments, each made at source**; and most of M15's test growth is
coverage that had never existed — the refusal wire bytes, `writeFrame`'s
golden bytes, the transport dispatch decision, `openFailure`'s 12 message
classes and `keyRefusalBody` all got their **first** pins
(decisions.md, M15, which carries the tables and the failure lines).
## M17 — web client: a wall of devices in a browser — complete
Shipped 2026-08-13 (04861b7..002d1f7 + close-out). **Field verdict:
PASSED, same day** — three real devices (desktop `--sock`, LAN box
`quic://:4433`, WAN box through the LAN UDP door `quic://:8787`) live
in one page under headless Chromium/CDP with screenshot evidence at
every stage: all three tiles `up`; LAN tile zoomed; vim run and its
file verified on the box ("web wall drives vim over quic"); 200 lines
of history wheeled into (`history -3`) and back; unzoom via the shade
ring; a concurrent 80×24 CLI client (ptyclient) rode the whole session
and **CONVERGED byte-clean** against the daemon's grid on detach; UDP
4433 firewalled at the box → tile `reconnecting` while neighbors
stayed `up` → rule removed → `up` with the grid content intact across
the epoch; page + /tiles served through an ssh tunnel (−R; the box has
no ssh route back, so −L was exercised as its mirror image). Two field
findings, filed below in the candidates: muxweb has ONE `--key` for
all quic tiles (per-box keys cannot mix), and the deployment habit of
per-box `muxd keygen` had left desktop/LAN/WAN with three different
keys — silent mutual-auth refusal presenting as "did not answer" on
every dial. Trial unified the boxes on a fresh key; the CLI observer
clip also confirmed renderClipped's top-left window is what an
undersized client sees of a larger grid.
What shipped: `muxweb` (localhost-only hub; tiles are argv in mux's own
TARGET spellings; one WebSocket per tile; std.http's upgrade with the
Origin gate ours), `replica.zig` (the replay core extracted from
`session()` and unit-tested for the first time), `keymap.zig`,
`mux_core.wasm` (ghostty-vt on wasm32-freestanding, 345KB, flat
damage-hinted viewport ABI, verify.js wired into `make test`), the
canvas shell (1×1 passive wall tiles — the daemon's degenerate-size
refusal is the passivity mechanism, pinned hub-free; zoomed-only
input; IME; once-wrapped chunked paste), and `wsclient` (a scripted
replica client speaking RFC 6455). Suite grew 20/33 → **23 scenarios /
35 convergence points**, units 255 → 285, plus 76 wasm-ABI assertions.
The process record is in decisions.md M17: no in-loop reviews ran (the
execution fork could not spawn subagents); the post-hoc pair found
1 Critical + 5 Important, every one in the untested JS layer, and the
fix rounds added-then-caught a Critical of their own (the headFrame
u64 overflow — 14 bytes could panic the hub). Banked follow-ups, led
by the headless browser-boot automation ("the page never ran" was the
milestone's defining defect), are listed there.
## Agent surface — native LLM integration — complete
Shipped 2026-08-13 (ac9373f..cbd7007 + close-out), twelve tasks. An agent
driving a mux session now *knows* when a command returned, with its exit
code and the rows its output occupies, over a unix socket or over QUIC —
which is the signal `tmux send-keys` + `capture-pane` structurally cannot
give and the reason this exists. What shipped: OSC 133 marks injected at
spawn (`shellint.zig` — zsh `ZDOTDIR` shim, bash `--init-file`, fish
`vendor_conf.d`), a mux-owned ghostty-vt stream handler that surfaces them
as row-stamped events, a pure command state machine (`cmd.zig`), three
frame pairs (`cmd_state`, `await_req`/`await_reply`,
`status_req`/`status_reply`) that ride `muxd proxy` untouched, server-held
awaits with a pgid edge and a settle floor under the marks path, and
`muxa` — a fourth binary, five verbs, one JSON object per verb, attaching
at 0×0 so an agent never claims the human's grid. e2e is `test/agent.sh`,
**9 scenarios**, including a QUIC tear healed mid-await with the command
proven to have run exactly once. The decisions worth reading before
touching it are in decisions.md: rows are locators and not anchors, an
await is answered from `last_return` and never from live tracker state,
and only `mechanism == "marks"` carries a real exit code.
**Field limitations, shipped knowingly** (each is a roadmap item, none is a
blocker):
*(Both shell-shim costs below were defused on 2026-08-15 by making the
injection **opt-in** — `MUX_SHELL_INTEGRATION=1` — once the target became
"replace tmux as a daily driver". Nobody pays them unless they ask for
marks. The fixes are still owed, because anyone who does ask still pays;
they are no longer on the default path. See decisions.md, that date.)*
- **zsh loses `~/.zshenv`** under the `ZDOTDIR` shim — the shim directory
has none. A ghostty-style `.zshenv` shim is the fix.
- **The bash shim's DEBUG trap displaces the user's**, silently: that is
bash-preexec, atuin and iTerm2. Coexistence via bash-preexec detection is
the fix. Two membership-guard limits stand meanwhile: a typed command
textually identical to a `PROMPT_COMMAND` member loses its marks
(degrading to pgid/settle), and a compound string member (`a; b`) can
re-arm the guard — arrays, the modern default, are exact.
- **A SIGKILLed daemon orphans its `mux-shellint-{pid}-{random}` directory**; a
startup sweep of dead-pid directories is owed. Injection failing is
always degraded and never fatal.
- **`--timeout N` can overshoot N** by the client's grace window (2s over a
socket, up to 30s over a slow QUIC handshake): an agent budgeting wall
clock must add it.
- **`muxa` has no idle-timeout flag**, deliberately — `--timeout` is the
only bound on a wait.
**Deferred, and named** (the spec's non-goals plus what review added): an
MCP server wrapping `muxa`; read-only or capability-scoped auth (one key =
full control still); input attribution; semantic event subscriptions
beyond the `cmd_state` push; `muxa drive` as single-shot sugar; the
`.zshenv` shim; bash-preexec coexistence; the shim-directory startup
sweep. The `parseQuicAddr`/`resolveHost` dedup that review endorsed is
**paid**, not deferred — one owner in `quic.zig`, landed after the e2e had
pinned the spellings.
**Dropped in execution, not decided — the one item here that nobody chose:**
`muxa capture --diff-since SEQ`. The spec promises it three times and no
code was written; the whole-branch review found it. A future one needs a
protocol field of its own for the live stream seq, because `status_reply`'s
seq now carries the return watermark — right for awaits, wrong for a diff
to quote (decisions.md). Two coverage narrowings ride with it: the **fish**
injection arm is unit-tested only (no fish on this box; zsh and bash are
driven live), and the spec's **version-skew fixture** was never built (the
behaviour is pinned through the dead-daemon paths, an old binary is not).
And `muxa capture` is **the one verb with no automated coverage at all** —
`test/agent.sh` drives run, await, send and status; capture is where
`--diff-since` would land, so the test and the flag are owed together.
*(Closed in M18: the multi-session e2e block drives `capture --session`
and asserts it returns one session's grid and not its neighbour's.
`--diff-since` is still owed.)*
## M18 — multi-session daemon — complete
Shipped 2026-08-15 (e7285d9..2b1c9bb + close-out). One daemon now holds
up to **four** independent sessions — engine, pty, tracker, epoch,
command tracker and name each — over **one socket path, one QUIC port,
one key**. The wall can finally show the same host twice, which is what
the milestone was for.
**A connection is a session.** The attach payload grew an optional UTF-8
name tail after its fixed 20 bytes and nothing after attach changed: no
frame tagging, no QUIC stream surgery, the transport still one bidi
stream per connection. Same-host tiles are separate connections to the
same port. Attach-or-create is tmux-style; a session ends when its shell
exits and the daemon exits with the last one. `status_req`, `await_req`
and `debug_dump` grew the same tail, which is what lets `muxa` and `muxd
dump` address one session of several without attaching to it.
**Field-verified 2026-08-15 on both real boxes.** They had been on
**0.0.1-3** — behind the agent surface as well as M18 — and a site power
cut had taken them down, which made the upgrade window a clean one: no
daemon running on either, so nothing to tear down and no session to lose.
Static musl `muxd` (stripped, 23MB → 8.6MB) installed to
`/usr/local/bin`, identical sha256 on both boxes and on the build host,
then `muxd start --quic` on each. On the LAN box over real QUIC, and on
the WAN box over QUIC through the LAN-side UDP forward (the gate carries
TCP only): two dials, `--session a` and `--session b`, produced
`sessions=3 session 0 / session a / session b` on **both** boxes, with
each session holding its own marker and neither holding its neighbour's,
and the default session holding neither. The milestone's central claim,
on real hardware over a real link.
Two deployment notes worth keeping. `muxd --version` still prints
**0.0.1-5** after the upgrade — M18 is untagged, so the version string
cannot tell you whether a box has it; `muxd stats | grep sessions=` is
the check that can, which is the same discriminator the banked
capability probe uses. And the trial PSK survived the power cycle
identical on both boxes (`ba046148…`), so the M17 key-drift failure did
not repeat — fingerprint before blaming the network stays the rule.
**One name, one session, either door.** The session table is the
daemon's, not the accepting listener's, so `--session a` reaches the same
shell over QUIC as over the unix socket, and two QUIC dials to one port
are two shells. Pinned end to end by the QUIC block: two dials into two
sessions, then a socket client joins one of them and both clients'
markers land on one grid with the session count unmoved. That also makes
a mixed-transport wall of a single host work — `muxweb HOST#a
quic://HOST:8787#b` is tile 0 over ssh and tile 1 over QUIC against one
daemon. (`muxd run --sock PATH --quic ADDR` binds both; ssh needs no
listener of its own, since `muxd proxy` pumps into the same socket.)
**The compat story held**, measured against main @ 5d41e10 (the branch
point) in both directions. An empty name is the default session and
encodes to byte-identical pre-M18 payloads: an M18 client drove a
pre-M18 daemon's default session; a pre-M18 client drove an M18
daemon's and created nothing (`sessions=1`, `session 0`); and a pre-M18
`muxa`'s empty `status_req` still answered. The known limitation is
documented rather than fixed, and turns out to be **two** failures
split by transport — socket/ssh get a clean exit 1 (the old
`serviceObserver` drops the connection), **QUIC hangs** (the old
`handleFrame` ignores the frame in silence). Remote boxes are the QUIC
ones, so a client upgraded ahead of its box hangs with nothing printed;
a bounded first-snapshot wait on a named attach is banked as the cheap
mitigation. See decisions.md.
**One real defect, found by the new e2e and not by review** (f25f62c).
`forkpty` returns the master without CLOEXEC, so every session spawned
later inherited every earlier session's master — one spare `/dev/ptmx`
per predecessor, riding through exec into a shell that had no idea it
held it. A master with a second holder never sees its last close, so
`Pty.deinit`'s close stopped hanging up, the interactive shell ignored
the SIGTERM that followed, and the blocking `waitpid` never returned:
`muxd stop` unlinked the socket, printed `muxd: stopped`, and left the
daemon in `do_wait` forever with every session's shell alive. Strictly
unreachable at one session per daemon. The pin closes a master while a
later session is live and polls for the hangup with a deadline — the bug
is a hang, and a hung `zig build test` step prints nothing at all.
**The cross-version check stopped being hand-rolled** (`test/xversion.sh`,
`make xversion`). This tree's client against a previous version's daemon
and the reverse, each daemon in a container of its own, both sides built
static musl so one artifact runs in the container and on the host. Eight
legs: both directions on the default session, both halves of the
`--session` limitation, an old `muxa`'s empty `status_req`, the
`stats_req` version discriminator the banked mitigation would need, and a
teardown observed rather than claimed. It exists because M18's by-hand
run found a *documentation* error days after the sentence was written; a
gate finds that on the next run.
Two things about it stated rather than left implied. **Containers share
the host kernel**, so nothing there validates pty, poll or fd semantics
against a different one — most of what muxd is. That needs VMs and is not
claimed. And the rig is **falsifiable on purpose**: point `XVER_OLD_BIN`
at the new binaries and legs must fail. Doing exactly that caught a false
pass in the gate's own first draft — the QUIC arm keyed on the client's
exit code, and a daemon that HONOURS the name also leaves the client
attached until `timeout` kills it, so 124 meant both things. It now asks
the daemon whether a session called `zz` appeared, which only a daemon
that understood the name can produce.
`muxa capture` is **no longer the one verb with no automated coverage**:
the M18 block drives it by name and asserts it returns one session's
grid and not its neighbour's. `--diff-since` is still owed.
## The wall is the home screen — complete
Shipped 2026-08-18..20 (the runtime wall, the wheel, then `f9e45eb..180db14`).
`mux [TARGET]` is now a wall of one tile entered zoomed, and `mux wall` is
the same program entered on the wall. There is **one interaction loop in the
tree, and it is a tile pump** — `client.zig`'s session loop is gone.
**Zoom is a lens, not a spawn.** An unzoomed tile claims nothing; zooming
resizes that tile's existing connection from 0×0 to the terminal and starts
forwarding keystrokes down it, so the client that types is a full-size one
claiming under latest-wins like any other. Nothing is dialled, no frame was
added, and `server.zig` did not change by one line. Demote is client-local
and sends nothing: a 0×0 "relinquish" would be refused by `applySize`
anyway, and a tile that is never active again claims nothing under
latest-wins. Moving the zoom is therefore a repaint, not a reconnect — every
tile's pump keeps running, so every replica is hot and `Ctrl-\` `n`/`p`/`l`
land instantly.
**The wall file became attach history.** Any attach that claims the grid
records its own tile, and the seam is inside the pump rather than in
`mux_main` — the chord switches re-dial without ever returning to `main`, so
a seam up there would have recorded the session you started at and none of
the ones you visited. It fires on the first STATE, not on a successful dial:
a dial that comes up can still be refused, and a refusal that had already
written its line would strand a tile naming a session that never existed.
That is why nothing ever needs to un-record. `muxa` and `--via` record
nothing; `mux wall`'s argv is a view, not an attach; `muxweb`'s argv ADDS
rather than replaces, because the file stopped being "the last wall muxweb
was told to show" the moment attaches started writing to it. Forgetting
stays explicit: the page's `×`, the wall's `x`, `mux wall rm`.
**The wheel, and who owns the mouse.** Spinning the wheel used to walk the
shell's history, because nothing in mux read the wheel at all: a terminal
asked for no mouse reporting answers a notch on the alternate screen by
synthesising arrow keys (DEC 1007), and those went to the pty as input. The
client now asks for real wheel events (1000 in 1006) and spends them on the
scrollback it already had — asking is also what stops the synthesis, so one
change removes symptom and cause. An application that wants the mouse still
gets every byte of it.
**What the convergence had to invent.** A session that ends under the zoom
ends `mux` with the shell's exit code when it was the wall's only tile — `mux`
is what people put in scripts — and drops to the wall when it was not,
landing on `[exited]` beside the survivors. `w` unzooms and `d` detaches and
leaves; they were the same key while the zoom was still a child-spawn's
replacement, and are not any more. `Transport.adopt` carries the dialled
connection from the main thread to the pump, legal only while the out-queue
is empty — the one moment between `open` and the first frame, asserted
because there is no other.
## Side-channel passthrough — complete
Shipped 2026-08-16. The three discovery issues are now accounted for:
`7c777ec6`'s OSC 52 **set** reaches the host clipboard (the query direction
is deliberately refused, with a 64 KiB cap); `ee062dd9`'s bracketed-paste
half is mirrored; and `063cec67` now has implementation evidence for its
**muxweb** half: mouse-drag selection reaches daemon scrollback and explicit
copy. Native CLI keyboard copy mode remains a separate problem. Window-title
state and bells rode the same delivery mechanisms.
What remains banked is intentionally narrower: that native CLI copy mode
itself, and OSC 10/11 colour queries. Terminal mouse-mode mirroring shipped on
2026-08-19 with the wheel (decisions.md): the client asks its own terminal for
wheel events and mirrors the session's mouse modes when an application asks for
them. It was banked because its reporting takes native drag selection away
until the CLI has a copy mode — that cost is real and now paid, with Shift+drag
(which every terminal in use here honours) as the fallback, which makes the
copy mode more wanted rather than less.
Browser copy/paste and mouse selection shipped on 2026-08-18: the CLI and
web/WASM consume one shared semantic decoder, browser paste follows the
sampled DEC mode 2004 state, accepted OSC 52 payloads reach a guarded
Clipboard API path, and mouse dragging paints immediately while daemon-owned
selection extraction decides what is copyable across live rows and
scrollback. Only the zoomed tile may act on either clipboard path. Automated
coverage pins those semantics, and the nine-case Firefox interaction matrix —
soft wraps, hard breaks, wide cells, history auto-scroll, clipboard
permissions, and Inspector-shortcut preservation — was walked by hand against
a live daemon. The cross-version gate also
proves, over socket and QUIC, that a real
v0.0.1-5 client ignores the new daemon-to-client frames and keeps parsing.
## M16 candidates — and still outranked by trial feedback
What remains is what was ranked behind M13 and M14 and survived M15,
plus the seven items M15 created. Nothing here is a field finding; the
next one that arrives outranks all of it.
- **Probe for named-session support before sending a named attach** —
M18's measured cross-version gap. `--session NAME` against a pre-M18
daemon is dropped cleanly on the socket path but ignored in **silence
over QUIC**, where the client hangs with nothing printed. The two
differ because a pre-M18 QUIC connection takes a client slot at
`quicOnOpen`, so its attach reaches `handleFrame`'s silent
`decodeAttach catch return`, while a socket connection is an observer
until it attaches and `serviceObserver` closes on a bad one.
A `hello`/version frame cannot fix this: `MsgType` is non-exhaustive
and both switch arms end in `else => {}`, so an old daemon ignores an
unknown frame in exactly the silence that is the bug. What works is a
probe using a verb old daemons ALREADY answer — `stats_req`, which
pre-M18 `handleFrame` serves without an attach, on QUIC included — and
reading the reply: M18's stats text carries `sessions=`, pre-M18's
does not. Zero protocol change, effective against shipped binaries,
and it yields a true message rather than a timeout's guess.
Gate it on the capability and refuse only the ATTACH, never the
connection: an unnamed attach is byte-identical to pre-M18 and works
today, so a version-equality check would break configurations that are
currently fine. Ranks above the rest of this list for anyone running a
wall against boxes that upgrade on their own schedule, which is every
wall. A `hello` frame is still worth adding for its own sake — free,
safe, and it makes the NEXT version pair a negotiation instead of
another archaeology exercise.
- **A refused attach should say which refusal it was** — M18 review
finding. `resolveSession` returning null is answered with
`exit_status 1` for four different causes: a name that failed
validation, a full session table, an unknown name with no size to
create at, and a spawn that failed. That byte is also what a shell
exiting 1 sends, so five distinct outcomes arrive identical. The
full-table case was the model — "the same honest no a full client
table gives" — and it is honest there, where there is genuinely one
answer; it stopped being honest once four causes started sharing it.
The evidence that this costs something is that **the tests cannot read
it either**. `test/e2e.sh` has to disambiguate through a side channel
(`muxd stats | grep sessions=`), and `test/xversion.sh` had to invent
`assert_name_not_honoured` to ask "did a session appear?" because the
reply itself could not say. When a protocol's own suite has to go
around the answer to find out what happened, an operator staring at a
bare exit code has strictly less to work with.
Not worth a protocol change on its own — bank it with the probe above,
which is the other half of the same story and would land in the same
place. The cheap client-side half needs no wire change at all: on a
**named** attach that never produced a snapshot, `mux` can print "the
daemon refused the attach or the shell exited 1" instead of exiting
silently. That one line covers the case an operator actually hits,
which is a typo in `--session`.
- ~~**`drain()` swallows `ECONNREFUSED`**~~ — paid by M15 (Task 4). Both
socket paths now route the refusal through one verdict, and an
unreachable port fails in **1ms** instead of a full deadline
(decisions.md, M15).
- ~~**The transport recipe as a `union(enum)`**~~ — paid by M15 (Task 6).
`Transport.open` takes `union(enum) { sock, via, quic, hand }`, and the
dispatch decision it makes got its first pin in the same task.
- ~~**The stat-after-close reorder in `deinit`**~~ — paid (M16-b,
c492eb2). Review found the old order was worse than vestigial: a
successor cannot pass `claim()` while our listener fd is open, so the
stat-before-close guard could never fire in the very race it guarded.
The residual stat→unlink gap is the floor — Linux has no
inode-predicated unlink.
- **`egress_cap` ↔ transport-params, one owner** — also M15. `256*1024`
is spelled three times and the tie between the copies is prose only.
- **Fold `Transport`'s `alloc`/`qout` into the `.quic` `Link` payload** —
deletes the struct's one remaining `undefined`.
- ~~**`Pty.write` is dead in production**~~ — deleted (M16-a, 9db679f);
pty.zig's tests now drive the master fd the way production does.
- **e2e scenario-naming legibility** — a scenario can die *unnamed*,
because under `set -eu` a failing client pipeline kills the script
before its own `FAIL` line prints (decisions.md, M15, finding 7).
- ~~**`predict.zig`'s retired channel**~~ — deleted (M16-a, 3d4e3f0);
full-repaint rollback is the adopted design, and the readoption record
(design + the expire-does-not-clear rule) is in decisions.md M16-a.
- **`keyRefusalBody`'s e2e coverage** — the announce-none path pins it
only loosely; M15's regrade caught that mutant on unit pins alone.
- **First-backoff tuning**: a reconnect after a tear can pay a 200ms–2s
backoff step; measured ~4x win available on fast links, ~2x on slow
(decisions.md, M7).
- **The unit-layer mutation sweep** — carried from the test-debt section
below, where it has the evidence that it would pay. M13 adds a data
point of its own: the `stop_req` resurrection was killed at the unit
layer *and* end to end, and the unit pair failed first and cheapest.
- **Log rotation, via `logPathFor`** — created by M13 and its named,
accepted cost. Auto-start opens the daemon log append-only, so a user
who never types `muxd start` never truncates it and the file grows
without bound. The seam is already in place; only the policy is owed
(decisions.md, M13).
- **Per-socket daemon logs** — also from M13. Auto-start makes a second
daemon on a second socket an ssh side effect rather than a decision,
and every one of them appends to the same file. Per-socket paths are
what make a multi-daemon box readable after the fact.
## Test debt, banked by M11
- ~~A pty-driving client fixture~~ — paid by M12; rows 7/18 and the
412f38f pin all caught (decisions.md, M12).
- **The unit-layer mutation sweep** — still banked, now with evidence it
would pay. Two of M11's findings were resolved by pins at the unit
layer rather than the e2e one (the delta paint's synchronized-update
wrapper, which turned out to be pinned at no layer at all, and the
delta row's leading reset, where the existing unit pin turned out to
be the whole guard). Both were discovered by accident, while aiming an
e2e campaign at the product. A sweep aimed at the unit layer would not
need the accident. M15 adds the cost argument: all three of its regrade
mutants died at the unit layer, in seconds a row, and `make e2e` was
never reached (decisions.md, M15).
- **ASAN or valgrind over the QUIC tests** — carried from M9 unchanged,
and unchanged is the finding: an e2e mutation campaign says nothing
about use-after-free. M9's two reasoned-not-pinned `deinit` orderings
are still owed the only honest instrument for them.
## Banked, ranked by expected win
0. **Prediction polish, as a group** — backspace prediction (the
most-missed omission in ordinary typing); underline only once a
prediction has been outstanding long enough to matter, rather than
always; per-context confidence memory, which would return the first two
keystrokes after every readline prompt. All three are small and all
three are felt on every keystroke (decisions.md, "Banked by M9").
1. **Retry-skip via NEW_TOKEN** — ~1 RTT off QUIC cold attach, which is
most of QUIC's remaining margin over ssh-via. Not "skip Retry": token
for validated clients, RFC 9000 3x anti-amplification for the rest —
dropping validation without one of those is a regression wearing a
performance win's clothes (decisions.md, "Banked by M8").
2. **Half-open reaping policy** — handshake-without-attach holds a slot
until idle timeout; bounded, measured at 0 held under a ten-tear loop;
`muxd stats clients=N` is the instrument for deciding the policy.
3. **`idle_ms` split three ways** — death detection, handshake bound,
half-open reaping are one knob doing three jobs.
4. **`timeoutMs` buffered-frame short-circuit** — a whole frame already
buffered can wait out the poll, up to ~100ms of render lag; one line if
it ever shows against a real RTT.
5. **Protocol criterion restatement + dedup pass** — leg 4's "protocol.zig
unchanged" restated as "no transport may need a protocol change", and a
dedup across the three transport arms now that there are three.
6. **`addCSourceFiles` for the QUIC deps**, replacing the build script.
7. **Autotools wolfSSL + ticket resumption** — the only route to 0-RTT.
8. **Certificates / TOFU · QUIC datagrams for input · multi-session per
*connection* · NAT traversal** — parked as a group. Multi-session
**shipped in M18, but per *daemon***: one socket path / port / key
holding N sessions, each its own connection. What stays banked here
is the narrower thing this line originally meant — several sessions
multiplexed inside ONE connection, which needs QUIC stream surgery
the M18 design deliberately refused (a connection IS a session).
Worth doing only if the connection count per host ever becomes the
cost. The wall is now unbounded attach history rather than a fixed
handful of tiles, so that count is whatever a user's history grew to —
the old "a wall is four tiles, so who cares" argument is dead, and
nothing has replaced it with a measurement. Measure before building.
9. **Scrollback retention tuning** — policy decided (engine-native 10k
ring); making the limit configurable is what remains, and nobody has
hit the ceiling.