Skip to content
Back to skills

Kolu

ASecurity

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, read the reply, prompt again. PRIMARY PATH: the kolu MCP tools (`lifecycle_create`/`lifecycle_sendInput`, `wait_outputSettled`/ `wait_agentState`, `screen_text`, the `terminals` resource). FALLBACK: the `kolu` CLI — same verbs, same discipline. Triggers on "drive another agent", "agent drives agent", "make Claude drive Codex", or wiring a loop where o...

  • 81 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 5, 2026
ai-agentsrustgoshellgit

Works with

  • claude code
  • terminal
  • cli
  • mcp

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned September 5, 2026

npx -y skills add juspay/kolu --skill kolu --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Kolu?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Kolu
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/juspay-kolu/badge)](https://www.skillsdirectory.com/skills/juspay-kolu)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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, read
  the reply, prompt again. PRIMARY PATH: the kolu MCP tools
  (`lifecycle_create`/`lifecycle_sendInput`, `wait_outputSettled`/
  `wait_agentState`, `screen_text`, the `terminals` resource). FALLBACK: the
  `kolu` CLI — same verbs, same discipline. Triggers on "drive
  another agent", "agent drives agent", "make Claude drive Codex", or wiring a
  loop where one coding agent supervises another.
---

# kolu — drive one agent from another through its terminal

Run a coding agent inside a kolu-owned PTY and steer it from outside. **Use the
kolu MCP tools first** (`mcp__kolu__*`, wired in this repo's `.mcp.json`; other
hosts: `claude mcp add kolu -- kolu mcp`) — structured results, typed refusals,
no quoting hazards. Fall back to the `kolu` CLI's verbs (last section) when no
MCP is connected. Both faces are pure padi clients driving the same daemon, so
every discipline here applies to both.

## The loop

```
lifecycle_create   { placement: {kind: "toplevel"},                     → { id }
                     intent: "🔧 parser refactor", cwd: "/abs/repo" }
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, screenTail: 40,                   # 4. let its turn finish
                     timeoutMs: 600000 }                                #    AND read the screen
```

`screenTail` is why step 5 is gone: the screen comes back **on the met**, read
inside the wait. A follow-up `screen_text` is a second call the terminal can
move under — that gap is a race, not a formality.

- `lifecycle_create` spawns a padi-tracked canvas tile. **`placement` is
  REQUIRED and has no default** — `{kind: "toplevel"}` for a tile of its own, or
  `{kind: "child-of", parentId: <id>}` to open it as a **split INSIDE** that
  tile; a call that omits it is refused, naming both spellings. Decide it per
  spawn: the canvas and the Dock read that edge as who-works-for-whom, so a
  worker you supervise belongs under you rather than beside you by accident.
  `intent` labels it, `cwd` sets the directory. `repo` + `worktree` cut a fresh git worktree at
  `<repo>/.worktrees/<name>` and open the terminal IN it, and `run` types a
  command line at the first shell prompt (submitted with Enter) — the whole
  `kolu create --toplevel --repo … --worktree … -- <agent>` in ONE call. It spawns a
  shell — to drive a live TUI prompt afterwards, still use the three-step
  submit.
- `lifecycle_sendInput` writes **text OR one named key, never both** —
  `{ text, key }` together is a typed hard error. Keys: `Enter`, `Escape`,
  `Tab`, arrows, `Home`/`End`, `Backspace`, `Space`, `Shift-Tab`,
  `C-<char>`/`M-<char>`. Unknown key names error loudly. Multiline text goes
  as one bracketed paste.
- `wait_outputSettled` / `wait_agentState` return a uniform frame —
  `{ result: "met", met: {…} }` or `{ result: "timeout" | "gone" | "closed" }`.
  Read `result`; never guess from silence.
- `screen_text { tail: N }` reads the last N content lines; omit `tail` for the
  whole scrollback; `screen_history` pages older output.
- `lifecycle_kill { id }` — kill exactly the terminals you created.
- The `terminals` resource (`surface://collections/terminals`) is the live
  roster; each id's record carries intent, cwd, agent kind + state, `parentId`.
  `surface://cells/urgency` lists terminals whose agent awaits a human.
- Interrupt a runaway before redirecting: `{ key: "Escape" }` (stop Claude Code
  mid-stream), `{ key: "C-c" }` (SIGINT).

## The three-step submit — text · settle · Enter

An Enter sent in the same breath as the text races the TUI's bracketed-paste
handling and is **silently dropped** — the prompt sits staged on the `❯` line
while the send reports success (the #1 cause of "the turn never started";
`screen_text` will show it). The daemon can't observe the TUI settle, so *you*
do: send text, wait for settle, send Enter as its own call.

> **Multi-line pastes don't submit** ([#1702](https://github.com/juspay/kolu/issues/1702)):
> past a handful of lines, Claude Code folds the paste into a `[Pasted text]`
> placeholder that Enter does not reliably submit. For any message beyond a
> couple of lines, write it to a file and send a short pointer prompt instead
> (`read /tmp/brief.md and carry it out`).

> Step 2's idle fires only when the agent is **at the prompt**. Against a busy,
> mid-turn agent the wait times out — treat that `timeout` as "target busy":
> send the Enter anyway (it buffers and submits when the turn ends), then
> `screen_text` to confirm. `result: "gone"` is real — the terminal died;
> surface it.

## Done-signals

- `wait_outputSettled { idleMs, timeoutMs }` — raw output quiescence,
  agent-agnostic. `800` is a good default. It can't tell "finished" from
  "blocked asking you" — read the screen before responding.
- `wait_agentState { until: […], timeoutMs }` — padi's detected state:
  `working`, `awaiting` (asking you), `waiting` (post-turn lull). It matches
  the state **the instant it connects**, so right after a submit it can return
  on the *previous* turn's `waiting` — wait in two phases:
  `until: ["working"]`, then `until: ["awaiting","waiting"]`.

Both take two modifiers, and both exist because the thing they close is a race
**between calls** that no caller can close from outside:

- `settledMs` — a **conjunct**, not a second wait. The met needs the condition
  to hold AND no output byte for that long; bytes still moving keep the wait
  open, and a bucket that drops back to `working` re-enters it. This is the fix
  for the failure that reads as a finished agent: a main loop that ends its turn
  while an async subagent is three minutes into a deliberate plan is `waiting`
  within milliseconds. **15000** is the field-calibrated value.
- `screenTail: N` — the met carries the last N screen lines, read inside the
  same wait, so nothing can move between the signal and the read.

**The debrief call.** `wait_agentState { until: ["awaiting","waiting"],
settledMs: 15000, screenTail: 40 }` is "is this worker's turn really over, and
what did it say?" in one race-free call — the same protocol `kolu debrief`
spells on the CLI. Prefer it to the three-call version whenever you are driving
an agent rather than a bare shell.

Always pass `timeoutMs`, and keep it **under your own harness's per-call cap**
(most MCP hosts kill a tool call at ~1–2 min) — for a long turn, poll in
bounded slices, treating each `timeout` as "still busy".

Only `result: "gone"` means the terminal died. **`closed` is a dropped
subscription over a live terminal** — retry it; never report a worker dead off a
`closed`, and never bolt on a verify-with-`screen_text` rule to compensate.

## Supervising more than one terminal — subscribe, don't re-arm

The waits above are **edge-triggered on a live call**: they observe only while
open, so anything between two waits is unobservable. Driving several workers off
them means one wait per worker, kept armed by hand — and every gap is a hole a
report falls through. Do not hand-roll that layer (background watcher agents
looping waits and reporting back); it drops reports at four seams and it is what
`watch_*` exists to retire.

```jsonc
// once — omit `ids` to watch every terminal. The three state knobs are what
// make an ignored terminal come BACK instead of being reported once and lost.
// ignoreSelf keeps YOUR house out of the feed (the terminal this MCP server
// is running inside). ignoreIds mutes known terminals; a stale id costs nothing.
watch_open { name: "campaign", states: ["awaiting", "waiting"],
             heldForMs: 60000, nagMs: 300000, ignoreSelf: true }
watch_next { name: "campaign", timeoutMs: 60000 }                // first call
watch_next { name: "campaign", after: <ackAfter>, timeoutMs: … } // then ACK the last batch each time
watch_close { name: "campaign" }                                 // when the campaign ends
```

- Events that land while you are **not** calling `watch_next` are buffered in
  padi, so the gap between calls is not a blind spot.
- **Acknowledge by passing each result's `ackAfter` back as the next `after`.**
  Unacknowledged events are handed over again, so a reply you never received is
  never lost — dedupe on each event's `seq` if you see one twice.
- The queue outlives your MCP process **and** kaval. Re-`watch_open` the SAME
  name after either to reattach; a new name starts empty. A **padi** restart
  (upgrade) clears subscriptions — `watch_next` then FAILS naming the
  subscription rather than reporting quiet, so re-`watch_open` and continue.
- **Open it with the state knobs.** Without them a settle fires ONCE per
  episode: a worker that finishes while you are mid-turn is reported into the
  queue and never mentioned again, and you find it an hour later. With them the
  subscription reports a LEVEL — `states` picks the buckets (default
  `awaiting`+`waiting`), `heldForMs` waits for the state to hold (so an agent
  handed more work inside the window is never reported), and `nagMs` re-reports
  it every interval it keeps holding. It also hands over the currently-matching
  set on every (re)open, so reattaching after a restart shows you what is
  STANDING, not just what changed since.
- Each event is `{ id, kind, at, parentId?, intent? }`. `kind` depends on how you
  opened it — one subscription is fed by one of the two, never both:
  - **no state knobs** → `asking` (blocked on input) · `finished` (turn ended AND
    output settled) · `gone` (the terminal no longer exists — stop waiting on it).
  - **with them** → `snapshot` (already in that state when you opened) ·
    `transition` (entered it and held for `heldForMs`) · `nag` (still in it,
    `nagMs` later). These also carry `state` and `since`, so `at - since` is how
    long it has been sitting there.
- kolu asks the AGENT for its state; it never infers idleness from the screen.
  An idle agent that repaints its prompt every second is still idle, so
  `heldForMs` times the state and not the output.
- **One subscription answers ONE of the two questions.** A state-knob
  subscription reports no `gone` — it is a level, so a terminal that disappears
  just stops being reported and nothing is left waiting on it. If you need to
  hear about a terminal DYING, keep a second `watch_open` without the knobs, or
  re-read the `terminals` resource. And re-opening a name with DIFFERENT knobs
  starts its queue over (those events answer the question you stopped asking);
  re-opening with the SAME knobs — the ordinary restart — keeps it.
- `timeout` and `closed` both lose nothing — the queue is still there next call.
  Neither means a terminal died. A nonzero `dropped` means you were away long
  enough to overflow the 512-event queue; re-read the `terminals` resource to
  reconcile rather than trusting the delta.
- Prefer the default (all terminals) over an `ids` list: a kaval recycle retires
  every active terminal id, so a frozen id list ages out where "all" does not.
  Mute with `ignoreIds` / `ignoreSelf` instead of enumerating who to watch —
  a mute fails open (new terminals are always watched; a stale id is inert),
  an id watch-list fails closed.

## Provisioning the inner agent

- **Worktree'd agent:** no MCP path in v1 (`git.worktreeCreate` is a named
  denial) — `kolu create --toplevel --repo /abs/repo --worktree my-branch -- claude
  --dangerously-skip-permissions`, then drive the returned id over MCP.
- **Never hardcode the agent CLI** — default to the agent *you* run as, unless
  the human named one.
- **`create` returning ≠ ready:** a first-run agent may sit on a one-time
  dialog needing its own Enter. Drive every boot step by reading the screen.
- Launch unattended agents with bypass permissions
  (`claude --dangerously-skip-permissions`) and confirm from the footer via
  `screen_text` before dispatching.
- Restarting the CLI in place: send its quit command (`/exit`) as a three-step
  submit, confirm the shell prompt, relaunch.
- **Terminal ids are not stable across a kaval restart** — a cached id can go
  stale mid-run. Re-find the terminal via the `terminals` resource by its
  stable `intent` label (set it at create for exactly this).

## Fallback — the `kolu` CLI

`kolu` is the ONE terminal CLI: the same verbs, spelled for a shell. Full
treatment (three-step submit in CLI form, `--file`, exit codes, endpoint flags,
worktree provisioning, the interim agent-spawn doctrine): **[TUI.md](TUI.md)**.
The verb map:

| MCP | CLI |
| --- | --- |
| `lifecycle_create` (`placement` REQUIRED) | `kolu create (--toplevel \| --parent <id>) [--intent …] [--repo … --worktree …] -- <agent>` |
| `lifecycle_sendInput { text }` | `kolu send "$id" "text"` (`--file <path>` for tricky payloads) |
| `lifecycle_sendInput { key: "Enter" }` | `kolu send "$id" --key Enter` |
| `wait_outputSettled` | `kolu wait "$id" --until idle:<ms> --timeout <ms>` (also `--until match:'<regex>'`) |
| `wait_agentState` | `kolu wait "$id" --until working` · `--until awaiting,waiting` |
| `{ settledMs, screenTail }` on either wait | `--settled <ms>` · `--snapshot <N>` on `kolu wait` |
| `wait_agentState { until: ["awaiting","waiting"], settledMs: 15000, screenTail: 40 }` | **`kolu debrief "$id"`** — the same protocol either way, and what you should reach for when driving an agent: turn over **AND** output quiet, then the screen, in one call. |
| `screen_text { tail }` | `kolu snapshot "$id" [--tail N]` (never bare `snapshot \| tail` — that's the buffer bottom incl. trailing blanks; `--tail` drops them) |
| `screen_history` | `kolu history "$id" [--lines N]` |
| `terminals` resource | `kolu ls [--json]` · `kolu watch [id]` for the live feed |
| `lifecycle_kill` | `kolu kill "$id"` |

Every verb takes the same **endpoint flags** — `--socket <path>` ·
`--state-root <dir>` · `--host <ssh>`, mutually exclusive, and accepted on
**either side of the verb** (`kolu --host box create` ≡ `kolu create --host
box`). Inside a kolu terminal `$PADI_SOCKET` is already stamped, so you pass
none. Ids accept **any unique prefix**.

## Acceptance

- Submitted with a **separate Enter**, sent after an observed settle.
- The reply is **actually in the screen read** — idle means output stopped, not
  that the answer is right.
- Every wait had a timeout under your harness's per-call cap.
- If the screen settled on a **question**, you read and answered it — not sent
  the next task on top of a blocked prompt.
- Killed exactly the terminals you created, no others.

Files in this skill

  • SKILL.md14.7 KB
  • TUI.md29 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…