kolu CLI — web · tui · mcp, the binary's three faces
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
- create/kill, single host, plus an MCP face** (srid, 2026-07-16).
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.
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.
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.
- Focus moves with
hjkl/arrows; the focused tile shows a live screen preview (thescreencollection), not a frozen snapshot. The grid is the terminal-native projection of the canvas: tiles sorted by the host arrangement’s reading order, so spatial memory survives as ordering, not geometry.An infinite-canvas viewport (pan over the browser’s plane) was considered and rejected: characters don’t scale, so a terminal has no continuous zoom — a tile is either 1:1 readable or downsampled-to-decoration, and panning a plane where at most ~4 tiles are readable just loses terminals off-screen. Pixel-preview escape hatches (sixel, kitty graphics) were rejected with it: terminal-specific electricity against the raw-ANSI decision. Maximized + grid is what every TUI multiplexer converges on, for this reason. - Enter attaches: full-screen raw passthrough to that terminal — keystrokes
in, bytes out, exactly the
kaval-tui attachUX (same detach chord, same grid-carrying attach — the snapshot comes back laid out for the local tty, because the grid rides on the request) but ridingpadiSurface’sterminalAttachstream. Detach returns to the canvas. ccreates a terminal (prompted command, default shell) without stealing focus by default — spawning a helper must not yank your view;xkills the focused one, and the confirm states the blast radius (“kill 1 terminal, agent working 14m”) rather than a generic are-you-sure. Both arepadiSurfaceprocedure calls — the same intents the browser sends.- Notifications are edge-triggered and focus-suppressed: state transitions only (never levels), and never for the tile you’re already looking at — the two rules that keep a multi-agent watcher quiet.
- Urgency (
awaitingbadge) reorders nothing and blinks nothing; it renders the daemon’s ownurgencycell, so a padi-side change appears without a CLI release.
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 (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:
- kaval recycle (
lifecycle.recycleKaval, padiSurface 1.1) is session-preserving by definition — terminals and their PTYs survive; the browser shows a warming canvas and re-attaches. An agent’s terminal ids stay valid; at most astatus/activitytransition is visible. - padi restart is the warm path the process split exists for
(warm-across-restart metadata,
padi.mdx): kaval keeps holding the PTYs, the restarted padi re-binds them, ids stay valid. What breaks is the transport: the unix socket endpoint dies — and the socket path is digest-keyed (resolveRunningPadiSocket), so an upgraded padi listens at a different path. Redial is re-resolve + dial, not retry-same-path.
The MCP face’s discipline across that gap, in order:
- 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 anupdatednotification per resource, never a spliced delta across generations. - 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.connectPadialready hands back aDaemonConnectionwhoseonClosefires 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--hostdial does not yet carry the announcement:sshConnectorobserves its child’s exit and the session exposes it per attempt asConnection.closed, butAgentDialprojects neither, soconnectKoluCliViaHosthas 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.) - Tools during the gap fail fast, typed, retryable — a
createorsendInputwhile 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). - Generations are visible, never spliced: padi’s
identitycell carries its boot time and build commit; it is exposed read-only via thestatusresource 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
- A pure
padiSurfaceclient. The load-bearing grounding:terminalAttachis apadiSurfacecollection — terminal byte streams ride the surface itself. So the CLI client needs no kaval import and no kolu app import; its dependencies are@kolu/surface(client),@kolu/surface-mcp, and@kolu/padi’s surface definition (the contract module kolu-server already imports). This keeps padi’s restart hash clean of app churn — the arrow rulepadi.mdxrecords (the second frontend exists to prove padi needs no kolu) is honored by construction. - The graduation criterion, owned here (moved from
padi.mdx, which now links): the unit of done is the named path —padiSurfaceconsumed throughsurfaceClient, including theterminalAttach{seq}stream, over both transports (unix socket and ssh stdio), rendering a live canvas. A feature built any other way (raw mirror iteration, a value-bearing cell read) satisfies the words and proves nothing — the pin below can only go green through the real path. - Boundary verdict, conditioned on the build (the loop, not a waterfall):
the CLI faces are leaf app packages (
packages/kolu-mcp,packages/kolu-tui), not electricity — because the renderer is raw ANSI with no engine dependency (decision below) and every hard volatility it touches (transport, reconnect, stream retry, MCP lifecycle) already lives in its receptacle (@kolu/surface,@kolu/surface-mcp). If the renderer choice ever escalates to an engine owning alternate-screen/context-loss recovery, that engine wrapper — not kolu-cli — would be the extraction candidate. - Transport = the seams that already exist — nothing hand-rolled. Local:
resolveRunningPadiSocket(@kolu/padi/dial) resolves,connectPadidials (handshake + compatibility gate). ssh:dialAgentOncefrom@kolu/surface-remote— the same one-shot dial both sibling TUIs’ 15–24 linehostConnect.tswrappers already consume; kolu-cli writes its own thin options literal and nothing else.An earlier draft said “not@kolu/surface-remote” — the lens run confirmed that contradicts the actual shape: the ssh-stdio dial issurface-remote’sdialAgentOnce, and both TUIs use it. What stays out is the rest of surface-remote (provision, realise, reconnect supervision — kolu-server’s session machinery): the CLI faces dial a padi that already runs and fails fast otherwise. A pre-liftedconnectPadiViaHostcomposition was proposed and REFUTED — padi-tui’s wrapper carries padi-specific probe/scoping the CLI client may diverge from; compose in-app, extract only if a byte-identical twin actually emerges. - One
surfaceClient,STREAM_RETRYmounted. The #1827 flake’s lesson binds: a raw client without the retry plugin flakes on attach-vs-reconnect races that production consumers survive. The CLI client mounts the same plugin production mounts — both faces share the one client, so the MCP face inherits it. escape.tsgraduates to@kolu/terminal-protocolin this PR (confirmed by both lenses): kaval-tui’s 143-line line-start escape scanner (~./~~/~?, bracketed-paste-suspended) is protocol policy with zero transport coupling — its only import is already@kolu/terminal-protocol, and the tui face is the second verbatim consumer, which is that package’s exact birth condition (it was created when kaval-tui made the same concept fragment across three modules). Move the file, point kaval-tui at the export, consume from kolu-cli — a copy here would be two lockstep owners of a detach grammar.- The attach passthrough scaffold is a PORT, not an extraction (confirmed):
kaval-tui’s
attach.tspattern (ordered write queue, snapshot-first-frame, the grid-carrying attach, tty-less test harness via theAttachTtyseam) carries over, but its call sites bind kaval’sptyHostSurfacemembers while the tui face ridespadiSurface— non-verbatim twins. No@kolu/tui-kitreceptacle now; the recorded gate: after the tui face’s loop lands, diff the two scaffolds — if they’re identical modulo an injected ops object, that diff IS the extraction spec and the receptacle earns itself then. - From the herdr study, adopted at the architecture level (herdr = a Rust
agent multiplexer whose daemon owns every VT state — the same server-owns-
state shape as padiSurface, independently arrived at, which is the strongest
available validation of the thin render-only client): per-pane damage
tracking (an idle preview costs one clean-check; only changed rows repaint);
a single dirty-flag + ~16ms coalescing render loop (N noisy panes → at most
one repaint per frame; first-producer-notifies dedup); each composite frame
wrapped in synchronized-output
DECSET ?2026(no tearing, RAII-style begin/end); the direct-attach resize lock (while a terminal is attached full-screen it owns its PTY geometry — the grid’s resize path must yield, or layout and attach thrash the child); previews stay LIVE and pre-sized, never paused — throttle their repaint rate, never their input; and scroll events in attach mode drive the server-side viewport rather than forwarding to the child (except when the child requested mouse mode).
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.
- PR1 — the CLI restructure (srid’s sequencing + placement): the
kolubin MOVES to a newpackages/kolu-cli— the composition root owning cleye subcommand dispatch — andpackages/serverstops being the bin, exporting its boot as a function thewebarm calls (dispatch in packages/server would braid the product’s argv face with the web server’s boot; the volatility is in the set of faces, and only a dedicated package encapsulates it).kolu webnames today’s behavior, barekolustays its alias (byte-for-byte: same flags, same boot),tui/mcpreserved with a clear not-yet-shipped error. The nixkoluBinentry moves with the bin. Pin: barekoluandkolu webbehaviorally identical (flag matrix test) + the reserved subcommands fail with the named message. A composition root is the one module allowed to import everything — kolu-cli importing packages/server is main() doing its job, not a leak. - PR2 — the MCP face (
kolu mcp): newpackages/kolu-mcp(manifest = the fence: padi/surface deps only) exporting the serve-function over an injected client; kolu-cli gains the connect layer (both transports, the onesurfaceClient+STREAM_RETRY) and themcparm;serveSurfaceAsMcp+ the expose map, the compositewait.*tools (extracting the shared WaitOutcome scaffold — the third consumer), the restart discipline. Its pin: the headless leg of step 9 — create → sendInput → wait.outputSettled (the{seq}stream, watched not rendered) → screen.text → kill, over BOTH transports, plus the restart legs. This already exercises most of the graduation path, headlessly. The /bridge and /kolu skills can adopt MCP-first-CLI-fallback the day this merges. - PR3 — the canvas (
kolu tui): newpackages/kolu-tui(same manifest-fence), taking the injected client from kolu-cli’stuiarm: renderer, attach loop,escape.tsgraduation (attach-scoped, so it rides this PR), create/kill gestures, navigator/rail. Its pin: the rendering leg of step 9 — the live canvas over the same path.
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):
- Face packages, not face modules:
packages/kolu-mcp(PR2) andpackages/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. - Connect: local socket resolution via
resolveRunningPadiSocket+connectPadi(@kolu/padi/dial— the resolution never lived inconnect.ts; the TUIs’ connect files only dial an already-resolved path);--hostvia a thindialAgentOnceoptions wrapper (@kolu/surface-remote, the siblinghostConnect.tsshape). Build the onesurfaceClientwithSTREAM_RETRY. - 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). - Renderer: raw ANSI to the alternate screen — no OpenTUI, no Bun
(the
pulam-tuished-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 iskaval-tui/attach.ts(\x1b[H\x1b[2J+setRawMode— NOTrender.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,?2026synchronized-output around each composite frame. - Graduate the escape scanner: move
kaval-tui/escape.ts(verbatim, 143 lines) into@kolu/terminal-protocol, repoint kaval-tui, add nothing. - 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 fromkaval-tui/attach.ts(call sites re-bound fromptyHostSurfacemembers topadiSurface’s; theAttachTtyseam + 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. - create/kill as canvas gestures calling the surface procedures; confirm on kill.
mcpsubcommand:serveSurfaceAsMcp({ surface, client, expose })with an explicit default-deny expose map (resources:terminals,screen,activity,urgency; tools: create/kill/send,mutates: true).- The pin (e2e, red-if-broken): spawn a real padi, run
kolu tui/kolu mcpagainst it over the unix socket and overssh 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:
- Attach passthrough vs canvas rendering fight over the tty → strict modal
split: canvas owns the alternate screen; attach tears it down before raw
mode and restores after detach (the
kaval-tuisnapshot-reciprocal reset already encodes the discipline — reuse, don’t rewrite). {seq}gaps under reconnect →STREAM_RETRYfrom day one (step 2), and the pin’s ssh leg kills the pipe mid-attach once to assert the re-subscribe path (that’s the #1827 class, pinned at the consumer this time).- MCP stdio discipline vs canvas stdout → the two faces never share a
process mode:
mcpis its own subcommand, no canvas rendering; stdout is the protocol channel (@kolu/surface-mcpowns the discipline). - padi.mdx drift → same PR edits
padi.mdx’s future-work items to link here (the criterion moves, one owner), and the §207 wait-helper upstream item becomes actionable once this ships — recorded there as the follow-up, gated on this PR merging, not part of it.