← the Atlas

pulam-web — the browser twin (kolu's surface-consumption leg, proven standalone)

Features·seedling·proposed·

A browser ↔ Node ↔ ssh app (drishti's twin for the terminal-workspace surface) that proves kolu's exact browser-consumption leg ahead of remote-terminals R9 — a live git-status view reaching a browser over websocketLink + surfaceClient + Solid reconcile, sourced from a mirrored pulam, auto-provisioned over ssh. Node + Vite, matching kolu's own client stack (the surface leg is identical @kolu/surface code, so it is a faithful reproduction of kolu's leg). Lands cheapest-first — R-pulamweb-1 (shipped) graduated 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; R-pulamweb-3 layers the agent dashboard (every agent sorted by what needs you, with a live activity dot); R-pulamweb-4 adds the live git status (dirty/clean cell + drill-in).

pulam-web is drishti’s twin, pointed at the terminal-workspace surface. Where drishti monitors per-host processes in a browser, pulam-web is an agent dashboard — a bird’s-eye view of every agent across every host, sorted by what needs you (needs-you · working · idle), auto-provisioned over ssh. It’s the pulam-tui fleet board, in a browser. And on top of being a real product, it earns its engineering keep: it is kolu’s own browser-consumption leg, minus koluwebsocketLinksurfaceClient → Solid reconcile — so it de-risks the remote-consumption leg remote-terminals R9 leans on, in a build whose console you can actually see.

User-facing description

pulam.local — every agent, every host⟳ live
hostslocalnix@prod sshstaging copying…+ host
2 agents need you— zest · nix@prod
zest· 3 agents
claudekolu · feat/dial-ssh #1412 ✓✎5needs you3s
codexdrishti · masterworking0s
claudeanyforge · fix/checks #1408 ✗✎5working4s
nix@prod· 2 agentsssh
claudekolu · fix/heap-oom #1427 ✓✎2needs you9s
claudeinfra · deploy✎2working1s
staging· provisioning…
showing agents+ idle+ non-agent terminals+ sleeping● 2 need you◜ 3 working

Leave it open on a second monitor: every agent across every host, sorted by what needs you. A blocked agent (awaiting_user) floats to the top and a warm strip breathes, so a glance tells you someone’s waiting before you’ve read a word; working agents spin cyan, idle ones sit dim. Each row carries a green activity dot when it’s moving bytes right now (like the Dock), its repo · branch, a compact dirty/clean mark, and how long it’s been in that state. You don’t care about terminals not running an agent, or sleeping ones — so they’re hidden by default, with one-click toggles to fold them back in. Hosts are added on demand and auto-provisioned over ssh (the provisioning… row is the live state), so a teammate opens one URL and watches the whole fleet. The per-agent git drill-in — a live changed-file tree — is the one heavier piece; it’s split out as R-pulamweb-4.

Architecture-level changes

browser (SolidJS)surfaceClient(websocketLink) · .collections.awareness.use() + reconcilepulam-web backend (Node)app-local re-serve + ws-upgrade · RPCHandler.upgrade per /rpc/ws?host=@kolu/surface-nix-host (NEW shared)buildHostRegistry · pumpRemoteSurface (drishti adopts too)per host: getHostSession (reconnecting)mirrorRemoteSurface → re-serve whole surfacepulam (host A) — terminal-workspace surfacepulam (host B) — terminal-workspace surface ws · oRPC (kolu's exact leg)fans out viadrivesssh stdio · nix copy --derivationssh stdio
pulam-web is browser ↔ backend ↔ N pulam over ssh. The backend auto-provisions + mirrors each remote pulam via the shared @kolu/surface-nix-host helpers (getHostSession + pumpRemoteSurface, keyed by buildHostRegistry), re-serves the whole terminalWorkspaceSurface per host, and dispatches one /rpc/ws?host= per host to its oRPC RPCHandler. The browser reads it through websocketLink + surfaceClient + Solid reconcile — the SAME @kolu/surface code kolu's own Code tab uses, which is why a green pulam-web proves kolu's R9 remote-consumption leg. Green = shipped @kolu/* primitives reused as-is; teal = the newly-shared fan-out helpers (drishti adopts them too); violet = the app-local pulam-web wiring (re-serve + ws-upgrade).

It is the same @kolu/surface code kolu and drishti run — that is the whole point. Serve side: implementSurface → oRPC RPCHandler (@orpc/server/ws) → handler.upgrade(ws) on a /rpc/ws socket. Consume side: websocketLinksurfaceClient/surfaceClients → the reactive .use() hooks (surfaceClient.ts owns snapshot-then-deltas + reconcile). drishti proves this trio is electricity; kolu’s Code tab proves .streams.use() for a value-bearing gitStatus stream. pulam-web is the first standalone app to put those together — a live surface consumed over surfaceClient sourced from a mirror, the exact remote leg R9 needs. R-pulamweb-3 proves it with the value-bearing activity stream (the live dot); the git status leg — git.getStatus re-queried on the subscribeRepoChange pulse, the same shape R9’s Code tab adopts — is R-pulamweb-4’s, where it ships with the drill-in.

Node, matching kolu’s own stack. The surface leg is identical @kolu/surface code on any runtime — drishti even writes its server Node-style (@hono/node-server + the ws package) and runs it on Bun only for its Bun.build client bundler. We go Node, like kolu-server, and bundle the SolidJS frontend with Vite + vite-plugin-solid (kolu’s own client toolchain) rather than drishti’s Bun.build — so pulam-web is kolu’s stack end-to-end.

Implementation details

Three steps, each isolating one risk. R-pulamweb-1 (in drishti) graduates the reactive stream consumer — kolu’s failing leg — on its own. R-pulamweb-2 stands up the entire framework (provision · dial · fan-out · mirror · re-serve · browser-consume) rendering only a terminal list — no features, so a plumbing failure has nowhere to hide. R-pulamweb-3 is the agent dashboard — a render/sort/filter layer over the awareness collection plus the value-bearing activity stream (R-pulamweb-1’s proven .streams.use() consumer); R-pulamweb-4 adds the live git status (the dirty/clean cell) and the file-tree drill-in. R-pulamweb-1 and R-pulamweb-2 are independent (one’s a drishti change, the other a fresh app); R-pulamweb-3 needs both; R-pulamweb-4 needs R-pulamweb-3 and de-risks R9’s remote git leg.

R-pulamweb-1 — the reactive stream consumer, in drishti ✅ shipped

R-pulamweb-1· reactive stream consumer (drishti)✓ shipped
blocks → R-pulamweb-3links drishti #72

Shipped (drishti #72). Before pulam-web exists, prove its riskiest piece — a reactive stream consumer that survives delta accumulation — where the browser ⇄ ssh ⇄ mirror stack already runs: drishti. drishti’s processesSnapshot (packages/common/src/surface.ts) is mirrored per host and was consumed imperativelyunenrolledStreamCall(app.rpc.surface.processesSnapshot.get) + for await + a teardown controller. This graduates that one call site to a declarative reactive subscription. The exercise surfaced the trap that is R9’s lesson:

.streams.use() is for value-bearing streams; processesSnapshot is delta-accumulate. The first cut switched the call site to app.streams.processesSnapshot.use() and copied the latest frame into a reconcile store from a coarse createEffect. That silently drops same-shape delta frames: .streams.use() writes each frame into a reconcile store, and a coarse reader (const msg = sub()) only re-fires for the paths it actually reads — so two consecutive deltas differing only in a nested field (a hot PID’s cpuPct ticking under an unchanged key set) coalesce and the second is lost; live values freeze in steady state. processesSnapshot is snapshot-then-delta — each frame is a change, not the full state — so the consumer must accumulate.

The fix — and the rule it pins. Drop one level to createSubscription + reduce: the reducer folds every frame in the subscription’s own for await loop (no frame can be coalesced away), and the table renders fine-grained off the accumulated value (processes()[pid].cpuPct), so an in-place reconcile leaf update re-notifies exactly that cell. The hermetic test drives a same-shape delta (cpu 10 → 20 → 30) and asserts a fine-grained reader observes 30 — the exact case the coarse copy dropped. Because drishti’s CI is typecheck + nix only (no test lane), the review gauntlet, not CI, is what caught this — which is precisely why graduating it in drishti before the kolu work earns its keep. A drishti PR — independent of R-pulamweb-2’s framework, the thing R-pulamweb-3’s value-bearing activity consumer (and R-pulamweb-4’s git-status consumer) rides on.

R-pulamweb-2 — the framework (terminal list only) ✅ #1524

R-pulamweb-2· framework — provision · fan-out · list✓ shipped
blocks → R-pulamweb-3links #1524

R-pulamweb-2 stands up everything hard — auto-provision + dial N pulam over ssh, mirror each, fan out, re-serve to the browser over /rpc/ws, and consume in the browser — but renders only a plain list of terminals (grouped by host). No git status, no drill-in, no features. The point is to prove the plumbing with a payload so small a failure has nowhere to hide.

Done when: the browser shows a live terminal list across N auto-provisioned pulam hosts — its terminals appear and come/go live (awareness deltas) — all over the real ws. The mirror → re-serve → browser-store path is proven deterministically by the hermetic test (below). No features yet. That alone proves the framework: provision · dial · reconnect · fan-out · ws-serve · browser collection-consume.

R-pulamweb-3 — the agent dashboard ✅ #1535

R-pulamweb-3· agent dashboard — agents by state✓ shipped
needs ← R-pulamweb-2blocks → R-pulamweb-4links #1535

The dashboard you actually want — every agent across the fleet, sorted by what needs you — has nothing to build underneath it. The state you sort by (awaiting_user · working · idle) already lives in the awareness collection R-pulamweb-2 consumes (AgentInfoSchema, terminal-workspace/schema.ts:54; HostGroup.tsx:49 reads value.agent today), and pulam-tui already has the state-bucketing, needs-you-first sort, and colour map (render.ts:79-201, renderer-agnostic). So R-pulamweb-3 is a render / sort / filter layerno surface change, no @pierre/trees, nothing gated:

Done when the browser shows the live agent dashboard across the fleet — blocked agents floated and breathing, states colour-coded, updating live over the real ws (video), with the toggles working. No file tree yet — that’s R-pulamweb-4.

R-pulamweb-4 — the live git drill-in (file tree)

R-pulamweb-4· git drill-in — live changed-file tree○ todo
needs ← R-pulamweb-3blocks → R9links renderer non-issue #1534

The git-status phase, in two parts: the per-agent dirty/clean cell (the ✎5 / mark + ahead/behind), and — clicking an agent — a drill-in to its live changed-file tree (the file tree like kolu’s Code tab, not just git status text). Both consume the same git-status data, and it is the same git procedure + pulse R9’s Code tab reuses (which is why pulam-web de-risks it). It is a plain render/consume feature — the Pierre renderer everyone feared is not a blocker (risk note below).

Build it end-to-end. The load-bearing decision a first attempt got wrong (feat/pulamweb-git-drillin, discarded) is where the new streams live — pin it:

  1. Consume the surface’s existing git procedure + pulse — add nothing. terminalWorkspaceSurface already serves git status the shared-surface way (R6): git.getStatus / fs.listAll are procedures (request→response) and subscribeRepoChange is a payload-free {seq} pulse. The drill-in calls the procedure for a snapshot, then re-queries on each pulse (surface live data explains the pattern). No new stream, no pollOnEvent, no surface change — pulam-web reads terminalWorkspaceSurface exactly as it is, which is the whole point.
  2. Browser: the dirty/clean cell. Consume app.streams.gitStatus.use(...) fine-grained (each frame the full status), reusing pulam-tui’s gitCell projection — ported into pulam-web’s own fleet.ts and pinned by fleet.test.ts (the reuse-map row below; do not import the TUI/OpenTUI package into the Vite bundle). Render ✎<n> / + ahead/behind in the agent row, replacing the NO git dirty/clean count placeholder at packages/pulam-web/src/client/HostGroup.tsx:27.
  3. Browser: the drill-in file tree. On clicking an agent, render the changed-file tree through @kolu/solid-pierre’s <FileTree> — kolu’s Code-tab wrapper (packages/client/src/right-panel/CodeTab.tsx, packages/solid-pierre/src/FileTree.tsx): app.streams.fsListAll.use(...) for the paths + app.streams.gitStatus.use(...) for decorations (the gitStatus prop → setGitStatus). Reuse the porcelain→Pierre mapping, don’t copy it — lift packages/client/src/ui/gitStatusEntries.ts into @kolu/solid-pierre so kolu and pulam-web import the one copy (the discarded attempt duplicated it into pulam-web). Add @kolu/solid-pierre + @pierre/trees to packages/pulam-web/package.json — pulam-web does not depend on Pierre today. Keep the tree mounted for flicker-free updates.

Surface coverage this phase adds. Before R-pulamweb-4 the browser consumed cells + the awareness collection + the activity value-bearing stream. This phase adds the procedure + pulse pattern — git.getStatus/fs.listAll called over the mirror, re-queried on each subscribeRepoChange pulse — over kolu’s exact surface, not a pulam-web variant, proving the remote/mirror leg. It’s the same git shape kolu’s Code tab adopts in R9 (the fs/git half), so R-pulamweb-4 de-risks it. (The file-content/diff viewer — git.getDiff / fs.readFile via @pierre/diffs — stays out: same pattern, rides R9.)

Done when the dirty/clean cell and the drill-in’s file tree update within ~1s of a working-tree change over the real ws (video), and a hermetic test fires a subscribeRepoChange pulse and asserts the drill-in re-queries git.getStatus/fs.listAll and repaints, over the agent → mirror → re-serve → browser leg. No Pierre patch is required (see the risk note). This is the same procedure + pulse git leg kolu’s Code tab adopts in R9.

R-pulamweb-5 — fleet notifications (the alertClass mirror)

R-pulamweb-5· fleet notifications — alertClass○ todo
needs ← R-pulamweb-3links born in #1541

The last Dock-mirror gap. kolu’s Dock fires an OS notification (+ PWA badge) when an agent crosses into the notify class — finished (waiting) or blocked (awaiting_user) — for a terminal you aren’t watching; the membership is the shared alertClass fold (@kolu/terminal-workspace/agentProjection). The fleet board is the exact surface that wants this — you “leave it open on a second monitor”, so a ping when something needs you is the point — and pulam-web’s server already serves the notify service worker (installFreshStatic({ serviceWorker: "notify" })), so the transport is in place. What’s missing is the client firing leg, which today lives kolu-local (useActivityAlerts / useTerminalAlerts — permission request, the fire-on-crossing effect, the SW click-routing).

So this phase is not a fold-consume — it’s a feature: (1) extract the renderer-agnostic firing channels (request-permission · fire-notification · SW message routing) out of the kolu client into a shared home — @kolu/surface-app already owns NOTIFICATION_SW_SOURCE, the natural electricity boundary — so kolu and pulam-web fire through one path, not two; (2) wire pulam-web: a per-host effect over the awareness collection that fires on an alertClass crossing for a background host, gated on a permission opt-in (the dashboard’s own toggle). The paint + rank mirror shipped with R-dock-unify (#1541); this is the third fold catching up. Done when a fleet agent finishing/blocking on an unfocused tab fires one OS notification whose click focuses pulam-web (video), and kolu + pulam-web fire through the same extracted channel (no duplicated firing logic).

Reuse map — how the framework (R-pulamweb-1/2) was built (grounded against installed code + /home/srid/code/drishti). Shared = consumed from the surface family, not copied; app-local = pulam-web’s own. The rows R-pulamweb-3/4 add are the last (agent-state cells, then the drill-in); everything else here already shipped.

Concern Source Note
per-host mirror loop shared @kolu/surface-nix-host pumpRemoteSurface extracted from drishti bridgeAgentToParent; makeClientCursor reconnect + mirrorRemoteSurface per spawn + live-procedure/live-client holders
fan-out registry shared @kolu/surface-nix-host buildHostRegistry extracted from drishti hostRegistry.ts; Map<host,{session,handler}>, generic over the handler type
ws-serve (~18 lines) app-local in pulam-web composed from shared gateWsOrigin (@kolu/surface/ws-origin) + gateStaleSocket + startWsHeartbeat (@kolu/surface-app/server); ?host= dispatch is the app’s (see the common-it-up callout above)
whole-surface re-serve app-local implementSurface(terminalWorkspaceSurface, …) folds version+awareness from the mirror, forwards fs.*/git.* + the watcher streams (fail-fast: every primitive implemented)
autoprovision + dial shared @kolu/surface-nix-host getHostSession (+ resolveSystem) dial template = pulam-tui/src/hostConnect.ts; pins binary:"pulam", PULAM_AGENT_DRVS_JSON. Provisioning lives inside the session’s reconnect cycle — no hand-rolled provisionAgent call
browser bootstrap drishti app/src/client/wire.ts (pattern) one surfaceClient(pulamSurface, websocketLink(ws?host=)) per host — the browser reads the mirrored surface (pulamSurface = mirroredSurface(terminalWorkspaceSurface)), which carries the connection cell; the base is connection-free (@kolu/surface/solid + @kolu/surface/links/websocket)
serve the bundle shared installFreshStatic (@kolu/surface-app/server) the same fresh-static contract kolu-server uses
render.ts (state buckets · agentUrgency · sort · relativeTime) pulam-tuiR-pulamweb-3 ported to pulam-web’s own fleet.ts (pinned by fleet.test.ts), not imported — no TUI/OpenTUI dep in the Vite bundle; the agent rows + needs-you-first sort
render.ts / fleet.ts (gitCell, gitDetail, RepoWatchSet) pulam-tuiR-pulamweb-4 the dirty/clean cell + the live file-tree drill-in (all the git-status consumption)
bundler + nix Vite + vite-plugin-solid (kolu’s packages/client toolchain), Node in-repo package: @kolu/* are workspace deps (no npins); nix derivation + flake/justfile wiring modeled on packages/client

The hermetic test. No in-memory websocketLink double exists (only directLink/stdio/unix-socket). Model on R7’s mirrorRemoteSurface.test.ts: stand up a real terminalWorkspaceSurface agent via implementSurface, connect it with directLink, drive pulam-web’s buildReServe fold path (the mirror sink) with that client, then consume the re-served surface through a second surfaceClient + a Solid reconcile store — asserting the re-served value re-notifies on the second delta (agent → mirror → re-serve → browser-store). buildReServe is split so the mirror step takes an injected client, which is exactly what makes it driveable without ssh. Per R-pulamweb-1’s correction, R-pulamweb-3’s value-bearing activity consumer reads fine-grained and its test drives a same-shape second frame — two same-cardinality membership swaps ([A]→[B]→[A]) that each must re-notify a liveSet().has(id) dot reader (the coalescing regression a coarse copy-into-store silently drops); R-pulamweb-2’s awareness consumer asserts the simpler key re-notify. (R-pulamweb-4’s git-status consumer is the procedure + pulse leg — re-query on the pulse; activity is R-pulamweb-3’s value-bearing leg.) That catches a coalescing/reconcile break deterministically; a ws-flush break only shows over a real socket — so pulam-web over real ws is the full proof, the unit test necessary-but-not-sufficient.

Definition of done — by phase. R-pulamweb-1 (drishti): ✅ shipped (drishti #72) — processesSnapshot rendered live via createSubscription + reduce, read fine-grained, guarded by a same-shape-delta regression test. R-pulamweb-2: a live terminal list across N auto-provisioned hosts over the real ws — the framework, no features. R-pulamweb-3: ✅ shipped (#1535) — the live agent dashboard across the fleet (blocked agents floated + breathing, states colour-coded, the activity dot live, the toggles working, over the real ws); the value-bearing activity consumer guarded by a same-shape-swap re-notify test. No git cells, no file tree, nothing gated. R-pulamweb-4: the dirty/clean cell + the drill-in’s file tree update live over the real ws, the git-status procedure + pulse pinned by a re-query-on-pulse test — no Pierre patch needed (the swallow-emit is measured harmless in kolu, #1534); the same git shape R9’s Code tab adopts.

History