pulam-tui — the thin CLI client for the pulam daemon
Now that pulam-web carries the rich fleet dashboard, pulam-tui no longer needs to be a full-blown TUI. It sheds Bun + OpenTUI and reverts to a kaval-tui-style raw client — status / status --json / watch <id> / watch <id> --json / wait <id> --until <state> against one pulam daemon over a unix socket or ssh. The CLI face of pulam; the multi-host fleet board moves wholesale to the browser.
pulam-tui is the raw client of the pulam daemon — kaval-tui’s sibling, one layer up. Where kaval-tui is the thin shell client of the kaval PTY daemon (list / attach / kill over a socket, no browser), pulam-tui is the thin shell client of the pulam awareness daemon: status / watch over the same socket-or-ssh transport. The rich, leave-it-on-a-second-monitor fleet view is pulam-web’s job now — so pulam-tui sheds the Bun + OpenTUI machinery it grew for that view and reverts to what kaval-tui has always been: a scriptable, single-daemon CLI face.
User-facing description
Three verbs against one daemon — status (a one-shot snapshot), watch (a live follow), and wait (block until a terminal’s agent reaches a state, then exit) — each scriptable with --json, and watch optionally narrowed to a single terminal id. No alt-screen, no full-screen UI: pulam-tui prints to your terminal and exits, or streams line-by-line, exactly the way kaval-tui’s list / snapshot do. wait is the awareness analog of a blocking read: it’s the done-signal an agent uses to drive another agent — prompt a Claude Code / Codex / opencode in a kaval terminal, then wait --until awaiting,waiting for its turn to end before reading the reply.
wait matches the agent’s state the instant it connects (it replays the current value), so a robust driver waits in two phases — --until working to confirm the prompt was picked up, then --until awaiting,waiting for the turn to end — rather than a lone post-send wait that the previous turn’s stale waiting/awaiting would satisfy immediately. It fails loud on a --timeout <ms> (exit 2) and on the terminal exiting before the state lands (exit 3 — the agent you were driving died), so a stuck or dead agent can’t hang the loop.
The surface mapping — each command is a thin read over the terminalWorkspaceSurface the daemon serves:
| Subcommand | Surface read | Behaviour |
|---|---|---|
pulam-tui status |
awareness collection (one-shot) |
Print one row per terminal — repo·branch · PR · agent · foreground · idle — then exit. The awareness snapshot as a plain text table. (No working-tree dirty count: that needs git.getStatus, which the single-daemon snapshot deliberately doesn’t call — it’s pulam-web’s drill-in.) |
pulam-tui status --json |
awareness collection (one-shot) |
Emit the flat [{ id, ...AwarenessValue }] array and exit. The machine-readable face. |
pulam-tui watch |
awareness collection + activity stream (subscribe) |
Follow every terminal on the daemon: print a line each time any terminal’s awareness changes (agent state, branch, foreground), with a trailing ● when it’s moving bytes right now, until Ctrl+C. |
pulam-tui watch --json |
same | One JSON object per line (newline-delimited) per update, across all terminals — the streaming scriptable feed. |
pulam-tui watch <id> |
same, filtered to one id | The same live follow, narrowed to a single terminal. --json likewise. |
pulam-tui wait <id> --until <state> |
awareness collection (subscribe, one terminal) |
Block until that terminal’s agent enters a target bucket — working / awaiting / waiting (the shared agentBucket fold; awaiting,waiting = its turn ended), then exit. The done-signal for scripting an agent that drives another agent. --timeout <ms> caps it (fails loud, exit 2); --json emits { id, agent }. |
Flags mirror kaval-tui exactly: --socket <path> points at a local daemon (default $XDG_RUNTIME_DIR/pulam/awareness.sock); --host <ssh> dials and Nix-provisions a single remote pulam over ssh. The two are mutually exclusive, and there is no multi-host mode — one invocation, one daemon. (Watching the whole fleet across hosts is what you open pulam-web for.)
Workflows
Glance — what is every terminal in, right now. status prints the snapshot and exits:
Follow the whole daemon live. watch (no id) streams every terminal’s changes as they land — leave it running in a spare pane. Each line is HH:MM:SS id repo·branch agent · state, with a trailing ● when that terminal is moving bytes right now:
Follow one terminal. Narrow to an id (the short id from status, or a unique prefix) when you only care about one agent:
Script it. --json turns either verb into a feed — pipe status --json through jq, or alert off a watch --json line (NDJSON, one object per line):
Reach a remote daemon. --host dials and Nix-provisions one pulam over ssh — same two verbs, no kolu-server:
Architecture-level changes
The whole point is the raw-vs-rich split, the same one kaval-tui draws against its daemon: the daemon is durable and serves the full typed surface; the -tui client is the bare, scriptable face, and the browser is the rich one. pulam-tui reading awareness is the exact analog of kaval-tui reading PTYs.
pulam-tui reverts to a leaf — and loses no electricity proof by doing so. The original note justified the OpenTUI fleet board as the awareness analog of drishti: a second consumer that proves pulam’s own surface is a receptacle other apps plug into. That proof now stands on pulam-web — a second consumer, and the better one, reading the same surface the same way kolu’s browser will. With the electricity carried elsewhere, the TUI no longer has to be rich to earn its keep; it can be the bare scriptable client and nothing is lost. The renderer that the old note called “electricity, but already OpenTUI” is simply not needed in the TUI anymore — the rich render lives in the browser, where it belongs.
What leaves the package, and why it was only ever there for the fleet board:
- The Bun runtime → back to Node (
tsx). OpenTUI’s renderer is a native Zig core loaded viaBun.dlopen, so the dashboard needed Bun. With no dashboard, Node is enough — matching kaval-tui’stsx src/main.tsand the pulam daemon’s own runtime. - OpenTUI (
@opentui/core,@opentui/solid) + the per-arch Zig lib. The whole SolidJS-into-terminal render layer (render.ts, the live clock, the breathing alert strip) goes;statusprints a table and exits. - bun2nix and its scaffolding.
bun.lock/ the autogeneratedbun.nix/ the per-arch Zig closure / the pnpm-workspace exclusion that kept the Bun manifest out of pnpm’s glob — all of it existed to package the Bun viewer. It is replaced by the same plain Node packaging kaval-tui uses. - The multi-host
fleetsubcommand. The N-host fan-out, the(host, terminalId)aggregate, theFleetSink/startFleet/ multi-hostRepoWatchSet, the--by/--no-local/--ssh-configflags — the entire aggregation surface moves to pulam-web, which already owns it.pulam-tuidials one daemon.
What stays — the thin-client spine, shared with kaval-tui:
- The single-daemon dial.
--socketfor a local daemon and--hostfor one remote pulam over ssh, both through@kolu/surface-nix-host’sdialAgentOnce— the same one-shot primitive kaval-tui’s--hostrides.pulam-tuiand kaval-tui stay thin wrappers over one shared dial. - The awareness read +
--json. The one-shotawareness-collection dump (today’s--json) is already exactly whatstatusneeds; it keeps that path and grows the human table beside it.
Implementation details
A subtractive change, mostly: delete the viewer, keep the dial, rename the snapshot. The decisions, to be echoed as code comments at their sites (the kaval-tui convention):
| Decision | Choice | Why |
|---|---|---|
| Runtime | tsx, not Bun |
The daemon already runs Node; with OpenTUI gone there is nothing left that needs Bun’s dlopen. One runtime across daemon + CLI, and a smaller closure (no per-arch Zig). |
| Render | plain line output, no alt-screen | A raw status client owns zero pixels — it prints rows and exits, or streams lines. The rich, full-screen render is pulam-web’s. This mirrors kaval-tui’s raw-passthrough render-fidelity decision. |
| Scope | one daemon, no fleet | --socket or one --host; the multi-host aggregation is pulam-web’s. Keeping a degraded text-mode fleet in the TUI would duplicate pulam-web for no gain — the glance view is the browser’s. |
| Commands | status (snapshot) / watch (live) / wait (block until a state), each with --json; watch takes an optional <id>, wait a required <id> + --until |
status is the awareness snapshot (kaval-tui’s list/snapshot analog); watch is the streaming verb pulam adds over kaval-tui’s one-shot-only model — bare watch follows every terminal, watch <id> narrows to one. wait rides the same awareness subscription but exits on the first frame whose agent enters a --until bucket (the shared agentBucket fold) — the done-signal for agent-drives-agent scripting (send a prompt, wait for the turn to end, snapshot the reply). The natural CLI expression of the awareness/activity stream. |
The steps:
- Strip the viewer. Remove
@opentui/*, therender.tsOpenTUI tree, the live clock, and thefleetsubcommand frompackages/pulam-tui. Drop the Bun manifest (bun.lock/bun.nix/ the Zig closure) and the pnpm-workspace exclusion; repointpackage.json’sstarttotsx src/bin.ts. - Keep the dial, rename the snapshot. The existing single-endpoint awareness consume (today rendered by the OpenTUI list, dumped by
--json) becomesstatus/status --jsonover--socket|--host(dialAgentOnce). No surface change —statusis a one-shot read of theawarenesscollection the daemon already serves. - Add
watch. Subscribe to theawarenesscollection plus theactivitystream and print each update — a line in default mode, one JSON object per line under--json— untilCtrl+C. Barewatchfollows every terminal; an optional<id>filters to one. A pure consumer; no contract bump. - Repackage like kaval-tui. The Nix derivation drops
bun2nixfor the plain Node packagingpackages/kaval-tuiuses;nix run …#pulam-tuiand the dev recipe follow kaval-tui’s wholesale.
History
- Shipped (2026-06-26, #1582) — with pulam-web shipped as the browser fleet dashboard (R-pulamweb-3, #1535), the OpenTUI/Bun fleet board in
pulam-tuiwas redundant. This revertspulam-tuito a kaval-tui-style raw client —statusandwatch(all terminals, or one by id) over one daemon, Node/tsxinstead of Bun, no OpenTUI, no multi-host fleet. Split out of pulam’s R4.5 (the fleet board) and its “Why this stays a TUI” / “Why Bun” sections, which this supersedes; the daemon note keeps the daemon story and points here for the client.