← the Atlas

padi — the per-host terminal-workspace daemon

seedling·proposed·

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.

ROADMAP — padi (replaces R9/R10) · kept up to date✓ shipped · ▶ do next · ◐ build-clean · ○ todo · → drill in
W0 · decks clearedall landed
overflow frame — typed overflow → re-attach on kaval's attach contract#1591 · kaval 5.0
#1637–#1640 closed · finale superseded2026-07-01
freeze terminalWorkspaceSurface at 3.0 · retire pulam-web#1650
W1 · the padi seam, in place — ONE PR; commit stages contract → motion → rewiring#1652 · 2026-07-02
W1.C · the contract — packages/padi born: padiSurface 1.0 + control core + testsin #1652
W1.M · the motion — the terminal domain relocates into @kolu/padi (severing commits named)in #1652
W1.R · the rewiring — serve complete · client onto the surface · root RPC dies · sealin #1652
W2 · padi the process — 3 PRs · byte-identical · warm restartsall shipped
W2.1 · the re-serve machinery in @kolu/surface* — forwarding policy · per-binding scope#1661 · drishti #84 adopts
W2.2 · padi the process — ONE PR: binary + identity + owns kaval + the cutover (bind · re-serve · store migration)#1664 · 2026-07-03
W2.3 · dial carve + padi-tui born + the burial — wait/status/create/worktree · pulam* retired · terminalWorkspaceSurface DELETED#1665 · 2026-07-03
W3 · remote — the binding · the full-peer gaps · CI parityW3.1 ✓ · gaps ✓
W3.1 · the remote binding — provision + front a remote padi over ssh (KOLU_PADI_HOST)#1675 · 2026-07-04
W3.2 · the full-peer gaps — remote file preview + remote daemon list#1685 · #1686 · 2026-07-05
W3.4 · remote e2e parity in CI — machinery DONE; gate parked on the parity tail#1689 parked → remote-bind-parity note
W4 · the switch — 2 PRs: pick-your-host (reload-apply) → switch-without-reloadREADY after L23 merges
W5 · cross-host attention — urgency fan-in · OS badge · notification deep-linkneeds W4
W6 · the honest connect — surface the copying phase + live build progress (no more mute 'Connecting…')data already flows; client-only
Consolidation — the cleanup ledger (unnumbered · parallel · ongoing)L6 L14 in flight · → the ledger note
Future work — cross-host dock · kolu-tui · hybrid only if earned (unnumbered · demand-driven)
phase · ◉ user sees · Δ behaviourarchitectural rework: = 3/5
✓ W0
○ nothing newΔ a shed slow tab now recovers instead of freezing (shows only when it fires); kaval 4.x survivors recycled once at the 5.0 upgradedeletions + one shipped kaval rev
✓ W1
○ nothing — pixels byte-identical, pinned by testsΔ same process, but the rewiring stages (ΔR) changed how data flows — timestamps now stamped by the server · the Code tab re-fetches on change instead of receiving pushed values · preview bytes come through a procedure (with ranges), not a raw HTTP route · restore runs server-side (the old respawn race is gone); C and M changed nothing by constructionthe client re-wired member-by-member; the root RPC namespace dies; one new contract✓C ✓M Δ
✓ W2
○ nothing — except kolu-server restarts stop losing terminal metadataΔ the big shift, behind identical pixels — Δ2.2 a second daemon appears; sockets get new names (kaval-<port> retires); keystrokes take one extra hop (budget < 5ms); boot order changes; the session file moves (one-time import) · Δ2.3 `pulam-tui wait` becomes padi's waitprocess split · new daemon identity · re-serve machinery (drishti-gated) · store migration✓2.1 Δ2.2 Δ2.3 
W3
◉ everything — host switcher · remote canvas as a full peer · cross-host badgesΔ Δ3.2 each connection now belongs to one host — the client rebuilds its plumbing when you switch · Δ3.3 notifications now fire for hosts you are not looking at (3.1 ships switched off by default)thin by design: lights seams the prep built (dial stack reused as-is)Δ3.1 Δ3.2 Δ3.3 3.4 
W4
○ nothing — that is the pointΔ no runtime-behaviour change until builtpaying down what W2–W3's pace deferred: refactors, upstreaming, deletions off the ledger
W5
◉ cross-host dock rows · kolu-tui — only if demandedΔ no runtime-behaviour change until builtadditive layers over unchanged daemons
✓ shipped · ◉ user-visible · ○ invisible · Δ runtime-behaviour change — pixels can be byte-identical while the system runs differently (a Δ-marked sub-PR is where that happens). The shape to notice: rework and the behaviour cliff (W1–W2) land where the user sees nothing; the user-loud phase (W3) is deliberately thin.

User-facing description

hostlocalsshnix@prodsshpu-kolu-32+ host⌥H switch
The whole canvas is nix@prod's — its own layout, its own dock, exactly as any other device sees it. pu-kolu-3 has 2 agents awaiting you: the chip badge, the PWA app badge, and an OS notification all say so — clicking the notification switches the view and focuses the tile (one action).

Nothing changes until W3. Then: the ChromeBar grows a host switcherkinda 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

PWA window · view = local canvas · dock · Code tab PWA window · view = nix@prod same client · other binding per-connection binding — a switch swaps scopes, never a global rebind kolu-server — the web shell (no terminal state) HTTP · PWA · preferences · notification fan-in bindings: local ● nix@prod ● pu-box-3 ○ — adopt-or-spawn, never recycle re-serve: values hold-open · delta streams fail-through unix socket ssh · frontDaemonOverStdio Nix-provisioned HOST · this machine padi — the workspace authority terminals (authored ⋈ snapshot) the ONE fold + memory — host clock lifecycle · fs/git · bytes · restore session store · supervises kaval serves padiSurface · 0700 · pid-gate taps · attach 1:1 kaval — PTY survivor (unchanged) PTYs outlive padi + kolu restarts state-root/ = identity · store · kaval ns HOST · nix@prod — same stack padi — identical closure its own fold · clock · store cross-host clocks never compared the host owns its koḷu — every device sees the same canvas ephemeral pu-box or permanent server kaval — PTY survivor survives ssh blips + padi restarts state-root/ — the host owns its state dead: HostLocation · resolveTerminalEndpoint · localOnly · terminalEvents wire · pulam · pulam-web
The padi architecture. Every host runs kaval (PTY survivor, unchanged role) + padi (the workspace authority: registry, one fold on the host's clock, lifecycle, fs/git+bytes, persistence, kaval supervision) serving padiSurface — the ONE complete surface. kolu-server is a web shell holding a pool of warm bindings; each client connection is scoped to one binding, so a host switch closes one scope and opens another — no global rebind exists. Local and remote legs are byte-identical; remote adds ssh + frontDaemonOverStdio + Nix provisioning.

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

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:

  1. 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.
  2. 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 stay sleeping, 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 calls session.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.
  3. 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.
  4. 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)

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

W0· decks cleared — freeze the old contract, retire pulam-web, land the overflow frame, close the PRs○ todo
W1· the padi seam, in place — padiSurface served in-process, client migrated, root oRPC dispositioned✓ shipped
needs ← W0links #1652
W2· padi the process — state-root identity, owns kaval, on-host persistence; kolu-server binds + re-serves✓ shipped
needs ← W1links #1661 · #1664 · #1665
W3· remote — the ssh/Nix binding, the full-peer gaps; CI parity (W3.4) parked on the mock-agent burn-down✓ shipped
needs ← W2links #1675 · #1685 · #1686
W4· the switch — host picker, per-view bindings, warm pool (+ @kolu/surface-map)✓ shipped
needs ← W3links #1714
W7· per-host state by ownership, not enumeration✓ shipped
needs ← W4links #1723
W8· remote terminals, documented honestly (docs phase)✓ shipped
needs ← W4links #1722 · #1727
W6· the honest connect — the connection type made perfect + the connect overlay○ todo
needs ← W4links #1730
cleanup· consolidation — the cleanup ledger (unnumbered, parallel, ongoing)○ todo
needs ← W2
W5· cross-host attention — urgency fan-in · OS badge · notification deep-link○ todo
needs ← W4
future· future work — cross-host dock (B-lite) · kolu-tui · hybrid canvas only if earned○ todo
needs ← W4

Where this stands (2026-07-09) — and what’s next, in order:

  1. W6 lands#1730 + its drishti pair (srid/drishti#92) are in review endgame (type dedup + the ConnectPhase framework graduation folded in); srid merges both.
  2. 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).
  3. The cleanup ledger runs in parallel — batched into right-sized PRs, not one-per-entry; the batch plan lives in the ledger note.
  4. 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 previewCLOSED (#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” listCLOSED (#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.

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:

  1. 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().)
  2. 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.
  3. 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).
  4. 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.
  5. 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:

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.

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.

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

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

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 failureCausenetwork (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 copyingprobing|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

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.

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:

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

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.