← the Atlas

pulam-tui — the thin CLI client for the pulam daemon

Features·budding·implemented·

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:

pulam-tui · unixSocketLink → /run/user/1000/pulam/awareness.sock
$ pulam-tui status
 
ID REPO·BRANCH PR AGENT FOREGROUND IDLE
a3f10000 kolu·feat/dial-ssh #1412 open ✓ claude · working node 4s
b7c20000 drishti·master — codex · waiting codex 1s
c9d40000 kolu·fix/fold #1408 open ✗ — nvim 12m

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:

pulam-tui · watch
$ pulam-tui watch
14:02:11 a3f10000 kolu·feat/dial-ssh claude · working ●
14:02:19 b7c20000 drishti·master codex · waiting
14:02:30 a3f10000 kolu·feat/dial-ssh claude · awaiting
14:05:48 c9d40000 (gone)
^C

Follow one terminal. Narrow to an id (the short id from status, or a unique prefix) when you only care about one agent:

pulam-tui · watch a3f10000
$ pulam-tui watch a3f10000
14:02:11 a3f10000 kolu·feat/dial-ssh claude · working ●
14:02:30 a3f10000 kolu·feat/dial-ssh claude · awaiting
^C

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):

pulam-tui · scripted
$ pulam-tui status --json | jq -r '.[] | select(.agent.kind=="claude-code" and .agent.state=="awaiting_user") | .id'
c9d40000-1111-4222-8333-444455556666
 
$ pulam-tui watch --json | jq -rc 'select(.agent.state=="awaiting_user") | "\(.id) needs you"'
a3f10000-1111-4222-8333-444455556666 needs you
# {"id":"a3f10000-…","live":false,"cwd":"/code/kolu","git":{"repoName":"kolu","branch":"feat/dial-ssh"},"agent":{"kind":"claude-code","state":"awaiting_user"},"pr":{…}}

Reach a remote daemon. --host dials and Nix-provisions one pulam over ssh — same two verbs, no kolu-server:

pulam-tui · status --host prod
$ pulam-tui status --host prod
 
ID REPO·BRANCH PR AGENT FOREGROUND IDLE
d4e20000 infra·deploy — — ansible 8s
f1a80000 kolu·fix/heap-oom #1427 open ✓ claude · working node 2s

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 (raw client)Node · tsx · status + watch + wait · ONE daemonpulam-web (rich client)browser · fans out N hosts · the fleet boardpulam (daemon)runs the sensors · serves terminalWorkspaceSurfacekavaldurable PTY · taps dials · awareness + activity (--socket | --host)mirrors over ws (rich fleet)taps
One daemon, two clients, split by richness — the kaval picture, one layer up. pulam serves the whole terminalWorkspaceSurface; pulam-web (browser) is the rich client that fans out over N hosts and renders the fleet dashboard; pulam-tui is the raw client — one daemon, status + watch + wait, scriptable. pulam-tui no longer needs Bun or OpenTUI: it is a plain Node/tsx CLI over @kolu/surface's link family, dialing one --socket or one --host.

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:

What stays — the thin-client spine, shared with kaval-tui:

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:

  1. Strip the viewer. Remove @opentui/*, the render.ts OpenTUI tree, the live clock, and the fleet subcommand from packages/pulam-tui. Drop the Bun manifest (bun.lock / bun.nix / the Zig closure) and the pnpm-workspace exclusion; repoint package.json’s start to tsx src/bin.ts.
  2. Keep the dial, rename the snapshot. The existing single-endpoint awareness consume (today rendered by the OpenTUI list, dumped by --json) becomes status / status --json over --socket | --host (dialAgentOnce). No surface change — status is a one-shot read of the awareness collection the daemon already serves.
  3. Add watch. Subscribe to the awareness collection plus the activity stream and print each update — a line in default mode, one JSON object per line under --json — until Ctrl+C. Bare watch follows every terminal; an optional <id> filters to one. A pure consumer; no contract bump.
  4. Repackage like kaval-tui. The Nix derivation drops bun2nix for the plain Node packaging packages/kaval-tui uses; nix run …#pulam-tui and the dev recipe follow kaval-tui’s wholesale.

History