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.
For a raw kaval-tui-spawned terminal, the done-signal is kaval-tui wait --until idle:<ms> — it blocks in the daemon on the raw PTY output and returns
the moment the agent stops streaming, with no shell hooks (below). pulam-tui
adds a precise agent-state done-signal (wait --until <state>), but only for
hooked terminals (the last section); for raw terminals, reach for
kaval-tui wait.
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)
kaval-tui wait "$id" --until idle:800 --timeout 600000 # 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) · wait (block until the turn ends) ·
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 — kaval-tui wait --until idle:<ms>
After you submit, you need to know when the turn ends. For a raw
kaval-tui-spawned terminal there's no agent-state feed — but the daemon already
sees every output byte, so kaval-tui wait blocks on that raw stream and
returns the instant the agent goes quiet, with no shell hooks and no
busy-word guessing:
kaval-tui wait "$id" --until idle:800 --timeout 600000 # block until the turn ends
--until idle:<ms>resolves once no output byte has arrived for<ms>— the agent-agnostic "turn ended / awaiting input" signal, and the common case.800is a good default; raise it for an agent that pauses mid-thought, lower it for a snappier loop. It works identically forclaude/codex/grok/opencodebecause it keys on bytes, not on any agent's rendering.--until match:'<regex>'resolves once new output matches — use it for a completion marker or a returned-prompt sentinel (e.g.--until match:'\$ $').--timeout <ms>caps the wait and fails loud (exit 2) so a wedged agent can't hang the loop. If the terminal exits before the condition fires,waitexits 3 (the agent you were driving died). Met → exit 0.--json→ one result frame per outcome:{ id, result, … }, whereresultismet/timeout/gone/interrupted/closed. Ametframe addsfired(idle/match),elapsedMs, andmatchedLineon a match — so a driver reads the structuredresult, never just the exit code.
Quiescence ≠ "the reply is correct": idle fires whether the agent finished or
is blocked asking you something (both mean "your move"). So after wait
returns, snapshot --viewport and read what's on screen before responding.
Fallback — screen-settle polling (only for an old daemon). If you're driving a kaval that predates
wait(it errors "unhandled command"), fall back to pollingsnapshot --viewportuntil the screen holds still across two reads, capped by a deadline. It's coarser and laggier (a busy agent that pauses mid-thought can read as settled, and the poll lags the real settle), so preferkaval-tui waitwhenever the daemon has it.wait_until_settled() { # fallback only — kaval-tui wait is preferred 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) # the live screen, not full scrollback if [ "$cur" = "$prev" ]; then stable=$((stable + 1)); else stable=0; fi prev=$cur done }
pulam-tui wait vs kaval-tui wait — two done-signals
They are not rivals; they read different things:
kaval-tui waitkeys on raw output quiescence/match — works on any terminal, no hooks. This is the one to reach for when driving a rawkaval-tui createagent (above). It can't tell "finished" from "blocked asking you" — both are quiescence — so read the snapshot after.pulam-tui waitkeys on agent-state buckets (working/awaiting/waiting) — more precise (it distinguishes awaiting-you from finished), but only on hooked terminals. Use it when you're driving terminals a kolu-server spawned (below).
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, usekaval-tui wait --until idle:<ms>above — it needs no hooks.
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.wait --until idlemeans "output stopped", not "the answer is right"; verify the content. - Your wait had a
--timeout(or deadline) so a wedged agent fails loud (exit 2) 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.