| name | kolu |
| description | Drive one AI agent from another through kolu's terminals: spawn a Claude Code / Codex / opencode session in a PTY, prompt it, watch the screen for its reply, read it, and prompt again — a create→send→snapshot loop run with the `kaval-tui` CLI directly (no MCP). `kaval-tui` writes input, reads scrollback, and provides a hook-free done-signal (`wait --until idle:<ms>` on raw PTY output); `pulam-tui` adds a precise agent-state done-signal when you drive hooked terminals. Triggers on "drive another agent", "send a prompt to a terminal agent", "have one agent prompt another", "agent drives agent", "orchestrate agents in terminals", "make Claude drive Codex", "prompt the agent running in that terminal", or wiring a loop where one coding agent supervises another. |
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)
kaval-tui send "$id" "refactor the parser to use a lexer"
kaval-tui send "$id" --key Enter
kaval-tui wait "$id" --until idle:800 --timeout 600000
kaval-tui snapshot "$id" --viewport
kaval-tui send "$id" "now add tests for it"; kaval-tui send "$id" --key Enter
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 bare snapshot prints the
whole scrollback — thousands of lines on a long-running or compacted agent —
so snapshot | tail -8 hands you the bottom of the buffer (often just trailing
blanks), not the live screen. --viewport asks 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 --host the 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"
kaval-tui send "$id" --key Enter
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-paste force it). For a big prompt, pipe it —
cat task.md | kaval-tui send "$id" — then send "$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
kaval-tui send "$id" --key C-c
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
--until idle:<ms> resolves once no output byte has arrived for <ms> —
the agent-agnostic "turn ended / awaiting input" signal, and the common case.
800 is a good default; raise it for an agent that pauses mid-thought, lower
it for a snappier loop. It works identically for claude/codex/grok/
opencode because 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,
wait exits 3 (the agent you were driving died). Met → exit 0.
--json → one result frame per outcome: { id, result, … }, where
result is met/timeout/gone/interrupted/closed. A met frame adds
fired (idle/match), elapsedMs, and matchedLine on a match — so a
driver reads the structured result, 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 polling snapshot --viewport until 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 prefer
kaval-tui wait whenever the daemon has it.
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)
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 wait keys on raw output quiescence/match — works on any
terminal, no hooks. This is the one to reach for when driving a raw
kaval-tui create agent (above). It can't tell "finished" from "blocked asking
you" — both are quiescence — so read the snapshot after.
pulam-tui wait keys 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. wait matches the agent's
state the instant it connects, replaying whatever it is right now. So right
after a send, the agent may still report the previous turn's
waiting/awaiting for a beat before it picks up the new prompt — and a lone
wait --until awaiting,waiting would 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
pulam-tui wait "$id" --until awaiting,waiting
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 create
is the raw multiplexer — a plain $SHELL, no hooks by design — so an agent
you spawn that way often isn't detected, and wait will just time out. wait
is reliable when you drive already-hooked terminals: the ones a running
kolu-server spawned (point kaval-tui --socket $XDG_RUNTIME_DIR/kolu/pty-host.sock
at them), or a future kolu-tui. For a raw kaval-tui create loop, use
kaval-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, so pulam-tui wait works
against them once a pulam reads 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,
not send "text" --key Enter in 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 idle means "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.