feat(padi): W4 — the switch (warm host pool + live per-tab host switching) - #1708
feat(padi): W4 — the switch (warm host pool + live per-tab host switching)#1708srid wants to merge 38 commits into
Conversation
Layer 1 (framework, @kolu/surface-app): SurfaceAppProvider.controlPlane becomes Accessor<ControlPlane<T>>; the buildInfo subscription is re-derived per control-plane identity (createMemo + createRoot-per-identity), so a live host swap tears down the old host's stream and re-subscribes — the framework guarantee, pinned by a new provider test. Callers pass () => client. Layer 2 (server, kolu — additive): the warm pool. hostPool.ts holds a buildHostRegistry with one PadiSession per machine (local endpoint arm for the local host, ssh arm otherwise); each entry re-serves padi into a per-host router (shared kolu+surfaceApp fragment + this host's padi) + a per-host WsRPCHandler. ws-upgrade dispatches by ?host (unknown host rejected loud). New hosts.add/ hosts.remove root RPCs (deliberate add-then-connect, never a side-effectful GET). preferences.recentHosts (schema + 1.32.0 migration) is the pool's persisted host set. The preview route reads through the per-request host's session, killing the remoteHost fork (closes the #1685 deferral). server.info.defaultHost lets a tab pick its initial binding (KOLU_PADI_HOST boots CI through it). Samplers bound to the default host's binding for now; padiLink retirement rides the client layer. Layer 3 (client) is next: the binding/ module, the Debug picker, the misroute guard, per-host connection-cell readiness, and the padiLink retirement.
…guard Layer 3 (client — the de-entangling). The wire stops being one page-lifetime module-global bundle: binding/bindings.ts holds one connectSurfaces bundle per host (a ?host=<host> socket + its own lifecycle), an activeHost signal picks the one the tab views, and switchHost swaps it IN PLACE — no page reload. wire.ts re-exports the sibling clients as PROXIES over the active binding, so ~35 consumers don't rename; the app-lifetime singleton subs (preferences, recents, saved-session, terminal list) re-key via bindingScoped (tear down + re-subscribe on a switch — the client twin of the surface-app framework guarantee, no leak across the swap). - MISROUTE GUARD (in this PR, not L11): each binding has a retired flag; switching retires + disposes the old one, so an in-flight/stale call rejects loudly (assertLive) rather than landing on the wrong or dead host — and separate sockets make a cross-host route impossible by construction. Pinned by bindings.test.ts. - Per-host readiness rides the padi `connection` cell (mirroredSurface, liveWhen), folding into padi.health().live per binding; useDaemonStatus derives readiness / the create-refused-while-warming gate / the loud degraded states off the ACTIVE host. The single-host `kolu.padiLink` cell RETIRED (it can't carry N hosts). - The HIDDEN picker: a nested group under the command palette's Debug section only (no ChromeBar, no keybind, no tip) — local + the default host + server-persisted recents + a free-typed ssh host; picking calls switchHost (add-then-connect). - Boot: restore the per-tab host, and fall to the server default (KOLU_PADI_HOST) on a fresh tab. IdentityRail names the active host. Tests: surface-app swap guarantee, hostPool ?host dispatch + recentHosts persist + hosts.add/remove, the misroute guard, connectionToPadiLink. Full client (555) + server (162) suites green; typecheck + biome clean. Clock-offset consumption (piece 5, "consumed at last") is deferred as acceptable-for-scope: the server plumbing (control.core.clockNow) exists; wiring the RTT-halved offset through to the client is a refinement not gating the DONE list.
The per-scenario Before hook test__set's a FULL Preferences object; the new required recentHosts field made every reset 400 (all 494 scenarios failed at the hook). Reset it to [] so each scenario starts with no remembered hosts.
…values Drop the pool's parallel `mirrors`/`routers` Maps (which `registry.remove` could never reach, leaking a removed host's MirrorClient + router) and capture single `defaultMirror`/`defaultRouter` inside buildEntry when host===defaultHost; narrow getMirror()/getRouter() to no-arg and update the two index.ts callers. Agreed by the lowy ⇄ hickey lens debate (finding lowy-1, raised by lowy). Not pushed or merged.
`Binding.id`/`nextBindingId` were minted and documented as the misroute guard's identity but never read (assertLive checks only `retired`). Delete the field, its counter, and the object-literal entry; correct the module header and drop the 'unique id' clause so the guard is described by its load-bearing teeth — the `retired` flag plus separate per-host sockets. Agreed by the lowy ⇄ hickey lens debate (finding lowy-2, raised by lowy). Not pushed or merged.
…op the re-patch createServerLifecycle needs only `conn` (never the Binding), so derive it before constructing the object and build the Binding once with the real status/lifecycle/ serverProcessId accessors. Removes the three placeholder accessors, the three `as`-cast re-patch assignments, and the inaccurate 'the lifecycle needs binding' comment. Agreed by the lowy ⇄ hickey lens debate (finding lowy-5, raised by lowy). Not pushed or merged.
…ntract The `"local"` sentinel was duplicated as three independent module-level consts that had to stay byte-identical across the client/server wire boundary. Define it once in kolu-common/contract.ts (beside HostInputSchema), import it in hostPool.ts, bindings.ts, and useDaemonStatus.ts (re-exporting from the first two for their callers), and drop the three local definitions plus the reconciliation comment. Agreed by the lowy ⇄ hickey lens debate (finding hickey-2, raised by hickey). Not pushed or merged.
The `surfaceApp` proxy had no importers — its one intended consumer, the SurfaceAppProvider, deliberately bypasses it (`() => activeBinding().clients .surfaceApp`), and rpc.ts no longer uses it. Delete the export and its doc comment; the `Clients` type alias stays (app/padi/the singleton types use it). Agreed by the lowy ⇄ hickey lens debate (finding hickey-3, raised by hickey). Not pushed or merged.
W4 retired the server-side `padiLink` cell; padi's link state now comes from the padi `connection` cell via `padiLinkState()`/`connectionToPadiLink`. Fix the three comments this PR left describing the dead mechanism: padiPresentation.ts's padiDot doc, IdentityRail.tsx's padiStateText note, and the orphaned "Drive koluSurface's padiLink cell" block in server/index.ts (deleted — its surviving uptime publish is already documented at the onState call below). Agreed by the lowy ⇄ hickey lens debate (finding hickey-8, raised by hickey). Not pushed or merged.
Address codex's 7 findings on the W4 "the switch" branch. F1 (blocking) — host-scoped subscriptions that pinned the boot binding now re-key through `bindingScoped`: the terminal-metadata collection (useTerminalMetadata) and KavalUpdateBadge's padi `status` cell that codex named, PLUS four more of the same class found while auditing — useMemoryUsage / useProcessUptime / useDaemonInventory (kolu-surface, but the socket is per-binding) and useHostInventory (per-host padi cell). These became mandatory once F4 closes the retired socket. F2 (major) — thread the active host into preview URLs. `buildTerminalFileUrl` takes a required `host` and appends `?host=`; the binary/img/video/iframe path and markdown-image resolution both pass `activeHost()`, so a tab on a non-default host reads files off the right host instead of the default. F3 (major) — each binding owns its lifecycle under its own `createRoot`, torn down by `binding.dispose()`, so the lifecycle's lifetime is the binding's — not whatever incidental owner first read `activeBinding()` (fixes the leak AND the premature-free case). Dispose is idempotent. F4 (major) — remove the dead `activePadiRpc` and make the misroute guard's teeth real: `binding.dispose()` never actually closed the socket (only detached listeners), so a stale-captured proc could re-dial the still-open old host. Added `retireSocket(conn.ws)` on dispose (close + poison send). Kept the ratified `assertLive` and corrected the guard comment. F5 (minor) — server.info() carries the functional default-host seed; its failure no longer collapses silently (console.error naming the consequence). F6 (minor) — active-host persistence moved from localStorage to sessionStorage so it is genuinely per-tab (survives reload, scoped to the tab). F7 (nit) — corrected the koluSurface comments that still listed the retired `padiLink` cell. Client 555 + server 162 unit suites green; typecheck + biome clean.
Simplify pass over the W4 change (reuse/simplification/efficiency agents): - wire.ts: collapse the three near-identical Subscription-shaped forwarders (preferencesSub/savedSessionSub/terminalListSub) into one `reSub` helper. - wire.ts: `window.__koluWs` re-pointer becomes a plain re-pointing effect — it opens no disposable sub, so it needs none of bindingScoped's per-switch root/dispose machinery (efficiency + simplification both flagged it). - bindings.ts: drop `hasStoredHost`; `readStoredHost() === undefined` is the presence check the one caller needs. - hostPickerCommand.ts: unify the dedupe idiom to `[...new Set(...)]` (matches hostPool.ts; the ...Set preserves first-seen order). Skipped (noted): sessionStorage helpers → makePersisted (the presence check is load-bearing and makePersisted's fallback would mask it); LOCAL_HOST vs padi's package-private LOCAL_HOST_ID (not cleanly single-sourceable).
Pass 2 (fact-check) — correctness bugs, each now pinned by a test: - hostPool.ts: `hosts.add` had a TOCTOU race — two devices adding the same new host both passed the `has()` check and each dialed a live session; the loser's session/pump was orphaned (never destroyed) and its fail-loud pump could crash the whole server. Serialize per-host via an in-flight `Map` so the 2nd add joins the 1st. (hostPool.test: "serializes CONCURRENT adds of the same new host".) - bindings.ts: removing a host another tab was viewing left that tab re-dialing `?host=<gone>` forever (server rejects 1008) behind a misleading "Reconnecting…". On a 1008 close the tab now falls back to local. (bindings.test: 2 cases — 1008 → fallback; 1006 → no fallback.) Pass 1 (rule checklist): - no-dead-code: delete the never-called `assertLive`; the misroute guard's runtime teeth are the retired socket (dispose closes it + stubs `send`). Doc + test updated. - no-unbounded-growth: cap `recentHosts` at MAX_RECENT_HOSTS=20 (MRU) — every recent host is re-warmed into a live session at boot, so the list can't grow without bound. - dry-rule-of-three: extract `useBindingScopedSub` for the `createSharedRoot(() => bindingScoped(...))` pattern repeated across 6 hooks. - silent-swallow / collapse-to-empty: comment `readStoredHost`'s catch (a broken sessionStorage genuinely has no stored host → local is the correct answer). - errors-must-log-at-error: reconcile the unknown-host ws comment — it's a RECOVERABLE stale-tab case (the client now falls to local), so `warn` is right. - watcher-lifecycle-logs: log the per-host binding install/retire in a greppable form.
⚖️ Lowy ⇄ Hickey lens debate — ✅ consensus (1 round)Base:
Deliberate, coordinator-ratified decisions (wire-clients-as-proxies = interim/L11, the retired |
Codex ⇄ Claude debate — ✅ consensus (2 rounds, reasoning effort: xhigh)Base: Round 1codex — approved: The branch has the right broad shape for per-host bindings, but several host-scoped paths still pin the first binding or omit the active host. That means the main switch can show a new host while parts of the app still read or call the old/default host. I'm not approving until those cross-host correctness issues are fixed. Findings:
claude — Fixed all seven findings: re-keyed the pinned host-scoped subscriptions behind
commit: Round 2codex — approved: All seven prior findings are resolved in the current tree. I re-checked the changed areas tied to those findings and did not find a new regression introduced by Claude's fixes. Findings:
codex on the rebuttal: F1 accepted: the subscriptions I flagged now go through |
👮 Code-police — 2 correctness bugs + 6 rule findings, all actionedTwo passes (rule-checklist + fact-check) over the change vs Pass 2 (fact-check) — real bugs, each now pinned by a test:
Pass 1 (rule checklist):
|
Found by driving the built client live (chrome-devtools): the dev render crashed with `Cannot read properties of undefined (reading 'byKey'/'value')`. Root cause: `bindingScoped` set its value from a deferred `createEffect`, so the accessor returned `undefined` until the effect phase — and a consumer reading `daemonStatusSub()().byKey(...)` / `memory()().value()` on the first synchronous render hit `undefined.<member>`. The pre-W4 direct subs (`app.collections.X.use`) were never undefined; a render effect restores that invariant by running synchronously on creation, so the factory result is in `value` before any read. The production build's timing happened to avoid the window (CI's ci::smoke passed), but relying on render-order luck is fragile — this makes it correct by construction for every bindingScoped consumer (daemon status, memory, uptime, inventory, kaval status, and wire.ts's preferences/terminal-list singletons) at once. Pinned by a new test asserting the value is present on the first synchronous read.
EvidenceW4 "the switch" is proven leg-by-leg. Stated honestly — the parts were proven different ways, and one leg is deferred with the reason named. 1. CI — green on both platforms
2. The live switch UI, driven with chrome-devtools (a dev kolu on random ports — never production)I drove the built client live (chrome-devtools against a dev server on random ports; production
This drive also caught a real robustness bug: the first render crashed on Honestly not claimed: the interactive picker→switch→back drive and full-canvas screenshots are withheld, and here's exactly why — the padi daemon socket namespace is machine-global, so a dev kolu on this box necessarily adopts the live session's own padi daemon rather than an isolated one. The canvas therefore shows real, in-flight internal agent terminals (not postable to a public PR), and keying into it drives live sessions. So the interactive switch drive against a clean isolated host is deferred with the cross-machine leg below; the switch mechanics are pinned by the tests in §4. 3. The remote arm (the ssh hop) — proven, and exactly howW4 does not touch the remote-bind machinery: The full cross-machine chrome-devtools drive (local → a real 2nd machine → local, creating a terminal on the remote) is deferred pending pu capacity: the Incus cluster was saturated all night ("No cluster member has capacity — limit 4 per member"; logged on #1204), and self-ssh was ruled out (it fakes the second machine — same clock, same fs). The remote leg is proven by the unchanged W3.1 e2e-ssh arm + the pool unit tests; only the visual cross-machine UI drive is deferred, and it is not claimed to have happened. 4. The mechanism, pinned by tests (run in normal CI, both platforms)
5. Paired drishti PRThe framework half ( |
B0 (structural backstop): strip `.use()` off the wire.ts app/padi PROXY exports at the type level, so a persistent subscription opened on the module-global active- binding proxy is a COMPILE ERROR — the compiler enumerates the violating sites instead of a reviewer. It caught all three: - B1: createRepoPolledQuery's `pulseProc`/`client` pinned the mount-time binding, and createPolledQuery's pulse effect keyed only on `input` — so after a host switch all five Code-tab queries pulsed the RETIRED binding forever (Code tab dead post-switch). Fix: re-key the pulse on `activeHost`, resolve client+proc from the active binding. - B2: BrowseFileDispatcher's file-content pulse — same class; preview stopped live-updating after a switch. Same fix via the shared createPolledQuery. - B3: useTerminals' terminalExit `.use()` went through the proxy — thread the ACTIVE binding (its per-terminal list-keyed owner re-keys on switch). Pinned: createPolledQuery.test "re-keys the pulse onto the NEW host's client after a switch" (asserts the pulse re-opens against the switched-to client).
The default host (KOLU_PADI_HOST) may be REMOTE, and its session/mirror/router are boot-captured by the HTTP /rpc handler + the memory/identity/inventory samplers. `hosts.remove` guarded only local, and every persist writes the pool set (minus local) into recentHosts — so a remote default lands in recents and the picker's forget list offered it (the "default is never forgettable" comment was FALSE). Forgetting it destroyed the session under the boot captures → permanent brick (no re-add heals a captured ref). - hostPool.ts: guard `host === defaultHost` in hosts.remove (mirrors the local guard) — closes both the picker path and the raw hosts.remove RPC. - hostPickerCommand.ts: filter the default out of the forget list; correct the comment. - Pinned: hostPool.test "never removes the DEFAULT host — even a REMOTE one". (The stronger "stop boot-capturing" is deferred: the guard makes the captured default un-removable for the process lifetime, so the stale-capture path is now unreachable; un-capturing would ripple through the onState wiring + the RPCHandler construction.)
…re-pick) `hosts.add` takes seconds over ssh, so picks overlap. Two verified holes: - Hole 1: re-picking the CURRENT host as a cancel was a pure no-op (early return), so a pending add for a different host still resolved and YANKED the tab. - Hole 2: two pending adds were first-RESOLVE-wins, not last-pick-wins, and the loser toasted a misleading "couldn't reach" error. Fix: a module `pickEpoch`; `const myPick = ++pickEpoch` as switchHost's FIRST statement (before the same-host early return — that placement is what makes re-picking the current host cancel an in-flight add); after the await AND in the catch, `myPick !== pickEpoch` bows out. Pinned: bindings.test "re-picking the CURRENT host cancels an in-flight add" and "two overlapping picks: the LAST pick wins".
Every per-host router's `server.info` folded the boot `remotePadiHost()` into the PWA identity, so a tab on host A titled itself with the DEFAULT host's name. `buildAppRouter` didn't even receive its host. Thread `host` into BuildAppRouterDeps; label the identity from it (local → `Kolu [srv]` byte-identical, remote → `Kolu [srv → host]`). Pinned: pwaIdentity.test "server.info labels the identity from THIS router's host".
RULING: the re-serve pump's `process.exit(1)` per host means any added host's fault kills the whole server for every device — and W4 lets users add arbitrary hosts, so a divergent-build adopt-loudly fault is a plausible per-guest event. Scope containment to the binding: the DEFAULT/local binding stays fail-fast (the server is useless without it; the supervisor restarts clean); a GUEST host's pump fault logs at error and RETIRES the binding via hosts.remove (its ?host sockets close → the picker drops it → each viewing tab's reconnect is rejected 1008 → the client's fall-to-local guard). Pinned: hostPool.test — guest fault retires + no exit; default fault exits(1).
…fallback - H3: lift the 1008 unknown-host → fall-to-local handler out of makeBinding into a named `installUnknownHostFallback(binding, ws)` (reads binding.host). - H4: the preview route's `?host ?? defaultHost` silently served the DEFAULT host's fs for RELATIVE SUBRESOURCES inside a preview iframe (the client always appends ?host via buildTerminalFileUrl, so a request without it is a subresource that dropped the parent's query). Log it loudly (warn) rather than fail silent; the full per-subresource host routing is noted as an A1-shaped follow-up.
- F-a: refuseIfWarming BLOCKS + toasts the terminal create while the active binding is (re)connecting, and lets it through once connected (useDaemonStatus.test — the warming DECISION itself is already pinned in daemonPresentation.test). - F-b: the stale-call rejection was pinned only by asserting a MOCKED retireSocket was called (≠ a call rejecting). Add a call-level test driving the REAL retireSocket: after retirement the socket's send THROWS (the misroute guard's real teeth). - F-c: two-tabs independence — pin storeHost → sessionStorage (per-tab, not localStorage) and restoreStoredHost re-reading it on reload (a regression to localStorage now fails). (F-d — a live-switch "tiles live" e2e — needs a real 2nd host; pu is saturated (#1204), so it's recorded as a named gap in the PR alongside E1, not run tonight.)
…e) + H2 THE HEADLINE. kolu-server sampled padi uptime / build / convergence / RSS off the BOOT DEFAULT host and served ONE value on every host's socket, while the client dressed them per-host — so IdentityRail rendered "ssh · zest" beside the DEFAULT host's version + RSS. Ruling (B)-PLUS: source them per-host from each pool entry's cached, ssh-FREE `session.identity()` / `session.convergence()` (the #1705 identity work) — no @kolu/padi surface change. Server (per-host cells): - NEW perHostKoluCells.ts: a minimal per-entry `implementSurfaces` for just `processStartedAt` + `daemonInventory` (cell specs derived from koluSurface), over its OWN in-memory channel (the reServeSurface isolation pattern), wired to THAT entry's session. Byte-identical wire paths → spliced over `kolu` with no contract change. - hostPool: injects `buildHostKoluCells(host, session)`, splices the two cells over the shared `kolu` namespace per entry, and disposes them on host removal. - index.ts: deletes the `defaultSession`-pinned processStartedAt driver + startDaemonInventorySampler block (moved per-host). - daemonInventory.ts: the sampler returns a disposer (leak-free per-host teardown) — pinned by a test; the named corner from the design. Client (the per-host reads, killing the three "acceptable-for-scope" comments): - useMemoryUsage: padi+kaval RSS now ride the ACTIVE host's `padi.cells.processMemory`; serverRss stays kolu-server's own (host-independent) cell. - useDaemonInventory: surfaceVersion reads padi's own per-host `padi.cells.version`; the daemonInventory cell is now per-host, so its comment says so. - useProcessUptime: `processStartedAt` is per-host now — comment corrected. H2: the "active"/"bound" reader names (activePadiSurfaceVersion, boundPadiConvergence, padiMemoryDisplay, …) are now ACCURATE per-host names — A1 made them true (server* stays host-independent), so no rename is needed.
"recents persist across devices" was half-broken: recentHosts was a PreferencesSchema field, and the client reads preferences authority:"local" — seeds from the first yield, ignores every later server push. So a hosts.add on device A published via preferences.patch but never reached device B's OPEN picker until reload. Fix (ruling A, cheap): recents are SERVER-owned pool state, not a user preference — give them their OWN home. recentHosts was new in THIS PR (never shipped in preferences), so this places it right at first release, no version bump: - common/surface.ts: remove recentHosts from PreferencesSchema; add a read-only, SERVER-authority `recentHosts` koluSurface cell. - state.ts: recentHosts is its OWN top-level Conf key; the 1.32.0 migration (never shipped) is reworked to move any branch residue out of preferences + strip it (pinned by stateMigration.test). - surface.ts: the cell is Conf-backed — one `.set` both persists AND publishes. - index.ts: persistRecentHosts writes the cell; boot warms from the new key. - client: a server-authority `recentHosts()` accessor (wire.ts, no authority:"local"); the picker reads it, so another device's add lands live. - hooks.ts: reset the recentHosts cell (its own test__set) not the preferences field. surface.test updated for the new cell.
…e value-mode Enter)
Cross-machine functional verification (the deferred F-d drive, now run on a leased
kolu-ci box) caught a feature-breaking bug the unit suite couldn't: typing a new ssh
host into the picker's "Connect to a host…" and pressing Enter did NOTHING.
Root cause in CommandPalette: value-mode Enter runs `execute(items[selectedIndex()])`,
but `items` is `partitioned().interactive`, which EXCLUDES hints. The host picker's
value input has only a hint child (no label), so `items` is empty, `selected` is
`undefined`, and `if (selected) execute(selected)` skips submit entirely — `onSubmit`
never fires. Every other value input (worktree create) happens to ship a default label
("Plain shell") as the Enter target, so the gap only bit the picker. And since recents
start empty, the free-typed connect is the ONLY first path to a remote host — so the
whole remote feature was unreachable from a clean install.
Fix at altitude (generalize, not a dummy label): a value input may legitimately offer NO
options — Enter submits the typed value with `selected: undefined`. `onSubmit`'s type now
admits `undefined`; value-mode Enter routes through a `submitValue(selected?)` helper
regardless of whether a label is highlighted; the worktree onSubmit reads `selected?.data`
defensively (it always has a label, but the type no longer promises one).
Verified LIVE end-to-end on kolu-ci-5 over ssh: palette switch local → kolu-ci-5 → local
with no reload, a real terminal spawned on the remote (toor@kolu-ci-5), and two browser
tabs holding different hosts at once. See ## Evidence. (The palette has no component-render
test harness — adding @kolu/solid-testing is a noted follow-up, not in this fix's scope.)
…Cell subs on owner teardown
srid hit "server restarted — reload required (stale tab)" toasts (Memory / Padi memory /
Host inventory) on EVERY host switch, both directions. zest's kolu-server had 9h54m uptime
— it never restarted. The toast was the visible edge of a real bug: every switch LEAKED
all three live subscriptions.
Root cause (coordinator ruling via architecture-first-principles + perfection): NOT an
unguarded error sink. `useCellServer`/`useCellLocal` wrapped `createSubscription` in a
DETACHED `createRoot(() => …)` whose dispose was DISCARDED. So a bindingScoped switch
disposed the per-key owner but never the inner root → `createSubscription`'s
`onCleanup(() => controller.abort())` never fired → the sub leaked on live
(`aborted === false`). When the outgoing socket retired, the leaked sub's for-await
errored and the (correctly guarded) catch saw `aborted === false` → `setError` → toast.
"Why only these 3": only re-keyed (bindingScoped) cells have an owner that tears down on
switch to expose the leak.
The fix realizes the invariant "a disposed subscription cannot report anything":
- (1) tie the useCell detached-root dispose to the caller's reactive owner (`getOwner()`),
so a switch disposes → aborts the sub; ownerless callers keep app-lifetime behaviour.
- (2) thread the abort signal INTO `createSubscription`'s `source()` (via `streamingThunk`
/ useCollection), so a disposed sub's pending `next()` cancels at once instead of
lingering until the transport errors — removing an asymmetry, since the sibling
`createReactiveSubscription` already passed its signal.
- retireSocket now throws the honest, distinctly-typed fact ("socket retired by this
client", `SURFACE_TRANSPORT_RETIRED`) — used for BOTH a host switch and a stale-restart
teardown — not a "server restarted" claim. The genuine-restart affordance stays the
lifecycle-status reload overlay (unchanged).
Pins: `useCellDispose.test` — disposing the owner ABORTS the sub's stream signal and
reports nothing (server + local authority; RED before the fix). Ordering in
setActiveHostInternal (flip activeHost → dispose subs, before retire) stays as hygiene.
Framework touch (@kolu/surface, @kolu/surface-app) — drishti re-pinned + re-greened.
…a known-issues link Per srid's ruling: the host picker (W4 "the switch") is a works-but-unstable BETA feature, so its truthful home is a new "Labs" palette group — not "Debug" (diagnostics only, which never conveyed "beta, unsupported"). Same hidden posture (palette-only, undocumented; no ChromeBar, no keybind, no tip), but a finder now gets the signal: - new "Labs" group under Help, with a conical-flask icon (lucide flask-conical, the industry idiom for experiments), matching the palette's inline-SVG icon set; - the picker entry's description carries the beta known-issues link (kolu.dev/atlas/remote-bind-parity.html — goes live when the plan PR #1649 merges), as plain URL text (the palette description is secondary text, not a live link). Debug keeps its diagnostics. No e2e/unit selector targeted the picker's Debug grouping (the picker is deliberately hidden, no e2e), so none needed updating. Client-only.
…ndpoint) [electricity 2/3] Graduate kolu's `bindingScoped` re-key into a generic framework primitive: `connectionScoped(connectionKey, connection, factory)` — a subscription factory keyed to a SWAPPABLE connection accessor, re-run under a fresh root when the active connection changes (prior root disposed first — no leak, #1687), value populated synchronously on first read. It rides `createKeyedRoot` (already graduated), inheriting every guarantee. Two accessors deliberately: `connectionKey` is the STABLE identity of the active connection, `connection` the current VALUE (often a fresh object each swap — a retired binding is rebuilt — so keying on the value would re-run on every incidental rebuild). This is ledger L11's declared endpoint: kolu's app-lifetime singleton subscriptions re-key onto whichever host a tab views WITHOUT a per-consumer scope-through-context port. kolu's `bindingScoped` is now a one-line policy alias: `connectionScoped(activeHost, activeBinding, factory)`. Additive to the @kolu/surface-app/solid barrel (no signature drishti touches changes). Pinned: createKeyedRoot.test's new connectionScoped case (re-runs on KEY change, NOT on a same-key VALUE rebuild); bindings.test's sync-populate still green.
…ce-app [electricity 1/3] Graduate kolu's binding machinery — the keyed cache of live client connections with ONE active — into a generic framework receptacle: the CLIENT-side twin of buildHostRegistry. createActiveConnectionManager<K,C> owns the volatility (retire-on-switch with the typed retired-transport stub, pick-epoch last-intent-wins over slow async warms, the server-rejected-key/1008 fallback predicate, per-tab persistence); kolu plugs in padi POLICY — makeConnection (connectSurfaces + lifecycle), warm (hosts.add via the current link), the toasts, socketOf, LOCAL as the fallback. Per the coordinator's steer, the 1008 reaction is a POLICY CALLBACK (onServerRejected), not a config field: a string can't express which key to land on or gate wording on context. onWarmError carries `superseded` so policy stays silent on a superseded pick. kolu's binding/ module is now pure policy: activeHost/activeBinding/switchHost/ bindingScoped/restoreStoredHost are the manager's surface; only makeBinding, the padi policy object, seedDefaultHost, forgetHost, and recents remain. createKeyedRoot + connectionScoped move to their own keyedRoot.ts so the manager composes connectionScoped without an index-barrel import cycle (both still re-exported from /solid). Additive to the barrel — no signature drishti consumes changes. Pinned: activeConnectionManager.test (cache + rebuild-if-retired, flip-then-retire, epoch last-intent-wins incl. re-pick-as-cancel, the 1008 predicate, restore); bindings.test (11, the padi-policy wiring) still green through the real manager.
…[electricity 3/3]
Generalize kolu's per-host padi cells into a registry capability: HostEntry gains an
optional `cells?: { dispose() }` — per-entry SERVED cells derived from the entry's
session — and buildHostRegistry disposes them beside `session.destroy()` at EVERY
teardown site (remove, add-rollback, destroyAll). The registry stays ignorant of which
cells or what they carry, same discipline it applies to `session` (it knows only
`.destroy()`).
kolu's hostPool is the first consumer: buildEntry returns
`cells: { dispose: perHostKolu.dispose }`; the hand-rolled `koluCellDisposers` side-map +
its manual dispose in `hosts.remove` are gone. This CLOSES two latent leaks the side-map
had — it only disposed on `hosts.remove`, never on add-rollback (a persist-reject that
already destroyed the never-admitted session) or destroyAll (server shutdown) — now the
per-host sampler timers + onState closures ride the entry's exact lifetime.
perHostKoluCells.ts + the buildHostKoluCells adapter are unchanged. Additive optional
member on HostEntry — drishti's registry usage compiles unchanged.
Pinned: hostFanout.test's new "disposes each entry's cells at EVERY teardown site".
With this, kolu's binding/ + hostPool shrink to pure policy — the L11 end-state across
all three electricity extractions.
… surface
The three framework packages changed src but their READMEs didn't — closing that gap
(do.md "update every part that no longer matches"). Doc-only; no code change.
- surface-app/README: document the three multi-connection primitives added to /solid —
createActiveConnectionManager (the client twin of buildHostRegistry, with its policy
shape + the 1008 policy-callback rationale), connectionScoped (+ the bindingScoped/
useBindingScopedSub alias note), createKeyedRoot — in a new "Multiple connections, one
active" section mirroring how surface-nix-host documents buildHostRegistry. Also fix the
stale controlPlane usage: it is now an Accessor<ControlPlane<T>> (S5), re-deriving
buildInfo per control-plane identity on a host swap (the JSX example + the prose both
showed a bare client).
- surface-nix-host/README: HostEntry now carries an optional cells?:{dispose()} — the
buildHostRegistry table row + the fan-out invariant now note the registry disposes each
entry's cells at EVERY teardown site.
- surface/README: document surface.system.identity beside system.live — the reserved
identity twin (SurfaceIdentity sum, no nulls). This was #1705's to document and was
absent; completed here while syncing the reserved-member story.
- root README: the Architecture section said the provider is "fed the surfaceApp sibling
client" — corrected to the S5 accessor of the active host's client.
drishti unaffected (READMEs don't cross the surface.md gate).
… a REAL kolu usage snippet (from the #1708 head: the manager construction with its policy args, bindingScoped=manager.connectionScoped + the KavalUpdateBadge consumer, the provider prop, socketOf-inherited retirement, buildEntry cells)
… loud unremovable-host [batch group 1]
1a — a remembered guest host no longer bricks a from-source boot. parsePadiDrvMap was
fail-fast at BIND time (written for the KOLU_PADI_HOST singleton era); the warm pool now
dials it synchronously at boot for every persisted recentHosts entry, so a from-source run
(no drv map baked) with any remembered remote host crashed at startup. Parse LAZILY inside
makeResolvePadiDrv's dial thunk, throwing a per-entry ResolveDrvError("remote") — that
host's own connection failure (loud in the picker / connection cell) while the server boots
and serves local. Reworded the single-host-era log + drv-map error (folds in 5e's
remotePadiBinding part). Pin: remotePadiDrvMap.test — construction survives (boot doesn't
throw), the deferred thunk carries the fault.
1b(i) — hosts.remove(local | default) now throws a typed UnremovableHostError instead of the
silent-success no-op that resolved as if the removal succeeded; the client toasts it
honestly. hostPickerCommand comment reconciled (the forget-list filter is the UX layer over
the real server guard). The re-serve pump's structural-host handling is made explicit: the
default is fatal, the non-default LOCAL binding is logged + LEFT (not retired — remove(local)
would reject — and not fatal, since a remote default still serves), a guest is retired.
Pins: hostPool.test remove-rejects + the local-non-default log-and-leave path.
1b(ii) — getMirror/getRouter read THROUGH a call-time host-keyed map instead of two
boot-captured `let` consts (the registry stays the single source of truth); the sampler's
defaultSession stays a stable subscription ref, sound ONLY because the default is
UNREMOVABLE — pinned by a comment naming UnremovableHostError as the guard + a
reference-identity test (getSession(defaultHost) is identical across a renew()). If anyone
makes the default removable, that pin fails loudly rather than the sampler going stale.
… [batch group 2] 2a — no `X()()` anywhere. useBindingScopedSub now DEREFS the shared re-keying accessor internally, returning the current host's sub handle in ONE call — consumers write `useX().value()` / `useX().byKey()`, never `useX()()`. wire.ts's four hand-spelled `Accessor<ReturnType<…>>` chains collapse to the helper's inferred types (the `let _X` + module-load createRoot block become five `useBindingScopedSub` calls); `reSub` stays (the Subscription-shaped exports `terminalListSub`/`savedSessionSub` are consumed as stable objects) but no longer double-calls. Every `()()` consumer site (daemon status, inventory, memory, uptime, kaval, host inventory) collapses. Grep-proof: zero `()()` code sites. 2b — adopt @solid-primitives/rootless (already transitive; promoted to a direct dep of @kolu/surface). `createSubRoot` replaces BOTH hand-rolled owner-tied-dispose dances in useCell.ts (the `let disposeSub!` + detached createRoot + `if (getOwner()) onCleanup` pattern) — byte-for-byte its contract, incl. ownerless-stays-app-lifetime. Pure deduplication, behavior-neutral (the leak pin `useCellDispose.test` stays green); no context-propagation gain claimed. createSingletonRoot was NOT adopted for createSharedRoot: an 18/18 audit falsified the "duplicate" premise — createSingletonRoot ref-counts (dispose-when-idle) where every createSharedRoot consumer needs app-lifetime never-teardown. A header note records the semantic distinction so it isn't re-flagged. (Disposition: finding falsified by audit — semantic mismatch, keep.)
…ager [batch group 3]
Four verified findings, one coherent reshape of the active-connection manager:
3a — `fallbackKey` was a dead required option (never read), and `onServerRejected` couldn't
reach the manager, which forced kolu's `let manager`-then-assign forward-reference dance.
FUSED: the manager ENACTS the fallback itself — on the server-rejected close predicate it
calls its own `switchTo(fallbackKey)`; `onServerRejected` demotes to a notification-only
hook (kolu's toast). `fallbackKey` is now genuinely read; kolu's forward-ref dies (`const
manager`).
3b — deleted `manager.restore()` / kolu's `restoreStoredHost`: dead exported API whose
semantics silently SKIPPED the misroute guard (a raw signal flip). The construction-time
`persistence.read()` already covers boot; a late-restore consumer, if one appears, goes
through `setActive` and inherits retirement.
3c — retirement bookkeeping goes INTERNAL: `ManagedConnection.retired` (a two-part consumer
obligation) becomes a manager-owned `WeakSet`. The consumer's connection type shrinks to
`{ dispose() }` + whatever it needs; the manager marks + fences retirement itself.
3d — the manager owns a reactive root PER cache entry: it runs `makeConnection(k)` under a
`createRoot`, disposed on retire/rebuild. kolu's `makeBinding` sheds its 17-line dedicated
lifecycle-root dance + the `retired` flag — `createServerLifecycle`'s onCleanup now ties to
the manager's per-entry root — and is pure construction returning a plain `dispose()`.
Pins updated: activeConnectionManager.test (rebuild-after-retire via setActive, the
fallback-enaction on 1008, construction-time restore); bindings.test's misroute guard now
asserts the socket-retire teeth (retirement is internal). Leak pin `useCellDispose` green.
Additive-safe for drishti (it never used ManagedConnection.retired).
…ce/solid [batch group 4]
4a — createKeyedRoot becomes a thin alias over solid-js's own `mapArray`:
`createMemo(mapArray(() => [key()], factory))` + an eager `createRenderEffect`. Deletes the
hand-rolled `disposePrev` bookkeeping, the manual `untrack` fence, and the hand-written
dispose ordering (~20 lines) — mapArray keys by value-identity (the same fence) and
disposes each key's per-item owner on a change or on the caller's teardown. The eager
render effect is KEPT + newly pinned: the swap disposes the old root SYNCHRONOUSLY on a key
flip (before any read), which is load-bearing for the switch-toast abort ordering.
4b — moved createKeyedRoot + connectionScoped down to the base `@kolu/surface/solid` layer
(pure solid-generic). Dissolves the import-cycle contortion that kept them in a standalone
surface-app file; `@kolu/surface-app/solid` re-exports both for compat (drishti + kolu
imports unchanged). Test co-located in @kolu/surface.
4c — ownerless misuse now THROWS the documented contract instead of silently degrading
(lazy dispose + a leaked final root): one up-front `getOwner()` throw replaces the two
`if (getOwner())` guards. Pinned.
4d — observableHolder is backed by a private `EventEmitter` + `node:events` `once(...,
{ signal })` (max-listeners lifted, since waiters are legitimately unbounded), deleting the
hand-rolled waiter Set + abort plumbing. relayStream.test green.
… toast helper, dialog clarity [batch group 5]
5a — ONE shared local-machine daemon-inventory scan. kolu-server's own box is
host-independent, but each remote pool entry ran its OWN 10s sampler re-scanning it — N
writers of one fact (a P3 single-writer violation, not just waste). `startSharedLocalDaemonScan`
owns the read-only scan; the per-host `daemonInventory` cells become EVENT-DRIVEN readers
(re-publish on session.onState + the shared-scan refresh), reading its cached result. The
cell's SERVED shape is unchanged (behavioral test assertions intact); only the driver moved.
5b — `evictArch(host)` drops the per-process nix-system cache when a host leaves the pool
(hostFanout's remove), so a forgotten-then-re-added host (reimaged / alias reused) re-probes
instead of realising the wrong .drv off a stale arch.
5c — the picker's free-type input rejects the literal `local` sentinel (an in-band value in
the ssh keyspace, now user-typable) with a helpful message; local is already the first action.
5d — the repeated `(err) => toast.error(\`label: ${err.message}\`)` lambda at every
useBindingScopedSub / cell `.use()` site collapses to a `subErrorToast("label")` helper
owned beside useBindingScopedSub; call sites shrink to their varying label.
5e — folded into group 1 (the single-host-era remotePadiBinding log + drv-map error reword).
5f — padi/kaval info-dialog daemon-list clarity (srid, live screenshot): the BOUND daemon
list gets the accent treatment (border + `● bound` marker + a "serving your terminals"
badge) tying together the socket / bound-host / in-use signals; the local-machine
diagnostic list is DEMOTED (dimmed, dashed, "Diagnostic only — not your terminals"), so
"which daemon serves my terminals" is answerable in one glance. Client-only, no data change.
|
Closing in favor of a redesign: the client-side switch machinery here (activeConnectionManager + per-host sockets + wrapper layer) is superseded by a first-class surface notion — a dynamic keyed family of remote surfaces ( |
…ection as the membership authority (absent variant dead, P3), branded family key + misroute claim stated at its true strength (P4 at the typed API, P5 on the wire), pure entry()/Solid useEntry() split, dedup rebuilt ON createSingletonRoot with the membership-tied lifetime contract (C1+P1 answered), the one-socket ops gate as a blocking acceptance item, ONE kolu PR + substantive drishti adoption PR (the two hand-rollings unified; #1708 + drishti#89 closed with successor pointers), padi-dashboard demoted to future work
Per the coordinator's Phase-2-go rulings, pre-consumer:
(A) Uniform fold envelope { mapKey, input } for EVERY proc (was a spread merge).
One wire shape for any input — object, primitive (padi's terminalAttach), or
none — and an entry input carrying its own `mapKey` field cannot collide with
the folded key (it rides `input`, nested): misroute-by-collision is now
unconstructible (P4), not merely unlikely. Reworks foldInput (define) +
foldMapKey (client) + unwrapInput (server).
(B) Transport-liveness seam. @kolu/surface/solid now exposes buildSurfaceClient +
resolveTransport for framework composition. connectSurfaceMap resolves the app
transport ONCE (half-open guard applied) and threads the resolved `live` into
the entries client AND every per-key client, and exposes client.live — so a
per-key chip floors its status on real transport liveness (#1568), not a green
dot over a dead link. Constant-true only for an in-process directLink.
(C) createKeyedRoot moved to @kolu/surface/solid (pure solid-generic, the spec's
layering) with the eager synchronous-dispose pin restored (the #1708
switch-abort ordering the local copy had dropped) + a test pinning it;
surface-map imports it, no package-local copy.
Base-package API additions (buildSurfaceClient, resolveTransport, createKeyedRoot)
— drishti gate at final HEAD. All green: surface 267, surface-map 5, biome + tsc.
The wire pivot: the `padi` sibling is no longer a single bound surface but the `padiHostMap` — one entry surface (padiSurface) served N times, keyed by host. env KOLU_PADI_HOST unset ⇒ a 1-member map (local) ⇒ pixel-identical to before. Server + client + tests move together (the wire shape changes; there is no green intermediate). SERVER (index.ts / surface.ts / common surface.ts): - Boot: a warm `buildRemotePool` over the parsed seed (LOCAL_HOST the unremovable default); `reServeFor(host, session)` caches the per-host re-serve with the #1708 pump-fault pins (default death ⇒ fatal, guest death ⇒ pool.remove); `serveHostMap` splices the map's folded members + `entries` under `padi`. Default (local) session boot-awaited; the samplers/onState/preview bind to it (host-independent facts). - `servedContract`: the `padi` sibling is `padiHostMap.contract.surface`. - New `hostMapGate` cell on koluSurface — set once from `isMultiHost()`; the client reads it to render the multi-host UI (it never reads env). `boundHost` ⇒ null. CLIENT (wire.ts + ~28 consumers): - `connectSurfaces` drops to the padi-less siblings; `padiMap = connectSurfaceMap( padiHostMap, scopeSibling(conn.link, "padi"), { live })` threads conn's watchdog liveness through the composition seam. Per-tab `activeHost` (sessionStorage, parseKey-guarded). - The 4 import-time module-const subs DIE. `preferences` (host-independent) stays a plain kolu sub; activityFeed/session/terminal-keys (host-scoped) ride `padiMap.useEntry(activeHost)` under ONE app-scope `createRoot` (inline in wire.ts to avoid an import cycle — commented so it isn't "tidied" apart). ~30 procedure point-calls go `padiRpcOf(activeHost()).surface.<ns>.<verb>` (the concrete-cast rpc); the terminal attach stays `unenrolledStreamCall` via the map entry. TESTS: surface.test (hostMapGate in the cell set), seal.test (measureClockOffset allowlisted), client mocks re-shaped to padiMap/padiRpcOf/activeHost. Green: whole-workspace tsc; server 166, client 559, surface-app 142, surface-remote 123, common 22, surface-map 6; biome error-on-warnings.
…ship
The selector strip's add/remove actions, as root RPCs (they mutate the POOL, not one
host's surface):
- `HostKeySchema`/`HostKey`/`LOCAL_HOST` move to a padi-LESS `common/hostKey.ts` so the
padi-less `contract.ts` can type the `hosts.*` RPCs without pulling `@kolu/padi` into
the client contract (the seal). `surfacesWithPadi` re-exports them + builds the map.
- `contract.ts`: a `hosts` root namespace (`add`/`remove`, each `{ host: HostKey }`) on
the SHARED contract — the client strip calls it, so not a kolu-server-local splice.
- `router.ts` + `buildAppRouter`: `addHost`/`removeHost` deps; `index.ts` forwards to
`pool.add`/`pool.remove`. `pool.add` uses the pool's stored `buildEntry` (a guest host
→ the remote ssh arm).
- `assertRemovableHost(host, defaultHost)` (extracted, testable): a `remove` of
LOCAL_HOST or the boot default throws `UnremovableHostError` — fails LOUD, never a
silent no-op (the #1708 pin; "being able to override" is never a feature).
Pins: 3 assertRemovableHost tests (LOCAL_HOST throws, default-parameterized throws, guest
removable); seal root-namespace assertion → [daemon, hosts, server]; stub deps threaded
through the two buildAppRouter test call sites. tsc clean (common/server/client), server
169 tests, biome.
…1714) `@kolu/surface-map` is a **keyed map of remote surfaces** — one entry surface, typed once, served N times, keyed at runtime — and this PR is **two honest halves at once**: it builds that framework *and* ships the product surface it unlocks. A browser tab now **live-switches which machine's terminals it views** — no reload — over a warm pool of per-host padi bindings, reaching a host's cells through `padiMap.entry("zest").cells.daemonStatus.use(…)`. Supersedes the closed #1708 (clean-room redesign; the ratified Atlas note `remote-surfaces.mdx` is the sole spec). First of three serial PRs — this one, then a drishti adoption (#90), then a mechanical odu rename (#40); both companions' pins are already bumped to this HEAD and green. ## The shape `KOLU_PADI_HOST` stops being an all-or-nothing "the whole canvas becomes that box" knob and becomes a **comma-seed list** for a warm pool. `local` is always the implicit, **unremovable** default, so **env-unset is a 1-member map — pixel-identical to today's single-host canvas** (the multi-host UI is gated off and renders *nothing*). No dual code path: the map is *always* the wire shape; N=1 is the degenerate case. ## Half 1 — the framework (`@kolu/surface-map` + `@kolu/surface{,-remote}`) - `defineSurfaceMap(keySchema, entry)` + the uniform fold envelope `{ mapKey, input }` on every proc; `entries` is the one membership authority (a `Collection<Key, EntryStatus>`), status a **projection**, never a second writer. A void-input member folds to **no input key** (schema declares none), so the fold is zod-version-independent — closing the drishti-fleet incident where zod ≥4.3.7's stricter missing-key check silently broke every void fold. - Client `connectSurfaceMap`: `entry(key)` (pure lens, total, cached) + `useEntry(acc)` (Solid, owns swap disposal, re-keys on switch); existence is always a **value** (`state()`), never a nullable `entry()`. `Entry.rpc` is the per-key procedure client (folds `{mapKey}` into every call; absent-key → typed `MAP_KEY_UNKNOWN`). - `@kolu/surface`: ref-counted base subscription **dedup** (N views of one `(proc, input)` → one upstream sub). - `@kolu/surface-remote` (**renamed** from `surface-nix-host`): `serveHostMap` bridges the warm ssh pool → the map, fusing pool membership with each session's `onState`, and folds a **clock offset** (RTT-halved at admit) into `connected` so two hosts render on two clocks without comparing them. ### Typed status vocabulary (D1/D2) - **`EntryStatus<Cause>`** — the `failed` arm carries a *typed* domain-cause discriminant, generic so `@kolu/surface-map` stays volatility-neutral (`= string` default keeps every consumer compiling); padi instantiates `EntryStatus<PadiEntryFailedCause>`. - **Typed contract-skew pair (D2)** — a `contract-skew-refused` cause carries a real `{ running, expected }` version pair on the wire, never re-scanned from a human string. - The **projection contract**: `warming` = IN MOTION (coming up, or coming back on its own); `failed` = NOT PROCEEDING WITHOUT INTERVENTION (a standing refuse *or* a terminal give-up). A retriable reconnect-backoff reads `warming`; a standing refuse reads `failed` + its cause. ## Half 2 — the gated product surface (kolu app) The **always-map flip**: the server serves + the client dials the map in one atomic change (the 4 import-time module-const subs die; host-scoped readouts ride `useEntry(activeHost)` under one app-scope owner; ~30 procedure sites go `padiRpcOf(activeHost())`). Gated entirely on `hostMapGate` (from `isMultiHost()`) — the client never reads env. - **Host selector strip** — one chip per pool host: a fact-only-green connection dot, a live awaiting-you badge read off that machine, active ring, guest remove-✕ (visible-dimmed at rest), a `+ add` input, and a real DOM-measured overflow row (`⋯ +N` menu) + a `sm` dropdown switcher. `hosts.add`/`hosts.remove` root RPCs; removing the unremovable default fails **loud**. - **Per-host canvas (shape B)** — each host keeps its own `HostView` (active tile, MRU, per-tile attention, restore latches) keyed by the canonical host string; a switch **swaps** the record, so host A's tiles never bleed into B, and a returning host re-hydrates its splits + focus. A pure switch fires **no wrong-host RPC**. A tile's split survives an A→B→A round trip (a transient Corvu collapse is no longer persisted). - **Stationary daemon slot** (header) — the active host's padi + kaval identity live in ONE fixed segment right after the Kolu chip; content re-keys on `activeHost`, position/size never change, so a host-switch never reflows the strip. - **Host-down cards (Skew-UX)** — when a host's binding is down, a cause-typed card (each `EntryFailedCause` first-class, incl. **`cross-supervisor` → "Another kolu owns this host"**) with a single **`[Switch to local]`** action and **no Retry**; a `contract-skew-refused` host shows a running→expected skew badge in the stationary slot. `CanvasFacts` is a discriminated union so kaval-derived facts are reachable ONLY on the `connected` arm (`.test-d.ts`-pinned). ## Ops — never two supervisors on one padi (P0 ownership-gated drain) Only **one** kolu-server may supervise a given padi state root — two would drain-and-respawn the same daemon in a livelock. - **Local pid-gate** — a sibling `supervisor.pid` (reusing the daemon pid-gate's atomic `link(2)` claim); a second kolu on the same root **fails fast at boot** with the `KOLU_STATE_DIR`/`KOLU_PADI_STATE_DIR` remedy. A same-lineage restart (dead predecessor) reaps the stale gate and still drains. - **Remote (D3)** — the existing anti-livelock fight-detection surfaces the typed **`cross-supervisor`** cause and fail-honest-refuses on both axes (never rides a contested build). **`KOLU_REMOTE_PADI_STATE_DIR`** forwards `--state-root` to the remote padi as the primary isolation lever (two kolus reach distinct remote padis). - dev / e2e paths isolate their state so the gate never false-positives beside production. ## Review + validation trail - **Serial gauntlet** (lens-debate → codex-debate → simplify → code-police) on top of a perfection-review + six adversarial re-sweeps. Defense-in-depth earned its keep — a `process.exit(1)`-defeating unguarded guest `session.destroy()` throw was caught by code-police alone (guarded at the source: a throwing teardown leaves the server alive + other entries streaming). - **Architecture review** (23 agents / 6 lenses / 16 refuted) → one confirmed framework defect + 2 minors, all fixed + pinned: the **P4** `projectStatus` collapse of retriable `disconnected` onto terminal `failed` (now → `warming`); a bare-field-presence `EntrySession|EntryFault` (now `kind`-tagged); an adopt path that could silently no-op `markConnected` (now enters `connected` directly + loud on a connector-contract breach). - **Live user-flow retest** (isolated `nix run` kolu ⇄ a real remote box via chrome-devtools) caught **two card-masking bugs no unit test could see**: `projectStatus` masking a standing refuse as `warming` (the chip), and `resolveCanvasMode`'s loading gate stranding a `failed` host so the host-down card never rendered (the canvas). Both fixed + pinned; each reproduced against a live host first. - **D3 validated for real** — driving a genuinely contested `sincereintent` (another kolu supervising its padi on a different build), the cross-supervisor path fired, the card rendered verbatim, `[Switch to local]` worked, and setting `KOLU_REMOTE_PADI_STATE_DIR` brought the remote up `connected` with no fight. - **Known tail**: an intermittent stdio-teardown `AbortError` flake (an orphaned orpc pull rejection on transport close) is root-caused with a deterministic mechanism repro + fix hypotheses in #1719 — deferred (a real fix would be an unvalidated shared-teardown change) rather than guessed; the wrong-premise in-PR comment is corrected so no false-premise code remains. ## Green Two-platform CI green (x86_64-linux + aarch64-darwin). Whole-workspace tsc; biome `--error-on-warnings`; nix build (client + server) + the full production `kolu.drv`. ## Try it locally ```sh KOLU_PADI_HOST="<ssh-host>" nix run github:juspay/kolu/surface-map ``` _Generated by [`/be`](https://github.com/srid/agency) on Claude Code (model `claude-opus-4-8`)._
…1710) The switch (W4, #1708) makes kolu multi-host; this note is the researched answer to "what next?" — the menu for the next planning pass, not a plan of record (`status: proposed`). Built from two ground-up research passes, not memory: an inventory of every shipped primitive and already-named future item in this repo, and a source-grounded survey of 17 capabilities across tmux/mosh/Eternal Terminal/Zellij/VS Code Remote/Coder/Gitpod/Tailscale/Warp/tmate/Blink/Wave — each cited, each assessed for whether kolu's architecture makes it easier or uniquely better. The organizing insight the survey produced: every existing product owns one slice (persistence, roaming, sharing, fleet cost, agent notification) and **none can know what an agent is waiting for** — so the note tiers all candidates by how directly they exploit kolu's typed agent-urgency advantage. Tier 1: the attention products (W5 validated, the dock, agent-aware idle suspension — the gap Coder's own users file). Tier 2: daemon-ownership dividends (agent-aware resurrection via `--resume` after a fleet reboot; auto port-forward with preview URLs; agent audit trails). Tier 3: already-named mechanics (honest clocks, `padi-tui --host` with the S10 correction — L24's cited `getHostSession` was deleted, kolu-tui). Four items parked with reasons; a constraint ledger (parity tail, urgency-count-sized wires, ssh-user trust model, drishti gate, clock gap) closes it. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
**`buildRemotePool`'s `remove` carried two different facts under one verb** — a *user* removing a host (which should persist the departure) and the pool shedding a dead session on its *own* initiative (#1708's guest re-serve pump-death). Once `remove` is wired to a `persist` hook, that conflation means an internal shed **permanently un-remembers a host the user never removed** — the defect the [W10 host-persistence PR](#1772) surfaced and is now blocked on. This splits the verb so persistence follows *intent*. ### Two verbs, not a `persist` flag | verb | teardown | persists? | for | | --- | --- | --- | --- | | `remove(host)` | evict sockets → drop membership → destroy session | **yes** (the `persist` hook fires first) | the **user's** explicit removal | | `retire(host)` | *identical* | **no** | an **internal** shed — a dead session the pool sheds itself; the host stays in the persisted set and a membership store re-seeds it next boot | The destructive teardown is factored into one shared `tearDownEntry`; the two verbs differ *only* in whether they `persist` before it. Because `retire` has no persist step and swallows its own teardown fault, it **can't reject** — so a fire-and-forget `void pool.retire(h)` needs no `.catch` (unlike `remove`, whose persist step can). kolu's #1708 guest pump-death path now calls `pool.retire`. > **On master this is behaviour-neutral** — kolu passes no `persist` hook, so `retire` and `remove` are identical today. It lands **first** so W10 can rebase onto a correct contract, per srid's **W11** ruling (the padi note, #1770). This is the sanctioned "framework change whose consumer lands right after it" shape. ### Drishti gate Additive API change — a new `retire` method on `RemotePool`. Drishti consumes `buildRemotePool`/`RemotePool` (`hostRegistry.ts`, `admin-router.ts`) but calls only existing verbs (`add`/`remove`/`reconnect`/`recheckAll`/`has`/`subscribe`/`destroyAll`) — never `retire`. Drishti's app **typechecks green** against this new surface-remote (verified locally by hydrating the change into drishti's tree). **No paired drishti PR is forced** — no drishti code changes are needed. ### Carried finding (from W10's review) **F3:** if `retire`'s teardown fails, a dead re-serve mirror could be left in kolu's `reServes` cache *and* the pool's `entries`. The teardown ordering here (drop membership + notify before destroy, destroy swallowed) is the pool half; kolu's per-host `reServeSurface` cache eviction is keyed to `pool.subscribe` membership, so a retired host's mirror is pruned by `pruneToMembers` on the membership drop. Tracked so it isn't lost. Tests: `retire` runs the same teardown as `remove` but doesn't persist; a no-op on an unknown host; swallows a destroy fault and resolves (proving the fire-and-forget is safe). _Generated by [`/be`](https://github.com/srid/agency) on Claude Code (model `claude-opus-4-8`)._
> ## 📚 Stacked on #1775 (the retire-verb split) > > This now sits on **#1775**, which delivers the framework fix W10 needed. The root cause — one verb (`pool.remove`) carrying both *user-remove* (persist) and *system-retire* (#1708 pump-death, must not persist) — is fixed there: kolu's guest pump-death path now calls **`pool.retire`**, which tears the host out of the live pool **without** persisting, so a transient guest fault no longer un-remembers a host. W10's earlier `.catch` guard on `pool.remove` was dropped in the rebase (retire provably can't reject). > > **Carried findings, resolved:** F2 (a guest pump-death permanently forgot a host) — fixed by the split's `retire`. F3 (a dead re-serve mirror stranded on retire) — kolu's per-host `reServeSurface` cache is evicted by `pruneToMembers` wired to `pool.subscribe`, which fires on `retire`'s membership drop, so the mirror is pruned. > > **Merge order:** split **#1775** first, then this. Base is `w11-retire-split`; it retargets to `master` automatically once #1775 merges. Tracked as **W11** in the padi note (#1770). > > _CI + evidence run on the final (rebased) tree — pending a free pu box (shared-pool saturation)._ --- **The remote hosts you add from the selector strip now survive a kolu restart.** Today a restart forgets everything but the local default and you re-add your fleet by hand; after this, every remembered host reappears in the strip and reconnects through the normal connect pipeline — a host that has since gone away shows its honest **failed** chip with the cause rather than silently vanishing. The only way a host leaves the strip is your explicit remove. Membership is a **server** fact — kolu-server's pool (`buildRemotePool`) is its one writer — so its memory lives beside that authority, never in the browser, where localStorage would fork the one list into per-device copies. The persisted artifact is a value replaced whole: a zod-schemed `{ version: 1, hosts: string[] }` JSON of **encoded host keys only**, beside `conf`'s `config.json` under `KOLU_STATE_DIR`, written atomically (tmp + rename, async so it never blocks the serving loop). ### The seam this wires (srid-approved over the brief's sketch) The brief sketched a hand-rolled write *inside the add/removeHost handlers*. But the pool already ships a purpose-built, transactional `persist` hook (from #1714) that kolu's own `buildRemotePool` call never wired. Hand-rolling would duplicate it — the "parallel hand-rolled mechanism" the *reuse the existing source of truth* rule names as a defect — so we **wired the existing seam** instead: | | Handler-side write (brief's sketch) | Pool `persist` hook (this PR) | | --- | --- | --- | | Ordering | writes *after* `pool.add` commits → disk/memory can diverge on failure | write ordered **before** the in-memory commit | | Concurrency | two rapid strip-adds race the file | serialized through the pool's one mutation queue | | Failure | host live but unpersisted | just-built session **rolled back** | ### Boot: seed, don't replay ``` parseKoluPadiHostSeed() ─┐ ├─▶ dedup ─▶ initialHosts ─▶ buildEntry ─▶ W6 connect pipeline loadPersistedHosts(file) ─┘ ``` Persisted hosts are merged into `initialHosts` (deduped against the env seed via order-preserving `new Set`) rather than re-added via a post-build `pool.add` loop. Both flow the identical connect pipeline, but seeding at construction *doesn't re-fire `persist`* — so the file is only ever rewritten by a genuine runtime add/remove, and **an interrupted boot can't truncate it**. ### Design philosophy - **Fail fast** — a file that exists but fails the schema *crashes the boot* naming the path (delete it to recover). Never start-with-empty-fleet, which would silently eat the user's hosts (`caught-error-must-not-collapse-to-empty`). This is why it's a **separate** file, not the `conf` store: `conf` data is reconstructible so a corrupt file resets to defaults; a fleet is not. Only `ENOENT` reads as "fresh install"; a permission error surfaces. - **Electricity** — persistence policy is app-level (`packages/server` only); the durability *mechanism* is the framework's `persist` contract. No framework change in *this* PR — that's exactly the split PR's job. - **Reuse the source of truth** — the pool's `persist` seam, `KOLU_STATE_DIR` (via a narrowed export from `state.ts`), the pool's own `encodeHostKey` vocabulary, and a shared `isEncodedHostKey` promoted into `kolu-common` (de-duping a copy the client held). ### Review gauntlet outcome - **architecture-first-principles** — 1 confirmed defect **fixed** (W10 made `pool.remove` rejectable; guarded the fire-and-forget guest-retire so a disk-write failure can't crash the whole server) + 3 minors folded in. It also surfaced the deeper verb-conflation that became the block above. - **lens-debate (lowy ⇄ hickey)** — consensus, 1 round, 1 fix (key the persist-exclusion off `LOCAL_HOST`, not the boot-default). - **codex-debate** — *stopped as non-converging* after 4 rounds (findings grew 13→16, never approved) when its author spiralled into an out-of-scope retry/overlay mechanism that generated its own blocking bugs. Reset to the lens-approved tree and triaged codex's **round-1** review (of the clean diff) by hand — the legit in-scope findings are applied (fail-fast read vs `existsSync` masking `EACCES`, `0600` mode on the ssh-target file, APM-source sync, atlas doc-sync). The full round-1–4 codex trail is posted as a comment for audit. - **simplify** — efficiency + altitude clean; applied a reuse de-dup and dropped dead test state. - **code-police** — 1 fix (async fs on the persist path). Two findings **recorded, not actioned** because they contradict the ratified plan: *prefer-focused-library* (use `conf`) — the plan mandates a zod JSON tmp+rename kept outside conf's ladder; *persisted-schema-stays-tolerant* (filter, don't crash) — the plan mandates a fail-fast crash-with-path, with a dedicated test. Surfaced for srid. Plan of record: the W10 section of `docs/atlas/src/content/atlas/padi.mdx` (#1770); the block resolution is minted there as **W11**. _Generated by [`/be`](https://github.com/srid/agency) on Claude Code (model `claude-opus-4-8`)._ --- ### Carried finding → resolved in the W11 split **F3 (codex round-1, residual):** if the internal guest-retire's teardown fails, a **dead re-serve mirror can be left stranded in both cache layers** (`reServes` in `index.ts` and the pool's `entries`). This is legitimately the **W11 split's** world — the retire path is being rebuilt there (the non-persisting internal retire), so the mirror-eviction ordering is fixed as part of that rebuild, not patched here. Named here so it isn't dropped; tracked on the W11 PR. _Disposition table for the full codex trail is in the coordinator report; the round-1 in-scope fixes are applied on this branch (fail-fast read, 0600 + fsync durability, env-seed provenance, strict schema, doc-sync)._
…2126) Kolu has been on `effect@4.0.0-beta.103` since it was current. Three betas later, [beta.106](https://github.com/Effect-TS/effect/releases/tag/effect%404.0.0-beta.106) is out, and the interesting thing about the gap is how little of it Kolu had to react to — and which one thing it *couldn't* avoid. ## The forcing change [beta.104](https://github.com/Effect-TS/effect/releases/tag/effect%404.0.0-beta.104) renamed the Schema error constructors to line up with their `Data` counterparts: | before | after | | --- | --- | | `Schema.ErrorClass` | `Schema.Error` | | `Schema.TaggedErrorClass` | `Schema.TaggedError` | | `Schema.ErrorReviver` | `Schema.ErrorInstanceReviver` | The old names are **gone**, not deprecated. Kolu spells `Schema.TaggedErrorClass` 37 times across 15 files — every wire-visible error in `@kolu/surface`, `@kolu/padi` and `kaval` is one — so the bump and the rename are one commit. None of the other renames in these betas (`Schedule.andThen`, `RateLimiter.makeSleep`, CLI `withHidden`) appear anywhere in the tree. **The rename also lands in a repo Kolu doesn't own.** `osfacts-client` is grafted into the workspace from the `osfacts` npins pin, it is `.gitignore`d, and it declares three `Schema.TaggedErrorClass` errors of its own — so it broke the typecheck from a file no grep of this repo would ever surface. That fix is **juspay/osfacts#6** (green on both platforms), and the pin here advances to that commit so the graft and the workspace agree on one Effect version. ## What the new betas gave us, and what we took Most of the new API has no home here — verified by sweep, not assumption: no `HttpClient` anywhere (so `withRateLimiter`'s new bounded 429 retries have nothing to attach to), no `Cron`, no `ExecutionPlan`, no Effect `Config`/`ConfigProvider`, no `HttpApi` (only `HttpRouter`), no multipart, no fast-check, no `File.seek`, and no Effect CLI. **YAML parsing moves into Effect — but only where it belongs.** beta.104 added lightweight INI/YAML/TOML parsers under `effect/unstable/encoding`. The two governance checks now use `Yaml.parse`, which **drops the `yaml` dependency from `packages/tests` outright** (Effect was already a dependency there). Both parsers were run over the real inputs first — `pnpm-workspace.yaml` and the 87 KB `coverage-ledger.yaml` — and agree deep-equal, 4ms→2ms and 34ms→5ms. `@kolu/solid-markdown` deliberately **keeps** the `yaml` package, because the same differential says the new parser is the wrong tool for *that* input. Front matter is user-authored, and against a corpus of all 312 front-matter blocks in this repo plus the cases `render.test.ts` pins, the new parser: - throws on a multi-line plain scalar (`desc: one⏎ two`), which `yaml` folds — a front-matter block that renders as a table today would fall back to a raw code block; - throws on the cyclic anchor `a: &a [*a]` that `render.test.ts` explicitly pins as "must still render a table"; - stops recognising a following ` #` comment once a plain scalar contains an apostrophe — `kolu's closed #1708 manager` keeps the comment as content where `yaml` truncates. Two real Atlas notes parse differently because of it. (It is *better* on one axis — it parses the compact nested mappings in six `SKILL.md` files that `yaml` rejects — but "differently wrong on user content" is not a trade worth taking for a preview surface.) **A `Stdio` adoption was tried and removed.** `kaval-tui attach` briefly asked Effect's new `Stdio` service whether stdin/stdout were terminals. The review gauntlet killed it and it is **not** in this diff — see the lens comment below. Short version: `NodeStdio.layer` is `Effect.sync(() => process.stdin.isTTY === true)`, a pass-through of the global it replaced; `main.ts` runs `cli()` at module load so no test can ever import it to substitute the service; and `attach.ts` already has `AttachTty`, a *tested* seam carrying the raw-mode and resize operations `Stdio` structurally cannot express. `packages/kaval-tui/src/main.ts` is byte-identical to master. ## The beta-assumption gate, honoured rather than satisfied `packages/tests/governance/betaAssumptions.ts` exists so a version bump can't land quietly: every `BETA-ASSUMPTION(<tag>)` marker must carry the current pin's tag, so a bump turns all of them red until someone **re-measures the behaviour**. Re-stamping them is the easy way to defeat the mechanism, so each was actually re-measured against beta.106: - the reactor, socket-redial and frame-limit assumptions by the named tests they cite — `@kolu/surface` is green at **61 files / 628 tests**; - a failed `Effect.forkChild` child still does **not** fail its parent fiber; - `Schema.optionalKey(Schema.Never)` still accepts a missing key and rejects any present value, explicit `undefined` included; - the RPC pinger still `Effect.delay("5 seconds")` and opens its kill latch on the next unanswered tick — so `probeDaemonIdentity`'s derived 8 s still sits inside the band it was derived from. **The gate had a blind spot, and this PR closes it.** Its regex validates the marker *line* only — so three markers here were re-stamped to beta.106 while the prose recording *what was measured* still said beta.103, and that shipped green. Code-police caught it. The prose is now re-verified (all of `frameLimit`'s quoted dist facts still hold verbatim in beta.106), and a second scan holds every `effect@<version>` written in prose under `packages/` to the pinned version. Proven in both directions: green as-is, and failing loudly when the exact original defect is re-introduced. ## One claim worth retracting An earlier reading of beta.105's release note suggested Kolu's ~8 schema-decode sites would lose their human-readable errors, since "schema issues no longer format implicitly." Measured across both versions, that is **not** what happens: the change moved the **constructor** adapters to a generic `"Schema validation failed"` with the issue in `cause`, while `decodeUnknownSync` — which is what every one of those sites uses — still throws a `SchemaError` whose message is the path-annotated tree, byte-identical on beta.103 and beta.106. No migration was needed and none was done. ## Verification - **CI green on both platforms** — `6a6c4c2#1`, 24 recipes per lane, 14m26s. All 48 required checks green. - `@kolu/surface` unit lane — 61 files, 628 passed - governance gate — `effect@4.0.0-beta.106 agreed across 68 pin sites, 11 beta-behavior assumptions stamped (3 evidence citations agreed)` - no open code-scanning alerts on this PR **Review gauntlet**: lens (lowy ∥ hickey) applied, simplify clean + 1 altitude fix, code-police 4 findings all actioned. The **agent-debate track has not run** — the `grok` CLI is unauthenticated on this machine (`grok login`), and the gauntlet forbids substituting another peer. **Merge order**: juspay/osfacts#6 first, then re-point this pin at the merged commit (its `branch` field currently names the feature branch deliberately, so `npins update` can't silently walk it back to the pre-rename client). 🤖 Generated with [Claude Code](https://claude.com/claude-code)





kolu keeps warm connections to several machines and each browser tab picks — and live-switches, no reload — which machine it's looking at. Your laptop tab can sit on
zestwhile your phone sits onsincereintent, against the same kolu, neither disturbing the other. This is W4 "the switch" (padi plan of record) — ONE kolu PR + a paired drishti PR (the framework half).Implements the plan of record's three layers, top to bottom.
Framework —
@kolu/surface-app(drishti-paired)SurfaceAppProvider'scontrolPlanebecomesAccessor<ControlPlane<T>>(the ratified S5 shape). The build-identity subscription is re-derived per control-plane identity (acreateMemo+createRoot-per-identity), so a live host swap tears down the old host's stream and re-subscribes — a framework guarantee, pinned by a new provider test ("swap tears down + re-subscribes", where the gray-chip #1687 class goes to die). Static callers pass() => client. drishti is the proving second consumer (.claude/rules/surface.mdgate); its migration is a one-character edit (surfaceAppClient()→surfaceAppClient, already accessor-shaped).Server — kolu (additive; the layer L6+L23+#1705 cleaned)
The warm pool:
hostPool.tsholds abuildHostRegistrywith onePadiSession(DaemonSession<PadiSurfaceClient>) per machine — the local endpoint arm for the local host, the ssh arm otherwise — each re-served into a per-host router (the sharedkolu+surfaceAppfragment spliced with THIS host's padi) and a per-hostWsRPCHandler. Nocontrols(kolu needs no fleet verbs). ws-upgrade dispatches by the connection's declared?host; an unknown host is rejected loud (close 1008). New root RPCshosts.add/hosts.remove— the picker adds before it connects (deliberate intent, never a side-effectful GET that provisions ssh from a stray query param).preferences.recentHosts(schema +1.32.0migration) is the pool's persisted host set, shared across a user's devices. The preview route reads through the per-request host's session — killing theremoteHostfork (closing the #1685 deferral).server.info.defaultHostlets a tab pick its initial binding (KOLU_PADI_HOSTboots CI through it).Client — kolu (the de-entangling)
The wire stops being one page-lifetime module-global bundle.
binding/bindings.tsholds oneconnectSurfacesbundle per host; anactiveHostsignal picks the one the tab views;switchHostswaps it in place — no reload.wire.tsre-exports the sibling clients as proxies over the active binding (so ~35 consumers don't rename), and the app-lifetime singleton subs re-key viabindingScoped(tear down + re-subscribe on a switch — the client twin of the framework guarantee).retiredflag; switching retires + disposes the old one, so a stale in-flight call rejects loudly rather than landing on the wrong or dead host — and separate sockets make a cross-host route impossible by construction. Pinned bybindings.test.ts.connectioncell (mirroredSurface,liveWhen), folding intopadi.health().liveper binding.useDaemonStatusderives readiness / the create-refused-while-warming gate / the loud degraded states off the active host. The single-hostkolu.padiLinkcell retired — it could never carry N bound hosts (the mirrored connection cell is its per-binding replacement).KOLU_PADI_HOSTare both undocumented — together the only way to reach remote terminals until the feature stabilizes.Done (the W4 list)
preferences.recentHostsinconfig.jsonrefuseIfWarming()reads the active host's readinessDesign-philosophy honored
?hostis rejected loud; a stale binding throws (no silent misroute); the re-serve pump failing isprocess.exit(1); illegal states unrepresentable via the typed pool slot (DestroyableSession) + the misroute guard.@kolu/surface-app's accessor — with drishti as the proving second consumer, not a new app-in-a-package.connectioncell (not a hand-rolled per-host padiLink);buildHostRegistry(not a bespokeMap);preferences(not a parallel store).Notes
padi-cleanup.mdx(coordinator-owned, RT-fable).session.identity()/convergence()(perHostKoluCells.ts), not the boot default — an earlier note here called them an "acceptable-for-scope default-host diagnostic"; the adversarial review killed that. serverRss / server uptime stay kolu-server's own (host-independent).Known gaps (ratified deferrals — for morning ratify/overrule)
control.core.clockNowsince W1, but NO binder consumes it: host-stamped timestamps (lastActivityAt,sleptAt,parkedAt) are formatted with the viewer's clock, so a cross-host "3m ago" is wrong under clock skew. Pre-existing (W3.1-era), not a W4 regression. The served half exists; the consumer half (wiring the RTT-halved offset through to the client) is the fast-follow.kolu-ci-5pu box: palette switch local → kolu-ci-5 → local with no reload, a live terminal on the remote (toor@kolu-ci-5), and two tabs holding different hosts at once — see the ## Evidence comment. The drive also caught two real bugs now fixed on this branch (the picker's free-typed connect couldn't submit; a first-boot D1 schema-validation false error). A repeatable e2e scenario still wants pu headroom to run every PR ([Log] pu issues #1204), but the behaviour itself is demonstrated.Post-review live testing (on real hardware) — 3 bugs + a beta relabel
Running the feature on a Mac (zest) binding another Mac (sincereintent) surfaced what the unit suite couldn't:
useCellwrapped itscreateSubscriptionin a detachedcreateRoot(() => …)whose dispose was discarded, so a bindingScoped switch disposed the per-key owner but never the sub →controller.abort()never fired → the leaked sub errored on the retired socket and the (correctly guarded) catch sawaborted === false→ toast. Fixed by tying the useCell root's dispose to the caller's owner + threading the abort signal intosource()— the invariant "a disposed subscription cannot report anything" — and retireSocket now throws an honest "socket retired by this client" fact, not a "server restarted" claim (the genuine-restart affordance stays the lifecycle-status reload overlay). Pinned byuseCellDispose.test(red before the fix).selected: undefined).recentHostskey, logging a scary "Delete your state file" for a store that was fine. Fixed by reading every schema key.Labs relabel. The picker moved from the "Debug" palette group to a new "Labs" group (flask icon) — the truthful home for a works-but-unstable beta feature — with the entry carrying a known-issues link (
kolu.dev/atlas/remote-bind-parity.html). Same hidden posture (palette-only, undocumented); just an honest "beta, unsupported" signal Debug never conveyed.Adversarial review (coordinator's 4-lens batch) — all 13 findings actioned
B0 (compile-error backstop for subs opened outside
bindingScoped) · B1/B2/B3 (stale-binding pins: the Code-tab pulse + preview + terminalExit re-key per host) · A1 (per-host padi diagnostics) · A2 (server.infolabels per-host) · A3 (default host un-forgettable — un-brick) · C1 (switchHost epoch: last-pick-wins + cancel-via-re-pick) · C2 (a guest host's pump fault is contained, not a global exit) · D1 (recents live cross-device via their own server-authority cell) · H1–H4 (createKeyedRoot extraction, per-host names, installUnknownHostFallback, loud preview fallback) · F-a/F-b/F-c (the done-list test-honesty debt pinned). Each fix keeps the suite green; see thefix(review)commits.🤖 Generated with Claude Code