the PTY daemon & its client · alpha
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.
Run the pair
-
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 -
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 kavalWhy 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.sockwhen$XDG_RUNTIME_DIRis unset) and appears when the daemon boots.--socketgoes after the subcommand (kaval-tui list --socket …). - A kolu’s kaval is spawned by its
padiand 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--socketonly 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, andkill— 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.