← the Atlas

kaval-tui — a CLI terminal client for kolu-server

Features·budding·accepted·

A terminal-side client of kolu-server's in-process pty-host — list / create / snapshot / send / attach / kill from the shell over a unix socket, no browser and no daemon. The CLI face of kolu and the seed of a tmux replacement, shipped in beta phases.

A terminal-side client of kolu-server’s pty-host. kolu-server owns its PTYs in-process today; kaval-tui connects to that same in-process pty-host over a local unix socket (speaking ptyHostSurface) and gives you list / create / snapshot / send / attach / kill from the terminal — no browser, no daemon. It is the CLI face of kolu and the seed of a tmux/zmx replacement (#671), shipped in phases as a beta.

Scoped to kaval-tui-on-the-in-process-server. The parent plan’s Phase R2 — moving the pty-host into a surviving daemon — is a separate plan this later dovetails with (see Later). Ancestry: child of the pty-daemon plan; cf. Ghostex vs. kolu remote-terminals.

Architecture — everything stays in-process

No new process. kolu-server keeps owning the PTYs exactly as it does now; it just exposes the same in-process router over an additional local socket so a second client — the CLI — can reach it. The web path is byte-identical.

browser (the GUI)kaval-tui — packages/kaval-tuikolu-server — ONE process · all in-process · NO daemonservePtyHost(deps).router — node-pty fds + @xterm/headless mirror + VT tapsdirectLink — web path, unchangedserveOverStdio — NEW, additive: the SAME router on a unix socketLocalTerminalEndpoint — providers, sessions…◆ pty-host.sock ◆ ws · rich: PTY + providers + sessionsptyHostSurface · raw: PTY + taps
One process, two transports onto one router. The browser is the rich client (kolu's full contract: PTY + the provider DAG); kaval-tui is the raw client (ptyHostSurface: PTY + the VT taps), at tmux altitude. The only server change is the additive serveOverStdio socket beside the unchanged directLink web path.

The split by richness. The browser is the rich client — it speaks kolu-server’s public contract and gets the whole world (PTY plus the provider DAG: git context, agent state, PR status, session grouping). kaval-tui is the raw client — it speaks ptyHostSurface directly and gets the bare multiplexer (PTY plus the VT taps), exactly the tmux altitude. Both attach to the one in-process pty-host.

Why a second client at all

Two reasons, both standing on their own without any reference to daemons.

The surface — ~850 LOC in packages/kaval-tui

A small node CLI in its own package, packages/kaval-tui, that dials the unix socket through unixSocketLink (the local-IPC member of @kolu/surface’s link family — same base64+newline framing the daemon’s ssh stdio path uses later) and gets a typed ptyHostSurface client. No new wire, no new framing — each accepted connection is pumped through the already-tested serveOverStdio.

Subcommand Surface call Behaviour
kaval-tui list terminal.list() One-shot — print each live PTY’s id · pid · idle · cmd · cwd (cmd = the OSC title or the foreground command), with the full entry under --json. The pty-host’s inventory. Shipped: the entry was enriched with title + foregroundProcess (contract 2.1) so this is one round-trip, not per-row tap fetches.
kaval-tui attach <id> terminalAttach.get({id, resizeTo}) (stream) + terminal.write / terminal.resize + exit.get({id}) Put the local tty in raw mode. First yield is the scrollback snapshot → paint to stdout; deltas stream after. Forward stdin via write; SIGWINCHresize({id, cols, rows}). A line-start ~. escape detaches — the CLI exits, kolu-server keeps the PTY. When the deltas end, the exit stream discriminates “PTY died” (report the real code, quit with it) from “stream dropped” (re-attach; the fresh snapshot repaints). The full client-side loop — reply filter, deterministic restore, sizing — shipped with Phase 2 below.
kaval-tui create [-- cmd] terminal.spawn({id, argv, cwd, env, initFiles}) Mint a UUID client-side and spawn a plain $SHELL (or the given [-- cmd]), then print its id and exit — it does not auto-attach. The whole spawn input is composed client-side (the host derives nothing of its own from R2.1); --json emits the full result for scripting.
kaval-tui snapshot <id> terminal.getScreenText({id, extent?}) One-shot, non-interactive: dump the rendered screen as plain rendered text (not the terminalAttach first frame — that’s serialized VT screen state for late attach, which would replay control sequences and defeat grep) + a trailer line to stderr, then exit. extent is a discriminated union bounding which slice comes back — omit it (or {kind:"full"}) for the full scrollback; --viewport sends {kind:"viewport"} (the host resolves it to its own rows — the CLI can’t know the daemon’s grid) and --tail N sends {kind:"tail", lines:N}, so an agent-driving loop reads the current screen instead of | tail-ing a huge buffer. (A {kind:"range", startLine?, endLine?} variant rounds out the union for callers wanting an explicit line window.) The scriptable primitive the headless test asserts on.
kaval-tui send <id> [text] terminal.write({id, data}) One-shot, non-interactive: write input to a PTY (typically a prompt to a Claude Code / Codex / opencode agent), then exit. Writes exactly the text OR the --keys — never both, no implicit Enter. Submitting is its own step, because a same-breath Enter races the TUI’s paste debounce and is dropped: send the text, wait --until idle:<ms> to observe the TUI settle, then send <id> --key Enter. --file <path> reads the text straight from a file (byte-exact, no "$(cat)" shell mangling — for a prompt with metacharacters, not as a large-paste path). Multiline / --file / piped-stdin text is wrapped in a bracketed paste so it lands as a block, not line-by-line; --key also sends control keys (Escape, C-c, arrows). A write that can’t complete fails loud in seconds instead of hanging. Known limitation: a large paste that Claude Code folds into a placeholder does not reliably submit on Enter (issue #1702); the three-step flow is verified for normal-size prompts. The write half of the createsendwaitsend --key Entersnapshot loop one agent uses to drive another.
kaval-tui wait <id> --until <cond> terminalAttach.get({id}) (stream) + exit.get({id}) Added later (#1629), beyond the R-4 phases below. Block until the terminal’s raw OUTPUT meets a condition, then exit — the hook-free done-signal for the create → send → wait → snapshot loop. --until idle:<ms> resolves on output quiescence (no byte for <ms> — the turn ended), --until match:<regex> on new output matching. Client-side debounce/scan over the existing output tap (no contract member added); --timeout fails loud (exit 2), a terminal that exits first exits 3. The raw-output analog of pulam-tui wait’s hooked agent-state done-signal.
kaval-tui kill <id> terminal.kill({id}) Out-of-band termination — exercises the order-safe kill path (abort the exit tap then kill) from a non-browser client.

Of the taps, attach consumes exit — stream-end discrimination plus the real exit code, which tombstones server-side so it’s retrievable even after the deltas end. The metadata taps (cwd / title / foreground) feed no persistent status line: that early sketch contradicted the raw-passthrough decision and was dropped (see the Phase 2 decisions below); they stay on the contract for a later richer UX. --json makes list machine-readable; --socket <path> points at a non-default server.

Decisions recorded here (to be echoed in code comments)

Decision Choice Why
Home a tiny separate packages/kaval-tui An independent CLI package, not a bin folded into kaval — keeps the kaval daemon package focused on the contract + primitive, and keeps the CLI’s own deps (raw-tty handling, arg parsing) out of the closure the A2 staleKey hashes.
Render fidelity raw VT passthrough Write the pty-host’s bytes straight to stdout; do not re-render through a second @xterm/headless mirror. Simplest and truest, and it avoids a second rendering path drifting from the server’s. Recorded as a design-decision comment at the passthrough site.
Detach / escape ssh-style line-start ~ escape — never Ctrl+B A passthrough multiplexer must not steal any control char the inner tools need — Ctrl+B and Ctrl+J are reserved by Claude Code (input/prohibitedKeybinds.ts). The escape is the unambiguous ssh model: ~ recognised only immediately after a newline. ~. detach · ~~ literal tilde · ~? help; configurable via --escape (a single character). The kill subcommand shipped (Phase 3, #1462), but the in-attach ~k escape is still later — killing is a standalone kaval-tui kill <id> today, not a keystroke from inside attach. A literal ~ mid-line passes through untouched, so every inner-app chord reaches the program unmodified.

Phasing — three beta increments, all in-process

Phases 1–3 build kaval-tui on the in-process server — each leaves Kolu working, ships on its own, and changes nothing in the web path. They’re preceded by a Phase 0 that lives outside kolu: a surface example that rehearses the whole “interactive TUI over oRPC stdio” pattern.

Phase Ships Server change User-visible
0 · surface example shipped #1073 A minimal CI-runner TUI over oRPC stdio in @kolu/surface’s examples — the 3rd example, after the worker demo + the remote-process-monitor (→ drishti). None — separate from kolu-server. A standalone example; proves the pattern and becomes kaval-tui’s reference skeleton.
1 · list shipped #1084 kolu-server serves its in-process pty-host router over a unix socket (@kolu/surface’s serveOverUnixSocket); new @kolu/pty-tui with list + read-only snapshot. list carries full metadata from the enriched terminal.list (contract 2.1). +1 local socket listener on the existing router (servePtyHostOverUnixSocket). Web path byte-identical. A beta CLI that lists and snapshots your live terminals — nix run …#kaval-tui.
2 · attach shipped #1255 Raw-tty passthrough, snapshot-then-delta, stdin→write, SIGWINCHresize, the ~-escape detach — plus the device-query reply filter and the deterministic terminal restore the loop needs (specced below). None beyond Phase 1, except one nicety, shipped: terminalAttach on a bad id is a clean NOT_FOUND (one composed requirePty guard; error shape only, no contract bump). Drive a terminal from the CLI; detach and re-attach while the server runs.
3 · create / --host / kill create (a plain $SHELL or a given command, --json) shipped #1370; remote --host (reach + provision over ssh) shipped #1373; kill <id> (resolve a short id, terminal.kill) shipped #1462. None for create/kill (the kill RPC already existed); --host adds a kaval --stdio front that relays the ssh link to the durable daemon’s socket. Create PTYs from the CLI, reach a remote kaval over ssh (a created terminal survives the link — create on prod, attach later), and kill them: a usable raw multiplexer (beta). The remote phasing lives in kaval-sessions.

Phase 1 alone is the smallest honest first ship of kaval-tui — a one-shot RPC, no raw-tty mode, the “hello world” that proves serveOverUnixSocket + unixSocketLink + the contract round-trip, at near-zero risk.

Phase 0 — a minimal CI-runner TUI (the 3rd surface example) shipped #1073

Before touching kolu, prove the pattern in @kolu/surface’s examples — the falsifiability test of lesson #3 applied to “interactive TUI over oRPC stdio,” the way the worker demo and the remote-process-monitor (which became drishti) validated the earlier patterns. The candidate: a minimal CI runner — justci-flavoured but self-contained, deliberately not the real justci. Just a small DAG of shell commands, runnable locally or on a single remote host.

It’s a clean structural twin of kaval-tui: a long-lived runner owns the DAG + each node’s process + its log buffer, and streams to ephemeral TUI clients over stdio. The plan’s nodes.list() / node.log(id) / node.rerun(id) map onto the framework-idiomatic spelling — a nodes cell (surface.nodes.get({})) ↔ kaval-tui’s list; a nodeLog stream (snapshot-then-delta) ↔ attach; a node.rerun procedure ↔ input. That this was clean to write against the surface primitives is the finding: the seam is at the right altitude for kaval-tui to inherit. The example is now kaval-tui’s copy-paste skeleton and a permanent regression test for the pattern.

mini-ci · pipeline remote-process-monitor · attached: monitor
✔ surface (2.3s)
✔ nix-host (1.9s)
▶ monitor running… ──▶ attached (needs: surface, nix-host)
────────────────────────────────
$ pnpm --filter @kolu/surface-example-remote-process-monitor typecheck
$ tsc --noEmit
❯ type-checking…
1-9 attach · n/p cycle · r rerun · q quit · ● 1 running · 2 ok · 0 pending

Remote mode ships the runner the drishti way: a prebuilt mini-ci-runner nix closure is nix copy’d to the host, realised, then run as ssh host mini-ci-runner --stdio with the TUI attached over stdio-over-ssh via @kolu/surface-nix-host’s getHostSession({ host, binary, resolveDrvPath }) (which owns ref-count, reconnect, watchdog, and a copying → connecting → connected state cell). localhost skips the nix copy and runs the realised binary directly — the same HostSession, only the transport differs.

Phase 2 — the attach loop shipped #1255

Recon verdict before implementation, borne out by the ship: no contract bump, no server changeterminalAttach / write / resize / exit all exist at contract 2.1 (and the web path already consumes the same host.attach as just another subscriber on the per-PTY broadcast channel, so a CLI attach violates no exclusivity). Attach is a pure consumer; no drishti-mirror PR. The whole phase is ~300–400 LOC in packages/kaval-tui plus tests. One delivery note: the reply filter first shipped as a move of the browser’s terminalResponseFilter into kolu-common; a blocking structural review then promoted the whole protocol policy — grammars + stripper, the headless forward/drop rule, the answered/silent device-query matrix, paste delimiters, and the snapshot-reciprocal TTY reset — into a dedicated zero-dep leaf, @kolu/terminal-protocol, that the browser, the pty-host, and kaval-tui all import. The pty-host’s staleKey hashes it (a protocol change is observable daemon behaviour), and its device-query matrix is executed as contract tests against a real headless.

your terminal — raw modekaval-tui attach — packages/kaval-tuikolu-server pty-host — unchangedescape machine — bytes · line-start ~ · paste-awarereply filter — drop the tty's auto-answers (DA1/DSR/XTVERSION...)delta pump — writeOut backpressurerestore — ONE path for detach · PTY exit · signals · crash stdin bytesterminal.write (+ resize on SIGWINCH)terminalAttach: snapshot, then deltasstdoutsetRawMode(false) + reset string
The attach loop, client-side only. Stdin bytes run through the escape machine and the reply filter before terminal.write; the attach stream pumps to stdout through the existing backpressure helper; every exit path funnels through one restore.

The sequence: connect → version gate (both shipped in Phase 1) → attach carrying the local grid → raw mode → one-shot ↻ snapshot restored… notice → paint snapshot → pump until detach or stream end. Five decisions carry the design weight (echoed as code comments at their sites, per this note’s convention):

Decision Choice Why
Device-query replies filter the tty’s auto-answers out of the stdin→write path The snapshot/deltas carry queries (DA1 · DSR/CPR · XTVERSION …) that the user’s real terminal auto-answers on stdin — but the headless mirror already answered them server-side, so forwarding the duplicate reply corrupts the inner program’s stdin (the yazi-class bug). Mirror the browser path’s suppression predicates, preserving the client-suppressed ⇒ server-answered invariant.
Terminal restore one deterministic reset on every exit path The snapshot replays modes onto the local terminal — alt-buffer (?1049h), mouse tracking, bracketed paste (?2004h), app cursor keys. Detach, PTY exit, SIGTERM/SIGHUP, and crash all run setRawMode(false) + a fixed reset string (alt-buffer off, mouse off, paste-wrap off, cursor visible). Restore is much more than un-raw-ing stdin.
Sizing the grid rides ON the attach as resizeTo; last-attach-wins across clients A serialized screen is bytes laid out for a specific cols×rows, so the attach request carries the local tty’s grid and the host resizes-and-serializes as one act — the snapshot always arrives laid out for the terminal that will paint it. This supersedes the original resize-then-attach pairing, which was two calls a caller had to order correctly and which raced whenever it didn’t; being one request, the mismatch can no longer be produced by two calls landing out of order. It is still expressible by omitting resizeTo, which only a caller with no grid of its own (a screen-dumping CLI) may do. The field is named for what it does: a real resize of a shared PTY, so a re-attach is now a resize — with two differently-sized viewers any stream re-subscribe, not only a user resize, moves the PTY. The contract still has no size-change tap, so a concurrently-attached browser tile may show wrap artifacts until its own next attach or resize — accepted and documented.
Status lines one-shot notices only — no persistent footer A live taps-fed footer needs scroll-region ownership, which violates the raw-passthrough render-fidelity decision above (the earlier sketch self-contradicted). One line before the paint (↻ snapshot restored…), one after restore on detach; the CLI owns zero pixels while attached.
Stream end & retry discriminate via the exit stream; never auto-retry The deltas iterator ends identically on PTY exit, server-side abort, and the silent slow-consumer drop (bounded 10k queue). On end, ask exit: code present → report it, quit with it; PTY still live → re-attach, and the fresh snapshot repaints — exactly the right recovery for the drop case. Stream auto-retry would replay the snapshot mid-session, so attach opts out; if the server itself goes away the CLI restores the tty and prints an honest one-liner — manual re-dial is the only reconnect.

The escape machine’s fine print: it runs on bytes, decoding via string_decoder only at the write boundary so multibyte characters split across stdin chunks survive; session start counts as line-start (ssh behaviour); recognition is suspended inside bracketed-paste brackets so a pasted \n~. cannot detach; in raw mode Ctrl+C arrives as byte 0x03 and is forwarded like everything else — only external SIGTERM/SIGHUP trigger the restore-and-exit path.

Testing. The escape machine, reply filter, and reset emission are pure functions — unit-tested with no tty (the render.test.ts pattern). The loop itself is factored over read/write streams plus a tty-ish interface and integration-tested against the real-socket harness (the serveOverSocket.test.ts pattern: in-process pty-host + unixSocketLink on a temp socket). The home-manager VM test keeps its list smoke — attach is infeasible headless.

The user flow

The interactive loop a user walks, then the same loop scripted as a headless test. The running example: a PTY 3f9a…c21 in ~/code/kolu that becomes a Claude Code session.

Interactive session

Is the server’s pty-host reachable? — an unreachable pty-host is a clear, immediate error, never a silent empty hang:

$ kaval-tui list
kaval-tui: no pty-host socket at /run/user/1000/kolu/pty-host.sock (ECONNREFUSED)
          is kolu-server running? the socket appears once it boots.

The inventorylist from another shell, one row per live PTY (the status line reports the in-process pty-host):

kaval-tui · unixSocketLink → /run/user/1000/kolu/pty-host.sock
$ kaval-tui list
 
ID PID IDLE CMD CWD
3f9a…c21 12843 5s claude: implement pty-tui ~/code/kolu
7b2e…0d4 12901 2m zsh ~/code/kolu/.worktrees/…
a18c…9ff 13044 1m vim notes.md ~/scratch
● 3 live PTYs (in kolu-server, in-process) · pty-host pid 9981

Birth → work → detach → re-attach (Phase 2/3). create mints a PTY and prints its id; attach <id> takes it over — everything passes through raw, the CLI owns no pixels while attached; a line-start ~. detaches (the server keeps the PTY); re-attach repaints the full scrollback snapshot instantly because the first yield of the attach stream is the snapshot, serialized at the local tty’s dimensions because the attach request carries them.

kaval-tui · re-attached 3f9a…c21 · claude: implement pty-tui
↻ snapshot restored — 1,284 lines · PTY pid 12843 unchanged
⏺ Bash(git status)
⎿ On branch master — working tree clean
> █
~. — detached · 3f9a…c21 stays live in kolu-server · re-attach anytime

This is client-side detach — distinct from server survival. If kolu-server itself restarts, the in-process PTY dies; that persistence is the separate daemon plan.

Headless test — client-death detach/reattach, no browser

The detach/reattach loop scripted: it asserts that the CLI client can die and a fresh one re-joins the same PTY (the server runs throughout) — fast, no browser.

#!/usr/bin/env bash
set -euo pipefail

# 1 · create a PTY with a unique marker in its scrollback (create never attaches)
id=$(kaval-tui create --json -- bash -c 'echo "MARK-$$"; exec sleep 1d' | jq -r .id)
pid_before=$(kaval-tui list --json | jq -r ".[] | select(.id==\"$id\").pid")

# 2 · (no kaval-tui process is attached — simulating client death)

# 3 · a fresh client re-attaches by id and asserts the state is intact
kaval-tui snapshot "$id" | grep -q "MARK-"            # scrollback preserved
pid_after=$(kaval-tui list --json | jq -r ".[] | select(.id==\"$id\").pid")
[ "$pid_before" = "$pid_after" ]                     # same PTY, not a respawn

echo "✓ client came and went; server held the PTY (pid $pid_after unchanged)"

This is the kaval-tui-scoped reattach test — the client can come and go while the server holds the PTY. (Surviving a server restart is a different assertion that belongs to the daemon plan, not here.)

Later — how this dovetails with the daemon plan (out of scope)

Stated only so we don’t trip over it: when the parent plan’s Phase R2 eventually moves the pty-host out of kolu-server into a surviving kolu --stdio daemon, kaval-tui’s socket target shifts from kolu-server to the daemon — with no contract change, because both serve the same ptyHostSurface. At that point the CLI’s terminals gain server-restart survival for free, and the raw-vs-rich client split is unchanged. But that is a separate plan; nothing in kaval-tui’s phases above depends on it, and kaval-tui ships and is useful entirely on the in-process server.