kolu CLI — web · tui · mcp · surface, the binary's agent faces
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
- 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 — 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:
- 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/stateRoot) 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 /kolu skill 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-client/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.
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:
- The endpoint moved to the ROOT as shared flags.
--socket/--state-root/--hostare mutually exclusive and parse on either side of the verb (kolu --host box create≡kolu create --host box). That is what retired cleye foreffect/cli: cleye binds a flag to the subcommand that precedes it, so the first spelling was a usage error. The parser ships inside theeffectthe workspace already pins, so it costs no new dependency. waitis ONE verb with three condition forms, not two verbs merged into a mode flag:idle:<ms>/match:<regex>(raw bytes — agent-agnostic, works on any terminal) and the agent bucketsworking/awaiting/waiting(precise — it distinguishes asking-you from finished — but only where padi detects an agent). The two done-signals stay two forms because they genuinely read different things; one verb because a driver shouldn’t pick a binary to express that choice.wait‘s two MODIFIERS are orthogonal to its three forms, anddebriefis definitional sugar over their composition (#2139).--settled <ms>is a conjunct on the condition (met = the condition holds AND no output byte for<ms>);--snapshot <N>is an enrichment of the payload (the met carries the rendered tail). They are modifiers rather than a fourth--untilprefix precisely because each composes with all three forms, and a mode flag would let a caller name a mode and a condition that disagree — the same reasoning that made the prefixes prefixes. They live daemon-side, in padi’sawaitTerminalCondition, because the races they close are between CLI invocations and cannot be closed from out here: output can move between onewaitreturning and the next arming, and a separatesnapshotreads a screen that is not the one the wait settled on.kolu debrief <id>expands towait --until awaiting,waiting --settled <quiet> --snapshot <tail>and holds no logic of its own — a second face that could disagree with the primitive is the one thing a CLI that just retired two near-duplicate TUIs must not grow. TWO named waits survive as spellings of the engine —awaitAgentStateandawaitOutputSettled, because their met payloads ARE the MCP tools’ wire frames — whileawaitOutputMatchwas DELETED:kolu waitwas its only production caller and now calls the engine directly, so keeping it would have been public dial surface with none. The quiescence window and the boundedmatch:scan therefore have one implementation rather than one per wait. The MCP face takes both modifiers too (settledMs/screenTail, kolu#2152), since the race is between calls and an agent driving over MCP makes the same three-call loop — forwarded through the two named waits rather than around them, so each wire frame keeps one owner. There is no composedwait_debrieftool: over MCP the protocol is three JSON keys, so a third tool would buy no ergonomics while costing its whole description in every request’s context, and it would forcedebriefProtocol.tsout of the CLI leaf that owns it into a home both faces reach. Inside the engine, each of the three condition forms now has its own runner (conditionForm) — the branch on which form this is happens once instead of at seven wiring points, and the shared spine (the attach feed, the conjunct’s window, the met-candidate cell) is lent to a form rather than re-derived by it, so a fourth form is a compile error rather than a list of sites to remember.snapshothas no--viewport. padi’s wire cannot express a viewport extent, so the read face is--tail <N>(or nothing for the whole rendered text) — the exact shape of the MCPscreen_text { tail }tool. One idea, one spelling, across both faces.
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
kolu attachdoes not exist yet — deferred to a follow-up. For interactive attach the browser is the face, withkaval-tui attachas the terminal-side fallback. The canvas (kolu tui, PR3 above) is unaffected; attach lands with it or before it, not in the verb set.padi-tuiandkaval-tuistill ship and still work. They are retired in a later PR, not by this one — so no driving script breaks on the day the verbs land. The exit-code contract (0 · 1 · 2 · 3 · 130) is carried over unchanged for exactly that reason.- The skill-parity contract above is unchanged in substance and cheaper to state: /kolu’s CLI fallback is now one command’s verbs rather than a two-binary verb map.
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:
- kaval hands over cells, not an image (
terminal.getScreenCells, contract 7.1 · additive). Rendering needs a theme, and the theme is a per-terminal user choice padi holds, so the PTY host is the one process that cannot answer “what colour is palette 4”. Keeping the render in padi also keeps a wasm rasteriser and several megabytes of font out of the hot process. - The layout is decided once, in a new
terminal-snapshotleaf: grid + theme → flat drawing instructions, executed by two dumb backends (the browser’s canvas for copy-screenshot-to-clipboard, the daemon’s SVG + resvg for these two verbs). “The user’s screenshot and the agent’s screenshot are the same picture” is then true by construction rather than by two code paths being kept in agreement by prose.
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:
- Exit codes are per-face, by ruling. The
kolu surfaceverbs answer@kolu/surface-cli’s matrix verbatim (1the verb’s typed refusal as verbatim JSON on stderr — nokolu surface:prefix — ·2a usage error ·3nothing serving the endpoint ·130interrupted); the native verbs keep kolu-cli’s own (1usage/link ·2wait timed out ·3terminal exited). The ONE binary-wide stance is the parse domain: a CLI-library refusal (a missing flag, an unknown subcommand) reaches neither face’s handler and exits1on BOTH faces — while the surface matrix’s2is the exact opposite direction: the face itself rejected the input and the request never left the process (1on that face always means the DAEMON answered). A driver picks the matrix by picking the face —packages/surface-cli/src/exit.ts’s header records the ruling where the collision was first written down. - The tree stays cheap. Effect CLI’s tree is static, so every
kolu --helpholds this face — and what it loads is schema-level only:KOLU_MCP_TOOLSlives inpackages/kolu-mcp/src/tools.ts, an SDK-free leaf (the package barrelindex.tsre-exports it, and the./toolssubpath mounts it without the barrel’s SDK chain — the leaf is the address the statically-built command tree reads) reading@kolu/surface-mcp/tools, the shard whose “agent definitions must not cost the transport” contract this face is the first-party proof of. The face also carries a WRITTEN help page (KOLU_SURFACE_HELPinsurfaceFace.ts— passed ashelp:so the verbs unlist and the page is the listing), because the flat auto-dump renders fifteen paragraph-long agent descriptions as eight screens of prose nobody asked for. the transport“ contract this face is the first-party proof of.