← the Atlas

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

feature · budding ·accepted ·

A terminal-side client of kolu-server's in-process pty-host — list / attach / spawn 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; kolu-tui connects to that same in-process pty-host over a local unix socket (speaking ptyHostSurface) and gives you list / attach / spawn 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 kolu-tui-on-the-in-process-server. The parent plan’s Phase B — 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, sibling of the chrome-bar rail; 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)kolu-tui — packages/pty-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 socketLocalTerminalBackend — 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); kolu-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). kolu-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/pty-tui

A small node CLI in its own package, packages/pty-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.

SubcommandSurface callBehaviour
kolu-tui listterminal.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.
kolu-tui attach <id>terminalAttach.get({id}) (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.
kolu-tui spawn [-- cmd]terminal.spawn({id: uuid, …}) then attachMint a UUID client-side, spawn, immediately attach. --print-id prints the id and does not attach (for scripting).
kolu-tui snapshot <id>terminal.getScreenText({id})One-shot, non-interactive: dump the current scrollback 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. The scriptable primitive the headless test asserts on.
kolu-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; --pty-host-socket <path> points at a non-default server.

Decisions recorded here (to be echoed in code comments)

DecisionChoiceWhy
Homea tiny separate packages/pty-tuiAn independent CLI package, not a bin folded into @kolu/pty-host — keeps the pty-host 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 fidelityraw VT passthroughWrite 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 / escapessh-style line-start ~ escape — never Ctrl+BA 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). ~k kill ships with Phase 3’s kill, not Phase 2. 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 kolu-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.

PhaseShipsServer changeUser-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 kolu-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 …#kolu-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 / killcreate (a plain $SHELL or a given command, --json) shipped #1370 ; remote --host (reach + provision over ssh) shipped #1373 ; kill later.None for create; --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 — later — kill them: a usable raw multiplexer (beta). The remote phasing lives in kaval-sessions.

Phase 1 alone is the smallest honest first ship of kolu-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 kolu-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({})) ↔ kolu-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 kolu-tui to inherit. The example is now kolu-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/pty-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 kolu-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 modekolu-tui attach — packages/pty-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) → resize-then-attach → 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):

DecisionChoiceWhy
Device-query repliesfilter the tty’s auto-answers out of the stdin→write pathThe 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 restoreone deterministic reset on every exit pathThe 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.
Sizingresize-then-attach; last-resize-wins across clientsThe snapshot serializes at the server-side grid (the browser’s last size, or the 80×24 default); resizing first renders it at the local dimensions. The contract has no size-change tap, so a concurrently-attached browser tile may show wrap artifacts until its own next resize — accepted and documented; a size tap would be contract 2.2, out of Phase 2 scope.
Status linesone-shot notices only — no persistent footerA 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 & retrydiscriminate via the exit stream; never auto-retryThe 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:

$ kolu-tui list
kolu-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):

kolu-tui · unixSocketLink → /run/user/1000/kolu/pty-host.sock
$ kolu-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). spawn mints a PTY and attaches; 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 thanks to resize-then-attach.

kolu-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 · spawn a PTY with a unique marker in its scrollback (no attach)
id=$(kolu-tui spawn --print-id -- bash -c 'echo "MARK-$$"; exec sleep 1d')
pid_before=$(kolu-tui list --json | jq -r ".[] | select(.id==\"$id\").pid")

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

# 3 · a fresh client re-attaches by id and asserts the state is intact
kolu-tui snapshot "$id" | grep -q "MARK-"            # scrollback preserved
pid_after=$(kolu-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 kolu-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 B eventually moves the pty-host out of kolu-server into a surviving kolu --stdio daemon, kolu-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 kolu-tui’s phases above depends on it, and kolu-tui ships and is useful entirely on the in-process server.