pulam — the terminal-workspace surface as an ephemeral daemon on kaval
Repeat the kaval decoupling one level up. The awareness sensors (git · PR · agent · foreground) plus fs/git become a generic library that fills one typed surface — kolu runs it in-process locally, and an ephemeral daemon (pulam) on top of kaval serves the same surface over ssh for remote terminals. kolu reads that observation through one seam, never folding a copy into its own record. One library, two homes. The standalone tools ship first; the R4.6–R4.8 forward roadmap (rename → pulam · fs/git in pulam-tui · pulam-web) graduates the browser-ws-stream primitive kolu's remote-terminals R8 depends on.
kaval decoupled the PTY out of kolu into a standalone, minimal, durable daemon. This note makes the same move one level up for the part kolu kept tangling: terminal awareness (git branch/dirty, PR status, agent detection, foreground). A new daemon — pulam (Tamil புலம், “field · domain” — and from the same root pulan = a sense; sibling to kaval = guard and odu = run) — sits on top of kaval, runs the sensors, and exposes the result as one typed @kolu/surface collection. One sensor library, two homes: locally kolu runs the sensors in-process; remotely the pulam daemon serves the same awareness over ssh. Either way the result is one typed observation surface that kolu reads — it never folds a copy into its own record. (That convergence is remote-terminals R8; until it lands, kolu’s local path still routes awareness into terminalMetadata.)
The core model
Three claims, each verified against the shipped code:
- The unit is a generic awareness value, not kolu’s record.
pulamexposesCollection<TerminalId, AwarenessValue>— exactlycwd · git · lastAgentCommand · lastActivityAtpluspr · agent · foreground. That schema lives in@kolu/terminal-workspace(the R4.1@kolu/terminal-awareness, renamed when it grew fs/git in R6), built from the vendor-neutralanyagent/anyforge/kolu-gitshapes with no kolu content — not evenlocation(no sensor can know its own kolu-sidehostId). Kolu’sTerminalServerMetadataisAwarenessValuepluslocationand UI fields: built on it, never carved from it. - kaval stays byte-for-byte minimal.
pulamdials kaval as aptyHostSurfaceclient for the four taps +getScreenText; it adds zero awareness/git/gh logic to kaval. A layer above, never a fork. - Ephemerality is the simplifier. Awareness never has to survive a restart — it’s re-derivable from live taps + current host fs. So
pulamsheds all of kaval’s durability machinery: no single-instance lock, no spawn/cleanup, no persisted list (it borrows kaval’sterminal.list), no adoption or reconnect. Every (re)start just re-runs the sensors from now.
One library, two homes — kolu reads, never folds
Local and remote share the sensor library and one schema — and, after remote-terminals R8, one shape of consumption too: the sensors fill the terminalWorkspaceSurface.awareness collection, and kolu reads it. The only thing that differs is where that collection is backed:
- Local → in-process. kolu-server runs the sensors and they fill the in-process awareness collection; kolu reads it through
awarenessFor(id)— no transport, no framing, and crucially no fold (the sensors no longer write kolu’sterminalMetadata). This reverses the earlier local writesterminalMetadatadirectly stance: at #1406 an in-process surface bought nothing (kolu folded into its record either way), so it read as ceremony; R8 makes it earn its keep — giving observation its own collection is exactly what deletes the fold and the persisted/live fence, and unifies the read seam with remote. - Remote → serve (pulam), then mirror (kolu).
pulamis Nix-provisioned over ssh (kaval’s staleKey recipe), runs the identical sensors, and serves the slice overstdioLink. kolu mirrors it (R7’s total dual) and reads the sameawarenessFor(id)seam — the backing is a mirror instead of an in-process collection.
So the old asymmetry — local mutates kolu’s record; remote serves → mirrors → folds — collapses to one shape, two backings: both fill the observation surface, kolu reads it, and local-vs-remote is a backing swap behind the seam. The one new artifact is the host-side hooks impl that reifies each mutate closure into a serialized slice frame — a wire carries values, not closures. (Until R8 lands, kolu’s local path still routes awareness into terminalMetadata; R8 is what moves it onto the surface kolu reads.)
mirrorRemoteSurfacehas graduated (#1497). It is now the one public mirror in@kolu/surface— the spec-driven dual ofimplementSurfacethat drives a served surface’s streaming primitives (cells/collections/streams) into caller-supplied sinks — mirroring procedures to a total dual is remote-terminals R7;mirrorRemoteCollectionis demoted to its private per-key engine (“you don’t sell half a house”). The trigger was pulam growing a realactivitystream (the green dot) — the second stream-bearing consumer the graduation waited for. It does not by itself dissolve kolu’s server-side fold — remote-terminals R8 does, by composing the surface into kolu’s own; until then the fold consumesmirrorRemoteSurface. As a@kolu/surfaceAPI change it rodesurface.md’s drishti merge-gate.
The standalone tools — pulam + its two clients
Like kaval, the daemon is a deliverable in its own right, shipped and proven before kolu touches it. Where kaval-tui shows what’s running in each PTY, the pulam clients show what each terminal is in — the awareness slice, with zero kolu-server, dialable over ssh against a prod box. Two clients read it, split by richness, the kaval picture one layer up:
- pulam-tui — the raw client, kaval-tui’s sibling: a thin Node CLI (
status/watch) against one daemon over a socket or ssh. It is deliberately not a full TUI — see its note. - pulam-web — the rich client: the browser fleet dashboard that fans out over N hosts, “what is every agent doing, across every repo, across every machine.” This is where the multi-host glance view lives.
It ships self-contained: the sensors (@kolu/terminal-workspace/sensors.ts) import nothing from kolu but a logger, and the host runtime deps are just node · git · gh (SQLite via Node’s built-in node:sqlite — no native addon), so the bin travels cleanly over ssh. Define the contract once and the consumers fall out of one schema — the daemon serves it, the clients read it, and remotely kolu mirrors and reads it.
The R4 tree — shipped end-to-end
The standalone pulam story (extract → daemon → remote viewer → Bun → OpenTUI → live fleet → live activity) is complete. kolu’s consume — kolu dialing this host’s pulam over a long-lived HostSession, mirroring it, and reading it into its own canvas — is not pulam’s job; it lives in the parent remote-terminals roadmap as R8–R9 (R8 composes the surface and deletes the fold, R9 dials), gated on the remote dial. And the fs/git a remote Code tab needs is roadmapped there as R6: this awareness library grows into @kolu/terminal-workspace — one @kolu/surface surface that adds fs/git procedures + watcher streams beside the awareness collection — so kolu runs it in-process locally and pulam hosts the same surface remotely, never a second fs/git impl. pulam just points there. (The total mirror is proven independently in R7 — drishti gains a “Kill process” action, the first forwarded procedure on a mirrored surface — so R7 needs neither pulam nor kolu.)
| Phase | Ships | #1413 etc. |
|---|---|---|
| R4.1 refactor | extract @kolu/terminal-awareness — sensors + generic schemas, off kolu-common; the schema home inverts (kolu-common now imports the schemas and adds location). Behaviour-preserving — green CI is the proof. |
#1413 |
| R4.2 refactor | finish the provider → adapter rename through the anyagent/anyforge leaves (symbols, files, exports — one adapter spine). The live Watcher handle + wire provider discriminant stay. Behaviour-preserving. |
#1419 |
| R4.3 feature | pulam + pulam-tui standalone — the daemon dials kaval and serves the awareness surface; the TUI reads it. Zero kolu-server. --stdio is the seam the ssh dial speaks to. |
#1428 |
| R4.4 feature | pulam-tui --host — dial + Nix-provision a remote pulam over ssh, render the same dashboard. The shared one-shot dial graduated to @kolu/surface-nix-host’s dialAgentOnce (a version-cell read is pulam’s connectivity probe). Zero kolu-server. |
#1439 |
| R4.5 feature | the fleet dashboard — see below. | #1470 · #1479 · #1486 · #1497 |
R4.5 — the fleet board
The user’s framing made literal — “what is every agent doing, across every repo, across every machine” — as a dashboard you leave open on a second monitor: glance over from the game and a colour you can read across the room tells you whether any agent is blocked on you, and where. It stays a TUI, runs zero kolu-server, and shipped as four steps:
- R4.5.1 — Bun runtime #1470. Re-platform
pulam-tuionto Bun via drishti’s bun2nix recipe — pinned via npins, resolved withbuiltins.getFlake(theodupath, so kolu’s zero-flake-input rule holds),bun.lock+ autogeneratedbun.nixkept outside the pnpm workspace,@kolu/*hydrated from the Nix store. Today’slist/watchrun unchanged. The foundation lands first so the OpenTUI bundle sits on proven ground. - R4.5.2 — OpenTUI render, one endpoint #1479. Retire
list/watch+ thecolumnifytext; render a SolidJS-canonical OpenTUI list of one endpoint’s terminals (the already-shipped--socketor single--host, reused verbatim — no new connection capability), with a one-second clock beside it as the liveness proof. The list itself is a one-shot snapshot; live deltas are R4.5.3’s.--jsondumps the awareness array. - R4.5.3 — the multi-host fleet board #1486. Fan the one-shot dial over N hosts, mirror each
awarenesscollection into one aggregate keyed by(host, terminalId), go live (repaint on any mirror delta), float everyawaiting_useragent to the top across the fleet, and render per-host groups + a breathing alert strip + honest unreachable/skew/empty states. - R4.5.4 — the live green dot + full-surface mirror #1497.
@kolu/pulam-contractgrows its firstactivitystream (terminals moving bytes right now, from kaval’s raw output tap); the board paints each a live green dot and drives the whole surface — version cell +awareness+activity— through onemirrorRemoteSurfacecall. Contract bumped0.1 → 0.2(additive); rodesurface.md’s drishti merge-gate.
When nothing needs you the board sits calm — cyan working spinners, dim idle rows, a quiet all clear ✓. The moment an agent hits awaiting_user its row lifts to the top of the fleet and a warm amber strip breathes, so a glance tells you someone’s waiting before you’ve read a word. Rows sort needs-you first across the whole fleet; --by agent regroups into one fleet-wide “who is waiting on input, anywhere” list; --json emits the flat [{ host, terminalId, ...AwarenessValue }] for a notifier. Unreachable / skew / empty hosts each render distinctly rather than silently vanishing.
Host is stamped at the dial site, never by the daemon — the awareness-layer echo of kolu’s kolu-side location stamp (AwarenessValue deliberately carries no hostId). The render reads the aggregate keyed by (host, terminalId), so two boxes’ identical terminal ids stay distinct.
The fleet board moved to the browser — and pulam-tui slimmed down
R4.5 shipped the fleet board as an OpenTUI dashboard inside pulam-tui — a Bun binary, a Zig renderer via Bun.dlopen, bun2nix packaging. That was the right call while the TUI was the only rich fleet view. It no longer is: pulam-web is the browser fleet dashboard, and it is the better home for the multi-host glance. So the OpenTUI/Bun half is walked back — pulam-tui reverts to a kaval-tui-style raw client (status / watch, one daemon over a socket or ssh), and the multi-host fleet lives in pulam-web. The full strip-back — what leaves the package and why — is pulam-tui’s own note.
The electricity call survives the move intact — it lands cleaner, in fact:
- pulam’s fleet was the awareness analog of drishti: a second consumer proving pulam’s own surface is a receptacle (the fan-out needed no contract change — that invariance is the proof). pulam-web is now that second consumer, and the better one, so the proof stands without the TUI having to be rich to carry it.
- The renderer is electricity, but a domain-agnostic Solid→surface capability the browser already owns — so the TUI no longer mints or even consumes a terminal renderer. The aggregation join stays a leaf reducer (a bounded host-keyed merge, not a hard volatility — transport + provision + reconnect belong to
@kolu/surface-nix-host), now living in pulam-web. No@kolu/fleet-mirror, the “tidy generic helper” trap electricity warns against.
R4.6–R4.8 — the forward roadmap: what pulam proves before kolu’s R8
The discipline that built this epic: every hard primitive graduates through a standalone consumer before kolu touches it — kaval-tui proved the PTY dial, the fleet board proved the awareness mirror, drishti proved the surface mirror + forwarded procedures. Kolu’s remote-terminals fs/git leg (now R9) reached for one thing no standalone consumer had exercised — the fs/git Code-tab live updates over the browser ws — and the discarded #1510 spent months there. (The real block turned out to be the file-tree renderer not repainting under change-pulse churn, not the transport — but it was blind: the prod client build hides the console, the server log lives in an ephemeral sandbox.) These three phases pay the debt. Each is a real pulam feature and the graduation gate for remote-terminals R9 — the drishti pattern, one more turn.
| Phase | Ships | …and the gate it is for kolu |
|---|---|---|
| R4.6 · rename refactor #1512 | arivu → pulam — the daemon, pulam-tui, this note; the package stays @kolu/terminal-workspace. Behaviour-preserving; green CI is the proof. Deliberately just the rename — across code, Nix, docs, and the website. |
clears the “knowing” misnomer before new surface area is born under it |
R4.7 · live git status in pulam-tui fleet feature #1519 |
each fleet row grows a live working-tree cell (changed-file count + branch ahead/behind), and a selected row (↑/↓ · Enter) drills in to the full git status — staged · modified · untracked + the changed-file list. Driven by subscribeRepoChange’s {seq} pulse re-running git.getStatus; no file content — the changed-file list, never bodies or diffs. git.getStatus’s local arm grew the branch header + section counts and dropped the always-null base — a breaking reshape, so the workspace contract bumps 0.3 → 1.0. |
the server source-arm {seq} shape, end-to-end and observable — “is the shape sound?” |
R4.8 · pulam-web feature |
the browser twin (its own note) — a drishti-shaped browser ↔ ssh app reading pulam’s surface over websocketLink + surfaceClient + Solid reconcile. Cheapest-first: R-pulamweb-1 drishti consumer ✅ · R-pulamweb-2 framework ✅ #1524 · R-pulamweb-3 agent dashboard ✅ #1535 · R-pulamweb-4 live git status + drill-in ◀ next. |
kolu’s exact failing leg — ws + surfaceClient + reconcile — proven kolu-free with a visible console; the recipe R9 rides on |
Why a web twin, when the TUI sufficed for the glance view. pulam-tui rides stdioLink + mirror sinks, so it structurally cannot exercise the leg kolu fails on: browser websocketLink → surfaceClient → Solid reconcile. pulam-web is that leg, minus kolu — it reads the same surface the same way kolu’s browser will. So it is not a prettier dashboard (that would be the ceremony the fleet-board callout warned against); it is the standalone proof of kolu’s own consumption shape. Only once R4.7 (shape) and R4.8 (transport) are green does kolu’s R9 compose the surface — now a backing-swap onto twice-proven electricity, not a speculative one.
R4.7 — pulam-tui fleet: a live git status view (proving the {seq} shape)
The fleet board already shows each terminal’s repo·branch from the awareness collection — the primitive R4.5 proved. R4.7 consumes the other arm of the surface, the one kolu’s Code tab needs and nothing had exercised: the subscribeRepoChange {seq} watcher stream re-running the git.getStatus procedure. Each fleet row grows a live working-tree cell — a changed-file count plus the branch’s ahead/behind — and selecting a row (↑/↓) and pressing Enter drills in to the full git status: the staged · modified · untracked summary and the changed-file list. No file content, no diff — the changed-file list (paths + status codes), never file bodies; git status alone drives the exact pulse-plus-requery loop kolu fails on, so it is the proof.
To paint ahead/behind, git.getStatus’s local output grew a branch tracking header (name · upstream · ahead · behind) and the working-tree section counts — read off the same git status the file list already reads, so it costs no extra git call (simple-git already computes both and the code just stopped discarding them). The same change models the result as a discriminated union on mode and drops the always-null base from the local arm; removing a field a 0.3 viewer’s schema still requires is a breaking reshape, so the workspace contract bumps 0.3 → 1.0 (a major, not a minor) and the gate marks 0.3 and 1.0 mutually skew in both directions. This is the one place R4.7 extends the surface rather than purely consuming it — and it stays in kolu-git + @kolu/terminal-workspace, touching no @kolu/surface* API, so it needs no drishti gate.
The consume loop is RepoWatchSet: per distinct repo across the fleet, subscribe to the {seq} pulse and re-query getStatus on each, keyed by repo root so repo-mates share one subscription and the last to leave tears it down. It runs over the real unix-socket / stdioLink link, so R4.7 answers one question: does the {seq} source-arm stream + requery survive a real link, end to end? Proven by an integration test over a real served socket (a working-tree change pulses → the re-query reflects it) and by raw-PTY capture — frame N+1 ≠ frame N on a touch / git add — never directLink (the in-process path that masked the bug).
So the shape is sound over a real wire. But the consumer here is raw for await iteration over a mirrorRemoteSurface handle — close kin to directLink, and not kolu’s consumer. kolu’s browser (and the #1510 prototype that stuck) reads through surfaceClient.streams.use() + a Solid reconcile store — a path R4.7 never touches. So two unknowns remain, both R4.8’s: that consumer, and whether the surface should hand a raw {seq} pulse to a browser at all.
R4.8 — pulam-web: the browser twin → its own note
R4.8 grew its own UI and a layered plan, so it moved to a dedicated note: pulam-web. The short of it — a drishti-shaped Node browser ↔ ssh app reading pulam’s surface over websocketLink + surfaceClient + Solid reconcile, kolu’s exact browser-consumption leg minus kolu (Node + Vite, matching kolu’s own stack). It lands cheapest-first: R-pulamweb-1 graduates the reactive stream consumer in drishti ✅; R-pulamweb-2 stands up the whole framework (provision · fan-out · mirror · re-serve) rendering only a terminal list ✅ #1524; R-pulamweb-3 layers the agent dashboard (every agent sorted by what needs you) ✅ #1535; R-pulamweb-4 adds the live git status + drill-in — do next. Full plan, UI mockup, and verified reuse map live in pulam-web.
History
- pulam-tui slimmed to a thin client; the fleet board is the browser’s (2026-06-26, #1582) — now that pulam-web carries the rich multi-host fleet dashboard (R-pulamweb-3, #1535),
pulam-tuino longer needs to be a full-blown TUI. It reverts to a kaval-tui-style raw client —status/watchagainst one daemon over a socket or ssh — shedding Bun + OpenTUI, bun2nix, and the multi-hostfleetboard (all of which leave for pulam-web). The R4.5 fleet board and the “Why this stays a TUI” / “Why Bun” sections are superseded for the client; the strip-back has its own note, pulam-tui. The electricity proof is unaffected — pulam-web is the second consumer that carries it. - pulam-web split into its own note (2026-06-22) — R4.8 grew its own UI + a layered plan, so it moved to pulam-web. Grounded against
/home/srid/code/drishti(Node + Vite chosen to match kolu’s stack — the surface leg is identical@kolu/surfacecode, so the runtime was free; Bun was only drishti’s bundler; autoprovision viagetHostSession+provisionAgent) andpulam-tui(thestartFleet/FleetSink/RepoWatchSetcore is renderer-agnostic). Layered as Step 0 (drishti graduates the reactive.streams.use()consumer viaprocessesSnapshot) · R4.8a the whole framework rendering only a terminal list · R4.8b the user-facing features. - R4.8 plan grounded against R4.7’s shipped code (2026-06-22) — verified R4.7 consumed the real
{seq}stream (not the value-bearing awarenessgitfield), so its charter is met — but viapulam-tui’s rawmirrorRemoteSurfaceiteration, not kolu’ssurfaceClient+reconcile(the path #1510 stuck on, which R4.7 never touches). R4.8 sharpened so that gap can’t recur: a trap callout (don’t port the raw loop), an explicit value-bearing-vs-procedure+pulse design fork (the two shapes are explained in surface live data), a definition-of-done demanding thesurfaceClient+reconcile consumer plus live-over-ws evidence, and a verified file map. The earlier R4.7 plan named the feature (“live git status”) without pinning the mechanism it existed to prove — a planning defect now corrected. - R4.7 live
git statusshipped (2026-06-22, #1519) — the fleet board’s first consumer of the surface’s fs/git{seq}watcher arm: each row gains a live working-tree cell (changed count + branch ahead/behind) and a ↑/↓-selectable drill-in to the fullgit status, all driven bysubscribeRepoChangere-queryinggit.getStatus.getStatus’s local mode grew the branch tracking header + working-tree counts (off the samegit status) and dropped the always-nullbase, a breaking reshape that bumps the workspace contract0.3 → 1.0; aRepoWatchSetowns the per-repo subscribe→requery lifecycle. Proven over a real link inpulam/daemon.test.ts(a working-tree change pulses → the re-query reflects it) — the graduation gate for kolu’s remote Code-tab live updates (remote-terminals R8b). The lens debate split extend-getStatus(hickey) vs ahead/behind-on-the-awareness-sensor (lowy); extending won because routing it through awareness would skip the very loop R4.7 exists to prove. - R4.6 rename shipped (2026-06-22, #1512) — arivu → pulam carried all the way through: the
pulam/pulam-tuipackages (dir + bin), the Nix flake/default.nixattrs +PULAM_*env vars, the$XDG_RUNTIME_DIR/pulam/awareness.socknamespace, the website’snix run …#pulamcommands + etymology, and the sibling Atlas notes. Behaviour-preserving — green CI on both platforms is the proof. - Forward roadmap branched in (2026-06-22) — arivu is renamed pulam (புலம் — field + sense) in R4.6 (kept small: just the rename), and two graduation gates for kolu’s remote-terminals R8 land as standalone pulam features — R4.7 gives
pulam-tuia livegit statusview (the first consumer of the surface’s{seq}stream — no file content needed), and R4.8 shipspulam-web(the browser twin) to prove the ws +surfaceClient+ reconcile leg kolu’s R8b depends on. Branched from remote-terminals after a prototype (#1510) validated the awareness one-writer-per-fact compose but stuck on that un-graduated stream-transport primitive. - The standalone story shipped end-to-end R4.1–R4.5: extract → daemon → remote viewer → Bun → OpenTUI → live fleet → live activity. Supersedes two closed plans (#1398, #1409) that kept awareness inside kolu and split hairs over an in-process compose-vs-serve boundary this design dissolves by moving the producer into a daemon.
- R4.4 →
dialAgentOnce— the gauntlet lifted the shared one-shot dial into@kolu/surface-nix-host; kaval-tui and pulam-tui are now thin wrappers over one primitive (paired drishti pin-bump merge-gate). - R4.5.3 as-built — the board is always live (
--jsonis the one-shot); a dropped one-shot dial flips a host tounreachablerather than reconnecting (long-lived reconnect stays remote-terminals R9 /HostSession); host discovery is--host+ a small~/.ssh/configenumerator (no new dep). Gauntlet + dogfooding added--kaval <host>=<socket>pinning, a responsiverepo·branchlayout, the--hoststderr-bleed fix (onLogsink), and a darwinbun install --backend=copyfilefix. - Packaging co-located (#1491) — the viewer’s Bun/Nix manifest moved from
nix/packages/intopackages/pulam-tui/nix/so the viewer is one self-contained directory (a pnpm-workspace exclusion keeps the bun manifest out of pnpm’s glob).