A CI runner you attach to — graduating mini-ci beyond justci
Every local CI tool runs your pipeline as a batch process and leaves you log files. mini-ci inverts that — the runner is a small live server that owns the pipeline as state, and your terminal and your coding agent attach to it over plain ssh. What that buys, what it costs — and Phase 1 — shipped in PR #1252 — which replaced justci in the kolu repo itself with the new package odu (Tamil for "run").
The plan — Phase 1 of which has shipped: grow mini-ci — a small CI runner that already works inside the kolu repo — into a standalone tool that takes over from juspay/justci, the way remote-process-monitor graduated into srid/drishti. Every load-bearing claim was checked against both codebases. 22-agent workflow · adversarial verification
Status: implemented · maturity seedling · Phase 1 shipped —
#1252 · odu replaced justci in this repo , dogfooded by posting that very PR’s required checks · the MCP agent face shipped (
#3 · juspay/odu#3 ) and is consumed here; the rest of Phase 2 + graduation are the open roadmap · named odu (Tamil ஓடு — “run”)
What it is — attach, don’t scrape
You give mini-ci a pipeline (a DAG of shell commands, JSON today) and a host — your laptop, a build box on your tailnet, anything reachable by ssh that has Nix. The runner materializes on that host and starts owning the pipeline. Here is what just run srid1 paints today, live in your terminal:
What makes this different from watching any CI process print:
- Late attach replays everything. The protocol is snapshot-then-delta: a client’s first frame is the complete current state — every node’s status, and the full buffered log of any node, even one that finished before you connected — then live updates follow. There are no log files to go find, and no “the run already exited.”
- Disconnect is free. Close the laptop, lose wifi, kill the terminal — the runner keeps going. Reconnect re-attaches and the snapshot catches you up. (Honest scope: this is client-side resilience; if the runner process itself dies, its state dies with it — see the ledger below.)
- Rerun is surgical and live.
rresets one failed node plus its transitive dependents and reschedules them on the running DAG — no new pipeline invocation, no re-running what already passed. - The remote story is just ssh + Nix. No runner daemon to register, no webhook infrastructure, no agent to install. The runner ships as a Nix closure (
nix copyof a prebuilt derivation), gets realised on the host, and runs overssh host mini-ci-runner --stdio. Your ssh key is the connection and the trust model.localhostskips the copy — same code path, no transport. - The wire is typed. What crosses ssh is schema-validated state (Zod end to end), not text for you — or your agent — to parse.
How that differs from justci
justci is the strongest local-CI comparison because it is the tool this proposal would replace, and it is genuinely good at what it is: a batch translator. It compiles your just recipe DAG into a process-compose document (its own dump-yaml proves the translation is a pure function), runs it strictly — clean tree refused, HEAD pinned, GitHub commit-statuses posted per recipe@platform — and exits with a verdict. ssh, in that model, is a dumb one-way pipe: a git bundle goes out to set a remote node up, each recipe re-ssh’s to just --no-deps, and only an exit code comes back. Per-recipe output lands in .ci/<sha>/<plat>/<recipe>.log files; the progress feed carries the on-disk path, never the bytes.
So “watching a justci run” means a side channel: its status / monitor / logs subcommands shell out to a separate baked process-compose client dialed at .ci/pc.sock — and justci’s own guard exits before reaching the socket when no run is in progress. The introspection only exists while a batch lives. That is not a justci bug; it is the batch shape. act replaying GitHub-Actions YAML in containers, or your own just/make over ssh, have the same property: observation is welded to the one process invocation that’s printing.
Faces over one surface
The runner exposes its entire capability as three typed primitives (the next section names them), and because that surface is the whole interface, every client is a thin adapter over the same thing. A CI runner wants two of those adapters — one for the human driving it, one for the agent driving it. The same surface leaves a third — a browser dashboard — available for free; it has since grown from a latent capability into its own plan of record, odu-web.
The human face — the TUI shipped
The live just run dashboard at the top of this note is this face: a (recipe × platform) matrix, a braille spinner, a log-tail footer, a coloured verdict, OSC-8 commit links. It is hand-rolled ANSI today — pure render functions over a repaint region — fine at 26 rows, but bespoke for every scroll pane, focus ring, and resize from here.
The plan to outgrow that is to rebuild it on OpenTUI proposed — the MIT-licensed, Zig-cored terminal framework from Anomaly that powers opencode in production, with a first-class SolidJS reconciler (@opentui/solid). It swaps the bespoke ANSI layer for flexbox layout (Yoga), real components (ScrollBox, Code, Diff, Input), and tree-sitter highlighting for the log pane. The costs were verified against 0.4.0, not guessed: packaging is a non-issue — @opentui/core ships prebuilt natives for eight targets (all four kolu systems included) as esbuild-style optionalDependencies, so consumers need no Zig and fetchPnpmDeps ingests them like it already ingests esbuild’s binary. The real gate is the runtime: the FFI layer is bun-only today — under node 24, createCliRenderer throws OpenTUI native FFI is not available for this runtime yet (reproduced empirically; the native package ships libopentui.so for bun:ffi, no napi addon), while the same probe renders fine under bun. Two smaller pins: @opentui/solid peer-depends on solid-js at exactly 1.9.12, and odu’s processes are all tsx-on-node today. So the plan is gated, not dated: prototype odu attach on it (read-only, smallest blast radius) when upstream’s node support lands — the error message’s own “yet” — or earlier under bun for just the attach face. The pull beyond the widgets: @kolu/surface’s reactive client hooks (useCell, useStream) are Solid-only, so the hand-rolled TUI can’t touch them and drives the raw oRPC client instead; on @opentui/solid the terminal becomes a Solid tree over those hooks — and if the browser face below ever ships, both render the same hooks over the same surface, so “faces over one surface” becomes shared view code, not just a shared protocol.
The agent face — MCP shipped
Shipped in
#3 (through the full codex + lens + police gauntlet, odu-on-odu CI green) and consumed here — kolu pins odu and re-exposes it as nix run .#odu -- mcp, with the .mcp.json entry deployed by apm. As of
#1271 the MCP face is the only way kolu’s own CI is driven — the ci/pu/run.sh wrapper is gone, and the warm-pool lease became a standalone primitive (ci/pu/lease.sh acquire/release/status) an agent holds across tool calls. Because the runner already serves its surface over a byte pipe, an MCP server is just another serve target: odu mcp is in-band — like odu status / logs / attach, it dials the .ci/odu.sock the coordinator already serves and re-exposes that surface as MCP tools (a thin adapter over the official MCP TypeScript SDK, no second protocol). This hand-built face is the validated — and deliberately partial — prior art for the generic @kolu/surface-mcp package (
#982 ), which would extract the surface→MCP lifecycle spine (the subscribe/teardown dance, the zod→JSON-Schema bridge) for any surface, leaving each consumer its own tool-selection and guards. It predetermines no host: which boxes run the lanes is the coordinator’s job — a linux box leased from the warm kolu-ci-* pool, a macos host from hosts.json — exactly as for the CLI, so the .mcp.json below is identical on every machine. Claude Code, Codex, opencode, or Gemini CLI then drive your CI with structured calls instead of scraping terminal output. Declare it over stdio — the transport everything else already rides:
// .mcp.json — Claude Code; Codex, opencode & Gemini CLI take the same shape (kolu's committed entry wraps this via the odu-mcp skill's bin/serve)
{ "mcpServers": { "odu": {
"type": "stdio", "command": "nix",
"args": ["run", "github:juspay/odu", "--", "mcp"] } } }
The tools are the surface, one-to-one — five MCP tools: three straight projections of the three primitives, one blocking wait, plus run — the entry point that spawns the pipeline coordinator:
| MCP tool | Surface call | What it returns |
|---|---|---|
run() | spawns the pipeline coordinator | Starts a pipeline run — since #1271 , the canonical way to start a kolu CI run. |
get_nodes() | surface.nodes.get({}) — Cell | Every node’s status / exit / duration in one structured snapshot. |
tail_log({ id }) | surface.nodeLog.get({ id }) — Stream | One node’s buffered output so far — even a node that finished before the agent attached. |
rerun_node({ id }) | surface.node.rerun({ id }) — Procedure | The only mutation: reset a node + its transitive dependents and reschedule on the live DAG. |
wait_for_settle({ timeoutMs }) | blocks on the live nodes Cell | Returns the instant a node fails (fail-fast) or the run settles — { passed, failed[], durationMs }. |
Liveness rides MCP’s own mechanisms — and the agent never scrapes. MCP is not only request/response: a client can resources/subscribe and the server pushes notifications/resources/updated on change — which maps odu’s nodes Cell one-to-one, the snapshot-then-delta becoming a subscribable resource and each delta an updated (the nodeLog Stream maps the same way). So a notification-aware host gets live pushes for free. The honest floor, though, is that a notification only helps if the host wakes the model on it — and many MCP hosts run a pure turn loop, refreshing the cached resource for the next read rather than interrupting the model mid-task. So the bridge also exposes the same liveness as a blocking pull: wait_for_settle holds a tool call open against the live Cell and returns the instant a node fails (fail-fast) or the run settles — which works on every host, because the model is already inside a tool call when the answer lands. Same Cell, two projections — a push resource and a blocking tool — and neither needs the raw byte stream process-compose’s MCP couldn’t give (#22’s “request/response only, no streaming”). The loop is then just the tools: wait_for_settle wakes on the first red → get_nodes names it → read that node’s tail_log → fix → rerun_node → wait_for_settle → confirm green (the transcript above).
This is the one place justci told us itself the batch shape is the obstacle.
#22 · MCP server for ci, done properly is justci’s own design note for an agent-controlled MCP mode. It built one and reverted it (
#18 · reverted in the same PR ) because, in a batch translator, launching the MCP server auto-ran every recipe — process-compose has no serve-only / --no-start mode. A runner that owns the DAG as idle state has that separation by construction, and the snapshot-then-delta log is precisely the live event source #22 couldn’t get.
No new authz boundary — which is exactly why MCP, not the browser, is next. An earlier draft of this note parked the agent face behind an authn/authz split. That conflated agent access with multi-client access. odu mcp runs as the same operator, on the same machine, dialing the same .ci/odu.sock the CLI’s status / logs / attach already use; rerun_node is remote code execution, but it is RCE the operator already holds through the CLI — the agent acts as the operator, under the same ssh trust. So the single-operator MCP face needs no new trust model and lands in Phase 2 directly. A read-observer-vs-mutator authz boundary becomes load-bearing only when a second, untrusted client appears — the browser face below — which is why that face, not this one, carries the prerequisite.
The browser face — not an attach face at all proposed — odu-web
The same surface would drive a browser PWA without a new line of protocol — the useCell / useStream hooks are Solid, and kolu already ships surface-app for exactly this. And this note’s original verdict still holds for the obvious reading: odu is a CLI CI runner, nobody opens a browser tab to watch a run they kicked off from their shell, and a hosted dashboard is the one client that does drag in the authz boundary the CLI and MCP faces dodge.
What changed is the diagnosis, not the verdict. The browser face worth building is not a tab on one live run — it is the service layer above the runner: the run ledger that survives coordinators, the page a GitHub commit status’s Details link points at, forge triggers, and a multi-repo fleet view. That product — its UI prototypes, its phases, and whether it replaces juspay/vira — has its own plan of record: odu-web. If it ships, it is also the face that forces the read-observer/mutator split, and the face that turns OpenTUI’s “shared view code” from a bonus into the point.
The architecture — three primitives over ssh
What’s architecturally interesting is how little there is. The entire protocol is three primitives, declared once with @kolu/surface (kolu’s typed-reactive-state framework — declare a surface, and the contract, server wiring, and client hooks are all derived from the one declaration):
| Primitive | Call | What it carries |
|---|---|---|
| Cell | surface.nodes.get({}) | The whole pipeline’s state — one snapshot, then deltas as nodes change. |
| Stream | surface.nodeLog.get({ id }) | One node’s output — a buffered snapshot frame first (so late subscribers replay from the top), then appends. A bounded tail (MAX_LOG_CHARS) caps memory on both ends. |
| Procedure | surface.node.rerun({ id }) | The only mutation: reset a node + its transitive dependents and reschedule. |
Three properties of the design do most of the work:
- ssh is the only transport. The surface multiplexes over stdio —
ssh host mini-ci-runner --stdio— so there are no ports to open, no TLS to provision, no auth system besides your ssh key. The same discipline MCP servers use (nothing non-protocol on stdout) is already enforced by the serve layer. - Deployment is a Nix closure copy. The client probes the host’s architecture,
nix copys the matching prebuilt runner derivation, realises it into the host’s store, and runs it. The host needs ssh + Nix and nothing else — and the runner version is pinned by construction. (The trade-off is real: the closure is read-only, which is why today’s default pipeline is typecheck-only — see the ledger.) - Snapshot-then-delta makes reconnect free. Every (re)subscription’s first frame is a fresh snapshot, and the client retries transport errors indefinitely — so a dropped ssh connection self-heals with no replay log and no session state to lose. The connection lifecycle (
copying → connecting → connected) is itself a cell the UI renders.
None of this is speculative: the protocol runs through the real stdio codec in mini-ci.test.ts (loopback pair → serve → link, byte-identical to the ssh path, real child processes). The tests bank: snapshot-then-delta with correct late-subscriber catch-up; race-free topological execution across every captured frame; full log replay for late subscribers to finished nodes; rerun re-running exactly the dependent closure; and a failed dependency skipping its dependents (no false greens). Plus the live end-to-end: just run localhost --json typechecks a real three-package dependency closure over a real session.
The honest ledger — where it beats justci, where it doesn’t yet
A CI tool has two halves. The watch-and-drive half — see state, read logs, rerun, reconnect — is where the live-service shape wins outright. The gate half — a strict run against a pinned commit whose verdict lands on the forge as commit-statuses that branch protection enforces — is what justci actually does for this repo, and mini-ci has none of it yet. One precision the dogfood decision forces: neither tool ingests forge events — a justci run starts when you, or an agent, invokes it. The trigger was never the debt; the gate is. And with the plan accepted, the second table below stops being a wishlist: it is Phase 1’s work order, scoped by exactly what justci does for kolu today.
What only a live service can do
| Live-service affordance | The justci limit it answers | Status |
|---|---|---|
In-band introspection (nodes Cell + nodeLog Stream) on the attach connection | A separate baked pc client on .ci/pc.sock that exits when no run is live | beyond |
| Per-node log replay as a live stream for late subscribers | Stdout scraped to .ci/<sha>/<plat>/<recipe>.log files; the feed carries only the path | beyond |
Typed rerun of a node + dependents on the live DAG (no new process) | justci run RECIPE@PLATFORM spins up a brand-new batch | beyond |
| Self-healing reconnect — fresh snapshot on every resubscribe, infinite retry, session respawn | No reattach to an in-flight run (side-socket introspection only) | beyond |
| Idle attach + selective start — clients connect before anything runs | #22 · MCP server for ci — no serve-only mode in process-compose; justci tried and reverted ( #18 ) | beyond |
| Faces over one surface — TUI (shipped) · MCP (shipped) · Web (planned — odu-web), each a thin adapter, only the link differs | No live dashboard and no agent-drivable API | beyond |
| Fan-in: N platform runners into one typed dashboard | Fan-out: one ssh + log + status context per (recipe × platform), plus a #47 · sharding aggregator hack | open |
Typed cancel / cron / mid-run reload | Sealed at preflight, pinned to a worktree SHA — foreclosed under the translator | open |
| Concurrent-client mutation semantics (who may rerun, when) | A non-question under one-run-one-driver — a new problem the live shape creates and must own | open |
Phase 1’s work order — replacing justci in this repo
The scope is set by what justci actually does for kolu today: the [metadata("ci")] DAG in ci/mod.just, the /ci skill’s CLI surface, .agency/do.md’s pool runbook, and the ci/pu warm-box lease. The work lands as a fresh package, packages/odu — the mini-ci example stays untouched as the reference substrate. Every row is harder — net-new design + code on a proven substrate. “The framework can” is not “mini-ci does.”
| justci does this for kolu today | What mini-ci builds to take it over |
|---|---|
Real builds — kolu’s nix, e2e, smoke recipes are write-heavy; justci ships source (a git bundle) into a writable checkout | As built: a writable per-SHA git worktree on the host, fetched from the origin remote (odu fetches pushed SHAs over anonymous https — no git-bundle transport; /do pushes before CI anyway), with a per-slug object cache and per-run unique paths. The surface contract didn’t change: builds are just more nodes. |
Runs the just DAG — the [metadata("ci")] root’s reachable subgraph | A just → PipelineSpec translator (today the pipeline is hand-written JSON), plus offline inspection as built: dump (the resolved pipeline as JSON — there is no process-compose YAML to dump) and graph (Mermaid). |
Strict by default — dirty-tree refusal, HEAD pinned via git worktree, logs at .ci/<sha>/<plat>/<recipe>.log | The same hygiene and the same on-disk per-SHA log layout — so a runner death never loses the verdict trail, matching justci’s durability where it matters. |
Gates PRs — commit-status per recipe@platform + protect (branch-protection PATCH) | As built: status posting on terminal transitions diffed off the fan-in nodes Cell, byte-compatible with justci’s contexts/descriptions (verified against live API data) plus one new state — infrastructure death posts error/Errored, so a dropped lane fails loudly instead of wedging. odu protect exists; the planned required-checks flip proved vacuous: the contexts are byte-identical, and PR #1252’s own checks satisfied the existing protection list. |
Multi-platform lanes — hosts.json (a macos host; linux leased from the kolu-ci-* warm pool via flock) | Fan the DAG out per platform over one HostSession each, reusing the warm-pool lease (then ci/pu/run.sh, since split into the standalone ci/pu/lease.sh by
#1271 ). |
The CLI the /ci skill documents — run (+ recipe@platform selectors, --no-strict / --no-post / --platform / --host), status / logs / attach, the verdict summary | As built: status/logs/attach are in-band — they dial .ci/odu.sock, where the coordinator serves the same typed surface every other face speaks (the contrast with .ci/pc.sock was never the socket file; it was justci’s separately-versioned, separately-shaped baked client). Idle attach — a runner you can reach with no run live — moved to Phase 2 with the long-lived-runner question. justci’s --tui is absorbed by odu attach (renamed from monitor in
#7 · juspay/odu#7 ); the run output itself dropped justci-UX mimicry for a colour lane-matrix with a log-tail footer, heartbeats when piped, and OSC-8 commit links — and attach now renders that same matrix (one shared renderer,
#9 · juspay/odu#9 ), not a separate table. The /ci skill and .agency/do.md were rewritten against it. |
Exit criterion: kolu’s branch protection is satisfied by contexts this tool posted; nix run github:juspay/justci appears nowhere in the repo; the /ci skill and .agency/do.md describe the new tool.
Deferred past the dogfood — none of these block replacing justci here, because justci doesn’t have them either (and the single-operator ssh trust model covers Phase 1 exactly as it covers justci): authn/authz + a read-observer vs. mutator split (required before a multi-client browser face — rerun is remote code execution; the single-operator MCP face needs no new boundary, acting as the operator under the same ssh trust); runner-restart survival of live state (per-SHA logs already survive); notifications and forge-event ingestion (beyond both tools); caching / artifact passing; the per-recipe [linux]/[macos] filter (justci’s own open roadmap item); a run-history model richer than per-SHA logs.
Shipping it — graduation, name, roadmap
The delivery path is proven, not hypothetical. remote-process-monitor — another example app in the kolu repo — graduated into the standalone srid/drishti by consuming the same packages this runner is built on (@kolu/surface, surface-nix-host, and later surface-app via
#47 · freshness by composition ), pinned via npins and hydrated by cp -rL. mini-ci’s own README already names the path: “mini-ci could graduate to its own repo the way remote-process-monitor became drishti.” The license is determined by composition — surface-app (needed for the PWA) is AGPL-3.0-or-later, which is already mini-ci’s license — and the electricity bar (domain-agnostic / hides hard volatility / has a second consumer) is already cleared by the underlying packages, with drishti as the proof. What graduates is the product, riding infrastructure that already graduated; reusable concerns grown in the new repo (strict hygiene, the trigger model, run identity) flow back to harden the libraries.
Named: odu decided — Tamil ஓடு, the verb “run”: three letters, vowel-ending, an imperative you can type. It joins kolu (கொலு) and drishti (दृष्टि) in the one-word product line, and it is the package name from day one — Phase 1 lands in packages/odu, and the standalone repo inherits the name at graduation.
- Phase 0 · the proven substrateShipped: a long-lived runner owns the DAG and serves
nodes+nodeLog+rerunover the real stdio transport; tests bank snapshot-then-delta, race-free topo order, late-subscriber log replay, rerun-the-closure, and no-false-greens; live e2e over a real ssh session. TUI-only, single host, read-only closure ⇒ typecheck-only. - Phase 1 · replace justci in this repoShipped:
#1252 · the Phase-1 PR —
packages/odubuilt fresh per the work order (writable per-SHA workspaces,just-DAG ingestion +dump/graph, strict hygiene with justci’s per-SHA log layout, byte-compatible commit statuses +protect, multi-platform lanes over the warm-pool lease untouched, in-bandstatus/logs/attachon.ci/odu.sock), the/ciskill +.agency/do.mdrewritten, the lease wrapper repointed, and the exit criterion met the strong way: the PR’s own required checks were posted by odu, withnix run github:juspay/justcigone from every live path (the justci-era ralph reports carry historical notes). Dogfooding fixed what only production could: real pipes for recipes that reopen/dev/stderr, host-provided nix (a pinned client corrupts CA handling against a newer daemon), one-shot lane semantics witherroredposts. - Phase 2 · the agent faceShipped: the MCP server face —
#3 · juspay/odu#3 ,
odu mcpre-exposing the live surface as agent tools (run·get_nodes·tail_log·rerun_node· fail-fastwait_for_settle) plus subscribable resources (odu://nodes·odu://log/{node},notifications/resources/updated) — the agent-controlled CI justci built and reverted ( #22 · MCP server for ci ) because its batch shape had no serve-only mode. It needs no new authz: single-operator, same ssh trust as the CLI. kolu consumes it via the npins pin + the apm-deployed.mcp.json. The rest of the live-service backlog rides behind it: idle-attach + selective start (the runner you reach with nothing live — now planned as odu-runner); the TUI rebuilt on OpenTUI’s Solid reconciler (prototypeodu attachfirst); typedcancel/cron/reload; multi-platform fan-in with defined concurrent-mutation semantics; notifications + forge-event ingestion (beyond what justci ever had); runner-restart survival of live state; the per-node platform filter. The browser face — and the read-observer/mutator authz boundary it alone forces — has graduated from latent capability to its own plan of record, odu-web. Price the idle-runner lifecycle against the warm pu-box pool. - Phase 3 · graduationLanded:
#1 · juspay/odu#1 (merged) stood the standalone repo up the drishti way —
npins-pinned@kolu/{surface,surface-nix-host}extracted by overlay and hydrated as raw TypeScript (no vendoring), a zero-input flake (nix run github:juspay/odu), and self-hosted CI from day one — GitHub Actions runs odu-on-odu per push (two platform lanes, strict, postingci::*statuses with the odu built from the commit under test; first run green in 2m31s). Remaining: thread the runner derivation for repos that don’t exposeodu-runnerthemselves, adoptsurface-app(with the PWA face), a home-manager service, and extracting reusable concerns back into the libraries. The dependency then flipped: kolu deletedpackages/oduand consumes the graduated repo via an npins pin re-exported through its own flake (npins update oduto bump).