- 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
```sh
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
`send`s** — 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 `--key`s) you pass, with NO implicit Enter**. It types; it does not
submit. **Submitting a prompt is its own second `send`:**
```sh
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-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:
```sh
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:
```sh
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.
`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.
>
> ```sh
> 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 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:
>
> ```sh
> 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 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.
Auf GitHub ansehen