padi — the per-host terminal-workspace daemon
The architecture that replaced the R9/R10 finale plan for remote terminals (#951). One workspace daemon per host — padi, atop kaval — knows everything about that host's terminals and serves it as one surface (padiSurface); kolu-server thins to a web shell that connects each browser view to one padi. The canvas shows one host at a time (instant switch; other hosts reach you via badges and OS notifications). The host owns its state — layout, memory, clock. Phases W0–W4 + W7/W8 shipped; W6 in review; next up W5 (cross-host attention), with the cleanup ledger in parallel.
The ground-up re-architecture for #951 — remote terminals — and the extraction that untangles kolu-server and the client along the way. The old plan (the finale) tagged every terminal with a HostLocation and checked that tag all over kolu-server, because it assumed one canvas could mix tiles from several hosts. Drop that assumption and the problem gets much simpler: “which host” is a property of the connection, not of each terminal. One daemon per host — padi (படி, the stepped stand a koḷu is arranged on) — knows everything about that host’s terminals and serves it as one complete surface. kolu-server becomes a thin web shell that connects each browser view to one padi. The four prep PRs (#1637 #1638 #1639 #1640) were closed; pulam and pulam-web dissolve into padi. Before committing, the design was stress-tested hard — seven independent reviewers each trying to break it a different way, every finding re-checked by a skeptic, plus a UX study of two real usage patterns; everything they caught is baked in below.
User-facing description
Nothing changes until W3. Then: the ChromeBar grows a host switcher — kinda like tmux sessions. A canvas shows one host’s terminals; switching hosts swaps the whole canvas instantly (bindings stay warm server-side). A host’s arrangement — tile layout, names, themes, remembered agent commands — lives on that host, so your desktop, laptop, and phone all see the same canvas for nix@prod, and a teammate-free second kolu (or a future kolu-tui) joins the same arrangement. A remote canvas is a full peer of the local one: Code tab, binary preview, paste/upload, transcript export, sleep/wake, session restore — all against the terminal’s own host, nothing silently wrong-host.
While you’re parked on one host, the others reach you through the channels an installed PWA already owns: the app badge aggregates awaiting-agents across all bound hosts, the switcher chips carry per-host counts, and an OS notification’s click is one action — switch binding + focus the tile. (The per-terminal plumbing — setAppBadge, service-worker notifications — already exists; W3 aggregates it across bindings.)
Two personas anchor the design, and both get the same shape: an ephemeral cloud box (a pu-style instance per project — teardown removes the host from the picker as one clean unit, never wreckage on a shared canvas) and a permanent headless server (days-long agents; hop local⇄server many times a day — instant switch + 1-action notification response is the tmux-grade loop that persona lives in).
Architecture-level changes
The gap this fixes. Today, no single thing can tell you everything about one host’s terminals. kaval has the PTYs. pulam has the awareness and fs/git — which it gets by dialing kaval itself. Everything else — layout, memory, restore targets, saved sessions, sleep/wake, boot adoption — lives only inside kolu-server. Because there was no one place to point at, the old plan had to tag every terminal with its host and check the tag at ~11 different places, behind two different lookup helpers. Meanwhile the client never needed any of it: it talks to one server over one socket, and no client code reads a host field. padi fixes this at the right level: below, one daemon that knows everything about its host’s terminals; above, one connection per view that picks the host; nothing per-terminal anywhere.
The three layers, and what each owns
| layer | owns | explicitly does NOT own |
|---|---|---|
| kaval (per padi) | PTYs, screen mirror, VT taps, inventory — unchanged role, byte-minimal; the overflow frame shipped (#1591, contract 5.0) | awareness, git, sleep, persistence (as today) |
| padi (per host × state-root) | the registry (terminals = authored ⋈ snapshot, composed server-side); the one fold + AgentMemory on the host’s clock; restoreTarget; lifecycle (create · kill · sleep/wake · boot adoption · inventory reconcile); spawn-policy composition against its kaval’s system.info; fs/git endpoint + watcher pulses; byte procedures (previewRead · scratch.write); transcript.exportHtml (the agent loaders run host-side — codex/opencode transcripts are SQLite queries, not file reads); worktree create/remove; the MRU activity feed (recentRepos/recentAgents — host-fs facts written inside the fold’s emit path); session persistence in its state-root; kaval supervision; status cells (kaval daemonStatus, expectedKaval); the urgency projection (awaiting counts + ids — no recency); the frozen control core (hello · version · drain · clock.now) |
serving browsers; user preferences; which client looks at it |
| kolu-server | HTTP · static · PWA · websocket; user-scoped preferences; buildInfo (minus expectedKaval, which moves to padi); the binding pool (dial + supervise-or-front padis; per-connection scope; re-serve); binding/link status on its own shell surface; notification fan-in across bindings |
terminals — it writes nothing about them. No registry, no fold, no adoption, no terminal-derived cells — with one read-only exception: the cross-binding urgency sum for badges, sourced solely from each padi’s urgency-projection member |
| client | one padiSurface consumer per view + the shell surface; ages recency against the bind-time clock offset (never the browser clock vs a foreign clock) | per-terminal host routing — there is nothing left to route with; HostLocation is deleted from records and create input |
padi’s identity is its state-root — the folder on the host that holds its data — and clients never kill a running padi. (kaval today is namespaced per kolu-server port precisely because its supervisor kills-and-respawns on version mismatch, and a shared daemon once let a dev server kill prod’s — the #1313 incident.) padi flips the relationship: one padi per (host, state-root). The deployed kolu binds the host’s default state-root; a dev kolu and each e2e worker pass their own private one, which also gives them a private kaval. A dev instance therefore can’t touch prod’s daemon, and a second kolu binding the same state-root does see the same canvas — sharing vs isolation is now an explicit choice, not a port accident. The mechanics (each exists because a reviewer constructed the failure without it):
- The data folder is permanent; the socket is not. The state-root must survive reboots — restore depends on it. But the socket and the lock file live in the runtime dir, named by a hash of the state-root path (
$XDG_RUNTIME_DIR/padi-<digest>/; same scheme for padi’s kaval). The runtime dir is wiped at every boot, so a stale lock can never make a dead padi look alive, and socket paths stay short no matter how deep the state-root sits. A small file in each runtime dir maps hash → state-root, sokaval-tui’s no-flags discovery keeps working and can label what it finds — that migration ships in the same PR, not a release note. - The padi binary decides the default state-root, on the host. One fixed rule in one place — never computed by a client, never sent over the wire. Otherwise two ways of reaching the host (login shell vs ssh, with different env) could compute two different “defaults” and silently split the host’s terminals across two padis. Clients pass nothing (default) or an explicit path (dev/e2e).
- Clients may start or join a padi — never restart one. This is a new, third supervisor policy, named so nobody reaches for the existing one (which kills-and-respawns on version mismatch — exactly what must not happen here). Two clients racing to start the same padi is safe: taking the lock is atomic, and the loser just connects to the winner.
- Upgrades converge on the newest version, through a small frozen “control core”. padiSurface will grow, so version mismatches will happen — and if “restart” only existed inside the versioned contract, the one moment you need it is the moment you can’t call it. So padi also serves a tiny never-changing side channel: hello · version · drain ·
clock.now. The rule: a client older than the running padi is refused, loudly (upgrade your kolu). A client newer may ask padi to drain — save everything and exit; the PTYs stay alive in kaval — and then start the newer version. Newest always wins, so two clients at different versions converge instead of fighting, and nothing ever force-kills a padi.
The state model — what survives from the awareness design, and what moves
The observe-vs-remember split survives unchanged inside padi: the sensors still cannot write memory (the types forbid it), the fold stays the one and only writer, and the shipped types are reused as-is. What moves is where remembering lives: sensors and fold now sit in the same process, so nothing folded ever has to be rebuilt across a wire — the whole planned event-stream apparatus (#1638’s framer, sequence numbers, gap frames) is simply never built, and the old fold-overwrite bug class (#1614) can’t happen because there’s no second writer left. Two consequences, decided out loud rather than slipped in:
- The host’s clock stamps memory now. (The old rule said the consumer’s clock — but that rule existed for a mixed-host canvas, which no longer exists. Within one host, its own clock is the only one; two hosts’ times are never compared, because no view ever shows two hosts.) For display (“2m ago”), the client measures the clock difference once per connection — one
clock.now()round-trip, halved — and ages everything locally against that offset, so even a badly-skewed host shows sane ages. We deliberately do NOT serve a ticking “now” value: it would either be stale at connect or push a useless frame to every connection forever. Anything that does cross hosts (badges, the future cross-host dock) carries counts only, never times. - The client-side join retires. R8 split a terminal’s data into two collections because two processes wrote the two halves. In padi one process writes both, so it serves one composed
terminalscollection (the same compose function, now run server-side; disk saving keeps using it too). The two-halves split lives on as internal types — the fence that keeps sensors from writing memory — it just stops being a wire format.
Session state and restore — the host restores itself
Today session state lives in kolu-server’s config store, and restore is client-driven: after a restart the browser re-creates saved terminals one create call at a time, with a known race against PTYs that survived. Under padi, session state lives with the process that can actually guarantee it, and the client stops participating:
| state | lives | survives |
|---|---|---|
| live PTYs + scrollback | kaval (RAM) | padi restarts, kolu-server restarts, ssh blips |
terminal session — authored records (layout · chrome · active tile · panel sizes · memory · restoreTarget) + the restore-relevant awareness projection |
padi’s state-root, on the host (persistent storage — pinned) | host reboots included — at a reboot only the PTYs die (kaval’s row); the records park for restore |
| user preferences · binding pool (which hosts, last binding per view) | kolu-server conf-store | kolu-server has no terminal state to lose |
| per-tab view posture (canvas-maximized, minimap-expanded, …) | browser localStorage, namespaced per binding | host switches don’t collide two hosts’ view-state. (Active tile + panel sizes are NOT here — they ride padi’s authored session via the chrome procedures, as today’s setActive/setSubPanel persistence does) |
The four restore flows, each owned by the layer that knows:
- padi restarts (deploy, crash): on boot it adopts surviving kaval PTYs by id, reconciles them against its persisted session, re-seeds fold memory from its store, and re-runs sensors fresh (the #1031 posture). By the time any client binds, the canvas is already whole — no client participates.
- Host reboots (the PTYs are gone): padi’s boot finds saved records with no live PTYs and marks them
parked— a distinct state. Deliberately-slept terminals staysleeping, untouched: waking a slept terminal resumes its agent, so parking must not blur the two (they restore differently, exactly as today). padi never auto-respawns agents with nobody watching — and a warm server-side connection is not a watcher — so restore is never a side effect of connecting. The browser shows today’s restore card over the parked records, and your click callssession.restore({resumeIds})(per-terminal opt-out preserved). The respawn then runs inside padi, the single writer, and calling it twice is harmless (each record flips parked→active exactly once). The old client-loop race is gone by construction. “Import session” becomes a padi procedure too. - kolu-server restarts: nothing happens to terminals at all — padi’s registry stays warm (strictly better than today, where every restart re-derives all metadata). The browser reconnects and rebinds; the canvas is byte-identical.
- Browser reload / device switch: pure re-subscribe. The client no longer contains respawn logic of any kind; a reload against a rebooted remote host lands in flow 2 on that host’s padi, same as local.
Two safety rules came out of W1’s shipping rounds, each pinned by a test that fails if its guard is removed (reattach.test.ts, restartLocal.test.ts): nothing but the user may ever empty or shrink a non-empty saved session — parked records freeze the autosave, and the restart’s capture→drain→park window freezes it entirely (that transiently-empty moment is exactly where a session got eaten on a real host); and a daemon we adopt that isn’t the one we recorded pairing with (compared by startedAt) is treated as a fresh boot — preserve and park, never overwrite the saved session with the new daemon’s emptiness.
padiSurface — one contract, with per-member forwarding semantics
padi serves a new surface, padiSurface 1.0. (The old terminalWorkspaceSurface is frozen at 3.0 and dies with the pulam daemons at W2.3 — growing it instead would have forced every new member through three packages we’re deleting, because a served surface must implement every member or refuse to start.) Members: version + status cells; the activityFeed (MRU) + session cells (padi-owned on the wire from W1; conf-store storage moves to the state-root at W2.2); the composed terminals collection (record states active | sleeping | parked; the dock projection carries the optional host axis, reserved); the urgency projection (awaiting counts + terminal ids, recency-free — the sole thing kolu-server reads from every warm binding, for badge fan-in); activity stream; repo/file {seq} pulses; fs/git procedures (incl. worktree ops); byte procedures; transcript.exportHtml; lifecycle + chrome procedures; session.restore / session.import; terminalExit event; terminalAttach. Beside the surface, the frozen control core (hello · version · drain · clock.now) that never versions.
Per-connection scope is new machinery, and we say so plainly. Today the framework wires every member to one store at startup, and every websocket shares one router — nothing is per-connection. W2/W3 add it: one mirror store per bound host (shared by all connections watching that host), a router chosen by the connection’s declared host, and the client’s global wire objects rebuilt per binding, so an in-app switch is a clean rebuild (v1 of the switch may simply be a page reload). Whatever part of this lands in @kolu/surface* goes through the usual drishti check.
The contract’s one novelty, forced by review: every member declares how it may be relayed. Value members (cells, collections, pulses) can be held open across a hiccup and replayed — replaying a value is harmless. Byte streams (terminalAttach, activity) must fail through instead: if the padi↔kolu-server leg drops, the browser’s stream must end too, so the existing retry reconnects end-to-end and a scrollback snapshot only ever arrives as the first frame of a fresh stream. (Holding a byte stream open and splicing a replayed snapshot into a live terminal would corrupt the screen — the relay helper makes that a type error, not a convention to remember.) Attach keeps its special path — one dedicated stream per subscriber, straight through every hop — and the shipped overflow signal (#1591) rides it unchanged. The relay helpers grow out of pulam-web’s reserve into the shared surface stack, behind the usual drishti check.
Rejected shapes (each killed by a specific review finding)
- Per-terminal
HostLocationtagging (the finale plan): checked the host at a dozen places when one place (the connection) suffices — and the client never needed it at all. Closed with #1637–#1640. - Merging kaval into padi: padi bundles the system’s highest-churn code (agent sensors, fs/git serving); kaval earns “restarts only on contract change” by containing nothing that churns. Fusing them means sensor churn kills PTYs. padi restarts are instead cheap by design: re-seed from the state-root + fresh sensors (the #1031 posture — warm across web-shell churn, honestly re-derived across padi churn).
- Server-global binding with a “rebind + client reset” switch (the brief’s own first draft): behind a constant origin, in-flight id/repoPath-keyed calls and every other device silently straddle a rebind — a two-actor temporal convention with a race window. Replaced by per-connection binding scope: a call minted under one binding cannot reach another, and other devices are untouched by your switch.
- Local padi as in-process library, process only for remote: keeps two assemblies (the drift the one-library-two-homes era already suffered), keeps kolu-server fat, and forfeits warm-across-restart metadata and multi-client (kolu-tui) for the local host.
Implementation details
The law of every PR in this plan (srid, promoted note-wide 2026-07-04): a PR is self-sufficient and end-to-end — it ships NOTHING that isn’t consumed within it. No framework halves awaiting their consumer, no pools nobody switches, no contracts nobody serves: a capability with zero consumers is a commit stage inside the PR that consumes it, never a PR of its own (W2.2’s “a daemon with zero consumers is a commit stage” ruling; the #1651 complete-but-unconsumed adapter is the canonical tell). The one sanctioned exception is a framework PR whose consumer-proof is a paired second consumer landing with it (the W2.1/drishti precedent).
Where this stands (2026-07-09) — and what’s next, in order:
- W6 lands — #1730 + its drishti pair (srid/drishti#92) are in review endgame (type dedup + the
ConnectPhaseframework graduation folded in); srid merges both. - W5 — cross-host attention is the next feature phase: everything it needs (the warm pool, per-host entries) is shipped, and the need is now user-visible (the 2026-07-08 field note below).
- The cleanup ledger runs in parallel — batched into right-sized PRs, not one-per-entry; the batch plan lives in the ledger note.
- Parked, not forgotten: W3.4 (remote e2e parity in CI) waits on the mock-agent burn-down (#1689); W4’s honest-clocks piece 5 is a named fast-follow; W7’s field-acceptance leg (srid confirms the camera symptom gone on a real deploy) stays open until observed.
When packages are born — graduation is a scheduled event, not a side effect:
| package | born | why then, not earlier/later |
|---|---|---|
@kolu/padi |
W1.C | location is structure: code destined for the daemon must not camp in packages/server. Package at W1, process at W2.2. The dependency arrow: padi imports no kolu app package (libraries only) — the app imports padi, never the reverse. Otherwise app churn would pollute padi’s restart hash, and kolu-tui (which exists to prove padi needs no kolu) would drag kolu back in |
| re-serve machinery | W2.1 ✓ #1661, into existing @kolu/surface / surface-nix-host |
it generalizes proven pulam-web code for a second consumer (kolu-server) — that is the graduation moment; no new package, it extends the receptacle that already owns mirroring (drishti-gated) |
| the control core | lives in @kolu/padi |
extraction is gated on electricity test ② (a real axis of variation), and with one daemon the axis doesn’t vary yet — nothing to encapsulate. If a second daemon adopts it, that’s test ③’s after-the-fact proof and it graduates to @kolu/surface-daemon. (Corrected 2026-07-03: an earlier wording here inverted the doctrine into ‘proof before extraction’ — the second consumer is the proof extraction was right, never the gate for doing it) |
padi’s wait (the agent done-signal) |
W2.3, a subcommand of the padi binary | no new package: the kaval-tui precedent (separate TUI package) is for a full client; a done-signal probe rides the daemon’s own CLI |
kolu-tui |
W5, new package | a new consumer app, not an extraction — it exists to prove padiSurface serves a second frontend (test ③’s proof). W5 because it’s demand-driven, not because anything forbids it earlier |
W0 — decks cleared (all three landed)
All three landed: the freeze of terminalWorkspaceSurface at 3.0 + pulam-web retired (#1650); the overflow frame (#1591, kaval contract 5.0 — typed overflow → re-attach, the recovery loop later migrated into padi at W2); and the four in-flight PRs closed (#1637 #1638 #1639 #1640, 2026-07-01), the finale marked superseded.
W1 — the padi seam, in place — shipped #1652 (ONE PR; commit stages contract → motion → rewiring)
Shipped 2026-07-02 as planned: one PR, the contract → motion → rewiring commit structure, no adapter ever existed, all five seal checks green, pixels identical, CI on both platforms. The review-and-deploy rounds added three session-loss fixes, each first reproduced as a failing test (and each test fails again if you remove its guard): (1) booting against a replaced, empty kaval — not the daemon we recorded pairing with, compared by startedAt — now preserves and parks the saved session instead of overwriting it with emptiness; (2) creating a terminal while parked records await restore no longer silently forfeits them; (3) the in-app “Restart kaval” had a moment where the autosave could fire while the registry was briefly empty and wipe the just-saved session (a real incident on a deployed host, diagnosed live from log-only tracing) — the autosave is now frozen across the whole restart window. Session writes carry permanent trace logging: every destructive write logs a stack, so it can never happen silently. The owed typing-speed baseline is now delivered too (#1660): p50 2.14 ms · p99 4.36 ms on a quiet box — so W2.2’s ceiling is ≈ 9.3 ms p99, same box, same method (see the baseline note). What’s left of koluSurface: exactly preferences + processMemory.
The shape that shipped and still governs: one PR (splitting kept forcing throwaway scaffolding — the #1651 lesson) with the C → M → R commit structure (contract · motion · rewiring); the whole root terminal.*/git.* RPC namespace deleted member-by-member into padiSurface procedures; and the seal — still-standing tests pinning the boundary both ways: packages/server contains no terminal code and imports only padi’s public entries; padi imports nothing from kolu’s packages, transitively. W1 exited with @kolu/padi complete as a library — W2.2 gave it a process without touching the contract.
W2 — padi the process (local; three PRs, phased below; UX byte-identical; restarts get warm)
W1.R’s seal guarantees the domain lives behind the contract, complete as a library; W2 gives it a process. The self-sufficiency law (now note-wide, above) was first articulated here. So: W2.1 is framework work, separate because it is self-contained — its capability is exercised by its own tests and its paired drishti run (the R7 precedent: drishti is the consumer-proof), and landing it first freezes the surface API before the big PR churns through review. W2.2 is ONE PR in which the padi binary is born and kolu-server binds it — a daemon with zero consumers is a commit stage, not a PR. W2.3 buries the pulam era (separable by nature: pure deletion + a new CLI over already-shipped procedures).
W2.1 — the re-serve machinery in @kolu/surface* — shipped #1661
Shipped 2026-07-03 (squash 7e7a5b4f1), hardened by the full gauntlet plus an external 8-angle / 23-candidate review with a 22-agent verification and a 3-auditor final pass. What the rounds added beyond the plan: the initialKeys reconcile hook on @kolu/surface’s mirrorCollection (kills stale rows across a reconnect; adopted by drishti — srid/drishti#84 fixes its own ghost-PID bug with it, upgrading the gate to a real second-consumer proof); the policy re-scoped to a streaming-survival axis (streams + events only — cells always fold, procedures always forward); mirrored cells became a read mirror (write verbs throw loudly until W2.2 forwards them); and the audit’s residuals were pinned into W2.2’s done-criteria (c)/(g)/(h) below.
W2.2 — padi the process: the binary AND the cutover — shipped #1664 (one PR, two commit stages)
Shipped 2026-07-03 (squash 590f49e48), fully green both platforms, drishti #85 re-pinned at the final head. Beyond the plan, the endgame rounds (a 5-axis pre-gauntlet review, the gauntlet, six adoption VM tests, and three live defects srid caught dogfooding) added: the newest-wins auto-drain arm (a newer binder drains a skewed padi via the control core and respawns its own closure — upgrades converge without a human; an older binder is refused, never recycles anything); Restart-kaval restored to its label (a padiSurface procedure: freeze autosave → capture+park → recycle kaval → restore, padi staying up — the W1 invariant tests regained their production caller); the migration adopter (a pre-W2.2→W2.2 deploy ADOPTS the running port-keyed kaval — live agents survive the upgrade; converges to digest on the next recycle or reboot, the reboot bound pinned by a VM arm; this replaced a reap I had wrongly ruled — deploy-adoption is doctrine: a deploy must adopt a compatible daemon and its live PTYs; restore is the path for a DEAD daemon only); the legacy-conf copy-aside (.pre-padi-import.bak) before the one-shot import; KAVAL_BUILD_ID baked into padi’s wrapper (the kaval-update nudge lives); padiLink on the shell surface (a padi drop shows honest connecting/degraded — padi cannot serve its own unreachability); the split-restore trio (client reconcile gated on daemon state · viewSeeded reset on in-session restore · parked records immutable — the record-level no-non-user-writer invariant); the capture merge (parked ∪ live, deduped by id, live wins — a restore-pending session can never be shrunk by a restart); the 3-process memory rail (kolu-server · padi · kaval, event-driven — the one sanctioned pixel delta); RemoteMirrorSession published with the variance fix (consumer side generic in C; a compile-time pin encodes drishti’s shape locally); and the measured hop: +1.3 ms p99 against the +5 budget.
W2.3 — padi-tui is born, and the burial — shipped #1665
Shipped 2026-07-03 (squash 30ea598fa) — all three movements: the dial carve (connectPadi + dialPadiHello → @kolu/padi/dial, the shared client-side dial kit; supervision/convergence/re-serve stay binder-only; the seal’s allowed set is exactly {assembly, surface, dial, log}); padi-tui born (wait/status with the two-phase done-signal off real agent state · create --parent · create --worktree <branch> -- <agent> · the activity stream lit LIVE with lazy kaval byte-taps, landing with its first consumer · $PADI_SOCKET stamped on every padi-spawned terminal so the agent-drives-agent loop is flagless · nix wrapper + home-manager padiTuiPackage); and the burial (packages/pulam + pulam-tui deleted; terminalWorkspaceSurface + its assemblers DELETED — the fs/git handlers absorbed into padi typed against padiSurface, @kolu/terminal-workspace now exports sensors·fold·schema·endpoint·agentProjection only; the legacy-conf fossils stripped by a 1.31 migration that backs the file up first (config.json.pre-1.31-strip.bak) — and padi’s one-shot import learned to read from that bak when a direct pre-W2.2→W2.3 jump strips the live file before import, closing that hole at zero loss; /kolu skill + Inspector reference rewritten to padi-tui with the two-phase stale-state pattern preserved; kolu-server’s conf now carries exactly preferences + markers). Review was checkpoint-driven (delete-list sanity, the A/B untangles, the bak-aware import) with the gauntlet at the end; CI green both platforms; pu-box two-phase evidence recorded on the PR.
W3 — remote (the binding · the full-peer gaps · CI parity)
W3.1 — the remote binding — shipped #1675
Shipped 2026-07-04 (merge 20a346c38). What landed, beyond the section’s original scope: the knob (KOLU_PADI_HOST) + RemotePadiSession as BoundPadi’s ssh arm through the same re-serve seam; the arch-keyed drv map with kaval inside padi’s closure (one dial provisions both, cross-arch verified); padi --stdio fronting the durable daemon; convergence hardened for the transport — the instance-keyed drain fence (no unix socket-close signal over ssh, so took-detection rides the hello probe; bounded per instance on BOTH axes via one admitDrain) and adopt-loudly on budget exhaustion (srid’s ruling — emergent owner/guest, parity with the local kit’s fence-spent row); every degraded bind a standing dialog state (adopted-stale banner · skew · unconverged · link-failed) with the bound host named in both dialogs, the header chip, and the browser title; deterministic bounded daemon logs (pino-roll + boot-rotated stderr crash-catcher, all spawn paths, no knob — born from the sincereintent watcher forensics, incl. the 0700 fix that unbroke fresh-host boots); the ssh e2e lane (just e2e-ssh self-ssh + e2e-ssh-2box, destructive-ack-guarded) with the typing bench recorded (p50 2.69ms / p99 14.38ms over a real hop vs 4.36ms local). Issues spawned: #1679 (orpc-teardown CI flake), #1680 (darwin WAL-watcher death, repro-first), #1681 (the gray kaval chip — CLOSED by #1687 + drishti #87, merged 2026-07-05: the true mechanism was a boot-window absent-key subscription dying on a non-retriable error; the framework now holds a get for a not-yet-existing key open until the key appears, and a rebind force-republishes an equal cell value). The two known remote-mode gaps below are W3.2-owned.
(The build-ready spec this shipped from lives in git history. What remains live:)
Known remote-mode gaps — exactly two, both loud, both W3.2-owned: (1) file preview — CLOSED (#1685, merged 2026-07-05): the route now dials the bound padi’s preview.read in bounded 8 MiB chunks, snapshot-pinned by a strong ETag (If-Range honored), fail-loud on every mid-stream inconsistency; the 501 and its guard are deleted; local mode byte-identical. (2) the “Running daemons” list — CLOSED (#1686, merged 2026-07-05): the list now shows the connected machine’s daemons (padi reports its own host via the hostInventory member, contract 1.2), with each group labeled by machine. Both gaps closed 2026-07-05 — the W3 overview’s “full peer… nothing silently wrong-host” promise is MET for the single-host binding; what remains of W3.2 is the switch itself. Standing permanently: the ssh-user caveat — the remote padi runs as the SSH user; 0700 sockets make the SSH identity the daemon owner.
W3.2 — the full-peer gaps — shipped #1685 #1686
Shipped 2026-07-05, two self-sufficient PRs. Preview (#1685): clicking a file while connected to a remote machine now shows the file — the route asks padi for the bytes in bounded 8 MiB chunks, pinned by a strong ETag so a file changing mid-read aborts loudly instead of serving spliced bytes; the old deliberate “preview unavailable” refusal is deleted; local mode byte-identical. Inventory (#1686): the “Running daemons” list now shows the connected machine’s daemons (padi reports its own host via the hostInventory member, contract 1.2), each group labeled by machine, with the mixed-version window made safe. Together they complete the W3 “full peer — nothing silently wrong-host” promise for the single-host binding. (This sub-phase originally also carried the switch; srid promoted it to its own phase, W4, on 2026-07-05.)
W3.4 — remote e2e parity in CI (machinery done + proven; the GATE is parked until the parity tail clears — see the remote-bind parity note, srid 2026-07-05)
Today the full e2e suite (~493 scenarios) runs in CI per-PR locally only; the KOLU_PADI_HOST path has zero CI coverage (the sandbox claim is honest but the exposure is real — the two live bugs of 2026-07-04 were both found by hand). This phase closes it: every PR’s CI runs the existing e2e suite AS-IS, twice — local (as today) and with kolu-server bound remotely (KOLU_PADI_HOST=<a second pu box leased from the pool>). No tagged subsets, no reduced tier: the same scenarios, a real ssh hop.
Mechanics (we do NOT control the pool boxes’ config — everything rides ssh we already have): the CI recipe leases a pair of pool boxes — box A runs the lane as today, box B is the bind target; box B’s per-run hygiene (state wipe before, destructive-ack, provisioning via the baked drv map — which also exercises the cross-arch arm when the darwin lane binds a linux box) is done over the ssh session, never via box config. The just e2e-ssh transport lane folds into the same node.
The real work is the burn-down so “as-is” is honest: (a) the ~70 scenarios that failed the one manual full-suite ssh run need their tight Playwright waits widened to remote-tolerant timeouts — behaviour was correct, the waits were not; (b) the agent-state scenarios get an honest mock — decided 2026-07-04 (srid), replacing an earlier copy-files-over-ssh idea: a small pretend-agent program runs INSIDE the kolu terminal like a real agent, driven through the terminal itself (“act busy”, “act done”), writing its session files on its own machine at the real default paths. Wherever the terminal is, the files land on the right machine by construction — the tests contain no remote-vs-local branches at all, and the pretending gets MORE realistic (a foreground process writing its own files is exactly what production senses — including the file-watching layer where #1680 lived); (c) lane wall-time budgeted (remote suite ran ~2× local on the manual run) — it runs as a parallel odu node on the paired box, so the wall grows only if it becomes the slowest node.
- Done: a PR that breaks any feature only-under-remote-binding goes RED in normal CI on both platform lanes; the remote node’s scenario count equals the local node’s (no silent exclusions — an excluded scenario is a named, counted skip with a reason); the pair-lease returns both boxes clean.
W4 — the switch — shipped #1714 (its own phase — promoted out of W3; the largest change since W2.2; gates were L6 #1698 · L23 #1700 · the surface-hosting simplification #1705 — see the ratified ledger)
Shipped (merged 2026-07-07) as @kolu/surface-map + the KOLU_PADI_HOST-gated multi-host canvas — four of the five pieces below landed in the one PR; piece 5 (honest clocks) was deferred to a named fast-follow (the review found the consuming half was never built anywhere — a pre-existing W3.1-era gap, not a switch regression). The per-host-state bugs the deploy surfaced (focus, splits, camera) got instance fixes in-PR and their class fix as W7.
(New to the machinery this phase builds on? The primer: the surface framework’s hosting side, taught — serve → mirror → sessions → the registry, ending at this phase’s own type-vs-volatility fork.)
In simple words: today kolu marries one machine when the server starts — everything you see comes from that one machine until you restart it. After this phase, kolu keeps connections to several machines warm at once, and each browser tab picks which machine it’s looking at — switched instantly from a picker (under the command palette’s Labs group until the feature stabilizes), like switching tmux sessions. Your laptop tab can sit on zest while your phone sits on sincereintent, against the same kolu, neither disturbing the other.
The five pieces of work, in plain words:
- The server holds a pool, not a choice. Instead of picking one padi at boot, kolu-server keeps a warm connection per machine, opened on demand. Everything W3.1 built (provisioning, reconnecting, converge-safely) is already per-connection — the pool just holds several. (The gates that made this safe are all merged: L6 + L23 made the layer being multiplied clean and single-policy, and #1705 shipped the exact shelf the pool sits on —
buildHostRegistry, whose slot asks a session for only the one thing the pool ever does to it,destroy().) - Each browser tab names its machine. “Which host” becomes part of every tab’s own conversation with the server, instead of a server-wide fact.
- The browser rebuilds its plumbing on switch — the hard part. The client currently creates its wiring once per page load; switching means tearing down every live subscription and rebuilding against the new machine without a reload. The biggest client rewiring since W1 — and the reason this phase waited for #1687: rebuilt subscriptions now behave sanely from their first moment (a get for a key that doesn’t exist yet waits instead of dying).
- The picker, honestly — and hidden. A host list (your
~/.ssh/config, free typing, and recents) living under the command palette’s Labs group (flask icon — the truthful home for works-but-unstable betas; Debug stays diagnostics-only — srid 2026-07-06) — the switching itself is deliberately undiscoverable until it stabilizes (the ChromeBar picker graduates later). Loud connecting/degraded/incompatible states while a binding warms, creating a terminal refused until it’s ready — never a frozen canvas pretending. - Honest clocks. Timestamps are stamped on each host’s own clock; the binder measures the offset at each bind (the control core’s
clock.now, RTT-halved — served since W1, consumed at last) so “3m ago” is true no matter which machine said it. (Status 2026-07-06, pending srid ratify: DEFERRED out of the switch PR to a named fast-follow — the review found the consuming half was never built anywhere (the serving half has existed since W1); the skew mislabels ages on remote hosts, a pre-existing W3.1-era gap, not a switch regression. The other four pieces shipped in the switch PR.)
Decided (2026-07-05, srid): KOLU_PADI_HOST sets the default host only — the picker switches freely away from it (W3.4’s CI boots through it, and CI never opens a picker). Recently seen servers live server-side in their own top-level key + own server-authority surface cell (recentHosts) — every device shares one list AND an open picker on device B updates live when device A adds a host. (Amended 2026-07-06 during the W4 review, pending srid ratify: the original preferences.recentHosts placement was the root cause of a broken done-item — the preferences cell is read authority-local, which seeds once and ignores server pushes, so cross-device updates were dead on arrival. Server-owned data gets its own server-authority cell; the field never shipped on master, so the move cost nothing.) each tab’s “which host am I on” and per-host view posture (zoom, panels) live in browser localStorage, namespaced per host, deliberately per-view.
ONE PR (decided 2026-07-05, srid — supersedes the earlier two-PR split): the split’s dividing line was “does switching reload the page?”, drawn when the no-reload half required a big new framework concept. The surface-hosting debate shrank that framework delta to one prop (S5: the provider takes an accessor), so the split was protecting against a cost that no longer exists — and per this note’s own law, two stages for no-longer-decoupled work is over-decomposition. Accepted trade, stated: the PR is drishti-gated (the framework half pairs a drishti PR) and the largest since W2.2, with no interim reload-only fallback banked — if the live-switch grows a tail, nothing ships until it clears.
The switch, one end-to-end PR — three layers, top to bottom:
- Framework (
@kolu/surface-app— the electricity, drishti-paired). The client’s real mud is one buried assumption — the wire lives exactly as long as the page — and that client-lifetime volatility already has its receptacle:surface-app, today one axis short (a static client handed to the provider at startup). It grows the ratified accessor shape (S5):SurfaceAppProvidertakescontrolPlane: Accessor<ControlPlane>— today’s callers just write() => surfaceApp— and every subscription keys off the accessor, so a swap tears down and re-subscribes as a FRAMEWORK guarantee, tested once, where the gray-chip class of bug goes to die (#1687’s held-open gets are why rebuilt subscriptions behave from their first moment). Drishti is the real second consumer (a fleet view is exactly “N live mirrored clients whose set changes”) — the ship-gate IS the second-consumer proof, the W2.1 precedent. First implementation step, named: verify the provider can grow this WITHOUT a breaking change for its existing static consumers (kolu today, drishti’s uses) — if it can’t, STOP and bring the break to the coordinator before building. - Server (kolu — additive; the layer L6+L23+#1705 cleaned). The warm pool:
buildHostRegistryholding onePadiSession(DaemonSession<PadiSurfaceClient>,padiSession.ts— #1705 deleted the oldBoundPadiname) per machine, created on demand viabuildEntry(local arm for the local host, remote arm otherwise), each entry’sreServeSurfacerouter dispatched by the connection’s declared host; nocontrols(kolu needs no fleet verbs —pool.reconnectdoesn’t even typecheck). Each browser connection declares its host (the per-connection router machinery W2.1 built); clock offset measured per bind (clock.now, consumed at last);preferences.recentHosts; the preview route reads through the bound session — killing itsremoteHostfork (deferred from #1685). - Client (kolu — the de-entangling). A thin
binding/module (which host · picker wiring · recents · localStorage) produces clients and hands them to the scope — components stop being ALLOWED to assume a global wire, because the wire arrives through scope; structural, not cosmetic. Picking a host swaps the accessor in place — no reload. The picker lives under the command palette’s Labs group for now, not the ChromeBar — the Labs item andKOLU_PADI_HOST(default host only; the picker switches freely away from it) are BOTH undocumented, and together the ONLY way to reach remote terminals until the feature stabilizes (no discoverable surface = dogfood-only; the ChromeBar picker graduates when it’s stable). Loud connecting/degraded/incompatible states while a binding warms; creating a terminal refused until it’s ready — never a frozen canvas pretending. The misroute guard lands co-located: binding-scoped clients whose ids cannot cross — a stale in-flight call from the old host is rejected, not misrouted.
Done — one list, all of it: the live-switch e2e (local → remote → local with tiles live on both ends; a stale in-flight call rejected; the switch never disturbing another device) · two tabs on two hosts stay independent · recents persist across devices · create refused while warming · local single-host mode byte-identical · paired drishti PR CI-green at the final kolu HEAD. The ledger’s L11 sweep (the client’s interim wire-shape scaffolding) follows this PR.
Considered — a packages/padi-web-client package (srid, 2026-07-05): rejected — an app module in a receptacle costume, population one. But the QUESTION behind it (“is there an electricity that de-entangles the client?”) had a real answer, adopted above: the volatility is CLIENT LIFETIME, its receptacle already exists (@kolu/surface-app) and was one axis short (static client → swappable accessor). The framework layer of this PR is that electricity landing in its true home, with drishti as the proving second consumer.
W5 — cross-host attention (needs W4 — the switch’s warm pool is what urgency fans in from; the A+ bar from the UX evaluation)
kolu-server subscribes each warm binding’s urgency-projection member (the one thing it reads from every pool host) and sums it onto the shell surface: PWA app badge aggregated across bindings; per-host switcher-chip counts; OS notification whose click = switch + focus (1 action). Urgency-only on any multi-host wire — no recency, no snapshots.
- Done: an agent flips to awaiting on host B while every view is parked on host A → chip count, app badge, and notification all fire; the notification click lands focused on the right tile; killing host B’s link degrades its chip, never the app.
- Field note (2026-07-08, srid on the W4 deploy): the asymmetry is now user-visible — OS notifications escape the app (cross-host reach), but unread marks land in per-host
HostView.attention, whose only render surface is the active host’s dock, so a background host’s attention is invisible. W5’s chip counts are the missing rollup. One wiring fact for the implementer:useTerminalAlerts’s transition watcher rides the ACTIVE host’s swapped store — the urgency-projection member (this phase’s wire design) is what makes background hosts observable, not the client-side watcher.
W7 — per-host state by ownership, not enumeration — shipped #1723 (the class fix for the “forgotten field” bug family; ratified + merged 2026-07-08)
In simple words: W4 made kolu multi-host, and a whole family of bugs followed the same shape: some piece of canvas state — the focused terminal, the split layout, the camera position — quietly lived at app lifetime while the tiles around it became per-host, so switching hosts showed the wrong host’s leftovers (blank canvas, lost splits, a viewport panned to empty space). Each was found live by srid, one at a time, because today per-host-ness is opt-in: the HostView record enumerates fields (activeId, mruOrder, attention, …), and every new fact must be remembered into it — the camera bug is literally a forgotten field. Host-independence, meanwhile, is a code comment nothing enforces, and ~62 client files can mint bare signals without ever being asked which axis they live on.
Status — SHIPPED (merged 2026-07-08): #1723 + its srid/drishti#91 framework-gate pair (merged together). scopedByEntry lives in @kolu/surface-map (over @solid-primitives/keyed’s keyArray, plus the codec now exported on SurfaceMapClient); kolu’s hostScope/hostScopes dissolves the HostView record, ensureHost, the camera-swap seam (useCanvasCameraSwap, deleted), useCanvasViewport’s three module-scope signals, and useSessionRestore’s hand-rolled latch Map into one per-host owner. The facades (useViewState, useCanvasViewport, useSessionRestore) became windows onto activeScope(); the ~35 store consumers are untouched. The boundary guard (canvas/canvasBoundaryGuard.test.ts) is the residual fence, and the comment-only HOST-SCOPING: convention retired into it.
Consciously EXCLUDED from W7 (K1): the owner retains only cheap, client-owned state (focus, MRU, attention, camera, the restore latch). The wire subscriptions — the terminals collection, saved session — stay active-host-only via padiMap.useEntry(activeHost); retaining THOSE for instant switch-back (no reconnect round-trip on return) is a deliberate future improvement, not this phase — the owner holds state, never sockets. Field acceptance still open: the deterministic pins prove the decision logic, but srid confirms the live camera symptom gone on a real deploy (a browser-verify against a real second host — the timing a unit pin structurally can’t see) before this is closed.
The ratified fix (per /architecture-first-principles P4 + /perfection-review — make the class unconstructible, not detected): flip the default from enumeration to ownership. The framework design — scopedByEntry over @solid-primitives/keyed’s keyArray (the ecosystem already ships the retained-per-key-root primitive; only the map-tied glue is ours), the kolu and drishti adoption snippets, and the settled defaults — lives in surface-map-101 § “Where this goes next”; this entry holds the phase (scope, gates, done-criteria). The canvas subtree mounts inside a per-host reactive owner — a Map<HostKey, Owner> of live roots; switching hosts swaps which owner renders (shape B generalized: state survives switch-away in its owner, GL/xterm stay active-host-only per the existing release/re-acquire discipline). Then everything born inside the subtree is per-host by construction — there is no record to forget a field in, and the HostView enumeration dissolves into the owner. App-level state requires deliberately stepping outside the subtree.
- The boundary guard (part of this phase, not a substitute for it): ownership covers what is born inside the owner; state born outside (module scope in a shared util — exactly
useCanvasViewport.ts’s three module-scope signals, the camera bug’s birthplace) stays expressible. So the phase ships a convention test at the door (keyboard.test.tsprecedent): no module/hook-lifetime state constructors in the canvas domain outside a declared owner; app-level state declares itself with a reason the test can see. The comment-onlyHOST-SCOPING:convention retires into that test. - The timing boundary (why instance fixes keep failing — from the 013e369b1 post-mortem): the reactive/deterministic test proves the logic (decision + geometry as fixed inputs) but structurally cannot see the timing — host-switch → tile unmount/remount → DOM/FitAddon measurement → camera restore/center is emergent from the real render pipeline. Any fix that BRIDGES two independently-timed lifecycles (app-lifetime camera ↔ per-host tile mount) is a race a defer-guard narrows but never closes. Ownership closes it by construction: the camera lives WITH the tiles it centers on — co-owned, co-mounted, co-measured under one host boundary — so the ordering is intrinsic, not bridged. (Same boundary beat the split fix’s round 1; twice is the pattern.)
- Acceptance leg from the field (2026-07-08): srid’s live camera symptom — switch hosts → viewport pans to empty instead of the active terminal — SURVIVED the instance fix
013e369b1(the second deterministic-test-passed/reality-failed miss in this family; splits round 1 was the first). W7’s done includes srid confirming this symptom gone on a real deploy, not a model. - Done when: the
HostViewrecord is gone (owner-held state replaces every enumerated field incl. the W4-era migrations: focus, MRU, attention, restore latches, splits, camera); a newcreateSignaladded anywhere in the canvas subtree is per-host with zero ceremony (proven by a test that adds one and switches hosts); the boundary test is red on a module-scope signal in the canvas domain; UX byte-identical to the shipped W4 behavior (the per-host semantics are already right — this phase changes where they come from, not what they are). - Why not sooner: the instance fixes (focus
621ea2c48, splits0dbf28e0a/c6ed48c56, camera — in flight) are all shipped in #1714 with pins; this phase deletes the mechanism that made them findable-only-by-srid. Detection-only alternatives (lint lists, sanctioned factories) were considered and rejected as the fix — the defect stays expressible under them (P5: a syntax gate can’t see meaning); the slim factory/test residue survives only as the owner’s boundary guard above.
W8 — remote terminals, documented honestly — shipped #1722 #1727 (a docs phase: the ssh-session environment gap; seeded by a live srid incident 2026-07-08)
Shipped 2026-07-08, two PRs: the atlas analysis (#1722) and the user-facing website page (#1727 — remote hosts as a feature: enable · add · switch, with the environment gaps as one compact limitations/FAQ section, marked alpha). The done-criteria below are met; the catalogue grows by incident as planned.
In simple words: a kolu remote terminal is an ssh session, and an ssh session is not the machine’s GUI login session — so a class of tools that “just work” in a local terminal silently break over the bind. The seed incident: on a macOS remote, gh auth status reports the token is invalid — gh stores its token in the macOS Keychain, which is unlocked only for GUI sessions; the identical command works in the box’s local terminal. The user’s first read is “kolu broke gh,” when the truth is “ssh sessions don’t get your keychain.”
The work (docs, not code): one page on the website’s product docs (beside remote-access.mdx) cataloguing the known local-vs-remote-terminal gaps and their standard remedies, so the first person to hit each one finds an answer instead of a mystery. Seed catalogue:
- macOS Keychain-backed tools (
gh,security, codesigning): remedies —security unlock-keychainper session · an env token (GH_TOKENguarded by$SSH_CONNECTION, ideally a fine-grained PAT scoped to the repos actually driven from that box) ·gh auth login --insecure-storage. - ssh-agent / forwarding: keys present locally but absent over the bind unless forwarded or a remote agent runs.
- GUI-session services (
pbcopy-over-ssh behavior,launchctluser-domain quirks, notification permissions). - Environment drift: login-shell vs non-login rc files, locale, PATH set by GUI-only launchers.
Each entry: the symptom as the user sees it, the one-line cause, the remedies ranked. Grows by incident — every future “works locally, breaks in kolu” report lands a row here (the flaky-tracker discipline, applied to environment gaps).
- Done when: the page exists with the seed catalogue; the gh/keychain incident is findable by its error text (“The token in default is invalid”); kolu’s docs link it from remote-access; new incidents have an obvious place to land.
W6 — the honest connect — in flight #1730 (overlay + the connection type made perfect; drishti-gated — pair srid/drishti#92)
Status (2026-07-09): implemented, in review endgame. Both halves below are built and CI-green; live testing then hardened the batch — the coarse-chip/fine-cell divergence trap (a stale connection cell could route the overlay over a connected host forever) is now a type error (connectPhase exists only on the not-yet-connected facts arms), the cell is floored on transport liveness, and the phase vocabulary gained probing (the warm realise-probe, presented calmly) plus a server-stamped sinceMs (per-episode elapsed, one clock). The type-dedup review then graduated ConnectPhase to a @kolu/surface-remote export (drishti adopts it in connectionColors.ts — the second-consumer proof). The types below are as shipped.
In simple words: connecting to a remote host can compile+copy padi onto that machine over ssh — on a first connect to a fresh host that Nix build takes minutes. Today the whole canvas shows one dead word, “Connecting…”, indistinguishable from a hang (srid hit exactly this live — twice: on the original W4, and re-confirmed 2026-07-07 on the surface-map redesign, where the remote chip shows amber but the canvas stays mute). Drishti already narrates this window properly (“copying derivation…”); kolu should match. After this phase the overlay tells the truth: which phase it’s in (provisioning vs connecting), a live tail of the actual build output, and an elapsed timer — so “it’s working, first-time setup takes a moment” is unmistakable, and a real failure shows the real reason.
The reason this is cheap — the signal already exists and already reaches the browser; only the UI discards it. The session lifecycle has a distinct copying state separate from connecting (CONNECTION_STATES in packages/surface-remote/src/connection.ts — and after the P4 type-split, copying is remote-arm-only by type, which makes the narration honest by construction), and the connection info already carries progressLines — the live nix copy / realise stderr, forwarded by nixCopy.ts’s onProgress and piped browser-ward by connectionPipe.ts. It also distinguishes failureCause — network (unreachable, retries) vs remote (reached, rejected, terminal). Under the surface-map redesign this all rides the entries collection’s per-host EntryConnectionState (serveHostMap’s projection preserves copying distinctly), so the strip’s chip and the canvas overlay read the same one authority. The bug is purely that the connect overlay collapses this rich state into "Connecting…".
The work (ratified 2026-07-08 — two halves, one PR + the drishti pair):
Half 1 — the connection type, made perfect (per /architecture-first-principles; the server sum is flattened into a lying 4-nullable-field product at the browser boundary today, and three lens findings land together). The final types, verbatim:
// @kolu/surface-remote — ONE framework type; the connector declares its own phases
type SessionState<Prov extends string = never> = {
log: readonly { source: "local" | "remote"; line: string }[]; // last 20; per-EPISODE (reset on a down→up crossing)
sinceMs: number; // current episode's elapsed, stamped on the SERVER's clock — a duration, never a foreign epoch
} & (
| { phase: "connecting" | "connected" | Prov } // up — error fields don't exist
| { phase: "disconnected"; error: string; cause: "network" | "remote" }
| { phase: "failed"; error: string; cause: "remote" } // network is never terminal — by type
);
type SshProv = "probing" | "copying" | "building";
// probing = the warm realise-probe (is padi already there?) — presented calmly, no build UI
// copying = nix copy --derivation … (the .drv push — fast)
// building = ssh $host nix-store --realise … (the remote compile — the minutes)
// sshConnector: SessionState<SshProv> · local endpoint: SessionState<never>
// browser cell: ConnectionInfo = SessionState<SshProv>, LITERALLY — the zod schema's
// inferred type is pinned identical by a test-d file, so schema/type drift is a compile error.
// ConnectPhase = Exclude<ConnectionInfo["phase"], "connected"|"disconnected"|"failed">
// — the one narratable-phase vocabulary, exported; kolu's overlay and drishti's
// connectionColors both derive from it (never hand-list phase names).
DELETED by this reshape: remoteProgressLines (a stored duplicate — now log.filter(source === "remote")), the [local] /[remote] in-band string prefixes (provenance becomes a field), and the ProvisioningPhase/UpConnectionState/LocalConnectionState/FailureCause/SessionStateCommon name-chain (one type + one connector vocabulary + one zod const replace all seven names). The copying→probing|copying|building split is the connector updating its own declared vocabulary — zero framework change; nixCopy.ts already runs the steps as separate commands (probing was added when live testing showed the warm-probe window conflated with copying and read as hung). Wire-schema change ⇒ the drishti gate applies (drishti reads the cell at App.tsx:690; paired PR, green at final kolu HEAD).
Half 2 — the kolu overlay (client-only, small): the connect overlay reads the active entry’s connection cell and switches exhaustively on phase —
copying→ “Provisioning padi onzest… (first connect ships the recipe)” ·building→ “Building padi onzest… this can take a few minutes” — each with a live tail of the last Nloglines (dimmed monospace) and an elapsed timer, so long-but-progressing never reads as hung;connecting→ the brief post-provision handshake, distinct;failed→ the realerror+ thelogtail (the actual “why”);disconnected+network(“zestunreachable — retrying”) reads differently from a standingremoterefuse (the Skew-UX card already owns those);- the fast path stays calm — a warm reconnect (the fused realise probe short-circuits) shows
connectedimmediately, no build UI.
Design care: show a tail (last few lines) + elapsed, not a firehose of the whole Nix log, and not a fake percentage bar (a Nix build has no honest progress %); the reassurance is “named phase + real output + elapsed + ‘first-time setup’”, which is truthful where a progress bar would lie.
- Done: connect to a fresh remote host (cold Nix build) → the overlay names copying vs building, streams live log lines, and shows elapsed — never a mute “Connecting…”; a warm reconnect connects fast with no build UI; a genuine failure shows the real error tail, and unreachable reads differently from rejected. AND the types hold the line:
failed+networkis a compile error,remoteProgressLinesno longer exists, the browser cell is the discriminated mirror of the server sum (drishti pair green).
Consolidation — the cleanup ledger (unnumbered — a parallel, ongoing track; scope lives in its own note)
W2 and W3 ship at pace (a deliberate call); W4 pays the pace’s debts — refactors, decomplecting, framework-level electricity upstreaming, deletions. Nothing user-visible ships here; that is the point. The full ledger — each entry a self-sufficient, lens-framed brief an implementing agent can execute under coordinator guidance — lives in padi — the consolidation ledger, appraised and appended as each W phase lands (a shipped phase’s “deferred / acceptable-for-scope” review items land there explicitly; an entry that never ripens is deleted with a reason). The index, for orientation:
- Shipped: L3 the convergence kit (#1674) · L5 (in W2.2) · L6
padiBindingsplit (#1698) · L7terminal-workspacere-cut (#1734) · L14 memory via the mirror (#1699) · L15+L16 write-seam/boot invariants (#1703) · L23 remote arm on the kit (#1700) - Ready — batched into right-sized PRs (the batch plan lives in the ledger note): the framework batch (L28 · L1 · L20·21, one drishti pair) · the hygiene batch (L8 · L13) · the padi/client batch (L17 · L18 · L24) · L26 (null/sentinel sweep + lint, its own PR) · L27 (server re-cut, its own pure-motion PR) · L11 (post-switch wire sweep, now unblocked) · L10 flaky debt · L12 (srid mints)
- Gated: L2 on kolu-tui · L4 at a named version · L9, L22 on evidence
Future work — demand-driven
(The researched, tiered menu for everything below — ecosystem survey × shipped primitives, plus the promoted port-preview and shared-canvas notes — lives in remote-terminals-future.)
Cross-host dock (B-lite: foreign hosts’ rows, urgency-ranked, click = switch + focus — the host axis is already in the contract) is the first escalation if switching proves insufficient. kolu-tui — the graduation proof: consume padiSurface over the socket/ssh without kolu-server; done only when that named path renders a live canvas (the path, not the feature, is the unit of done). Hybrid canvas (model B) only if one-window side-by-side proves a recurring demand — it stays an aggregation layer over N bindings, zero daemon changes.
Risks, named, with mitigations
- Attach latency gains a local hop (kaval→padi→kolu-server vs kaval→kolu-server). Measure typing-echo p99 before/after W2 with a budget (< 5ms added); if breached, the carve-out permits a raw-byte relay on the padi→kolu-server leg (frontDaemonOverStdio-style, no decode) without touching the contract.
- Sensor code changes often, and any padi change restarts padi (the restart-hash covers the whole package). Accepted: a padi restart re-reads its store and re-runs sensors — seconds, PTYs untouched. If deploys make this hurt in practice, the sensors can later be split out of the hash; we’ll decide that on evidence, not up front.
- Multi-client writes (two kolu-servers, one padi): padi serializes all chrome/layout procedures — one writer, last-write-wins, concurrent editors see each other’s moves live. Accepted semantics (same as any shared session).
- drishti gate: the graduated forwarding helpers (per-member policy, input-keyed forward) and any per-connection scope machinery landing in
@kolu/surface*— paired drishti PR, pinned to final kolu HEAD, per.claude/rules/surface.md. - N warm bindings have a standing cost (one ssh session + one urgency subscription per host, even while parked elsewhere). Bounded: the pool holds only user-added hosts, the subscription is the count-sized urgency member (never
terminals), and an unreachable host degrades its chip, not the app. Costed explicitly rather than discovered in production. - Session-file migration: one-shot import at padi’s first boot, then hard cutover — a failed import crashes loudly (fail-fast), never silently starts empty.
- Testing W2.2 on zest: that box runs TWO instances sharing
~/.config/kolu/config.json— the deployed one and afaint-bottomdev worktree carrying an older, pre-fix pairing implementation that can null the shared file and poison any repro. Stop or reconcilefaint-bottombefore testing there. (The state-root move being tested is exactly what ends this collision class.)
Superseded by this note
The finale (whole plan: PR-1/2/3, F-REMOTE, R10) — closed PRs #1637, #1638, #1639, #1640. Partially superseded: awareness-derive-store (the fold’s home moves to padi; owner-clock replaces consumer-clock; producer, fold, and types survive verbatim) and the terminal model (the reader-join collapses into padi’s composed terminals collection; the authored/snapshot split stays as padi-internal types). Unaffected: kaval’s design (pty-daemon) apart from the shipped overflow frame (contract 5.0, #1591); #1577.