Kaval sessions — dial daemons, multiplex the canvas
The R3 branch of remote-terminals — remote-over-ssh. A kaval is a daemon you dial (local unix socket, remote ssh via surface-nix-host), so remote is never a second backend. The spike shipped (R3.1–R3.6): kaval-tui create + --host reach/provision, the warm path deduped and connection-multiplexed. The forward work — composing one surface, dialing remotes, the canvas — now lives in the parent remote-terminals R5–R10 roadmap.
The R3 branch of remote-terminals — remote over ssh. A kaval is a daemon you dial — local over a unix socket, remote over ssh — so remote is not a second backend. One backend, bound to an endpoint; location never complected in. Put a tile on any kaval. Whether the canvas switches one kaval at a time (tmux-style) or multiplexes several side by side is a UI choice on the same substrate — and because kolu is a canvas, it leans multiplex.
One backend, bound to a dialed endpoint
kolu-server keys its pty-host endpoint by hostId (one key today — local). R3 just instantiates more keys: a registry of kaval endpoints, each reached by a driver — unix socket (local, shipped) or ssh stdio (HostSession, the same closure @kolu/surface-nix-host ships). One backend means one shape, not one host: three surfaces bound to an endpoint — PTY · fs · git — local vs remote only the transport each is dialed over.
| At the endpoint | local | remote — same HostSession link |
|---|---|---|
| PTY — kaval (fds + OSC taps) | unix socket | ssh stdio |
| awareness — provider DAG + git watcher | in kolu-server (in-process) | in pulam → mirrored, kolu reads (R8) |
| Code-tab fs — browse · read · watch | node fs (native) | a thin remote fs/git surface · snapshot-then-delta |
Awareness providers (cwd · title · foreground · command · agent-detection) are tap-fed, so transport-free. kaval stays the durable survivor; the provider DAG is pulam — the host-side awareness daemon, already shipped standalone and decoupled from kolu — dialed and read in by R8–R9. pulam is ephemeral and re-provisioned each dial, so its sensors are always re-derived from the current build (the #1031 “never serve stale detection” lesson, met by re-provisioning, not a kolu-coupled process). kaval itself is dialed directly over ssh via the shipped frontDaemonOverStdio (R3.4) — nothing fronts it. So R3.1’s TerminalEndpoint rename anticipated a second implementation the kaval model dissolves: not two backends, one whose surfaces bind to an endpoint. Adding a kaval is adoption you already shipped — adoptOrEnsure adopts a live survivor’s PTYs (else provisions + spawns), now run per endpoint — and multiple local kavals fall out free (kaval is namespaced per kolu-server port, kaval-<port>/, #1313).
Switch or multiplex is a later, reversible UI call the spike doesn’t depend on. The lean is multiplex: switch is a terminal-multiplexer metaphor, but kolu is a canvas — “watch a local build beside a prod-ssh tile” is the point, not a corner case. The cost over switch is modest and clean — per-tile location returns, but additively (no RemoteTerminalBackend, no screen mirror — just which endpoint holds this tile’s PTY), and the ChromeBar shows only the kavals this canvas actually has tiles on.
The spike — kaval-tui dials local and ssh (R3.1–R3.6, shipped)
kaval-tui is the minimal client — it proves the transport before any kolu UI, and because it and kolu-server dial the same socket through the same client, the spike is the production driver. Both halves landed.
shipped The full warm path, dialing a remote kaval:
$ kaval-tui list --host nix@prod
↳ ssh nix@prod · nix copy --derivation → realise … kaval up
↳ dialing the remote kaval over ssh stdio … connected · 0 terminals
$ kaval-tui create --host nix@prod → spawned a8f1… plain shell on prod
$ kaval-tui attach a8f1… --host nix@prod
↳ ~/app on prod — remote PTY survives · detach/reattach anytime
| Phase | What shipped |
|---|---|
| R3.1 · backend cleanup #1364 | collapsed the speculative TerminalBackend/getTerminalBackendFor(location) dispatch into one TerminalEndpoint (renamed, byte-identical) — remote becomes a transport, not a second backend. Its doc-comment is the seam the parent’s R9 fills |
R3.2 · kaval-tui create #1370 |
spawns a plain $SHELL (or create -- htop -d 5) and prints the id — a fresh daemon needs something to attach to. Thin over terminal.spawn (spawn is fully-specified; the caller composes {argv, env, initFiles}, no kolu policy). --json for scripts |
R3.3 · kaval-tui --host #1373 |
reach + provision in one PR (ssh). One getHostSession both provisions (nix copy --derivation → realise → pin) and dials over ssh stdioLink, handing back the same ptyHostSurface client the unix socket speaks. The one daemon-side addition — kaval --stdio — fronts the durable daemon (adopt-or-start the host’s kaval, raw-byte-relay the link to its unix socket), so a remote PTY survives the link. ssh-config auto-detect deferred |
R3.4 · upstream frontDaemonOverStdio #1374 |
homed R3.3’s durable-fronting bridge in @kolu/surface-daemon as frontDaemonOverStdio — the durable counterpart to serveOverStdio (“dtach/abduco for any surface daemon, over ssh”): a contract-agnostic relay + adopt-or-spawn, parameterized by socket path + spawn command. kaval’s --stdio became a thin call into it |
| R3.5 · skip redundant provisioning #1377 | a warm host re-ran the full nix copy → realise → pin (copy reports “copying 0 paths”). provisionAgent now tries the cheap realise+pin first (instant when the closure is present, doubles as the presence check), falling through to the full copy only on a miss — keyed on store-realisability, not the dangle-prone GC root. Internal to the shared ensure verb, so drishti gets the warm-skip free |
| R3.6 · multiplex ssh connections #1378 | the warm path still opened 3 separate ssh handshakes (arch probe · provision check · agent dial). One shared connection via ControlMaster=auto/ControlPersist, added as -o flags to the existing SSH_OPT_PAIRS (no ~/.ssh/config touch; a kolu-private %C ControlPath), plus a per-host arch cache. Warm dial → one handshake + near-instant channels |
Measured warm path (real ssh, median of 7, the exact opts the code emits). Three round-trips; multiplexing turns 2 of the 3 handshakes into channel reuse, so the saving scales with distance:
| host | fresh handshake | multiplexed channel | warm-path ssh: before → after |
|---|---|---|---|
| linux pu box (kolu-ci-1, Tailscale) | 5.42 s | 0.70 s | 16.3 s → 6.8 s (2.4×) |
| darwin (rasam, Tailscale WAN) | 2.89 s | 0.48 s | 8.7 s → 3.9 s (2.25×) |
What’s next — in the parent roadmap
Dialing remotes, composing awareness + fs/git, and the multiplex canvas now live as the linear R5–R10 roadmap in the parent remote-terminals note — including pulam growing an fs/git surface (R6). This note is the shipped spike (R3.1–R3.6).
History
- 2026-06 — the spike shipped R3.1→R3.6 end to end: backend cleanup (#1364),
kaval-tui create(#1370),--hostreach + provision (#1373),frontDaemonOverStdioupstreamed to@kolu/surface-daemon(#1374), warm-skip provisioning (#1377), sshControlMastermultiplexing (#1378). The spike is the production driver. - Renumbered to the one R1–R10 tree (2026-06-21) — this note is R3; was a local
P0…P4scheme, now gone (git holds the old labels). kolu composes awareness in the parent’s R8 (the server-side fold dissolves there) and dials remotes in R9; R4 is pulam (the awareness daemon); the foundation seam is R1.