Skip to content

feat: phase 1 — one terminal in the browser - #2

Merged
srid merged 15 commits into
masterfrom
claude/elastic-raman
Mar 20, 2026
Merged

feat: phase 1 — one terminal in the browser#2
srid merged 15 commits into
masterfrom
claude/elastic-raman

Conversation

@srid

@srid srid commented Mar 19, 2026

Copy link
Copy Markdown
Member
image

srid added 3 commits March 19, 2026 18:09
Full terminal stack: Axum spawns a PTY (portable-pty), pipes output
through a broadcast channel to WebSocket clients. Browser renders via
ghostty-web through a thin JS bridge (ghostty-bridge.js) with 1:1
wasm-bindgen bindings.

- server: pty.rs (spawn/read/write/resize via channels), ws.rs
  (bidirectional WS↔PTY pipe with scrollback replay), state.rs
- client: ghostty-bridge.js, terminal.rs (FFI bindings),
  terminal_view.rs (Leptos component with WS status signal)
- common: WsClientMessage/WsServerMessage enums, DEFAULT_COLS/ROWS
- nix: fetchurl ghostty-web tarball, inject into buildTrunkPackage
- UI: compact header with branding + WS status indicator
- e2e: smoke (branding + canvas), terminal input, resize survival
- justfile: test-dev recipe for fast testing against running dev server
- Add ResizeObserver so terminal reflows when browser viewport changes
- Extract send_resize() helper to deduplicate 3x WS resize pattern
- Create e2e test DSL (tests/e2e/dsl/) inspired by openspatial:
  scenario() entry point, TerminalView/AppView typed abstractions
- Rewrite smoke + terminal tests using the DSL
- Add border around terminal container
- Inline ghosttyWebTgz into single nix derivation
nix build compiles Rust + WASM from scratch, which can exceed the
previous 120s timeout. Bump to 600s.
Comment thread client/src/main.rs Outdated
Comment thread client/src/terminal_view.rs Outdated
Comment thread client/src/terminal_view.rs
Comment thread client/src/terminal_view.rs
Comment thread client/src/terminal_view.rs Outdated
Comment thread server/src/state.rs
Comment thread server/src/ws.rs
srid added 9 commits March 19, 2026 18:46
Address PR review: split terminal_view.rs monolith into focused modules,
extract named functions from closure nests, add doc comments throughout.
Update code-review skill with learnings on module structure and readability.
Replace manual Closure::wrap + forget with leptos-use hooks:
- use_websocket_with_options for WS connection
- use_resize_observer for container resize
- use_event_listener_with_options for font zoom keydown
- on_cleanup for terminal dispose on unmount

Extract bridge.rs for non-Leptos JS helpers (wait_animation_frame,
extract_size, localStorage, build_ws_url). Simplify ws.rs to just
WsStatus type with From<ConnectionReadyState>.
- measureCells() now uses actual grid dimensions instead of hardcoded
  80x24, so cell size is correct after font size changes
- stop_propagation() on zoom shortcuts prevents =/-/+ leaking to PTY
- Add e2e tests: canvas fills container, fills after zoom, no keystroke leaks
ws_open() is non-blocking — the resize message sent immediately after
was silently dropped. Now watches ready_state signal and sends resize
when WS transitions to Open. Adds e2e test verifying cols > 80.
@srid
srid marked this pull request as ready for review March 20, 2026 00:23
srid and others added 3 commits March 19, 2026 20:24
- bridge::extract_size now returns Option<(u16, u16)> instead of
  hardcoding defaults from kolu_common
- Collapse nested if in ws.rs (clippy::collapsible_if)
- Update CLAUDE.md with clippy and pre-commit quality gates
@srid
srid merged commit 9b93616 into master Mar 20, 2026
5 checks passed
@srid
srid deleted the claude/elastic-raman branch March 20, 2026 00:51
srid added a commit that referenced this pull request Jun 30, 2026
…review F1)

restoreTargetOf paired memory.lastAgentCommand with observed.agent without checking
they name the same agent kind, so a stale-command/new-agent race (or migrated/edited
state) could build exact{ command: 'opencode …', agent: { kind: 'claude-code', … } }
— which resumeAgentCommand silently downgrades to opencode's most-recent, the
wrong-agent resume #2 exists to make unspellable, relocated inside the exact arm.

Add exactRestoreTarget(command, agent) in anyagent — the ONE constructor that builds
exact ONLY when agentKindFromCommand(command) === agent.kind, else null. Both
production sites go through it: the fold (restoreTargetOf → none on mismatch) and the
migration (backfillAwarenessCutover → legacyMostRecent on mismatch). resumeFormFor
then always takes resumeAgentCommand's same-agent path (exact-by-id or refuse), never
the most-recent downgrade. Pinned in fold.test, agent-cli.test, sessionTransfer.test.
srid added a commit that referenced this pull request Jun 30, 2026
## Awareness — the producer observes, kolu remembers (S1 + S2 → R9.0)

Implements the merged plan
[`awareness-derive-store.mdx`](docs/atlas/src/content/atlas/awareness-derive-store.mdx)
(#1621) as **one coordinated change** — S1 (the memoryless producer) and
S2 (kolu's fold) are coupled, so they ship together — then **hardened
across two rounds of a 4-reviewer architecture-first-principles +
perfection-review gauntlet** (`debates/pr1626-review`,
`debates/pr1626-rereview`). The reviewers credited the fold spine
**sound**; every residual was in the shell around it — the first round's
four must-fixes (below), then two relocations codex caught on re-review
(F1/F2).

### The cut

The per-terminal awareness engine used to **derive** awareness by
**mutating a host record it also read back as memory**, through an
`AwarenessSink` two homes (kolu + `pulam`) each had to wire. This splits
it:

- **S1 — memoryless producer.** `startAwareness` → `startSensors`: it
*emits* per-field `TerminalEvent`s and takes no seed, no sink, no
record. Deleted: the `AwarenessSink` interface, **both**
`makeAwarenessSink` impls (kolu's + pulam's `hooks.ts`),
`AwarenessRecord`, the apply-and-publish read-back.
- **S2 — kolu's fold.** A pure `fold(cur, o, ctx) → TerminalState`
(`{snapshot: TerminalSnapshot, memory: AgentMemory}`). The producer's
emit type **cannot spell** a memory fact, so the old "two narrowed
mutators" write-fence is now the **type**.
- **R9.0 — local cutover.** kolu runs the producer in-process and folds
the stream, publishing the snapshot half to `snapshots` and the memory
half to `kolu.authored`.

### Review-gauntlet hardening (the four must-fixes)

| # | Finding | Fix |
| --- | --- | --- |
| **#2 / #7** | The restore target was a bare optional `resumeAgent`
identity read alongside `lastAgentCommand`, so `(command set, identity
absent)` meant **both** "quit, restore nothing" *and* "no id captured,
resume most-recent" — and the derivation lived in the emit shell, not
the fold. | The fold now produces a **discriminated `RestoreTarget`** —
`none \| { exact, command, agent } \| { legacyMostRecent, command }`
(`restoreTargetOf`, consumed by `resumeFormFor`). Absence can no longer
be read as most-recent: **quit-to-shell → `none` → bare shell by
construction** (#1492) for new records; a migrated pre-1.29 record keeps
most-recent as a *named* `legacyMostRecent`. The client restore card +
dormant tile require an `exact`/`legacyMostRecent` target. |
| **#5** | The authored *collection* re-published on **every**
observation tick (~150 ms) — only the disk arm was fenced. | The emit
loop computes deltas once and gates each arm by **its own** delta:
snapshot publish on a snapshot change, **authored publish on an
authored-fact change**, disk autosave on a restore-relevant change. |
| **#1** | Recency leaned on a 1.5 s wall-clock window; a slow agent
resolution crossing it could restamp saved recency on restore. | The
frame phase is decided **by value** — compare the re-resolved agent
identity to the saved restore target. The timer is deleted. |
| **#8** | The schema module's docstring still taught the deleted
`AwarenessValue` / `AwarenessSink` world. | Rewritten to
`TerminalSnapshot` / `AgentMemory` / `TerminalState` / the emit type. |

`AgentIdentity` + `RestoreTarget` moved down to `anyagent/schemas` (the
resume-vocabulary layer), re-exported through `terminal-workspace` — the
fold *produces* the target, `resumeFormFor` *consumes* it, one owned
type. The serial lens → codex → simplify → code-police pass then
collapsed the vestigial `AgentSessionRef` into `AgentIdentity`, shared
one `resumableCommand` projection across the client surfaces,
value-gated the emit fences, and hardened the migration against corrupt
input.

### Second re-review — two relocations codex caught (the panel earned
its keep again)

A second 4-reviewer pass on the hardened HEAD credited the four
must-fixes **closed by construction** — but codex (1 of 4) found two
real defects relocated *inside* the new shapes, both verified against
the diff:

| # | Finding | Fix |
| --- | --- | --- |
| **F1** | `restoreTargetOf` paired `lastAgentCommand` with
`snapshot.agent` **without checking they agree on agent kind**, so a
stale-command/new-agent race could build `exact{ command:"opencode …",
agent:{kind:"claude-code"} }` — which `resumeAgentCommand` silently
downgrades to opencode's most-recent: the wrong-agent resume #2 makes
unspellable, re-expressed in the `exact` arm. | One constructor,
**`exactRestoreTarget(command, agent)`** (anyagent), builds `exact`
*only when* `agentKindFromCommand(command) === agent.kind`, else `null`.
**Both** production sites go through it — the fold (→ `none` on
mismatch) and the migration (→ `legacyMostRecent` on mismatch) — so
`resumeFormFor` always takes the same-agent path (exact-by-id, or
refuse), never the downgrade. |
| **F2** | The funnel `guardedEmit` swallowed a thrown emit **after**
the producer advanced its dedup baseline, so "emitted" ≠ "accepted": the
fold never got the value while the baseline advanced → a later equal
value deduped → silent permanent divergence. The guard sat at the wrong
seam. | Error handling moved to where the fallible work is — the host's
**publish boundary**.
`commitSnapshot`/`updateMemory`/`emitTerminalsDirty` (kolu) and pulam's
snapshots `upsert` each **fold-accept first, then guard their own
publish** (logged, self-heals on the next change). With the publishes
infallible, `emit` is infallible — and the wrong-seam funnel guard is
**removed**. |

### Type-naming cleanup

A follow-up **mechanical, compiler-checked rename** gives the awareness
types **one root per shape** — `TerminalSnapshot` (the whole value a
host emits) · `TerminalEvent` (a per-field delta) · `TerminalState` (the
`{ snapshot, memory }` composite kolu folds) · `Known<T>` (resolution
status). The `Terminal` prefix scopes the wire-facing family, and a
field repeats its type's root (`entry.observed` → `entry.snapshot`). The
served collection key bumps `awareness` → `snapshots`
(`TERMINAL_WORKSPACE_CONTRACT_VERSION` `2.0` → `3.0` — breaking, the
wire path changed). "Observation"/"awareness" survive only as
feature-word prose; the deleted `AwarenessSink`/`AwarenessValue` keep
their old names where the text describes what was removed. The
schema-module docstring, the [design
note](docs/atlas/src/content/atlas/awareness-derive-store.mdx) (types,
prose, and the foldflow/homes diagrams), and the unit-test fixtures all
move with the code.

### Honoring the design philosophy

- **Fail-fast / no knobs** — the write-fence is the emit type; the
restore decision is a discriminated value, not a runtime fallback. No
override path.
- **Volatility boundary** — producer/fold/`RestoreTarget` stay in the
`@kolu/*` packages; kolu and pulam are consumers, the dependency arrow
points out.
- **Reuse the source of truth** — `foldSnapshot` is shared by kolu's
fold and pulam's accumulator; `restoreTargetOf` / `exactRestoreTarget`
are the *one* place a restore target is decided; the migration composes
the existing backfill ladder.

### Deferred refinements (documented honestly, with pins)

- **Active drain-at-sleep (#3).** `beginSleep` freezes the last
fold-written target and does **not** drain a final settle, so a
sub-second launch-then-sleep freezes a stale `none` and wakes to a
**false bare shell**. The docs no longer claim the freeze "holds by
construction"; under model B the consequence is fail-*safe* (a bare
shell, never the wrong conversation). The active drain is an
async-`beginSleep` follow-up.
- **`git`/`pr` reference equality (#6).** `restoreRelevantEqual`
compares `git`/`pr` by reference (correct today — upstream
`gitInfoEqual`/`prResultEqual` dedup before emit). A `fold.test.ts` pin
asserts a structurally-equal-but-new-ref `git`/`pr` still compares equal
at the fence, so a future fold copy can't silently break it.
- **R9.3 scope** — the framed `TerminalFrame` *wire* stream and
cross-host keying stay R9.3; R9.0 keys by `TerminalId` and folds
in-process.

### Migration

`SCHEMA_VERSION` 1.28.0 → 1.29.0: `backfillSnapshotCutover` backfills
`pr: { kind: "absent" }` and **synthesizes the discriminated
`restoreTarget`** from what a pre-cutover record remembered — a valid
`agentSession` whose kind matches the command → `exact`, a command alone
(or a kind mismatch) → `legacyMostRecent`, neither → absent (`none`);
corrupt `agentSession` value-types fall through safely rather than
producing a parse-rejecting record.

### Tests & CI

The pure `fold` / `restoreTargetOf` / `exactRestoreTarget` /
`resumeFormFor` are unit-tested (the discriminated switch, the
kind-mismatch refusal, the ref-stability pin, the publish-boundary
infallibility). The sleep/wake, adopt, reconcile, metadata,
session-transfer, and restore-card tests track the reshaped persistence;
the e2e session-restore + sleeping-terminals fixtures were fixed to
stamp the now-required `pr`. All **unit tests** green across the touched
packages, `just check` (tsc + biome) clean, and the **Atlas sync gate
idempotent** — re-confirmed after the type-naming cleanup. **Full CI
green on both `x86_64-linux` and `aarch64-darwin`** (every recipe × both
platforms) on the naming commit, including `ci::e2e` on each.

_Generated by [`/be`](https://github.com/srid/agency) on Claude Code
(model `claude-opus-4-8`)._
srid added a commit that referenced this pull request Jul 2, 2026
… padiSurface; delete surfaceCtx + reverse seal

Review #2 (W1 padi seam): padi's domain modules co-wrote three foreign
koluSurface cells (activityFeed, session, terminalList) and published the
terminalExit event through an INJECTED kolu ctx (packages/padi/src/surfaceCtx.ts)
— a reverse-direction dependency the forward seal couldn't see, and the mechanism
behind the boot-recursion crash. This relocates every terminal-DERIVED wire
member onto padi's OWN surface/ctx and seals the arrow.

- padiSurface gains `activityFeed` + `session` cells (value forwarding policy);
  `session` merges with the existing `session` procedure namespace onto one wire
  node (no verb overlap). koluSurface shrinks to just preferences + processMemory.
- The session/activityFeed conf-store STORAGE stays kolu-server's source of truth
  until W2.2: kolu-server builds the stores and INJECTS them into padi at boot
  (confStores.ts, fail-fast requireX getters mirroring requireServerProcessId).
- servePadi backs both cells; the session `get` reads the injected store DIRECTLY
  + normalizes empty→null INLINE (never delegating to getSavedSession — the
  non-recursive backing that fixes the boot crash), carrying the content-dedup
  `equals` + the autosave-cancel `onWrite`.
- session.ts / activity.ts / local.ts (terminalExit) now write padiSurfaceCtx;
  the client reads session/activityFeed/terminalExit off the `padi` client.
- terminalList cell deleted: the client derives its list from padi's `terminals`
  collection keys stream; local.ts's emitTerminalListChanged is removed (the
  collection's upsert/remove IS the list source). No client reads TerminalInfo.pid.
- getMetadata is made SOUND (no cast): it narrows padi's 3-arm PadiTerminal union
  to the honest 2-arm TerminalMetadata via the isParked guard (parked → undefined).
- Dormant terminalWorkspace sibling retired from kolu-server's serving (zero
  consumers; the @kolu/terminal-workspace package + pulam's serving are untouched);
  the dead `workspace` client export dropped.
- surfaceCtx.ts + workspaceSurfaceCtx.ts deleted; a REVERSE arm added to seal.test
  (d): no packages/padi/src file may import or type against koluSurface.
- Parked-forfeit reconcile: a plain lifecycle.create discards lingering parked
  entries (session.restore still consumes them via the parked→active flip).
- Restore toast restores the "Restored N terminals, resumed M agents" counts.

Tests: padi contract (cells list + forwarding), reverse seal (d, verified red on
a reintroduced koluSurface ref), session non-recursion + normalization, parked
forfeit, and the restore toast counts — each red without its fix.
srid added a commit that referenced this pull request Jul 7, 2026
… procedure ns (re-run #4 blocker)

BLOCKER — a SECOND boot-breaker the mocked test surfaces hid. serveSurfaceMap's router-build
loop reset `inner[member] = {}` unconditionally per member tuple from entryMemberVerbs, which
emits a shared name TWICE (primitives first, procedures last). padi's `session` is BOTH a cell
{get, test__set} AND a procedure namespace {restore, import, forfeit}, so the procedures pass
clobbered the cell's verbs — `/surface/padi/session/get` 404'd on EVERY default boot (even
single-host), firing "Saved-session subscription error" and leaving savedSession() null so the
restore card never mounted: session-restore broken end-to-end. The contract half MERGES
(define.ts:204) and oRPC's t.router doesn't validate completeness, so it was a SILENT
contract/router divergence with no boot throw.

The fix IS the pre-map guard restored: `inner[member] = inner[member] ?? {}` — exactly
implementSurface's `namespaces[ns] = namespaces[ns] ?? {}` (surface/server.ts:1757) and the
contract's own `?? {}` merge. This is a regression the always-map flip introduced; the pre-map
implementSurface path served padi's session cell correctly.

Pin closes the mocked-surface blind spot (both existing map test surfaces had ZERO procedures):
a collisionSurface whose `session` is a cell {get} AND a procedure ns {ping}, served through
real serveSurfaceMap → directLink → connectSurfaceMap, asserts BOTH routes resolve — the cell
value (would 404 under the bug) and the procedure.

Green: surface-map 17, client tsc, biome. (Re-run #4 blocker of 5; #2/#4/#1/#5 forks live.)
srid added a commit that referenced this pull request Jul 7, 2026
…(re-run #4 major)

activeHost was a per-tab persistedPref whose ONLY writer was the chip onClick — no effect
reconciled it against padiMap.entries membership. Removing the ACTIVE guest (user ✕, or the
SERVER auto-retire on guest re-serve-pump death at index.ts's `void pool.remove(h)`) left
activeHost pinned to the dead host: useEntry(activeHost) never re-keyed, the app-scope subs
stayed on the departed host, every padiRpcOf(activeHost()).surface.* mutation threw
MAP_KEY_UNKNOWN, and the canvas went blank with NO auto-switch and NO toast — a caught-error →
silent dead-end (violating fail-loud). index.ts:307's "the canvas falls back" comment asserted
a fallback that did not exist.

Fix (mirror the shipped terminal auto-switch pattern, at the host level): a createEffect in
wire.ts's app-lifetime hostScoped root reads padiMap.entries.use().keys() and, once a snapshot
has landed, reconciles a departed active host to LOCAL_HOST with a toast.warning naming it. The
decision is the pure hostReconcileTarget(keys, active, localHost) helper (wire.ts is module-init,
untestable in isolation — same pinnability precedent as floorOnLiveness/pruneToMembers). Guards:
no-op during the warming window (empty keys), when the active host is still a member, or when
it's the local default. The entries sub dedups with the strip's via the base-client ref-count.
index.ts:307's comment is corrected to name the client reconcile that now makes "falls back" true.

The reconcile is trigger-agnostic — it keys off entries membership, so BOTH the user-✕ and the
server auto-retire (index.ts:339) departures are covered by the one mechanism + its pin.

Green: client 574 (+4 hostReconcile pins), tsc, biome. (Re-run #4 major of 5; #2/#1/#5 forks live.)
srid added a commit that referenced this pull request Jul 7, 2026
…ackages

The remaining type-audit minors, batched disjoint-by-package (4 agents, no-git-ops
guard, one recovered cleanly from a mid-run API error). Combined green: tsc 49
projects; surface-map 22 / surface-remote 133 / padi 221 / server 186 / client 600.

surface-map+client: entries floored via the same floorOnLiveness as state() (the
  #1568 green-over-dead closed at the membership path); EntryConnectionState/MapRegistry
  parameterized over Prov (a local-only registry gets a compile error on copying,
  test-d pinned); Subscription.complete forwarded through delegateSubscription + wire +
  createPolledQuery (stays optional — non-factory sites have no typed-end to report);
  useIntentEditor collapsed to one clear-ability representation.
server: PadiSession made generic over Prov (local arm PadiSession-of-never can't be
  copying, test-d pinned — the split's last consumer); ProcessStartedAt seed uses the
  real serverStartedAt epoch, not the in-band 0.
padi: PadiIdentity union dropped the uninhabited undefined arm; HostLocation remote
  hostId min(1) + pin. (3 real-but-cross-package residuals documented for follow-up:
  PadiUrgency.awaiting, lastActivityAt-0 in terminal-workspace, SavedSession double-absence.)
surface-remote: getHandler returns the entry (membership by presence, not a colliding
  undefined handler); ResolveDrvError.failureCause tightened off network; ExitResult now
  a closed exit|signal|spawn-error union (honest signal, no code-null conflation);
  relayStream lead boxed; ClosedInfo gained transport-failed (ssh-255) + endpoint-down
  (no-process death) variants. ConnectionInfo flatten REFUTED (deliberate loose wire
  projection, safety inherited from the SessionState sum at source — a wire discriminated
  union would ripple to drishti for an unreachable, un-read contradiction).

drishti Phase-3 surface.md gate carries the API-facing surface-remote deltas (#2-#7).
Residual: the ClosedInfo endpoint-down PRODUCER (server padiBinding) migration is a small
paired follow-up. [never-defer]
srid added a commit that referenced this pull request Jul 8, 2026
…t at connecting/down, so the Skew-UX host-down card never rendered

Second step-5 catch (driving a live cross-supervisor sincereintent): projectStatus now
correctly reads the chip as failed, but the CANVAS still showed 'Connecting...' / the
kaval-dead DegradedCanvas, never the 'Another kolu owns this host' card. Root cause: a
failed host binding has no daemon-status coming (daemonPending stays true forever), and
resolveCanvasMode's loading gate had 'entry === failed' in its ceiling — so a failed
entry resolved to connecting/down BEFORE reaching the 'failed -> host-failed' arm. The
Skew-UX host-down card was thus unreachable for any failed host. Fix: the loading gate
excludes a failed entry (it has nothing to wait for), so it falls through to its
cause-typed card. Pinned: canvasModeResolver.test.ts (failed + daemonPending +
pendingTimedOut -> host-failed, cross-supervisor AND link-failed).
srid added a commit that referenced this pull request Jul 8, 2026
…xed; Skew-UX card verified end-to-end on live contested host
srid added a commit that referenced this pull request Jul 28, 2026
…d a source that says what its silence costs

The facet is the noun V2 introduces and it had no declaration: a bare `String`
in Rust, spelled as ~49 literals across the two sensors. It is now a `Facet`
enum, carried across to the TypeScript client by a checked-in `facets.json`
that both sides pin by test. Around that, four honesty defects where the tool
dropped a fact instead of reporting it — the failure mode this tool exists to
refuse.

- lowy F1 ≡ hickey #1 — facet names were a typed union on the consumer and a
  bare `String` on the producer, so a Rust typo shipped and surfaced as a
  consumer parse error
- hickey #5 — darwin's `kern_proc_all` named one facet, but its silence costs
  `proc`, `start_time`, `uid` and `status`; a `--uid` consumer scoped it away
  and read an empty table as "no process has a uid"
- lowy F7 ≡ hickey #4 — a failed host-global constant (linux page size, darwin
  mach timebase) degraded to N per-pid `U` rows while `CLK_TCK` emitted one `E`
  row; the comment at linux.rs forbids exactly that
- lowy F7 — "empty means blind" carried two wire codes across the platforms;
  `BLIND_OR_EMPTY` is now the single code for the one condition
- lowy F2 ≡ hickey #12 — `attribute_host_listeners` switched truth sources on a
  mode flag, so a PARTIALLY gated host table silently dropped listeners the fd
  walk had positively observed; it now always unions, and the caller owns the
  one emptiness decision
- lowy F11 — a malformed `pcblist` record `break`s and returned partial rows as
  healthy; malformed now fails loudly, with the 24-byte closing record kept as
  the terminator both fixtures actually end with
- lowy F6 — `Port.uid` was absent platform-wide on darwin with no honesty row,
  so a consumer had to know its own OS to read a field of a platform-
  independent contract; darwin now emits `E darwin_listeners ports_uid ENOTSUP`
- hickey #3 — `push_unreadable`, `source_error` and the sort block were verbatim
  twins in the two sensors and the sorts had ALREADY drifted (linux `(port,
  pid)`, darwin `port`); row order is now `Snapshot::normalize`, called once
- hickey #2 — `has_facts` re-enumerated the struct by hand; it is an exhaustive
  destructure, so a new field is a compile error rather than a wrong exit code
- hickey #13 — `write_json` and the `E`-row loop were byte-identical in both
  documents

The pure half of the darwin listener path (record walk + merge) moves to
`osfacts::pcblist`, so the two fixes above are compiled and tested on every
platform instead of only the darwin CI lane. Fixed on the way: a JSON
assertion in `cli_contract` that `b8514697` had left comparing a two-key
object against a three-key row — dead on linux, failing on darwin.

Raised by the lowy ∥ hickey lens review. Not pushed or merged.
srid added a commit that referenced this pull request Aug 1, 2026
- hickey #2 — rule 2's `seen` membership test now covers every entry reaching
  `overlay`, not just the top-level filter: a duplicated path makes
  `pathDiffOperations` emit two adds for one row, Pierre throws, and the
  recovery discards every hand-expanded folder.
- hickey #6 — the rule-4 queue walk becomes one self-contained recursive
  `emit`. Fixes `lazyDirs.push` sitting above the visited check (a directory
  reached twice was listed twice) and `queue.unshift(...children)`, a
  `RangeError` on the six-figure flat level this feature targets. The
  loaded-but-empty directory keeps its own row exactly as before.
- hickey #5 — `ignored` now names a loaded directory's own key as well. Pierre
  still paints that row from its children's prefixes, so dropping it un-dimmed
  the folder at the moment the user opened it, children dimmed below.
- hickey #10 — `diffInventory` is the one constructor for the no-overlay case,
  so a new `BrowseInventory` field can't be forgotten at CodeTab's literal.

Raised by the lowy ∥ hickey lens review. Not pushed or merged.
srid added a commit that referenced this pull request Aug 1, 2026
- hickey #3 — `openLazyDirs` carried three facts and two broke. A row that has
  no node right now (a search projection hid it) no longer retires the record,
  so a filter keystroke stops erasing the user's expansion; and a key the host
  no longer declares lazy is pruned, so an eye-toggle round trip reports afresh
  instead of showing an arbitrarily old cached level with no refetch path.
- lowy #2 — a `lazyEpoch` prop clears the record when the host's loaded levels
  stop describing this tree (a repo / host switch), the wrapper's half of the
  invalidation the host already performs on its children cache.
- lowy #6 — `onExpandLazyDirectory` may return a promise; on rejection the
  wrapper forgets the expansion, so a transient read failure no longer wedges
  the folder open-and-empty for the mount.
- hickey #7 — the `tree.subscribe` callback runs under `safeApply`, so a throw
  can't escape into Pierre's emit loop and take other subscribers with it.
- hickey #9 — "which directories should be open" is spelled once
  (`desiredExpandedPaths`) and used at both the constructor and `toOpen`.
- hickey #8 — `expandPaths`' JSDoc no longer asserts an invariant
  `openLazyDirs` has made false.

Raised by the lowy ∥ hickey lens review. Not pushed or merged.
srid added a commit that referenced this pull request Aug 1, 2026
- hickey #4 — one `AbortController` per directory replaces the hand-rolled
  `loadGeneration` + captured-`issuedSlot` pair, so "is this response still
  wanted" is ONE fact both callbacks read rather than two conditions each has
  to repeat. Fixes the real asymmetry where `.catch` checked only the slot and
  could toast a failure over a folder showing correct contents, and stops the
  superseded readdir server-side instead of racing it.
- lowy #2 — pass `lazyEpoch={slotKey()}` beside the existing clear, so the
  tree's record of open lazy directories is invalidated by the same signal.
- lowy #6 — the handler returns its promise and re-throws after toasting, so
  the tree drops its record and a retry costs one re-expand.

Raised by the lowy ∥ hickey lens review. Not pushed or merged.
srid added a commit that referenced this pull request Aug 5, 2026
…ng clean

The deploy-#2 frozen panes (kolu#2101): under the restore stampede, attach
streams ended with no `overflow` frame while their PTYs kept running.
`reattachingDeltas` read that as a graceful end and returned, and every retry
layer above retries FAILURES only — so the tile waited forever for a
`terminalExit` that was never coming. Blank pane, live title, no verdict.

kaval closes an attach fan-out in exactly one place — the PTY's own exit
teardown — with no idle timeout or reaper, so a plain end while the PTY is alive
never came from a healthy host. A plain end is therefore a question, not an
answer: `open()` IS the liveness probe, because kaval answers a re-open for a
departed PTY with `PtyNotFound`. Alive re-attaches and keeps going, gone ends
the stream (the real exit), and our own abort — the consumer's teardown, which
is what ended the iterator — still ends it graceful.

Bounded: PLAIN_END_REOPEN_ATTEMPTS = 3 with a doubling pause off
REATTACH_PAUSE_MS (150/300/600, ~1.05s), reset by any leg that delivered a
frame — the same rule STREAM_RETRY uses. Exhausted THROWS loud, which hands the
tile to the client's failure machinery (a strictly stronger repair: it rebuilds
the whole chain) rather than to a silent clean end.

`pullOnly`'s abort-swallow is left alone with a note: its signal is the stream's
own, so it can never convert a producer's death into a clean end.
srid added a commit that referenced this pull request Aug 5, 2026
`runtime.done` rejecting was logged and survived (#1792's disposition), which
in the deploy-#2 incident produced a zombie: padi alive, gate held, socket
answering, its runtime dead, and `done` already settled so no future fault
could ever be seen. The reviewer's point stands — under fail-fast a dead
runtime should die, because the supervisor respawns and session-restore
recovers in seconds, whereas a half-death needed an operator.

After G1 that channel carries only structural wiring death (see the audit on
`SurfaceRuntimeHandle.done`), so it is now fatal at both daemons:

- the spine gains `DaemonSpec.faultSignal`, a second abort arm resolving
  `reason: "runtime-fault"` — the ONE shutdown `daemonExitCode` scores
  non-zero, since the exit code is a supervisor's only channel for "crash,
  not stop". Routing through `waitForShutdown` (never a bare `process.exit`)
  is the point: listener closed, gate released, last rites run first.
- `armRuntimeFaultExit` is the shared observer both daemons ride: it logs the
  WHOLE error (the incident's line was message-only, so the failing cell was
  unidentifiable), runs the daemon's last rites — padi captures its final
  session — and aborts the arm. Last rites that throw are logged; the exit
  still happens.

The #1792 unhandled-rejection boundary is unchanged and stays loud-not-fatal:
it catches UNOWNED floats (teardown noise). The owned `done` channel is a
different thing. Both files now state that line.

Falsifiers: `runtimeFault.test.ts` (whole error logged, rites before abort,
rites-throw still exits, clean close never trips it); the spine's fault-arm
test, which on pre-fix code — the arm unwired — fails with `Test timed out in
5000ms`, i.e. the daemon serving on forever with a dead runtime.
srid added a commit that referenced this pull request Aug 5, 2026
…fact

G7: the cache gap recurred at deploy #2 despite F5a's gate being green. The
delta is not content, it is TIME — and a closure the deployed artifact never
carries.

Two receipts. First, `padi-agent` appears ZERO times in `.#default`'s closure,
by construction: `padi-agent` CONTAINS `default`, so it cannot be contained by
it. Deploying kolu installs the server plus the baked agent SOURCE and never
the built agent CLOSURE; the binder learns that store path only at dial time
and must then get the bits from a cache or build them. Second, the deploy beat
its own warm job: `beb2f7418` was deployed at 21:20:43, while the nix-cache run
for that exact sha was created 21:25:38 and finished 21:35:00. `nix flake
update` costs seconds, a two-platform matrix build costs tens of minutes, so
the operator can always outrun CI — and can deploy a branch with no open PR
(nothing triggers the workflow) or a dirty tree CI will never see. On this host
5 of the 7 `padi-agent` closures ever provisioned are absent from the cache.

No in-repo CI gate can produce a receipt about an artifact that exists only on
the deploy host. So:

* `ci/agent-substitutable` now takes a FLAKE ref (default `.`) and reads its
  inventory, cache list and sidecar name from the RESOLVED agent source rather
  than from cwd. The tree a remote resolves against is self-describing, so the
  script describes the artifact it was pointed at and has no opinion about
  where it ran. An operator can now ask the question BEFORE switching:
  `ci/agent-substitutable github:juspay/kolu/effect`.

* `ci/agent-preflight` is the deploy-host half, and it does not ask — it PUTS
  the closure where provisioning reads from. `nix copy --to ssh-ng://` ships
  the LOCALLY-VALID closure (nixCopy.ts:493), so realising the exposed agents
  into the binder's own store makes remote source-realise impossible whatever
  any cache holds. Needs no credentials — attic is not even on the deploy host
  — and it is the only thing that works for an unwarmed rev or a local tree.
  It ends by running the gate as its own proof.

Falsified: gate green against `github:juspay/kolu/master` (a remote artifact
ref, all three found), preflight green end-to-end against the same, both
shellcheck clean and bash-3.2 safe.

The structural fix stays out of this lane and is cheap: `padi` is ALREADY in
`.#default`'s closure, so having the deployed artifact carry its own agent
closure costs little more than a symlink farm.
srid added a commit that referenced this pull request Aug 5, 2026
The deploy-#2 freeze produced ZERO log lines. Two rows of the mirror's fiber
audit explain that exactly:

- a member's upstream stream dying RESOLVED `done` clean, after one prose line
  on an un-leveled `log` callback — which kolu-server wires to `log.debug` and
  production filters away. A "clean" resolve claimed a healthy mirror that had
  permanently stopped mirroring that member (nothing re-subscribes it).
- a per-key pump dying logged the same way and left the key silently unmirrored.

Both are now LOUD and structured. `MirrorFault { label, err, scope }` is a
second, separate channel from `log`'s chatter, carrying the error itself rather
than a stringified message, threaded through the pump and the re-serve so
kolu-server can wire it at ERROR level while keeping DEBUG for reconnect noise.
A member fault also unwinds the mirror's remaining subscriptions so `done`
settles PROMPTLY: previously one member could die while its siblings parked
forever, so `done` never settled and no observer could act at all.

Where this deviates from the brief, and why: the design said a rejecting
`mirror.done` should propagate to the re-serve observer (default host → exit).
The `reServeSurface` suite falsified that — a failing upstream stream IS the
ordinary link-drop signal the pump's reconnect loop exists for, so propagating
turns every ssh blip or padi restart into a kolu-server process exit. The pump
therefore CATCHES it, names it a death (never the "mirror ended" line a clean
close gets), and re-mirrors on the next spawn — which is both the reconnect path
and the repair. The muteness is fixed without inventing a new way to fall over.
The re-serve `.then` arm also stops guessing: a clean resolve means a clean end
again, so it says that instead of "session destroyed".

Falsifiers (pre-fix signatures):
- `AssertionError: promise resolved "undefined" instead of rejecting` — the mute
  clean-resolve, at the mirror;
- `AssertionError: expected [] to have a length of 1 but got +0` — the
  server-layer stampede: four host projections, one member's upstream dies
  mid-flight, and pre-fix NOTHING reaches the fault channel while the siblings
  carry on none the wiser.
srid added a commit that referenced this pull request Aug 5, 2026
…(G8)

Deploy #2 incident #3: after a hard reload, three panes died at once with
`Terminal attach failed unexpectedly: … snapshot answered 66x53, pane is now
65x53 — reopening`, the agents underneath still running. The toast was lying.

The guard is correct and fires by design — a snapshot is bytes laid out for one
grid, and painting it into a pane that has since resized wraps scrollback at the
wrong width, damage no repaint undoes. Its documented repair, written at the
throw site, was "fail the attempt, reset, reopen at the CURRENT grid". But the
frame handler is `onItem`, and the loop ran it inside `Effect.sync`: a throw
there is a DEFECT, and `Effect.retry` retries FAILURES only. The recovery path
was unreachable from the site that promised it.

The refusal is now a RETURNED `StaleSnapshotGrid`, so the channel rides in the
type and the compiler routes it: `Effect.suspend` turns it into `Effect.fail`,
which takes the same road as a mid-chain padi death — reset, 300ms, re-subscribe
through a thunk that reads the live grid. A throw from either callback is still
a defect, pinned by two tests; the thunk's measured-grid assert is unchanged.

Also fixes the same shape one line away, found by this round's sweep: the loop
called `onReattach()` bare inside `Effect.sync` under the retry. `xterm.reset()`
on a disposed terminal throws, which would kill the loop having JUST wiped the
pane, while the comment above it promises "fired ⇒ a re-subscribe follows". It
is now contained and reported loudly, so the promise holds.

The module header states the channel taxonomy once — defect / typed failure /
classified clean end — and the rule that binds it: no message may claim a
re-attach unless one follows. Both stale comment blocks and the message text are
corrected. G8d pins the multi-client ping-pong (last-attach-wins on a shared
pty) and, honestly, the gap it leaves: a purely FOREIGN resize is invisible to a
pane today, so nothing tells the viewer why the content re-wrapped.
srid added a commit that referenced this pull request Aug 5, 2026
The mandate's falsifier — the inverse of deploy #2. Two nodes: a binder running
the deployed generation, and `agenthost`, a bare sshd + nix box. Both force
`nix.settings.substituters = []` and the script reads the effective value back
before dialling, because a VM test is offline anyway and inheriting a property
is not asserting it. A `hosts/add` then has to converge with the closure coming
out of the binder's own store.

Three assertions: the journal says `agent closure shipped`; a terminal really
opens on the remote host's map key (the application contract, not a log line);
and neither incident string from nixCopy.ts ever appears — checked again after
convergence, so a late relapse cannot hide behind an early pass.

It bites for free on the harness's own mechanics: a NixOS VM registers each
node's nix db from its SYSTEM closure, so post-fix `padi-agent` is registered
and `check-validity` says valid, while pre-fix the bits sit unregistered on the
shared host store — production's "not valid locally", exactly.

Two things are scaffolding and say so in the file. Evaluation inputs (npins
pins) are registered on BOTH arms because I1 guarantees the closure, not the drv
resolution, which stays lazy per F6. And the target sets `require-sigs = false`:
measured in this nixpkgs, `nix copy --to ssh-ng://` does NOT credit the ssh
user's `trusted-users` membership (the same copy over `ssh://` succeeds as
root), and these paths are unsigned because the outer builder built them —
signature policy is a different axis, and leaving it would make the test measure
remote trust configuration instead of closure containment.

Green run: 113s end to end, the terminal opening 7s after the ship.

Refs #2101 (I1)
srid added a commit that referenced this pull request Aug 5, 2026
A woken tab sat on a stale card while its socket, its watchdog and its
header dot were all healthy. Root cause, measured: Effect RPC registers a
call's entry exactly once and never re-sends it across a re-dial, and an
answer can only travel the socket its request went out on — so a run that
ends with a swallowed `SocketOpenError` (the `retryTransientErrors` arm:
a pre-open dial failure, or the ping timeout on a socket that died
silently) orphans everything it carried, with no failure anywhere to
retry on. Every fenced subscription in the tab parked forever.

`websocketLink` now counts its own open EDGES — the wire epoch — off the
existing status funnel, and each dispatched call records the epoch it
binds to: the current one while `open`, otherwise the next (a call begun
while the wire is down parks in the socket's write latch and belongs to
the socket that flushes it, so the arriving open is its own and must not
fail it). When the wire opens past a call's binding epoch, the link
fails that call with an `RpcClientError` naming the cycle — streams and
unaries alike. The fence's existing retry road does the rest; an
unfenced caller gets a rejected promise instead of a dead one. Law 1 (a
live socket closing, which does broadcast) coalesces structurally: the
failed attempt's guard goes with the attempt, so one re-drive, never two.

The link also records a 20-deep dial history — startedAt/openedAt/
endedAt/closeCode and a classification, including `"ended-without-open"`,
the swallowed dial that until now left no trace on either side of the
wire — exposed additively as `WebsocketLink.diagnostics`, not as a
widening of `WatchableWire` (which consumers hand-build).

Law 3 in `socketRedialLaws.test.ts` drives the field shape over a real
served surface: socket #1 answers a subscribe, dies silently, the ping
timeout ends its run swallowed, socket #2 opens. Pre-fix, verbatim:

    wire.status()               : open
    socket #2 sent (subscribes) : []
    stream fiber.pollUnsafe()   : undefined
    in-flight unary promise     : NOT SETTLED

Post-fix: exactly one re-subscribe on #2, rendering state the server had
held all along with no server-side action after the reopen. Plus the
law-1 no-double-drive count, the cold-start non-regression, the unary
arm, and a status pin asserting every swallowed attempt still publishes
its `connecting → closed` pair — the observability the epoch rests on,
now a BETA-ASSUMPTION row of its own.
srid added a commit that referenced this pull request Aug 7, 2026
…aemons on Layers (#2101)

Kolu now runs end-to-end on **Effect 4.0 (beta.103)** — the wire, the
schemas, the daemons, the HTTP app, the client's business logic, the
reactive engine, and the CLIs. The oRPC wire is replaced by
`effect/unstable/rpc` (flat, slash-tagged `RpcGroup`s over ndjson),
every zod schema is Effect Schema, the surface framework's transports
ride Effect sockets, the daemons boot through Layer graphs, and hono is
gone — the entire HTTP app (static, PWA, artifact-sdk, health, preview,
`serve()` itself, and the example servers) rides `effect/unstable/http`.
Zero `zod`, `@orpc/*`, `partysocket`, `hono`, or `@preact/signals-core`
dependencies remain. vazhi is the one deliberate non-Effect island (Ink
rendering internals; nothing orchestral).

The work ran as two planned campaigns plus a review round, all on this
branch. **The campaign notes — plan of record, recon dossiers, the 26
adversarial review findings that reshaped the plan, and every per-stage
report — are attached as a gist:
https://gist.github.com/srid/76c5cfa5e52a21fc77196aa6237e09f4** (the
`PLAN D<n>` / `review #<n>` / wave-label citations in source comments
resolve there; the gist's index maps each citation form to its file).

## The wire epoch, and the hands-off upgrade

The migration is a **wire epoch break**, handled as a declared flag day:
`PTY_HOST_CONTRACT_VERSION` 6.0 → 7.0, `PADI_SURFACE_VERSION` 4.7 → 5.1.
The daemon supervisor gained a third convergence observation —
`unspeakable-protocol` — raised only for a **corroborated** peer (owned
gate file, verified pid, re-attested immediately before any signal) that
either sends undecodable frames or accepts and stays silent past 8s (the
measured behavior of a previous-epoch daemon, pinned between Effect
RPC's 5s ping and its ~10s connection kill).

Disposition is each daemon's declared policy: **kaval recycles**
(terminals don't survive a broken wire), **padi is taken over** —
SIGTERM so its own shutdown runs (padi also gained the SIGTERM
final-capture it never had), a 120s exit deadline, a 5s SIGKILL
backstop. Deploying this build over a running production kolu is a plain
service restart. Foreign socket-squatters keep the refuse protection
untouched. Proven against a **real previous-release binary** in the
upgrade-window e2e and in the six NixOS adoption VM tests
(`nix/home/example/adoption/`: `adoption-adopt`, `-skew`, `-currency`,
`-padi-upgrade`, `-upgrade`, `-upgrade-reboot`, all riding
`ci::home-manager`).

## What the migration itself caught

The typed edges and the harnesses found real bugs, several live in
production paths:

- Four client call sites crashed at the input-encode edge (`Cmd+T` with
no cwd, every split, first scrollback backfill, session restore) — zod
tolerated a present-but-`undefined` key; Effect Schema doesn't. A
systematic audit of all 87 `optionalKey` conversion sites found 8
producer/schema mismatches (2 already live), each fixed at the right
layer with byte fixtures.
- One dead terminal tap's defect killed the **whole** padi connection
(Effect RPC's `disableFatalDefects` default) — fixed centrally in
`surfaceRpcServerLayer`, seam test falsified both ways.
- A cell `get` served `Stream.concat(snapshot, bus)` — a zero-subscriber
window that dropped writes landing on a reconnect edge. Fixed with the
`subscribeBeforeSnapshot` discipline.
- A session-restore lost-write race (terminals push vs. session cell
echo under IO load) — the restore RPC now answers with the settled
`activeTerminalId` and hydration waits on the answer; deterministic
repro pinned it.
- **Two silent-failure classes, structurally closed**: `await` on a
now-Effect-returning call compiles and never dispatches (bit 11+ times
across kolu and its consumers, including a test that had disabled the
drain it exists to prove); and a `() => void` callback swallowing a
built-but-never-run Effect (four tile buttons). Both are
governance-banned with falsified scanners — including the
alias/stored-promise dodges.

## Governance (machine-checked invariants this PR adds)

- **Run edges**: every `Effect.run*` under `packages/` — production,
examples, testlibs, the harness — is a named, justified allowlist row
(109 sites in 54 files; test files are the awaited-face scanner's
jurisdiction, stated in the header). Uncalled `Effect.run*` references
(aliasing) are banned outright.
- **Effect pin agreement**: all 68 effect-family pin spellings are
discovered mechanically and must agree with the catalog — the 7 vendored
`@kolu/surface*` manifests owe the literal (`catalog:` doesn't resolve
for drishti/odu), everyone else owes `catalog:`.
- **Beta-behavior assumptions**: the sites that depend on beta.103
*behavior* (not API) carry grep-able `BETA-ASSUMPTION(beta.103)` markers
(eleven at HEAD, spanning the ping band, forkChild, optionalKey(Never),
the three Atom batch-semantics laws, the RPC frame-cap close, and the
socket-redial swallow and never-re-send pair behind the wire re-drive
epoch and the attach first-frame deadline); a pin bump fails the gate
until each is re-measured against its named measuring-law test.
- **`Schema.optional` tolerance shims**: the four deliberate
present-`undefined` tolerances are an allowlisted enumeration; a fifth
can't appear silently. (`exactOptionalPropertyTypes` was probed: 940
errors across 35 packages, including npins-grafted code this repo can't
edit — recorded in the gate's header as why the flag isn't the remedy.)
- **`kolu-rpc` quarantine**: the harness-only wire CLI (the VM tests'
probe after the HTTP `/rpc/*` arm was deleted) is scanner-pinned out of
`agentToolPackages`, the home-manager module, and the app closures (its
drv closure carries 0 references from anything a user installs).

The widened scans immediately paid for themselves: they found a docs
snippet that awaited a `kill()` Effect that never dispatched, and the
typing-echo latency bench dead in three independent ways — now ported to
the canonical client path and verified against a nix-built server with
real PTY echoes.

## Evidence

- Full local e2e: **509/509 scenarios, 4672/4672 steps, zero retries**.
- Repo-wide typecheck (TS 7.0.2/tsgo), unit lanes, daemon lane (incl.
the real previous-release upgrade-window run — now against **v2.2.0**,
the last stable release before this switch), e2e-governance (summary
line: `109 allowlisted Effect.run* edges in 54 files, 4 allowlisted
Schema.optional shims in 3 files, effect@4.0.0-beta.103 agreed across 68
pin sites, 11 beta-behavior assumptions stamped`), biome
`--error-on-warnings`, dev-smoke, website build: green.
- The remote upgrade window is proven over **real ssh**: `just
e2e-ssh-upgrade` plants a real previous-release padi daemon (v2.0.0 at
the time of the run; the harness tracks the latest release tag) on a
disposable box and asserts the current build produces **one takeover and
one clean converge in a single campaign** (20.3s wall, vs. the
incident's forever-loop). CI has no ssh lane, so this recipe is the
enforced run — stated, not silent.
- Two-platform CI on every push; final settle **`09700fa#1` green on
both platforms, first attempt** — the e2e metrics comment tracks it.

## Consumer repos (merge order)

- **drishti pair PR: srid/drishti#132** — full Effect adoption, CI green
at each kolu pin. Break classes: `Surface.contract` → `{group,
tagPrefix}`; async wire links `{dispatch, dispose[, wire]}`;
Encoded-side client face types; `ORPCError` → `Schema.TaggedErrorClass`
vocabulary; `ProcedureSpec.errors` → singular `error`; AbortSignal →
`Stream`/fiber interruption; `serveOver*` take `{group, handlers}`;
schemas Effect Schema throughout.
- **ODU-IMPACT VERDICT: `breaks-at-bump` — adoption PR juspay/odu#74**
(ledger odu#43 drained — plus one N3 line item: `serveOverUnixSocket`
now takes a required `log`, at the same call site the adoption already
rewrites; byte-compat proven against production data; odu's moved master
merged back in at `74f922e` — its new `odu wait`/`rerun` CLI re-ported
onto the Effect surface and #78's fresh zod import converted, **16/16
required contexts green** by odu's own runner, `MERGEABLE`).
- **Order**: **juspay/osfacts#5 merges first** (this PR pins its branch
head; re-pin the npins `osfacts` entry to the merge commit before
merging here), **then this PR** — **odu#74 must not merge before it** —
**then the consumer PRs** (drishti#132, odu#74), each re-pinning to the
kolu merge commit and re-confirming green.

## Review rounds

- **[Round 1
checklist](#2101 (comment)
— all 13 items addressed; the comment carries the per-item disposition.
Highlights: the campaign notes are attached (A1, the gist above); the
pre-1.30 `activityAlerts` ladder bug is **fixed** with a chained-ladder
test (A3); the six adoption VM tests are named above (A4, they were
in-tree all along); `kolu-rpc` is ratified *and* scanner-quarantined
(A5); the disclosed `sessionRestore` and `pumpRemoteSurface` holes are
fixed with falsified tests (C1, C2).
- **[Round 2
follow-up](#2101 (comment)
— F1: both pair PRs pinned to this PR's final HEAD with receipts; F2:
the restore seed's wait is **bounded** (`41d517754`) — a 10s deadline
that provably cannot fire on a healthy link (the written argument sits
at the site), converting the one reachable non-arrival (a stale answer
after a daemon recycle) into a conservative seed plus a loud report,
with the non-arrival case driven in the seam test.
- **Round 4, effect 4.0.0-beta.103** — the first pin bump, taken on this
branch by request: all 68 pin sites moved together (osfacts upstream
first), the three `BETA-ASSUMPTION` markers re-measured (ping band
5014ms/10019ms — unchanged; `forkChild` and `optionalKey(Never)`
unchanged), the [reviewer's six-surface
map](#2101 (comment))
walked explicitly (all clean; RPC frame cap 16MiB vs 0.40MiB measured
worst-case real frame; the five JSON-schema shim patches re-derived
individually), zero removed-API hits, CI green first attempt both
platforms.
- **[Round 5, the production
incident](#2101 (comment)
— deploying an earlier head broke every remote host in a permanent
connect loop: the wire-epoch machinery existed only on the local arm.
Fixed at the framework level so the class is unrepresentable: a stdio
wire link now **requires an un-forgeable readiness proof**
(WeakSet-branded, minted only by consuming the peer's pre-splice
readiness banner — no pinger before proven epoch), the `padi --stdio` /
`kaval --stdio` fronts run the **full convergence kit on the remote box
before relaying a byte** (unspeakable classification, gate
corroboration, takeover via SIGTERM — the same kit the local arm runs),
and a gate refusal is a typed `"remote"` verdict that reaches a terminal
`failed` within the existing 5-attempt budget and renders as "previous
protocol epoch" on the host map instead of an eternal spinner. Proven by
a real-ssh upgrade-window e2e (previous release resident → one takeover,
one converge — log attached to the gist) and a permanent inverted
falsifier reproducing the incident's infinite loop. Secondary finding
fixed along the way: the nix-cache workflow was warming the PR **merge
ref** — a tree nobody deploys — so deployed closures were never on the
cache; it now checks out the PR head and re-queries the cache (over
plain HTTP) for every exposed agent attr after the push.
- **[Round 6, deploy #2's four
incidents](#2101 (comment)
([G5–G7](#2101 (comment)),
[G8](#2101 (comment)),
[G9](#2101 (comment)))
— the second deploy surfaced four incident classes, all closed with
falsified tests: **(1) the half-dead daemon** — a transient kaval-probe
timeout at boot faulted padi's whole surface runtime into a zombie; poll
reads are now cell-local at every tick (the same failure was fatal at
T+0 and benign a second later — that timing dependence was the defect),
and a genuine runtime fault now **exits the daemon** through the
shutdown machinery (capture, gate release, supervisor respawn) instead
of the #1792 log-and-continue; **(2) the mute server freeze** —
projection-layer faults resolved *clean* at DEBUG; they are now typed,
loud, and prompt, every kolu-supplied callback on the engine's writer
stack is containment-bracketed, and Atom's beta.103 batch semantics
carry three measured `BETA-ASSUMPTION` markers (the severed-edge
hypothesis was **refuted by measurement** — pinned as a law); **(3)
frozen panes** — attach streams manufactured clean ends under load (now
re-opened server-side for a live PTY, re-attached client-side), and the
port had flipped a documented-recoverable resize race into the defect
channel (now a typed failure the loop retries; the channel taxonomy is
written law, and the same sweep fixed the framework twins on
`fenceStream`/`websocketLink`); **(4) the wire-killing upload** — a >16
MiB frame closed the whole multiplexed socket; uploads ride 3 MiB
chunks, the cap is an owned, marked constant at every serialization
site, and an oversized frame is refused client-side before send. Plus
the deploy-artifact cache gap root-caused structurally
(`ci/agent-substitutable <flake-ref>` + `ci/agent-preflight` on the
deploy host) and a latent HTTP 500 (a `Respondable` response answered as
an unhandled fault) found by the VM lane under saturation.
**[Verification closed the
docket](#2101 (comment)
with one non-blocking residual, since landed: N1 —
`connectPublishEffect`'s publish half now routes through the one shared
containment implementation (`containThrow`) instead of hand-rolling it.
Alongside it, `just ci::protect` now **enrols the nix-cache workflow's
two `build-and-push` contexts in branch protection** (derived from the
workflow file with a loud drift guard, re-appended after every `odu
protect` since odu replaces the list) — the cache-warm guard that G7
showed is deploy-critical is now a required check, applied live (48
required contexts on `master`).
- **[Round 7, the lid-close field
test](#2101 (comment)
— the wake machinery held (typed mirror death within ~15s, honest
bounded probing, hands-off reconvergence adopting the surviving daemon),
and the three residual defects from the wake window are closed: **(1)**
a grid publish to a host the client *knows* is down no longer toasts
"failed unexpectedly" — the publish gates on the client's own host-map
state, and the same refusal from a host believed up stays loud
(falsified both ways); **(2)** a waking client now fast-forwards every
down host's scheduled retry via a new narrow `Session.nudge()` fired on
websocket accept — the same attempt the backoff had scheduled, budget
and terminal-verdict semantics provably unchanged (`recheck()` was
measured to be the wrong verb: it refills the give-up budget); **(3)**
an attach stream that opens and never delivers its first frame now hits
a 10s first-frame deadline into the existing retry channel (budget 1,
loud verdict on repeat) — closing the one channel the loop's taxonomy
didn't cover (transport failures, clean ends, stale grids, and now *no
end at all*). The mandated socket-cycle hypothesis was tested and did
**not** confirm (a force-cycled socket already fails registered entries
— pinned as law); the genuine hole is Effect RPC's
`retryTransientErrors` swallowing `SocketOpenError` re-dials under
parked subscriptions, now the ninth measured `BETA-ASSUMPTION`.
- **[Round 8, I1 — the artifact carries its
agents](#2101 (comment)
— maintainer's call executed: a deployed kolu now guarantees every agent
closure it can provision is present in the deploying host's store, by
construction. The `padi-agent ⊃ default` circularity dissolves at the
deployment layer — the home-manager **generation** references both
without a cycle, via a new *required* `services.kolu.agentPackages`
option (`nonEmptyListOf` — an omitting or empty-list consumer fails at
eval, no degrade knob) defaulted by the flake wrapper from
`nix/agent-packages.json`'s `expose` list and anchored on both
supervisors with zero PATH pollution. Measured cost: **+1,760 bytes**
(one store path) over `default`'s 740.84 MiB closure; +0.0001% on the
end-to-end generation. Proven two ways in the `ci::home-manager` lane: a
closure-containment check (red pre-fix at `MISSING padi-agent`), and a
two-node **offline-provision VM test** — substituters forced empty and
asserted, a real ssh connect ships the closure and creates a terminal
with zero cache/source-realise narration, where the pre-fix run
reproduces deploy #2's exact `no local copy of the agent to ship …
realising from source` path down to the target compiling
`padi-agent.drv`. The G7 scripts are re-scoped to one narrative (the
module guarantees by construction;
`agent-preflight`/`agent-substitutable` cover the raw-install and
cache-warm flows outside it) and the user docs' cache-conditional
provisioning language is corrected.
- **[Round 9, J+K — the law-2 park and the attach-path
audit](#2101 (comment)
([K
audit](#2101 (comment)))
— a second field incident (wake at 13:11: healthy wire, watchdog
satisfied, every subscription in the tab parked on pre-sleep state) plus
a three-audit deep review of the attach path, closed as one round:
**(J1)** `websocketLink` now counts open edges and fails, itself, every
stream *and* in-flight unary a re-dial cycle orphaned — including cycles
whose intermediate failure Effect RPC swallows (`retryTransientErrors` +
never-re-send, now two cross-referenced `BETA-ASSUMPTION` rows, census 9
→ 11) — so the fence re-drives the whole tab with no clock and no
per-subscription machinery; **(K1)** the first-frame deadline was
measured to be a hard ceiling (a re-attach cancels the in-flight
snapshot), re-derived to 45s above every structural repair, budgets
refill per-episode, and no verdict executes a pane — exhaustion is one
loud toast then a 30s cadence; **(K2)** the abort-during-reopen leak
triad closed against kaval's real fan-out (including the WHATWG
already-aborted-signal hole, found twice); **(K3)** the two silent
unbounded lanes keep their unbounded-by-design arguments and lose their
silence (derived-N structured warn server-side, derived-N verdict
client-side); **(K4)** a grid suppressed while the host was down is
restated once on the connected flip; **(K5–K7)** the fourth reopen lane
joins the taxonomy, the inter-check PTY exit throws tagged, and the
tombstone-evicted exit fails loud instead of fabricating `0` (with one
genuinely-reachable caller identified and handled). **(J2)** Diagnostic
Info now carries a copy-pasteable plain-text snapshot that proves a wire
incident from the browser alone: dial history including the
previously-invisible swallowed dials, a per-subscription liveness table
with a parked verdict, host entries with client-stamped freshness,
heartbeat verdicts — built lazily from client-held state only, so it
works exactly when the wire is lying.
- **[Round 10, M — the give-up budget counted the failures it promised
to
ignore](#2101 (comment)
([disposition](#2101 (comment)))
— a first-overnight-sleep field incident: ~18 "host unreachable"
attempts plus ONE dark-wake remote failure went instantly terminal,
because the day-one shared `consecutiveFailures` counter incremented for
both causes while the ceiling gate read only `remote` — a master-born
latent defect this PR's own (correct) banner gate made reachable for the
first time. Killed as a class: `@kolu/surface/failure-ledger` is a new
leaf primitive where cross-class counting is **unrepresentable**
(per-class runs, verdicts computed only from a class's own run, the
interleaving rule declared as data, `attempts()` exposed strictly as
display/pacing tier), with the anti-conflation law — the one no test had
ever pinned — proven three ways in the framework. The session migrates
to the spec `network: unbounded, resets remote` (an unreachable gap
means the host *went away*; the next remote blip is fresh evidence, not
accumulation), the give-up message derives from the verdict so it can
only ever name the true remote run, three lying doc sites are corrected,
`makeStepBudget` is ratified hand-rolled (single-class — the disease
can't exist there), and the field shape is pinned by four seam
falsifiers whose pre-fix red reproduced the incident's exact lying
give-up line byte-for-byte.
- **[Round 11, N — a comatose kaval becomes survivable by
design](#2101 (comment)
— a field incident (kaval alive but comatose after macOS sleep: accepts
connections, answers nothing, zero error lines; padi diagnosed it in 10s
and was architecturally forbidden from treating; the host dot stayed
green) closed as four items: **(N1)** padi's probe verdicts feed an M1
failure ledger (`wedged`/`unreachable` at ceiling 3 ≈ 30s of coma, the
auto-repair itself ledger-bounded at 3 before the card returns) and
exhaustion runs *exactly what the "Restart kaval" button runs* — one
shared routine, two triggers — proven by a SIGSTOP-coma falsifier
against a real kaval (pre-fix red: "padi never repaired the comatose
kaval", post-fix hands-off repair in 168.8s); two *pre-existing*
unbounded waits reachable from the button were found and closed on the
way, including a drain that parked the recycle forever on the very fault
it was repairing (and the first fix attempt via fiber timeout provably
couldn't work — the effect was uninterruptible; the reasoning is written
at the site); **(N2)** kaval self-dials its own socket every 10s and
three consecutive failures exit through the existing G2 fault arm —
comatose-forever becomes dead-loudly, which everything already handles;
suspension is detected (a tick firing two cadences late resets the
budget), and the raw-timer-vs-Effect-Clock ruling is written at the site
citing heap-diag's precedent; **(N3)** the listener telemetry the Effect
port silenced is restored as a confirmed regression fix (pre-fix red:
the logger was never called at all), with the `log` parameter
deliberately *required*; **(N4)** the host dot composes the daemon chain
— reachable-host-dead-kaval renders amber "kaval down" with the daemon's
verdict, reusing the exact cell the kaval-down card already reads (no
new wire field). The kernel-level double-bind itself stays out of scope
as mandated: unprovable from this repo, and survivable-by-design once
N1/N2 hold.
- **[Round 3, the osfacts follow-up
campaign](#2101 (comment)
— delivered as separate PRs, per its own instruction not to reopen this
one: juspay/osfacts#5 (the client ported to Effect), the kolu adoption
(opened as #2103, then **folded into this branch** by maintainer
decision — flips the supervisor's last two Promise seams to Effect,
retires this PR's "osfacts is uneditable" premise from the governance
gates), and the drishti adaptation (opened as srid/drishti#133, likewise
folded into drishti#132). Dispositions with receipts on each.

## Docs & changelog, against the v2.2.0 baseline

v2.2.0 was released mid-flight as the deliberate **last stable release
before this switch**, and every user-facing surface was re-baselined
against it:

- **Changelog** (`unreleased.mdx`) rewritten **release-relative** — what
a user upgrading *from 2.2.0* experiences, as one squashed release, not
a diary of this branch's iterations. Every entry was then adversarially
fact-checked claim-by-claim against `origin/master` (nine claims
corrected, one wrongly-deleted entry restored) and audited against
`.claude/rules/changelog.md` (kinds are release-relative; branch-only
fixes don't narrate as user-facing leaks). A final entry-by-entry trial
against the v2.2.0 tree itself asked, for each `fixed`, *can a 2.2.0
user actually hit this?* — it re-kinded one entry, deleted one whose
failure mode was measured to be this branch's own engine (folded into
the Surface entry as a property of the new engine), and corrected four
more claims down to what the tag's code supports.
- **Docs pages** synced where the changelog exposed staleness: the
host-down card table's new "previous protocol epoch" row, the flag-day
exception on the sessions and troubleshooting update paths, the
blank-pane verdict on the tiles page, the Attention-alerts master toggle
on notifications.
- **Reference pages** carry the campaign's new public API
(`Session.nudge()`, `@kolu/surface/subscriptions`,
`StreamFenceOptions.label`, `WebsocketLink.diagnostics`, …) and all six
touched package READMEs were brought to the Effect-era surface (kaval's
`PtyHost` face was still fully pre-Effect prose).
- The release also moved the previous-release harness's target, catching
two **test-only** gaps: the gate assertion now asserts the #2011
pid-first law instead of one release's exact bytes, and the takeover arm
now composes the production identity reads instead of a unit-test fake —
so the e2e proves the exact 2.2.0 → this-release crossing every user
will make (kaval recycled, padi taken over, against the real previous
binary). The daemon machinery itself needed no change.

## Known remainders

- `.claude/skills/nix-typescript` (vendored from juspay/skills) names a
nonexistent `nix/modules/typescript.nix` — upstream doc fix, tracked
outside this PR.
- A pre-existing medium CodeQL alert on
`packages/padi/src/ports/scan.live.test.ts` (named exception; untouched
code).

### Try it locally

```sh
nix run github:juspay/kolu/effect
```

_Generated by [`/be`](https://github.com/srid/agency) on Claude Code
(model `claude-fable-5`)._
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant