kolu — drive one agent from another through its terminal
You can run a coding agent (Claude Code, Codex, opencode) inside a kolu-owned
PTY and steer it from the outside: type a prompt, submit it, watch the screen
until it's done, read what it said, type the next prompt. The whole toolkit is
kaval-tui — write input, read the screen, spawn, kill. The driver runs it
directly; there's no server to stand up and no MCP layer.
pulam-tui adds a precise done-signal (wait --until <state>) — but only for
hooked terminals (see the last section). For a raw kaval-tui-spawned
terminal, the done-signal is watching the screen settle, below.
The loop
id=$(kaval-tui create --json -- claude | jq -r .id) # spawn the inner agent
kaval-tui send "$id" "refactor the parser to use a lexer" # 1. TYPE the prompt
kaval-tui send "$id" --key Enter # 2. SUBMIT it (its own step)
wait_until_settled "$id" # 3. let its turn finish (below)
kaval-tui snapshot "$id" --viewport # 4. read the screen
kaval-tui send "$id" "now add tests for it"; kaval-tui send "$id" --key Enter # loop
Leaf commands, all kaval-tui: create (spawn) · send (type) ·
send --key Enter (submit) · snapshot (read) · kill. Typing and
submitting are two separate sends — that is load-bearing, see below.
Read with
snapshot --viewport, not| tail. A baresnapshotprints the whole scrollback — thousands of lines on a long-running or compacted agent — sosnapshot | tail -8hands you the bottom of the buffer (often just trailing blanks), not the live screen.--viewportasks the daemon for just its terminal's last screenful — the right "what's on screen now" read, and correct regardless of how tall your own shell is (over--hostthe remote terminal is a different size).--tail N(alias--lines N) bounds it to the last N lines when you want a fixed slice.
kaval-tui send — type, then submit (two steps)
kaval-tui send <id> [text...] writes input to the terminal — exactly the text
(and any --keys) you pass, with NO implicit Enter. It types; it does not
submit. Submitting a prompt is its own second send:
kaval-tui send "$id" "fix the failing test in parser.ts" # 1. type the prompt
kaval-tui send "$id" --key Enter # 2. submit it
Do this as two separate send commands — not send "text" --key Enter in one
call. The separation is load-bearing: an Enter sent in the same breath as the
text races Claude Code's bracketed-paste / debounced input handling — it arrives
before the pasted text has registered and is silently dropped, leaving the
prompt staged on the ❯ line while send reports success. A standalone
follow-up send --key Enter lands after the text has settled, so it actually
submits. (If a turn never seems to start, this is the #1 cause — snapshot and
look for the prompt sitting unsent on the ❯ line.)
Specifics:
- Multiline prompts and piped stdin go as one bracketed paste, so they land
in the input box as a block instead of submitting line-by-line. Automatic
(
--paste/--no-pasteforce it). For a big prompt, pipe it —cat task.md | kaval-tui send "$id"— thensend "$id" --key Enter. --key <name>(repeatable, sent after the text) is both the submit channel (Enter) and the control channel:Escape,C-c,Enter,Up/Down/Left/Right,Tab,Home,End,Backspace,M-<char>.--json→{ id, bytes, paste, keys }to confirm what was written.
send is blind — it writes whether or not the agent is ready for input.
Always pair it with snapshot so you don't fire a prompt into a not-yet-ready
session (e.g. before the TUI has drawn its input box, or over a trust prompt).
Interrupt a runaway before redirecting it:
kaval-tui send "$id" --key Escape # stop Claude Code mid-stream
kaval-tui send "$id" --key C-c # SIGINT whatever's running
The done-signal — watch the screen settle
After you submit, you need to know when the turn ends. For a raw
kaval-tui-spawned terminal there's no agent-state feed, so use the screen
itself: poll snapshot until it stops changing. A working agent streams
output, so a screen that's held still for a couple of polls means the turn ended
— it finished, or it's blocked asking you something (both mean "your move").
Then read the snapshot to see which, and respond.
# Block until <id>'s screen is unchanged across 2 polls, capped at a deadline so
# a wedged agent can't hang the loop forever (the manual equivalent of a timeout).
wait_until_settled() {
local id=$1 deadline=$(( $(date +%s) + 600 )) prev="" cur stable=0
while [ "$stable" -lt 2 ] && [ "$(date +%s)" -lt "$deadline" ]; do
sleep 3
cur=$(kaval-tui snapshot "$id" --viewport) # diff the live screen, not the whole scrollback
if [ "$cur" = "$prev" ]; then stable=$((stable + 1)); else stable=0; fi
prev=$cur
done
}
Poll --viewport, not a bare snapshot: a settle test diffs two reads, and
diffing two full-scrollback dumps is slow and noisy (any scrollback churn reads
as "still moving"), while two screenfuls compare cleanly.
Tune sleep/deadline to the work. It's coarser than pulam-tui wait (a busy
agent that pauses mid-thought can read as settled), so after it returns,
confirm the reply is actually present in the snapshot before moving on.
pulam-tui wait — the precise done-signal (hooked terminals only)
When you do have agent-state detection, pulam-tui wait <id> --until <buckets>
is the exact done-signal — it blocks until the agent reaches a coarse state, then
exits 0:
working— busy (thinking/tool_use/ background task).awaiting—awaiting_user: it's asking you a question.waiting— the just-finished post-turn lull.
awaiting and waiting both mean "your move", so --until awaiting,waiting
catches a turn ending; --timeout <ms> fails loud (exit 2) so a wedged agent
can't hang the loop; if the terminal exits before reaching the state, wait
fails loud too (exit 3 — the agent you were driving died); --json →
{ id, agent }.
Mind the stale-state race — wait in two phases.
waitmatches the agent's state the instant it connects, replaying whatever it is right now. So right after asend, the agent may still report the previous turn'swaiting/awaitingfor a beat before it picks up the new prompt — and a lonewait --until awaiting,waitingwould return immediately on that stale state, before the turn you asked for has even begun. For a robust loop, wait for the pickup first, then the turn-end:kaval-tui send "$id" "fix the parser"; kaval-tui send "$id" --key Enter pulam-tui wait "$id" --until working # 1. it picked up the prompt pulam-tui wait "$id" --until awaiting,waiting # 2. its turn ended
Caveat — agent state needs HOOKED terminals. Detection keys on kolu's shell rc-hooks (the OSC marks a terminal emits as commands run).
kaval-tui createis the raw multiplexer — a plain$SHELL, no hooks by design — so an agent you spawn that way often isn't detected, andwaitwill just time out.waitis reliable when you drive already-hooked terminals: the ones a running kolu-server spawned (pointkaval-tui --socket $XDG_RUNTIME_DIR/kolu/pty-host.sockat them), or a futurekolu-tui. For a rawkaval-tui createloop, use the screen-settle done-signal above.
Reach — which daemon you're driving
Bare kaval-tui autodiscovers a running daemon on this machine. Two ways to
point it elsewhere:
--socket <path>targets a specific local daemon — e.g. a running kolu-server's kaval ($XDG_RUNTIME_DIR/kolu/pty-host.sock), to drive the terminals you have open in kolu (these ARE hooked, sopulam-tui waitworks against them once apulamreads that kaval).--host <ssh>reaches a daemon on another machine (provisioned with Nix); a remote PTY survives the link.
Acceptance
Before calling a driven turn done:
- You submitted with a separate
send --key Enter(not an implicit Enter, notsend "text" --key Enterin one call) — a prompt left staged on the❯line is the #1 failure here. - The inner agent's reply is actually in the
snapshot— not an empty box or a half-rendered stream. The screen-settle wait is coarse; verify the content. - Your wait had a deadline /
--timeoutso a wedged agent fails instead of hanging the loop. - If the screen settled on a question (the agent is awaiting you), you read it and answered — you didn't send the next task on top of a blocked prompt.