← the Atlas

kaval-tui that roams — remote attach that survives the network

analysis · seedling ·

Today kaval-tui attaches to a remote kaval over ssh stdio (TCP — it drops the moment you change networks). zmosh's idea, applied to kaval — teach kaval to also bind an encrypted-UDP listener beside its unix socket, so you attach once and keep the session through Wi-Fi↔cellular and sleep/wake, with no reconnect and no lost scrollback. No extra process; kaval already owns the hard half (server-side VT + snapshot-then-delta); only the transport changes.

kaval-tui attaches to kaval sessions — local over a unix socket, and (R-2) remote over ssh. zmosh (mmonad/zmosh) is a session daemon whose remote attach survives Wi-Fi↔cellular and sleep/wake with no reconnect. This note: give kaval-tui the same. 24-agent workflow · verified vs. both codebases

See it

Same command, today vs. with roaming. The difference is the 20 minutes you don’t notice.

today · kaval-tui --host over ssh stdio
$ kaval-tui --host nix@prod attach build
↳ prod · vite build — watching… [ssh stdio · TCP]
— close laptop · walk to café · Wi-Fi → cellular —
⚠ ssh: connection reset by peer
↳ re-dialing prod… re-attaching… snapshot restored
✓ back — after ~6 s of dead air, and only because you noticed
with roaming · kaval-tui --host over QUIC
$ kaval-tui --host nix@prod attach build
↳ prod · vite build — watching… [QUIC]
— close laptop · walk to café · Wi-Fi → cellular —
✓ build finished — never dropped a frame
(one authenticated datagram re-pinned the peer · scrollback intact)
Attach once. Keep it.desk · Wi-Fi198.51.100.7attachedlid closed · movingWi-Fi → cellularoffline ~20 mincafé · cellular203.0.113.9still attachedone kaval session — never re-created · scrollback intact · no reconnect spinner

That is the whole user-facing win: attach once, and the session is yours until you kill it — across network switches, VPN flips, and a closed lid. No , no spinner, no lost output.

One hop, one swap

kaval-tui ↔ kaval is a single hop — exactly zmosh’s shape. Local attach (unix socket) is already roam-proof; only the remote transport changes. Nothing about kaval the daemon, your shell, or detach/reattach moves.

kaval-tui · your laptopkaval daemon · remote host — owns PTYs + server-side VTyour shell / build / agent — survives detach the ONE hop: ssh stdio (drops on roam) → QUIC (roams · connection migration · RFC 9000 ss9) owned in-process · OSC taps — unchanged
The only thing that changes is the wire between kaval-tui and a remote kaval. Today it's ssh stdio (a TCP child that dies on an IP change); the swap is teaching kaval to bind an encrypted-UDP listener beside its unix socket — no extra process — where one authenticated datagram re-pins the peer after a roam.

Already half-built

The expensive half of “mosh for a session daemon” is keeping a server-side terminal-state authority so a reconnecting client can be re-hydrated. kaval has it — @xterm/headless mirrors every byte and attach() hands back a race-free snapshot-then-delta (packages/kaval/src/ptyHost.ts:565-573). On overflow it sheds the wedged consumer and recovers with a fresh snapshot (packages/kaval/src/channel.ts:120-131). That is zmosh’s recovery model, already shipped.

The hard part is recovery — kaval already does itzmosh, on lossgap on a seq ⇒ replaythe full screen snapshotserve.zig:389-398kaval, on attachsnapshot-then-delta from@xterm/headlessptyHost.ts:565-573what's missinga roaming transport tocarry it (today: ssh stdio)the only new codeSame primitive — re-send the whole screen, never the lost bytes. kaval owns it server-side; only the pipe needs to roam.

So the work is only the transport — the daemon, the VT, the wire contract (ptyHostSurface · contract 3.0), and kaval-tui’s attach loop all stay as they are.

The transport — QUIC

The remote wire should be QUIC — and not as a hedge. QUIC is UDP + TLS 1.3 + reliable, multiplexed, ordered streams (RFC 9000/9001/9002). Two of its properties are exactly this problem:

What QUIC changes — the connection isn't the IPTCP / ssh — keyed by the 4-tupleconn = (198.51.100.7:51000 → prod:22)client IP changes → 203.0.113.9✗ 4-tuple broke — connection deadre-dial + fresh TLS/ssh handshakeQUIC — keyed by Connection IDconn = CID 0x9f3a… (in every packet)client IP changes → 203.0.113.9 · CID stays✓ same connection — keeps runningPATH_CHALLENGE validates the path · 1 RTTRFC 9000 §9 · only the client migrates (exactly kaval-tui's direction) · the stream stays reliable+ordered, so kaval's wire rides it unchanged

So QUIC isn’t “zmosh’s idea, maybe” — it is zmosh’s design (per-packet AEAD, roaming, reliable transport), standardized, fuzzed, interop-tested, with path-spoofing defenses the bare seq rule lacks. You write none of it. That’s why hand-rolling a UDP transport is the wrong call: raw UDP forces you to rebuild sequencing/retransmit/ARQ by hand — the one piece mosh got to skip (it discards lost frames; an RPC byte-stream can’t).

The one honest caveat is the Node library, not the protocol (verified, mid-2026): there is no turnkey-perfect QUIC for Node yet.

OptionStateCall
node:quic (Node built-in, ngtcp2)generic bidi streams ✓, but experimental (“Stability 1.0”) — needs a custom Node build (compile-time --experimental-quic + OpenSSL 3.5) plus a runtime flag; landed ~Node 26.2the target — bet on the standard; own the build
@matrixai/quic (quiche bindings)real ReadableStream/WritableStream ✓, but ~15 mo stale, single-sponsor, no linux-arm64 prebuilt (aarch64 compiles quiche from Rust)prototype only — validates the design, too thin to depend on
bespoke UDP porthand-rolled crypto + ARQrejected — rebuilds what QUIC ships

Doing it properly means node:quic, with the QUIC-enabled Node owned in the Nix closure — a clean cost that shrinks as the flag graduates — not an under-maintained binding. Nix is the ideal place to own that runtime build.

Phases

Prerequisite — node:quic in Nix (day one, not a phase). kolu is Nix-first, so the QUIC-enabled Node is baked into the flake from the start, not bolted on later: build Node with --experimental-quic (needs OpenSSL ≥ 3.5) in the devShell and in the kaval/agent closures. One change to the flake’s Node derivation; kolu and drishti both inherit it (both run node:quic). Everything below assumes it. Drop the compile flag if/when it graduates upstream — no code change, just the derivation.

There’s no decision-spike left (library/package settled: node:quic, codec in @kolu/surface/links/quic.ts), and nothing is demonstrable until the daemon roams — so the transport and its demo land together. The “session survives a roam” win is a daemon property, so the demo is kaval, not drishti: drishti has no surviving session (a dropped agent just re-streams), so it can de-risk the transport (and .claude/rules/surface.md mandates its PR) but it can’t show the feature.

PhaseShipsVisible?
P1 · transport + kaval-tui --host roaming demothe links/quic codec, kaval’s QUIC listener, the HostSession dial seam, and kaval-tui’s --host path — end to endyes — attach → roam Wi-Fi↔cellular → session + scrollback survive, no reconnect
P2 · kolu dials a remote kavalkolu’s TerminalEndpoint registry dials remote kavals over QUIC; ssh-stdio kept as the auto-fallbackyes — a roaming remote tile on the canvas

P1, file by file

A single vertical slice. Each piece mirrors an existing member of the link family, so the pattern is already established in-tree:

Bootstrap (mosh-style). (1) provisionAgent (nix copy --derivation → realise) puts kaval on the host. (2) ssh runs kaval --quic, which binds a unix socket (local) + a QUIC listener (ephemeral UDP port, ephemeral self-signed cert) and prints KAVAL_QUIC <port> <certFingerprint>. (3) kaval-tui reads that line and closes ssh. (4) It dials quic://host:<port> pinning the fingerprint — TLS secures the channel, and the fingerprint, delivered over the authenticated ssh hop, is the trust anchor (TLS alone doesn’t say which kaval). (5) Only QUIC after that; an IP change migrates by Connection ID, no re-dial.

Acceptance — prove the roam, don’t assume it. Attach to a remote kaval running a counter; force a client IP/port change (a NAT rebind, or move the client between two network namespaces); the session keeps streaming with one PATH_CHALLENGE/PATH_RESPONSE and no reconnect log line, scrollback intact. The same flow over transport: "stdio" drops (agent exited → reconnect) — that contrast is the demo.

Companion (mandated, not a phase). The paired drishti PR for the @kolu/surface + HostSession change (.claude/rules/surface.md). drishti dials its process-monitor agent with transport: "quic"; a sequence/echo agent proves the bytes migrate across an IP change. drishti can’t show session-survival (no daemon) — that’s P1’s job.

P2 — kolu + the fallback

kolu’s endpoint registry (the TerminalEndpoint seam, #1364) dials remote kavals through the same HostSession transport: "quic". ssh-stdio stays as the automatic fallback: if QUIC can’t establish (UDP blocked, handshake timeout) fall back to transport: "stdio" — no roaming, but it always connects. Terminal input never rides 0-RTT early data (no replay protection). The canvas multiplexes a roaming remote kaval beside local ones.

zmosh cloned at HEAD; kaval claims verified against packages/kaval; QUIC claims verified against RFC 9000/9001/9002 and the mid-2026 Node QUIC landscape; placement per a lowy + hickey lens pass. Sibling to kaval-sessions (the kaval-tui plan).