← the Atlas

kolu CLI — web · tui · mcp · surface, the binary's agent faces

Features·seedling·accepted·

The kolu binary grows subcommands — kolu web (the server), kolu tui (a tmux-like canvas), kolu mcp (the agent face) — the latter two pure padiSurface clients with no kolu-server, the graduation proof that padi serves a second frontend. Amended 2026-08-09 — kolu also subsumed the TUIs' scripting verbs; amended 2026-08-22 — and grew `kolu screenshot` / `screen_image`, the screen as a picture; amended 2026-08-23 — and `kolu surface` projects the same agent table as shell argv.

One binary, three faces: kolu web (today’s server+browser, still the bare-kolu default), kolu tui (the terminal canvas), kolu mcp (the agent face). The tui and mcp faces are the second frontend — the client that proves padiSurface serves someone other than kolu-server. Neither is a scripting CLI: padi-tui and kaval-tui keep their verbs as their daemons’ scripting faces.The note was born as “kolu-tui”, a new executable; the ratified shape is subcommands of the one product binary, so the plan covers the CLI restructure too — hence the rename.The subsume-the-CLIs option was considered and rejected at ratification: verb parity would drag a kaval dependency into the CLI faces and blur the proof. Agents that want verbs get them through the MCP face below — derived from padiSurface, not reimplemented. Ratified scope: **canvas + attach

User-facing description

One binary, two faces.

The canvas (humans). kolu tui connects to the local padi socket — kolu tui --host user@zest pipes over ssh — and renders that host’s live canvas: one tile per terminal, title, agent badge, urgency mark. The same arrangement the browser shows for this host, in a terminal.

Prototype A — the default view: urgency-ordered grid of live tiles + a one-column status rail for everything that doesn’t fit. Tile order follows the urgency ladder (blocked ▲ > done-unseen ● > working ✳ > idle), so the tile that needs you is always top-left; the rail’s dots carry the same states for off-grid terminals, and the most urgent off-grid state bubbles into the rail header.The rail + ladder + seen/unseen semantics are taken from the herdr study (an unseen-done agent outranks a still-working one — done+unseen needs a human, working does not; the ● badge self-clears on attach). herdr’s own layout is list+focus+zoom, not a grid — the grid stays kolu’s differentiator; the rail is its overflow answer.

kolu tui — zest
▲│┌─ 1 flake-5x ▲ blocked ────┐┌─ 2 live-fix ● done ───────┐
●││ Need your ruling on (a)/(b)││ ❯ 14/14 green — lease │
✳││ — see report 07. ││ released. Holding. │
✳│└───────────────────────────┘└───────────────────────────┘
·│┌─ 3 chlog-2 ✳ working ─────┐┌─ 4 sr11 · idle ───────────┐
·││ · Baking… (14m 27s) ││ ❯ │
││ 695 passed, 0 failed ││ │
│└───────────────────────────┘└───────────────────────────┘
# 1-9 focus · Enter attach · g goto · c create · x kill · ? keys

Prototype B — attached (maximized): full-screen raw passthrough; one reverse-video status line is the only chrome. Detach with the ssh-style ~. line-start chord (~~ sends a literal ~, ~? lists escapes — the kaval-tui attach grammar, verbatim). The status line keeps the rail’s most-urgent dot, so a blocked agent elsewhere is visible even while attached.

kolu tui — zest · attached: flake-5x
✻ Hatching… (31s · thinking)
⎿ streak 3/5 — r-cert 4 relaunched on kolu-ci-2
❯
 
[flake-5x ▲ · ~. detach · ~? help rail: ●✳✳ · 2 off-grid]

Prototype C — the navigator (g): fuzzy jump across every terminal, with single-key state filters — b blocked, d done-unseen, w working — because spatial navigation stops scaling past a dozen agents.Taken from herdr’s navigator/goto overlay; its state-filter chords are the triage tool a grid of agents needs. The overlay renders the user’s REAL bindings, not hardcoded docs — same rule for the ? help overlay.

kolu tui — zest · goto
goto ❯ fla█ [b]locked [d]one [w]orking [a]ll
▲ 1 flake-5x blocked 4m Need your ruling on (a)/(b)…
✳ 3 chlog-2 working 14m Baking…
· 7 scratch-fla idle 2h ❯
# Enter focus · ⏎⏎ attach · Esc cancel

The MCP face gets its own section below — one connected surface, two consumers.

Out of scope, deliberately: multi-host (the host map is kolu-server’s job; a later phase may dial N padis if demand shows), provisioning (the CLI faces dial a padi that already runs — if none listens it fails fast with the socket/ssh error, never a silent retry loop), and any padi-tui/kaval-tui verb.

The MCP face

The MCP face (agents). kolu mcp [--host …] serves the same connected surface to a coding agent over stdio via serveSurfaceAsMcp (@kolu/surface-mcp), default-deny — the v1 expose map is this table and nothing else:This is how “agents can drive padi/kaval verbs through the kolu binary” lands without it growing a verb CLI: the verbs are padiSurface’s own procedures, re-exposed by the existing adapter. Nothing is reimplemented; padi-tui/kaval-tui remain the human scripting faces. The kaval-tui verb set maps onto it directly: list → the terminals resource, snapshot → screen.text, send → lifecycle.sendInput, create/kill → lifecycle.create/kill, wait → subscribe urgency/activity and watch for the edge.

exposed as member what the agent gets
resource (subscribable) terminals the live roster — id, title, command, agent kind + state per terminal
resource (subscribable) urgency the awaiting-ids set — “which terminals need a human/agent now”
resource (subscribable) status daemon/kaval health
resource (subscribable) activity the host’s activity feed (state transitions to wait on)
tool, read-only screen.text · screen.history a terminal’s rendered screen / scrollback as text — the snapshot face
tool, read-only git.getStatus · git.getDiff the workspace’s git context, same source of truth the browser’s Code tab reads
tool, read-only fs.listAll · fs.readFile workspace file listing / file contents
tool, mutates lifecycle.create spawn a terminal — bespoke since the worktree completion: a REQUIRED placement ({"kind":"toplevel"} or {"kind":"child-of","parentId":…} — no default), cwd OR repo+worktree (cut at <repo>/.worktrees/<name>, terminal opens in it), intent, and run (a first command TYPED at the shell prompt — never a spawn argv) — the whole kolu create composition in one call, returning id/pid (+worktree, ran)
tool, mutates lifecycle.kill kill one terminal by id
tool, mutates lifecycle.sendInput write input to a terminal — text AND the named-key vocabulary (Enter, Escape, Backspace, C-u-style chords), because the skills’ three-step submit protocol sends text and Enter as SEPARATE calls
tool, composite (MCP-face-local) wait.outputSettled block until a terminal’s output is idle for N ms (with timeout) — the kaval-tui wait --until idle:<ms> done-signal, watched client-side off the terminalAttach stream
tool, composite (MCP-face-local) wait.agentState block until a terminal’s detected agent state enters a target bucket — the padi-tui wait --until <buckets> done-signal, watched off the terminals collection

Deliberately denied in v1 (each an explicit non-entry, not an oversight): terminalAttach (a raw {seq} byte stream is the wrong shape for MCP consumers — screen.text is the read face); lifecycle.killAll / recycleKaval / discardSleeping (daemon-admin blast radius — human verbs); lifecycle.sleep/wake/resize (layout/lifecycle policy the canvas owns); chrome.* (browser canvas arrangement — meaningless for an agent and hazardous to script); git.worktreeCreate as a RAW verb (it is composed inside the bespoke lifecycle_create, behind the CLI’s directory gates); git.worktreeRemove and scratch.write (write-side beyond terminal control — expandable later, on demand, one row at a time); session.restore + every test__set verb (admin/test). Widening the map is a one-row diff with a review, never a default.

Skill parity — the /kolu migration contract

The end state (ratified): the /kolu skill drives agents through this MCP when it’s available, falling back to kaval-tui/padi-tui when it isn’t — so the expose map is not a menu, it’s a parity contract with what that skill does today. The counts below were measured from both driving skills, /kolu and /bridge; /bridge was deleted (with /be and the srid/agency dependency — see the be-workflow note) in #2254, so the contract now binds /kolu alone and the counts are the record of what the pair used to do.

skill verb today (uses) MCP equivalent parity notes
kaval-tui wait --until idle:<ms> (28×) + padi-tui wait --until <state> (20×) wait.outputSettled / wait.agentState THE core done-signal — the send → settle → Enter → settle loop is the whole dispatch protocol; without blocking wait tools the MCP face fails the skills on their most-used verb
kaval-tui send "text" / send --key Enter (24×) lifecycle.sendInput the named-key vocabulary is load-bearing: submit is its OWN Enter after an observed settle, never text+newline fused
kaval-tui list (12×) terminals resource must carry id, title, cwd, and idle-time — the coordinator re-finds terminals BY TITLE after daemon re-keys
kaval-tui/padi-tui create [--toplevel / --parent, --repo] (10×) lifecycle.create parameter parity REACHED with the bespoke create: cwd/repo+worktree, the REQUIRED placement, intent, and the typed first command (run) — and the returned record carries the id the skills capture
kaval-tui snapshot [--viewport] (10×) screen.text viewport/tail modes, so “read the last N lines” stays one cheap call
kaval-tui kill lifecycle.kill with the canvas’s blast-radius text available to the agent too

Two consequences worth naming. First, the composite wait.* tools are client-side scaffolding, not padiSurface procedures — the same WaitOutcome/abort-chain scaffold padi-tui/read.ts and kaval-tui/wait.ts each hand-roll today. The MCP face is therefore the third consumer of that scaffold, which meets the unification gate padi.mdx records: the shared scaffold gets extracted (a small leaf) as part of this PR rather than a third copy. Second, wait.outputSettled consumes the terminalAttach {seq} stream without rendering it — a second, non-canvas consumer of the named graduation path, which strengthens the pin (the stream must serve a byte-watcher and a renderer from one subscription discipline).

Across padi/kaval restarts

The daemons own durability; the MCP face owes agents honesty about the seam. What actually survives, grounded in the contract:

The MCP face’s discipline across that gap, in order:

  1. Streams blip → STREAM_RETRY (already mounted on the one client) resubscribes transparently; surface consumption is snapshot-then-delta, so a resubscribe re-seeds each resource with a fresh snapshot — MCP subscribers get an updated notification per resource, never a spliced delta across generations.
  2. Socket death → bounded re-resolve + redial, then the same handshake a cold dial runs: control-core hello + assertPadiSurfaceCompatible. A restarted padi that no longer speaks our contract fails the gate loudly and the MCP server exits with that error — it never keeps serving a surface it can’t honestly represent (fail-fast; no silent degradation). The death is ANNOUNCED, not discovered. Redial says where a restart heals; it does not say when the adapter finds out, and that omission had a price (#2082): the adapter learned only by sending a request into the dead socket, so the first padi-backed request after every restart was destroyed. connectPadi already hands back a DaemonConnection whose onClose fires when the socket closes — kolu-cli carries it through to the adapter, which drops the corpse the moment it hears. A restart across an idle gap now costs zero requests. (Local socket arm only. The ssh --host dial does not yet carry the announcement: sshConnector observes its child’s exit and the session exposes it per attempt as Connection.closed, but AgentDial projects neither, so connectKoluCliViaHost has nothing to pass on. A missing projection at one hop — the same shape as #2082 itself — not a limitation of ssh. Closing it is the remote follow-up.)
  3. Tools during the gap fail fast, typed, retryable — a create or sendInput while disconnected returns an explicit transport-down error the agent can retry; nothing queues locally (a queued mutation replayed against a new daemon generation is exactly the two-clocks bug SR9/LIVE-FIX exist to prevent).
  4. Generations are visible, never spliced: padi’s identity cell carries its boot time and build commit; it is exposed read-only via the status resource story so an agent (or its human) can see “the daemon restarted under me” instead of inferring it from weirdness. The restart is data, not an anomaly.

The pin for this section rides the e2e (step 9): mid-attach and mid-subscribe, recycle kaval and restart padi; assert ids survive, resources re-seed, the in-gap tool call fails typed, and the compat-gate failure path exits loudly. A second restart leg covers the idle gap — restart padi with no MCP traffic in flight, then assert the very FIRST request afterwards lands, with no retry and no warm-up. That is the leg #2082 needed and the mid-subscribe one could never catch: calling during the gap spends the wasted request on a call that was going to fail anyway, which hides it.

Architecture-level changes

one padiSurface, two frontends — kolu tui/mcp never touch kolu-server kolu tui · kolu mcp (packages/kolu-cli) canvas face tiles · urgency attach · create/kill (human, raw ANSI) MCP face serveSurfaceAsMcp resources + tools (agents, stdio) kolu-server + browser the FIRST frontend — multi-host map, provisioning, web canvas absent from every kolu-tui path — that absence IS the graduation proof ONE surfaceClient (STREAM_RETRY mounted) unix socket ssh stdio pipe padi (per-host workspace daemon) padiSurface: terminals · urgency · status · activity · screen terminalAttach ({seq} byte stream — the path that breaks) procs: create · kill · send… kaval (PTY daemon) bytes + PTYs; padi binds it — the CLI faces never import it binds dials padiSurface too (today's only consumer)
The CLI faces: tui + mcp over one surfaceClient, two transports, zero kolu-server. kaval stays behind padi.

Implementation details

Two PRs — a real sequencing constraint, not staging.An earlier draft said one PR; the skill-parity contract changed the calculus. PR1’s deliverable is independently valuable the day it merges — the /orchestrator and /kolu skills gain their MCP path with CLI fallback — and it carries none of the canvas’s renderer/attach risk. Each PR has its own complete pin, so neither is staging for appearance.

The binary is kolu, not a new kolu-tui executable (ratified): today’s kolu cleye definition has flags and NO subcommands, so the namespace is free — kolu tui opens the canvas, kolu mcp serves agents, and bare kolu keeps meaning what it means today (the web server). Three packages, each a leaf in the house per-app style (padi-tui/kaval-tui/pulam-tui precedent): packages/kolu-cli — the bin + cleye dispatch, the composition root that may import everything (it resolves the padi socket, dials, mounts STREAM_RETRY, and boots whichever face); packages/kolu-mcp and packages/kolu-tui — the faces, each exporting a run-function that takes the injected, already-connected client (serveSurfaceAsMcp already takes a live-client factory, so the mcp face owns zero connect code). The graduation fence is STRUCTURAL, not tested: the face packages’ manifests simply do not list any kolu app package, so the illegal import is a missing-dependency build error — unrepresentable beats detected.An earlier draft kept the faces inside kolu-cli behind a module-level import-guard test. Rejected on the repo’s own doctrine: that test guards a structure that permits the violation, where separate manifests make it unspellable — and the per-app-package norm means the split costs nothing. It also makes the recorded escape hatch (a standalone MCP artifact for padi-only hosts) a trivial bin target on an existing package. The graduation proof is about the RUNTIME path — no kolu-server process behind kolu tui/kolu mcp — not the shipping closure; p2p-kolu.mdx already records this client shipping in-closure.The one cost: a padi-only remote host wanting a local kolu mcp pulls the kolu closure. Demand-gated escape hatch, recorded not built: a thin standalone bin target for the CLI package if that host profile ever materializes.

Steps, in build order (PR1 = the kolu-cli package + dispatch + boot-function extraction; PR2 = steps 1–3 + 8; PR3 = the rest):

  1. Face packages, not face modules: packages/kolu-mcp (PR2) and packages/kolu-tui (PR3), each exporting a run-function over an injected client; the connect layer (resolve + dial + STREAM_RETRY) lives in kolu-cli, the composition root.
  2. Connect: local socket resolution via resolveRunningPadiSocket + connectPadi (@kolu/padi-client/dial — the resolution never lived in connect.ts; the TUIs’ connect files only dial an already-resolved path); --host via a thin dialAgentOnce options wrapper (@kolu/surface-remote, the sibling hostConnect.ts shape). Build the one surfaceClient with STREAM_RETRY.
  3. Canvas model: subscribe terminals + urgency + status; derive the tile grid purely from the collection (no local arrangement state — the arrangement lives on the host, same as the browser).
  4. Renderer: raw ANSI to the alternate screen — no OpenTUI, no Bun (the pulam-tui shed-the-engine precedent binds; a TUI engine is the one dependency that could flip the boundary verdict above). The in-repo idiom to build on is kaval-tui/attach.ts (\x1b[H\x1b[2J + setRawMode — NOT render.ts, which is a columnify table formatter with zero ANSI; the plan’s earlier citation was wrong). Structure per the herdr findings: dirty-flag + ~16ms coalesce loop, per-tile damage checks, ?2026 synchronized-output around each composite frame.
  5. Graduate the escape scanner: move kaval-tui/escape.ts (verbatim, 143 lines) into @kolu/terminal-protocol, repoint kaval-tui, add nothing.
  6. Attach loop: full-screen passthrough pumping stdin → send intent and terminalAttach {seq} frames → stdout; the grid-carrying attach, ordered write queue, snapshot-first-frame, and exit-stream discrimination PORTED from kaval-tui/attach.ts (call sites re-bound from ptyHostSurface members to padiSurface’s; the AttachTty seam + tty-less test harness come along); the detach chord consumed from the newly-graduated scanner. Honor the direct-attach resize lock: attached tile owns geometry, grid yields.
  7. create/kill as canvas gestures calling the surface procedures; confirm on kill.
  8. mcp subcommand: serveSurfaceAsMcp({ surface, client, expose }) with an explicit default-deny expose map (resources: terminals, screen, activity, urgency; tools: create/kill/send, mutates: true).
  9. The pin (e2e, red-if-broken): spawn a real padi, run kolu tui/kolu mcp against it over the unix socket and over ssh localhost — script: create a terminal through the canvas gesture, attach, assert bytes round-trip through the {seq} stream, kill, assert the tile leaves the canvas. This test is the graduation proof; it cannot pass through any path but the named one.

Risks, named with mitigations:

Amendment — kolu subsumes the TUIs’ verbs

Appended 2026-08-09. It supersedes one clause of the ratified scope above; the rest of the note stands.

Superseded: “Neither is a scripting CLI: padi-tui and kaval-tui keep their verbs as their daemons’ scripting faces”, and with it the recorded rejection of subsuming the CLIs. kolu is the one terminal CLI now, and the two TUI binaries are the ones being retired.

Why the objection no longer applies

The rejection had a real reason, not a preference: verb parity would drag a kaval dependency into the CLI faces and blur the proof. That reasoning rested on an assumption about padiSurface’s reach that its own growth has since falsified. By v5.2 the contract already carries the union of what the two TUIs each needed:

what a TUI verb needed the padiSurface member it is already
kaval-tui create / send / kill, terminal geometry lifecycle.create · lifecycle.sendInput · lifecycle.kill · lifecycle.resize
kaval-tui snapshot / history screen.text · screen.history
kaval-tui wait --until idle:/match: the terminalAttach {seq} stream, watched not rendered
“the terminal I was driving died” (exit 3) the terminalExit event
padi-tui status / watch / wait --until <buckets> the terminals collection + activity / urgency

So verb parity is reachable as a pure padi client: kolu-cli imports @kolu/padi and @kolu/surface, and no kaval dependency enters it — the manifest fence the note already relies on for the face packages is exactly what proves it. The objection is answered, not overruled. Had padiSurface still lacked sendInput’s key vocabulary or the exit event, the honest move would have been to leave the verbs where they were.

The second half of the rejection — and blur the proof — is likewise answered by the shape rather than waived: the verbs are the strongest available form of the graduation criterion recorded above, because a scripting face that creates, writes to, waits on, reads, and kills terminals exercises the named path (padiSurface through surfaceClient, including the {seq} stream) on every invocation a user makes, not only in an e2e.

What shipped

kolu ls · create · send · wait · debrief · snapshot · history · kill · watch, alongside the web and mcp faces. Four consequences worth pinning here, because each is a decision a later reader would otherwise re-litigate:

Two breaking changes, both forced by the above: bare kolu no longer boots the web server (it prints the subcommand list and exits non-zero — the server is kolu web), and kolu web --host <addr> is renamed --bind <addr>, because --host now means “which padi” everywhere and Effect CLI refuses a parent/child flag collision outright.

What is deliberately NOT in it

The graduation this earned

padi-tui’s pure view and read layers followed the verbs out of the TUI: render.ts (the roster table’s columns and folds) and read.ts (readTerminalKeys, settledSnapshot) now ship as @kolu/padi/render and @kolu/padi/read — the same move, and the same trigger, as the LIVE watchers (watchTerminals / awaitAgentState) graduating into the dial entry when the MCP face became their verbatim second consumer. Two faces rendering the same records from one implementation, rather than two copies held in lockstep by a JSDoc cross-reference.

Amendment — the screen as a picture (2026-08-22)

A tenth verb landed: kolu screenshot <id> [--lines N] [-o FILE], with screen_image as its MCP twin. Both read padi’s new screen.image, which renders the terminal to a themed PNG.

The gap it closes is that every read face above hands over characters, and characters are not what a terminal uses to mean things: colour is how a test run says pass-vs-fail and how a diff says added-vs-removed, box drawing is what makes a TUI a layout, a highlighted row is what says “this one is selected”. An agent reading screen.text cannot see any of it. screen.text stays the default read — text is far cheaper in context and answers “did it finish?” on its own — and the picture is for when the answer is visual.

Two placement decisions are the substance:

This does not reopen the sixel question. The rejection of pixel-preview escape hatches for the kolu tui canvas stands exactly as written: that decision is about drawing pixels into a terminal, which remains terminal-specific electricity against the raw-ANSI decision. screen.image returns PNG bytes over the wire to a face that already knows how to display an image — an MCP host, or a file on disk. Nothing here paints a pixel into a tty; kolu screenshot will not even put the bytes on stdout unless the caller types -o -.

Amendment — the surface face (2026-08-23)

Appended 2026-08-23. It extends the MCP face section’s footnote — “agents can drive padi/kaval verbs through the kolu binary … without it growing a verb CLI” — with the face that clause did not foresee; the rest of the note stands.

kolu surface … landed: the same padiSurface the MCP face serves, projected as plain argv verbs through @kolu/surface-cli — kolu surface list, kolu surface get terminals --follow, kolu surface screen_text 3f9c --tail 40, kolu surface lifecycle_create --input '{…}'. The expose map and the tool table are the MCP face’s own (KOLU_MCP_EXPOSE · KOLU_MCP_TOOLS), named once in packages/kolu-mcp/src/{expose,tools}.ts and handed to BOTH faces verbatim, so an agent over MCP and an agent over a shell drive the same verbs by the same names and cannot drift.

Agents now drive padi and kaval through two agent faces on the one binary — kolu mcp (structured tool calls over stdio) and kolu surface (shell argv). The clause that lands a verb CLI on the binary is honored in the shape the footnote ratified, not waived: the padi and kaval packages still grow no verb CLIs of their own — the projection lives in the consumer packages (@kolu/surface-cli does the projecting), and the whole face is ONE module, packages/kolu-cli/src/surfaceFace.ts. The verbs are still padiSurface’s own procedures re-exposed by an existing adapter — the same shape, landed a second time through the sibling adapter, with the same “nothing is reimplemented” standing.

Two consequences the next reader should not have to re-derive: