← the Atlas

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

Features·seedling·accepted·

The kolu binary grows subcommands — kolu web (today's default), 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.

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, snapshotscreen.text, sendlifecycle.sendInput, create/killlifecycle.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 (command, cwd) — returns the TerminalInfo
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/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 /bridge · /kolu migration contract

The end state (ratified): the /bridge and /kolu skills drive 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 those skills do today. The audit of their actual verb usage:

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 [--parent --repo] (10×) lifecycle.create parameter parity: command, cwd/repo, parent — and the returned TerminalInfo must include 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/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: