| 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. PRIMARY PATH: the kolu MCP server's tools (`lifecycle_create`/`lifecycle_sendInput` with named keys, the `wait_outputSettled`/`wait_agentState` done-signals, `screen_text`, the `terminals` resource) — configured in this repo's .mcp.json as `kolu mcp`. FALLBACK when the MCP is unavailable: the `kaval-tui`/`padi-tui` CLIs, same verbs, same discipline. 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.
Reach for the kolu MCP tools first (mcp__kolu__* — served by kolu mcp,
wired in this repo's .mcp.json; other hosts: claude mcp add kolu -- kolu mcp). They speak padi's live surface directly — structured results, typed
refusals, no shell quoting hazards. Fall back to the kaval-tui / padi-tui
CLIs (the final section) when no kolu MCP is connected — a host without the
server configured, a machine with no padi running, or a RAW standalone-kaval
terminal padi doesn't track. The verbs map one-to-one, and every discipline
below (the three-step submit, the done-signal, the fold hazard) applies to both
faces because they drive the same daemon.
The loop (MCP)
lifecycle_create { intent: "🔧 parser refactor", cwd: "/abs/repo" } → { id }
lifecycle_sendInput{ id, text: "refactor the parser to use a lexer" } # 1. the text (no Enter)
wait_outputSettled { id, idleMs: 300, timeoutMs: 15000 } # 2. observe the TUI settle
lifecycle_sendInput{ id, key: "Enter" } # 3. submit (its own call)
wait_outputSettled { id, idleMs: 800, timeoutMs: 600000 } # 4. let its turn finish
screen_text { id, tail: 40 } # 5. read the screen
lifecycle_create spawns a canvas tile (a real padi-tracked terminal —
agent sensors, dock state, the works). intent labels the tile so a human
watching the canvas knows whose it is; cwd sets the working directory;
parentId: <your-or-another-terminal-id> opens it as a SPLIT beside that
tile (the sibling-reviewer layout /codex-debate uses). It spawns a shell —
launch the agent by sending its command line (claude --dangerously-skip-permissions etc.) through the three-step submit below.
lifecycle_sendInput writes text OR one named key, never both —
{ text, key } in one call is a hard, typed error (see the submit
discipline). The key vocabulary: Enter, Escape, Tab,
Up/Down/Left/Right, Home, End, Backspace, Space,
Shift-Tab, and C-<char> / M-<char> chords. An unknown key name is a
loud error, never a silent no-op. Multiline text goes as one bracketed
paste automatically.
wait_outputSettled is the agent-agnostic done-signal (below);
wait_agentState the precise one. Both return a uniform result frame —
{ result: "met", met: { …detail } } or
{ result: "timeout" | "gone" | "closed", elapsedMs?, error? } — read
result, never guess from silence; the met detail (fired/elapsedMs, or
agent/elapsedMs) nests under met.
screen_text { tail: N } reads the last N content lines (trailing
blank viewport rows are stripped) — the cheap "what's on screen now" read.
Omit tail for the whole scrollback; screen_history pages older
scrollback above it.
lifecycle_kill { id } ends a terminal. Kill exactly the terminals you
created, when you're done with them.
- The
terminals resource (surface://collections/terminals) is the live
roster — the key set; each id's full record (intent, cwd, git, agent kind +
state, parentId, foreground process) reads at
surface://collections/terminals/<id>. surface://cells/urgency lists
the terminals whose agent awaits a human right now.
Interrupt a runaway before redirecting it:
lifecycle_sendInput { id, key: "Escape" } (stop Claude Code mid-stream),
{ key: "C-c" } (SIGINT whatever's running).
The three-step submit — text · settle · Enter
Submitting a prompt to a TUI agent is three calls, because sendInput
bakes in no timing magic — it writes exactly what you pass:
lifecycle_sendInput{ id, text: "fix the failing test in parser.ts" }
wait_outputSettled { id, idleMs: 300, timeoutMs: 15000 }
lifecycle_sendInput{ id, key: "Enter" }
Why not one call? An Enter sent in the same breath as the text races Claude
Code's bracketed-paste / debounced input handling and is silently dropped,
leaving the prompt staged on the ❯ line while the send reports success (if a
turn never seems to start, this is the #1 cause — screen_text and look for
the prompt sitting unsent). The daemon cannot observe when the TUI
settled, so any fixed grace baked into the send is a race. The honest fix is
step 2: you observe the settle, then submit as its own call. { text, key } in one call is a typed hard error for exactly this reason — the
trap is unspellable, not merely discouraged.
⚠️ MULTI-LINE pastes don't submit — a known-open limitation (#1702).
The three-step flow is verified for a short prompt (a line or two). Claude
Code folds a paste into a [Pasted text +N lines] placeholder once it spans
more than a handful of lines (observed at ~a dozen lines, well under 1 KB), and
a folded paste does NOT reliably submit — idle firing proves the fold
finished, not that Enter submits it. Workaround for any multi-line message
(a brief, a report, a ruling): write it to a file and send a short prompt
pointing the agent at it — { text: "read /tmp/brief.md and carry it out" } —
then the three-step submit. Reach for the file-pointer the moment a message
runs past a couple of lines.
Step 2 fires cleanly only when the agent is AT THE PROMPT (the normal
case: idleMs: 300 fires in under a second). Messaging an agent that is
mid-turn and busy (streaming continuously), the idle window never
opens — so the timeoutMs fires instead. Treat result: "timeout" there as
"target busy": send the Enter anyway (it lands in the input buffer and
submits when the turn ends), then screen_text to confirm. A
result: "gone" is real (the terminal exited — the agent you were driving
died); surface it, don't send into the void.
The done-signals
After you submit, you need to know when the turn ends. Two tools, reading
different things:
wait_outputSettled { id, idleMs, timeoutMs } keys on raw output
quiescence — resolves met once no output byte has arrived for idleMs.
Agent-agnostic (bytes, not rendering): works identically for
claude/codex/grok/opencode. 800 is a good default; raise it for an
agent that pauses mid-thought. It can't tell "finished" from "blocked asking
you" — both are quiescence — so screen_text and read before responding.
wait_agentState { id, until: [...], timeoutMs } keys on padi's
detected agent state — precise buckets: working (thinking/tool_use),
awaiting (asking you a question), waiting (the just-finished
post-turn lull). awaiting/waiting both mean "your move". It needs a
terminal padi tracks (every lifecycle_create terminal is); a bare shell
with no agent running never enters a bucket — bound it with timeoutMs.
Always pass timeoutMs so a wedged agent fails loud (result: "timeout")
instead of hanging the loop — and keep it under your own harness's per-call
timeout (most MCP hosts cap a tool call at ~1–2 minutes by default; a longer
wait gets your call killed while the driven agent is fine). For a long turn,
poll in bounded slices: repeat wait_outputSettled { idleMs: 800, timeoutMs: 60000 } and treat each timeout as "still busy, wait again".
Mind the stale-state race — wait in two phases. wait_agentState
matches the state the instant it connects, replaying whatever it is right
now. Right after a submit, the agent may still report the previous turn's
waiting for a beat — a lone until: ["awaiting","waiting"] returns
immediately on that stale state. For a robust loop: first
until: ["working"] (it picked up the prompt), then
until: ["awaiting","waiting"] (its turn ended).
Provisioning the inner agent
- A split tile beside a terminal:
lifecycle_create { parentId: <id> } —
the record carries parentId and the canvas renders it as a sibling split.
- A worktree'd agent (fresh git worktree as the terminal's cwd) has no
MCP path in v1 —
git.worktreeCreate is a named denial. Provision with
the CLI: padi-tui create --repo /abs/repo --worktree my-branch -- claude --dangerously-skip-permissions (fast-forward the base repo first — the
worktree is cut from the checkout as it stands), then drive the returned id
over MCP as usual.
- Never hardcode the agent CLI. The inner agent defaults to the same agent
you are running as (a Claude Code orchestrator spawns
claude, a codex
one codex) — unless the human named a different agent, which wins.
create returning ≠ the agent is ready. screen_text before
dispatching: a first-run agent may sit on a one-time dialog (a trust prompt,
MCP selection) that needs its own { key: "Enter" }. Drive every boot step
by reading the screen, never by sleeping and hoping.
- Set the permission mode AT LAUNCH, then verify it from the footer. An
unattended agent launches with bypass permissions (
claude --dangerously-skip-permissions); read the footer via screen_text and
confirm before dispatching.
- Restarting the agent CLI in place: text typed at a running agent
becomes a prompt — send its quit command (
/exit in Claude Code) as its own
three-step submit, confirm the shell prompt in screen_text, then launch
again.
A terminal id is not stable across a kaval restart. kaval re-keys every
terminal when it restarts, so a cached id can go stale mid-run (a send to it
fails "not found"). Re-find the terminal through the terminals resource —
read each id's record and match on the stable intent label (set it at
create for exactly this) — then use its current id.
Fallback — the kaval-tui / padi-tui CLIs (no MCP connected)
When no kolu MCP server is available — the host has none configured, an older
kolu without the MCP face, a non-MCP agent runtime, or a raw standalone
kaval terminal padi doesn't track — the same loop runs on the CLIs. The
full CLI treatment lives in TUI.md (the three-step submit in CLI
form, --file, the done-signal exit codes, daemon discovery and the
$KAVAL_SOCKET/$KAVAL_TERMINAL_ID self-knowledge vars, worktree
provisioning, the old-daemon polling fallback). The verb map:
| MCP | CLI |
|---|
lifecycle_create | padi-tui create [--parent <id>] [--repo … --worktree …] -- <agent> (canvas tile) · kaval-tui create -- <agent> (raw, padi-blind) |
lifecycle_sendInput { text } | kaval-tui send "$id" "text" (--file <path> for shell-metacharacter payloads) |
lifecycle_sendInput { key: "Enter" } | kaval-tui send "$id" --key Enter |
wait_outputSettled | kaval-tui wait "$id" --until idle:<ms> --timeout <ms> (also --until match:'<regex>', CLI-only) |
wait_agentState | padi-tui wait "$id" --until working|awaiting,waiting |
screen_text { tail } | kaval-tui snapshot "$id" --viewport (never a bare snapshot | tail — that's the buffer bottom, not the screen) |
terminals resource | kaval-tui list (autodiscovers every daemon, self-labeling) · padi-tui status |
lifecycle_kill | kaval-tui kill "$id" |
Interim (agent-spawn-first-class, #1872). As of PR2 a command-rooted agent
(raw kaval-tui create -- <agent>, no shell) is Dock-visible and
agent-state-tracked — kaval seeds its command from the argv — so detection is no
longer a reason to prefer the MCP lifecycle_create / padi-tui create, though
those still give the fuller shell-rooted workspace. The one residual trap: a
fresh kaval-tui create shell is clean, but launching an agent from your own
shell (or one reached via ssh/sudo -E) still needs unset CLAUDE_CODE_CHILD_SESSION CLAUDECODE CLAUDE_CODE_SESSION_ID first. Full detail +
the remaining tagged deletion point: TUI.md → interim doctrine.
Acceptance
Before calling a driven turn done:
- You submitted with a separate Enter send, sent after you observed the
settle — never text+Enter fused (a typed hard error on both faces). A prompt
left staged on the
❯ line is the #1 failure here.
- The inner agent's reply is actually in the screen read — not an empty box
or a half-rendered stream. Idle means "output stopped", not "the answer is
right"; verify the content.
- Every wait had a timeout so a wedged agent fails loud instead of hanging
the loop — and the timeout sat under your own harness's per-call cap.
- 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.
- You killed exactly the terminals you created (
lifecycle_kill /
kaval-tui kill), and no others.