# Kolu

> 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.

- Skill: `srid/kolu-3` (Agent Skill)
- Install (CLI): `npx skillmds@latest add srid/kolu-3`
- Raw SKILL.md: https://api.skillmd.com/api/skills/srid/kolu-3/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: srid (https://skillmd.com/u/srid)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/srid/kolu-3

---


# 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.

