kolu
Docs

the PTY daemon & its client · alpha

Kaval: a watch over
your terminals.

kaval — a watch over your terminals

kaval (Tamil kāval — watch, guard; said KAH-val, the first a long, as in father) is a small, standalone PTY daemon: it owns your shells, mirrors their screens, and serves them over a local unix socket — outliving the clients that come and go. kaval-tui is its terminal client: list your shells, create new ones, dump their scrollback, attach and type, send a prompt to an agent running in one, or kill them — locally, or on a remote machine over ssh (--host). Run the pair on a box where kolu has never been installed — a tmux/zellij-shaped duo, minus the multiplexer’s session model. In kolu’s full stack, padi is the workspace daemon above kaval, and Architecture shows how the layers survive restarts. Kaval is the exemplar daemon of @kolu/surface — the single-instance gate, live recycle, and frozen control core it is built on are documented there.

How it fits together

One daemon owns the terminals; everything else is a client. kolu no longer runs PTYs in-process — its per-host daemon, padi (Tamil paḍi — the stepped stand a koḷu sits on), spawns and supervises its own kaval and dials it over a unix socket, exactly as kaval-tui does from the shell (the kolu-server is just the web shell your browser talks to in front of padi). Same daemon, same terminals, many faces — and the link runs both ways: kaval streams live updates back to every client, so a terminal you kaval-tui create from the shell shows up in kolu the moment it’s born.

padi (the per-host daemon behind kolu) and kaval-tui are both clients of one kaval daemon, which owns the PTYs and serves them over a unix socket — clients call the daemon and the daemon streams live updates back, so a terminal either client creates appears in the otherCLIENTSTHE DAEMONOWNS THE PTYSpadikolu’s host daemonkaval-tuithe shell clientkavalthe PTY daemon▸ zsh▸ claude▸ nvimunix socketlocal · over ssh

Run the pair

  1. Start the daemon. It claims its socket and stands watch — leave it running in a pane, a service, wherever.

    nix run github:juspay/kolu#kaval
  2. Drive it from any other shell. kaval-tui finds the running daemon on its own — no socket path to remember.

    nix run github:juspay/kolu#kaval-tui -- create
    nix run github:juspay/kolu#kaval-tui -- list
    nix run github:juspay/kolu#kaval-tui -- attach <id>
    nix run github:juspay/kolu#kaval-tui -- kill <id>

Drive a running kolu

kolu doesn’t run terminals in-process anymore — its per-host daemon padi spawns a kaval of its own and dials it, so kolu is just another consumer of it. So the same kaval-tui reaches the terminals you have open in kolu, from the shell, with no flags:

kaval-tui list                     # the terminals open in your kolu
kaval-tui snapshot <id> | grep BUILD-
kaval-tui history <id> --lines 200 # older scrollback above the screen

kaval-tui discovers the daemon by scanning the per-user runtime dir — both a standalone kaval and every kolu (each kolu’s padi namespaces its kaval by a digest of its state-root, and drops a manifest beside the socket so kaval-tui can label it back to the kolu it belongs to). One running → it’s picked automatically. More than one → kaval-tui lists them and asks you to choose with --socket <path>.

An agent inside a terminal locates itself — two env vars

A terminal kaval spawns carries two env vars so an agent running inside it can find its own daemon and its own tile with no flags to pass (the $TMUX convention, one for the socket and one for the id):

Var Names An agent uses it to
$KAVAL_SOCKET the socket of the kaval that owns this terminal skip discovery and point straight at its own daemon: kaval-tui list --socket "$KAVAL_SOCKET" — the unambiguous answer when several daemons are up
$KAVAL_TERMINAL_ID the id of this terminal act on itself without being told which tile it is: kaval-tui snapshot "$KAVAL_TERMINAL_ID" --socket "$KAVAL_SOCKET" reads its own screen

A kolu spawned inside a kolu terminal re-stamps its own id and socket over the inherited ones, so both always name this terminal, never the outer. A terminal that predates the feature (adopted across an upgrade without a respawn) may lack them — fall back to kaval-tui list. A raw kaval-tui create --host terminal (see below) gets $KAVAL_TERMINAL_ID but not $KAVAL_SOCKET: the id is path-invariant, while a local socket path means nothing on the remote box.

Reach a remote kaval — over ssh

--host <ssh> drives a kaval on another machine, with nothing to install there first. kaval-tui provisions the daemon with Nix (ships the right-arch build over ssh, realises it), runs it, and dials it — every subcommand works exactly as it does locally, just pointed at the remote. This is shell-level remote terminal control; Remote Access is the separate browser-HTTPS path.

kaval-tui create --host nix@prod
kaval-tui list   --host nix@prod
kaval-tui attach --host nix@prod <id>

The remote daemon is durable: a terminal you create outlives the ssh link, so create on prod and attach to it later — even after you closed the laptop and changed networks. One shared daemon per host. --host is mutually exclusive with --socket; it needs passwordless ssh and your user trusted by the remote’s Nix daemon.

A remote terminal runs in the host’s environment, not yours: its $SHELL, $HOME, and $PATH come from the remote machine (so its own commands resolve), and only your terminal’s presentation vars (TERM, COLORTERM, LANG/LC_*) are carried across. Your local environment — and any secrets in it — never crosses the wire.

What kaval owns

Under the daemon sits a single primitive — a multi-client PTY owner. One host owns any number of PTYs; each PTY is a real shell child paired with a headless screen mirror, fanned out to any number of consumers. It owns only the PTY: it knows nothing about git, pull requests, agent detection, or any wire protocol — those compose on top. The same primitive backs kolu’s terminals; kaval just serves it over a socket. The system split is why padi handles workspace meaning while kaval handles terminal survival.

race-free, bounded attach

A late-joining client gets a screen snapshot and then live deltas with no gap and no overlap — every byte lands in exactly one of the two, so the screen reconstructs perfectly and then streams. The snapshot is bounded to the recent screenful, so a cold or cross-host attach paints instantly instead of replaying the whole 10,000-line mirror; older history streams in on demand as the client scrolls up (getHistory).

drop-slow-subscriber

A wedged client that stops draining its output is dropped rather than pinning the daemon’s memory without bound — kaval-tui then transparently re-subscribes and gets a fresh snapshot.

taps kaval surfaces per terminal

tap source
screen snapshot + live deltas
cwd from OSC 7 reports
title from OSC 0/2 changes
command from OSC 633 preexec
exit the child’s exit code

What lives above it

kaval owns the PTYs and the raw taps above them: screen, cwd, title, command, and exit. padi is the workspace daemon above it — the layer that folds those terminal observations into repo context, PR state, agent state, session restore, and the padi-tui surface.

So this page stays about kaval: holding shells, attaching safely, detaching like ssh, and sending raw bytes. The workspace table, socket discovery, and agent-state wait semantics live on the padi page.

meet padi and padi-tuiworkspace memory, agent state, and the shell face above kaval

Why kaval-tui send, not a message bus

Driving one agent from another means typing a prompt and pressing Enter — raw bytes into the PTY, raw output back — not some agent-to-agent protocol. That’s a deliberate choice, not a missing feature. kolu’s rule is to stay out of the agent’s way: it never edits your ~/.claude (or codex / opencode) config and ships no per-agent glue. The terminal is the one interface every agent already speaks, so raw input is the honest floor. Run any agent CLI once — claude, codex, whatever ships next week — and it drives the same way, with nothing to install and no adapter to write.

Tools like hcom get a real message bus by going the other way — installing hooks into each agent’s config and routing messages through a shared database, one integration per tool. kolu won’t take on that setup step or that per-agent code — and here’s the thing: it doesn’t need to, because a channel every agent already speaks is sitting right there.

The channel is the filesystem. When one agent hands work to another, it doesn’t type an essay into the other’s terminal — it writes a file, and the other one reads it. kolu’s /debate skill (a port of srid/llm-debate) runs entirely this way: agents exchange arguments as numbered markdown files in a shared directory, each reading the others’ and writing its own by path — “pass file paths, not pasted argument text.” A turn being done is just its file existing. Every agent can read and write files with no hook and no adapter; it works headless and across machines over ssh; and because kolu already watches the working directory, a new file is a push, not a poll. An inbox is a file; a subscription is a watch on a path. That’s the whole coordination layer — no bus required.

So kaval-tui send stays deliberately dumb: it delivers the prompt — “your turn, read these files, write that one” — and nothing bigger flows over the wire. The one place the screen is genuinely irreplaceable is the interactive prompt: when Claude Code stops on an AskUserQuestion or a plan-approval menu, that question isn’t in any log or file until after you answer, so the only way to see “it’s waiting on you” is to read the rendered screen, and you answer it with arrow keys, not text. A message bus wouldn’t help there either. Files carry the content, the terminal carries the floor — the full argument is in the hcom vs. kolu note.

Commands · kaval-tui

Command What it does
kaval-tui list · --json One row per live terminal — id · pid · idle · cmd · cwd. --json emits a top-level array for jq.
kaval-tui create · [-- cmd] · --json Spawn a new terminal on the daemon and print its short id — a plain $SHELL, or a command you pass (create -- htop -d 5). A freshly-started daemon owns nothing, so create is what attach needs first; the daemon then holds the terminal until something kills it. --json emits { id, pid, cwd }.
kaval-tui snapshot <id> · --viewport · --tail N Print a terminal’s rendered screen as plain text and exit — built for piping and grepping (a trailer line goes to stderr, so stdout stays clean). Defaults to the full scrollback; --viewport prints just the visible screen (the daemon’s own last screenful — the right read when driving an agent), --tail N the last N rendered lines.
kaval-tui history <id> · --lines N Dump the terminal’s OLDER scrollback — the mirror content above the current screen that snapshot --viewport can’t show — VT-serialized so colors survive, cursor-paged from the newest older lines back. --lines N prints just the one page immediately above the screen; omit it for the full older history, oldest-first. Read-only, takes no TTY.
kaval-tui attach <id> · --escape <char> Take the terminal over, full screen. Raw passthrough — every keystroke and chord reaches the inner program; your window size follows along. Detach with the escape below.
kaval-tui send <id> [text...] · --file <path> · --paste/--no-paste · --key <name> · --json Write input to a terminal without attaching — a prompt to a Claude Code / Codex / opencode agent running in it, so one agent can drive another. send writes exactly the text OR the --keys you pass — never both, and 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). Multiline / –file / piped-stdin text is sent as one bracketed paste so it doesn’t submit line-by-line; --key also sends control keys (Escape, C-c, Up…). A write that can’t complete fails loud in seconds instead of hanging. Known limitation: a large paste Claude Code folds into a placeholder doesn’t reliably submit on Enter (issue #1702); the three-step flow is verified for normal-size prompts. --json emits { id, bytes, paste, keys }.
kaval-tui wait <id> --until <cond> · --timeout <ms> · --json Block until a terminal’s raw OUTPUT meets a condition, then exit — the hook-free done-signal for driving an agent that drives another. --until idle:<ms> resolves once no output byte has arrived for <ms> (the turn ended / it’s awaiting you — the common case); --until match:<regex> resolves once new output matches (a completion marker or returned-prompt sentinel). --timeout fails loud (exit 2) so a wedged agent can’t hang the loop; a terminal that exits first exits 3. Keyed on raw PTY bytes, so it needs no shell hooks and works for any terminal — unlike padi-tui wait, which reads real agent state off padi.
kaval-tui kill <id> End a terminal the daemon owns — the PTY is torn down, so it drops out of list and any attached client sees the shell exit. <id> is the short id or any unique prefix, like snapshot/attach; an id that matches nothing fails loud rather than silently doing nothing. Exits 0 on success.

Detaching — the ssh model

While attached, nothing is intercepted except a ~ typed at the start of a line (right after Enter — session start counts too). Mid-line tildes, every Ctrl chord, and pasted text all pass straight through, so the program inside never loses a key.

Escape What it does
~. detach — kaval-tui exits, the daemon keeps the terminal; re-attach anytime
~~ send one literal ~ to the shell
~? show this escape help
# ~ clashes (nested ssh?) — rebind it:
kaval-tui attach <id> --escape %

Fine print

  • A standalone kaval’s socket lives at $XDG_RUNTIME_DIR/kaval/pty-host.sock (or /tmp/kaval-$UID/pty-host.sock when $XDG_RUNTIME_DIR is unset) and appears when the daemon boots. --socket goes after the subcommand (kaval-tui list --socket …).
  • A kolu’s kaval is spawned by its padi and namespaced by a digest of padi’s state-root — $XDG_RUNTIME_DIR/kaval-<digest>/pty-host.sock — so two kolus on one box never collide. kaval-tui’s auto-discovery walks all of them; pass --socket only to disambiguate when several daemons are up.
  • Detach is client-side: kaval-tui can come and go while the daemon holds the terminal. Restarting the daemon itself still ends its terminals — that persistence is later work.
  • When the program inside exits, kaval-tui exits with the same code. An unreachable daemon is a one-line error, never a hang.
  • create, remote --host, and kill — ending a terminal from the shell — have all shipped: a usable raw multiplexer (beta).
  • The full design — architecture, phasing, and the decisions behind the escape model — lives in the kaval atlas note.