Scrollback backfill: instant attach, real scroll-up history - #1783
Conversation
… history Attach now paints only the recent screenful (kaval's bounded snapshot, SNAPSHOT_SCROLLBACK) instead of replaying the whole 10k-line mirror on every (cross-host) attach — the W9 full-replay cost. As the user scrolls up, the client fetches older raw chunks over a new kaval read verb (screen.history) and prepends them into the terminal's OWN scrollback via a pinned xterm-internals splice (scrollbackBackfill.ts, a fail-loud leaf), so history is the same buffer, scrollbar, and select/copy — no pager. The history cursor is an ABSOLUTE mirror-line index (kaval tracks eviction via the headless buffer's onTrim), seeded from the attach snapshot's topLine. This makes the seam where backfill meets existing content race-free against live output: a fetch serves strictly above the client's content regardless of the in-flight delta lag — a have-from-bottom cursor cannot (it compares the server's produced-line count against the client's received count). A width change pauses backfill until the next snapshot re-seeds (reflow shifts absolute indices). Baked invariant, asserted at padi startup: client DEFAULT_SCROLLBACK >= mirror + snapshot, so the prepend never splices past maxLength and silently evicts the rows it just inserted (the one demonstrated corruption); prependScrollback keeps a throw-on-overflow backstop. Contract-pin tests in both packages turn an xterm version bump that moves a pinned internal symbol into red CI. Contract PTY_HOST_CONTRACT_VERSION 5.0 -> 5.1 (additive: getHistory verb + a required topLine on the attach snapshot frame). Ported from the green prototype (xtermPrepend.spike.test.ts).
…ins, closure allowlist
Local verification (pu is down): typecheck clean; kaval 149, padi 283, client 702
tests green. Adapt existing tests to the 5.1 contract: add terminal.getHistory to
the corpus + coverage list, the padi screen.history verb to the surface pin, the
{data,topLine} attach frame to the dial round-trips, and kolu-common/config
(constants only) to padi's daemon-closure allowlist. Lockfile updated for the
new deps (client @xterm/headless devDep for the headless test env; padi
kolu-common for the startup sizing assertion). fmt applied.
…nsumer `kaval-tui history <id>` dumps a terminal's older scrollback (the mirror content above the current screen that `snapshot --viewport` can't show), VT-serialized so colors survive, cursor-paged. `--lines N` prints one page of the N lines just above the screen; omit it for the full older history, emitted oldest-first. Read-only, takes no TTY. Smoke-tested against a real kaval over a socket. To make the verb self-seeding for a plain pager (not just the browser's attach-seeded backfill), `getHistory`'s `before` is now optional — omitted starts from the top of the current screen region. The client still passes its absolute cursor, so its seam guarantee is unchanged.
…ed attach + history
…ings
Two real correctness bugs the local tests masked (both surfaced by the AFP
C1–C7 hunt + adversarial verify), plus lifecycle hardening:
- C7 (correctness): stealContentLines dropped the SEAM row of every chunk.
kaval's serialize({range}) output has no trailing newline, so the scratch
cursor lands ON the last content row — `ybase+y` excluded it, silently losing
one scrollback line at every backfill boundary. Now includes the cursor row
when it carries content (buf.x > 0). Regression test feeds a real
serialize({range}) chunk and asserts the seam survives (the raw-chunk tests
ended in \r\n and masked it).
- C6 (state/time): the epoch/cols guard was only checked BEFORE prepend, but
prepend itself awaits (scratch replay). A reset/resize landing in that window
spliced stale/old-width lines and clobbered the paused cursor. prependScrollback
now takes a shouldCommit guard it re-checks after the replay, before the splice;
the controller re-checks after prepend before committing the cursor.
- C4: dispose() now bumps epoch + sets a disposed flag, so an in-flight fetch
resolving after teardown can't prepend onto the disposed xterm; maybeBackfill
swallows the expected gone-terminal fetch rejection (scoped to the fetch — a
prepend fault stays fail-loud) instead of an unhandled promise rejection.
- C2: inlined the one genuine unused knob (chunkMax) as a module constant; kept
triggerRows/prepend as documented test seams and added the controller unit test
that exercises them (near-top trigger + the dispose/reset/resize races).
- C5: added the `history` row to the kaval.mdx command-reference table.
Refuted by verify: the "dead knobs are a defect" framing (they're DI seams) and
the "needs a discoverability tip" finding (the convention is advisory).
…ibling Soften the "exactly one module" claim to the two-file reality and list scrollbackBackfill.ts as a second, deliberately fail-loud _core reach to update in tandem on a buffers.normal.lines rename. Agreed by the lowy ⇄ hickey lens debate (finding lowy-1, raised by lowy). Not pushed or merged.
Replace the verbatim `cursor = null; exhausted = false; epoch++` in reset() and the onResize width branch with a shared local pause() helper, so the two copies can't drift. Agreed by the lowy ⇄ hickey lens debate (finding hickey-1, raised by hickey). Not pushed or merged.
Addressed codex's 14 findings on the scrollback-backfill PR (#1783). Fixed: - F1: terminalAttach was reshaped string→{data,topLine?} with no version bump — breaking both skew directions; bumped PADI_SURFACE_VERSION 2.0→3.0 + rationale + version/skew tests (surface.ts, surface.test.ts, dial.test.ts). - F5: seed the backfill cursor in the snapshot write-completion callback, not before it — onScroll emitted DURING the snapshot parse can no longer fire an unsolicited fetch onto a still-parsing buffer (Terminal.tsx). - F6: fetch catch now swallows ONLY a typed NOT_FOUND; every other fault is surfaced via a new onError → toast, never a silent hole (scrollbackBackfill.ts, Terminal.tsx) + tests. - F7: moved DEFAULT_SCROLLBACK to @kolu/terminal-vocab (a shared, hashed, browser-safe root), dropping the forbidden @kolu/padi → kolu-common back-edge this PR introduced and putting the constant back in PADI_BUILD_ID's closure (terminal-vocab/schema.ts, common/config.ts, servePadi.ts, package.json, buildId.closure.test.ts). - F8: getHistory self-seed now starts at the VISIBLE-screen top, not the bounded-snapshot top, so `kaval-tui history` no longer skips the newest ~1000 older lines (ptyHost.ts) + test. - F9: bounded snapshot start snaps back over wrapped continuations to the logical head, so a line straddling the snapshot↔history cut is never bisected (ptyHost.ts) + deterministic serialize-level test. - F10 (part): kaval-tui pages past all-blank chunks instead of stopping; the browser controller loops past a zero-row page instead of stalling (main.ts, scrollbackBackfill.ts) + test. - F11: a premature (non-aborted) attach-stream end now throws instead of fabricating a valid empty attachment (local.ts). - F13: getHistory throws RangeError on a non-positive max instead of silently returning an empty page (ptyHost.ts) + split test. - F14: deleted dead fetch2/c2Prepend test vars (scrollbackBackfill.test.ts). Disputed (see .codex-debate/section-001-2-claude.md for full reasoning): - F2: kaval stays 5.1 MINOR — the bounded-snapshot degradation to an old client is GRACEFUL (fewer scrollback lines, PTY intact), not a mis-parse; a major would kill live PTYs on a downgrade-only path, and the browser-reaching skew is already refused by F1's padi 3.0. Strengthened the rationale comment. - F3: the multi-client foreign-resize reflow edge is the author's documented reflow-invariant-cursor follow-up (needs a server-side generation stamp); the local resize is fully guarded. - F4: padi's {data,topLine?} frame is the deliberately-flattened consumer view; a `kind` tag re-introduces kaval's discriminator one layer too high. - F12: no in-repo spawn path sends a custom scrollback; an oversized mirror hits prependScrollback's fail-loud tripwire, not corruption. - F10 (part): declined exact blank-row materialization — trailing-blank trimming compresses whitespace only (absolute cursor keeps text correct); safe materialization needs reflow-unsafe aliasing.
Address codex's five open findings (F2/F3/F4/F6/F10).
F4 (fixed): model padi's terminalAttach frame as a discriminated union
{ kind:"delta", data } | { kind:"snapshot", data, topLine } instead of the
illegal-state-permitting { data, topLine? }. Reuses the existing source of
truth — kaval's own wire frame is already this union, so padi was flattening
a union one hop up. Matches streaming.md §2's snapshot/delta discriminator.
Touches endpoint.ts, surface.ts (z.discriminatedUnion), both producers
(reattachingDeltas.ts, servePadi.ts), the consumer (Terminal.tsx now keys on
frame.kind), and the 3.0 rationale + surface/dial/reattachingDeltas tests.
F6 (fixed): make onError REQUIRED on createBackfillController — the controller
exposes no other error accessor, so an omitted handler silently recreates the
swallow it prevents. Tests that ignore faults now pass an explicit () => {}.
F3 (partial): conceded it is a shipped path (kaval-tui attach resizes the
shared PTY), not speculative. Documented the foreign-resize reflow gap
explicitly in the controller doc as a tracked, self-healing fidelity gap that
belongs to the deferred reflow-invariant-cursor follow-up; did not thread new
fail-loud control semantics through kaval->padi->client on the always-on
attach path in the same round as the frame reshape, e2e verification down.
F2 (disputed): kaval stays minor (5.1). A 6.0 major would recycle a surviving
kaval and kill the user's live PTYs on a rollback — the trade the codebase's
own "a cosmetic readout must never cost a terminal" precedent forbids. The
old-padi/new-kaval degradation is graceful and reverses on roll-forward;
capability-negotiation would resurrect the full-replay path this PR deletes.
F10 (disputed, sharpened): codex's steal-the-scratch's-blank-rows mechanism
fails at 500-row page scale — content exceeding the scratch's viewport height
scrolls into scrollback, leaving no initialized blank rows to steal. The gap
is cosmetic (trailing seam whitespace); the absolute cursor keeps all text
correct and ordered. Not worth reworking the corruption-prone steal core.
Verification: just check (typecheck + biome) green; padi 283, client 709
unit tests pass (the lone spawnInput failure is a PADI_SOCKET env leak from
running inside a live padi, not this change — green with the var unset).
F10 (blank-row fidelity, FIXED): the backfilled buffer dropped one blank row
at every chunk seam — serialize({range}) has no trailing newline, so the
scratch cursor rests on the range's final blank row with x===0, which the
content-only steal omits. Materialize the full mirror-range span instead: the
client derives servedRows = before - res.topLine (no wire field needed), sizes
the scratch to hold that many rows, and steals max(content, servedRows) — the
extra rows are the scratch's own distinct initialized blank BufferLines
(codex's mechanism; no aliasing, no newline padding). Client 50k scrollback
>> mirror 10k, so materializing every span never trips the overflow tripwire.
New test proves before/after (blank dropped -> blank kept, seam still abuts
live content). All 159 terminal tests green; biome + tsc clean.
F3 (foreign-resize cursor staleness, PARTIAL): conceded it's a real defect on
the supported kaval-tui-attach path and that my "self-heals on own-resize"
comment was wrong (own-resize pauses but does not re-seed; a stale band
persists until re-attach). Corrected the comment to say exactly that. Disputed
landing the width-gate this round: F3's dup/skip is a facet of an
already-documented, accepted degraded state (kaval-tui/attach.ts:237-239 —
concurrent different-width tiles "may show wrap artifacts until their own next
resize"), whose live view is garbled regardless of backfill. A width-gate only
turns backfill-corruption into a backfill-halt while the view stays garbled — a
partial fix costing ~11 files across both wire contracts, unverifiable with pu
down. The complete fix is the note-5 reflow-invariant-cursor + size-negotiation
follow-up the code already earmarks. Absolute cursor stays correct for the
supported single-width-per-PTY case.
F10 (client, fixed): prependScrollback returned 0 on an empty chunk even when
servedRows > 0, so an entirely-blank history range (serialize({range}) encodes
it as "") was swallowed and its blank rows dropped. Guard is now
`rawChunk.length === 0 && servedRows === 0` — an empty chunk falls through only
when the range is truly empty (exhausted / gone-PTY reply), otherwise the sized
scratch's own initialized blank BufferLines materialize the servedRows span.
Added a test for the one-row and five-row all-blank cases.
F10 (CLI seam, disputed): kaval-tui history dumps raw VT bytes by design and
already trims the whole output's trailing blanks; the trimmed per-page count
can't be robustly derived from VT bytes (wrapped lines break newline-counting)
without a full xterm replay or a producer rework of the seam-guarantee test.
Marginal internal-seam artifact; common-path client defect is fixed.
F3 (disputed, deferred): conceded the durable-corruption defect on the
foreign-resize multi-client path; held the remedy as the deliberately-deferred
reflow follow-up (note 5 / attach.ts "contract 2.2") since it is a two-wire-
contract change re-touching the just-settled version surface and unverifiable
with pu down. Handed over the exact minimal fail-safe mechanism (opaque
reflowEpoch token + server `stale` flag -> client pause) in the section file.
All other findings (F1-F2, F4-F9, F11-F14) remain resolved from prior rounds.
F3 (only open finding) — implement the scrollback-backfill reflow-epoch
fail-safe end-to-end, so a FOREIGN attach that reflows the shared mirror
(our own term.cols unchanged) can no longer splice a duplicated/skipped
history band that PERSISTS until re-attach. Conceded to codex: persisted
scrollback corruption outlives the transient live-view garble, so the
code's "garbled anyway" deferral rationale doesn't cover it.
Mechanism (irreducible round-trip; no sound client-local shortcut):
- kaval Entry gains a monotonic reflowEpoch, bumped in resize() after the
rewrap; attach snapshot stamps it; getHistory(before, max, epoch?)
returns an empty {stale:true} reply when the stamped epoch no longer
matches, so the client HALTS (existing pause()) instead of splicing.
- Rides both wire contracts as ADDITIVE-OPTIONAL fields (reflowEpoch on
the snapshot frame; epoch in / stale out on getHistory), mirrored
kaval->padi->client. Optional => every mixed-version path is fail-open
(old daemon omits epoch -> no gate -> today's behavior), no skew
refusal, so the F1/F2 major surface is NOT reopened. Bumps are additive
minors: kaval 5.1->5.2, padi 3.0->3.1.
- Rewrote the KNOWN-GAP doc to describe the implemented guard; the
continuous-across-reflow logical-line cursor stays the note-5 follow-up.
Dispositions: F3 fixed (this commit). F1,F2,F4-F14 remained resolved from
rounds 1-4 (no re-litigation).
Verified: typecheck + biome clean; kaval 153 (new stale/fail-open test),
client 712 (new stale-halt + epoch-echo test), padi green except the
pre-existing spawnInput.test.ts:157 failure (identical on clean base).
Updated dial.test.ts / surface.test.ts version pins to track the bump.
…heck cast, trailing-newline
/simplify (4 parallel cleanup lenses). Efficiency and altitude found nothing;
reuse and simplification agreed on three within-diff DRY fixes:
- ptyHost.ts: extract snapToWrapHead() — the "snap a serialize cut back to the
logical-line head" loop was copy-pasted between the bounded-snapshot start and
getHistory; one home so the two seam edges can't drift.
- scrollbackBackfill.ts: drop the `term as unknown as {...}` cast at all three
isAltBufferActive call sites — XTerm's buffer.active.type structurally satisfies
the param already.
- kaval-tui/main.ts: extract writeOutLine() for the repeated trailing-newline
normalize shared by the snapshot and history dumps.
Left as-is (agents concurred): `disposed` stays explicit (a distinct lifecycle
concept, not folded into the epoch bump); the entry alt-check stays (avoids an
inFlight toggle).
…ing test refs
code-police (Pass 1 rules + Pass 2 fact-check), --no-elegance (simplify already ran).
- invalid-states-unrepresentable (Pass 1): the getHistory reply was a flat
{chunk, topLine, exhausted, stale?} where "stale never carries a real chunk"
was hand-maintained across four return sites — the PR's own TerminalAttachFrame
is the discriminated-union counter-example. Reshaped to `{kind:"chunk"; chunk;
topLine; exhausted} | {kind:"stale"}` through all four layers (kaval
PtyHistoryChunk + wire schema, padi TerminalHistoryChunk + surface schema,
client HistoryChunk) and every consumer/test. Skew stays graceful: the `stale`
arm is reachable only when the caller sends `epoch`, so a 5.1 client (which
sends none) only ever sees `chunk` frames.
- no-untyped-escape-hatches (Pass 1): kaval-tui `pages[i] as string` → reversed
for-of iteration; dial.test.ts `first.value as {kind:string;...}` → cast to the
real TerminalAttachFrame union (the stream iterator erases the value to `{}`, so
a cast is needed — but to the precise named type, then narrowed on `kind`).
- fact-check (Pass 2): comments cited `xtermPrepend.spike.test.ts` as present-tense
proof, but that file is on the unmerged spike branch, not this tree — reworded to
point at the shipping tests (scrollbackBackfill.test.ts + xtermMirrorContract.test.ts)
and name the spike as an out-of-tree ancestor.
Skipped (noted): the `expect(x).toBeTruthy(); x!` pattern in the contract-pin
tests — test-only, and the expect assertion already guards the non-null access.
⚖️ Lowy ⇄ Hickey lens debate✅ Consensus after 2 round(s) · lowy + hickey · base Independent findings: lowy=4, hickey=4 Applied (2)
Agreed — no change (6)
|
Codex ⇄ Claude debate✅ Consensus after 6 round(s) · codex reviewed at Round 1codex — approved: The change is not ready to merge. The overall design is promising, but the current implementation has two deployment-breaking versioning mistakes and several ways to omit, duplicate, or corrupt scrollback around wrapping, blank rows, resizing, and snapshot replay. The deliberate 5.1-additive reasoning is incorrect because it considers the schema change but not the bounded-snapshot behavior seen by an older client. Findings:
claude — Fixed the two real version misses, the fail-loud/error-swallow gaps, the CLI/browser blank-page bugs, the wrap-safe snapshot cut, the self-seed offset, the padi→kolu-common back-edge, and the dead test vars; disputed the kaval major bump and the two speculative-generality findings with reasons.
Verification: biome lint (authoritative commit: Round 2codex — approved: Nine findings are resolved after verifying the current tree. Five remain open: the kaval minor-version compatibility break, cursor invalidation by foreign-client resize, the optional-field attach state model, the still-optional error handler, and loss of blank history rows. No new findings were introduced. Findings:
codex on the rebuttal: F2: I hold firm. Losing roughly 9,000 retained terminal rows weakens an existing data-completeness guarantee in the old-client/new-daemon direction admitted by minor compatibility. That is not comparable to omitting a cosmetic lifetime readout. The padi 3.0 gate does not repair the direct old-padi/new-kaval contract. If avoiding PTY recycling is paramount, capability negotiation preserves full legacy snapshots without requiring a recycle. F3: I hold firm. This is not speculative in a daemon explicitly designed for multiple clients: the shipped F4: I hold firm. The appropriate union is padi's two-state consumer model, not kaval's three-way control protocol: F6: The narrowed F10: I accept the fixes for browser stalling and CLI early termination, but the original fidelity defect remains. Blank lines are terminal content; dropping them compresses spacing and produces a buffer different from native parsing. Exact row materialization does not require aliasing or newline padding: the scratch buffer already has distinct initialized blank rows, which can be stolen using a server-provided served-row count. F12: I concede. No current producer supplies a custom depth, and the maxLength tripwire preserves the required fail-loud behavior for the hypothetical case. claude — Fixed the required-error-handler and the padi frame's discriminated-union modelling; documented the foreign-resize reflow gap as a tracked follow-up; held the kaval minor-version and the blank-row fidelity findings with sharper technical reasons.
commit: Round 3codex — approved: The discriminated attach-frame union and required error handler are correctly implemented, resolving F4 and F6. I accept the strengthened F2 compatibility argument. Two findings remain open: foreign-client resize can still invalidate a supported consumer's history cursor, and blank history rows are still omitted rather than reconstructed. Findings:
codex on the rebuttal: F2: I concede. The stronger argument establishes that 5.1 preserves parseability and terminal operation, while snapshot depth was not a versioned wire-shape guarantee. In the only old-padi/new-kaval case—a rollback—the reduced cold-attach history is reversible, whereas forcing a major skew recycles kaval and irreversibly kills the live PTYs. I accept the terminal-survival tradeoff and mark F2 resolved. F3: I hold firm. Recording the gap is useful but does not resolve it. Moreover, the new comment overstates self-healing: the local resize handler only pauses by clearing the cursor; it does not reseed. Reseeding occurs on a later fresh snapshot. A foreign reflow can therefore cause one stale fetch to insert a duplicated or skipped band that remains in the client until a reset/re-attach. F4: Verified resolved. The new padi-level discriminated union is carried consistently through the schema, endpoint type, initial producer, overflow reattach producer, tests, and browser consumer. F6: Verified resolved. F10: I hold firm. The page-scale objection does not make exact reconstruction unsafe. For historical ranges ending above the viewport, addon-serialize represents trailing blank rows through row separators; the final blank is present as the scratch cursor row and is currently dropped only because claude — Fixed F10 (blank-row fidelity) client-side with no wire change; on F3 conceded the defect, corrected the inaccurate "self-heals" comment codex flagged, and disputed landing the partial width-gate this round with a codebase-grounded reason.
commit: Round 4codex — approved: F10's cursor-derived row count correctly restores trailing blank rows when the serialized chunk is nonempty, but it still bypasses reconstruction for an empty serialized range and does not preserve the final blank row in the CLI dump. F3 remains a documented but unfixed correctness defect. The other twelve findings remain resolved. Findings:
codex on the rebuttal: F3: I hold firm. Correcting the comment is valuable, but the pre-existing last-resize-wins artifact does not make the new persistent history duplication/skip acceptable. They are distinct effects: raw shell output may still parse at the browser's own width, while a stale absolute mirror cursor selects the wrong historical rows and splices them permanently until re-attach. A partial stale-width/generation gate remains useful because halting backfill prevents durable buffer corruption even if another live-view artifact remains. The number of touched files and unavailable e2e infrastructure are scope/process concerns, not evidence that the known code defect is resolved.\n\nF10: The cursor-derived claude — Closed F10's remaining client branch (an all-blank range with
Verification: commit: Round 5codex — approved: The latest F10 client fix is correct: all-blank history ranges now materialize their full row span, including the previously missed empty-chunk branch. I also accept the remaining CLI seam behavior as a bounded limitation of its documented raw-VT page dump, so F10 is resolved. F3 remains open because the author fully confirms a persistent duplicate/skip corruption on a supported multi-client path; documenting or scheduling that code defect does not resolve it. All other findings remain resolved. Findings:
codex on the rebuttal: F3: I hold firm. The author now explicitly confirms every material part of the finding: this occurs through a supported F10: The latest branch is verified fixed. The claude — Conceded F3 and IMPLEMENTED the reflow-epoch fail-safe end-to-end (kaval→padi→client, two additive-optional wire minors), turning the persisted foreign-reflow scrollback corruption into a halt-not-corrupt; all other findings stayed resolved from prior rounds.
commit: Round 6codex — approved: F3 is now resolved. The reflow generation is captured with each attach snapshot, propagated through both contracts, echoed by history requests, checked before serialization, and handled client-side by pausing without prepending. Re-attach snapshots refresh the generation, older peers remain wire-compatible through optional fields, and the contract versions were bumped as additive minors. I found no regression introduced by this round, so all findings are resolved and the change is approved. Findings:
codex on the rebuttal: F3: Verified and accepted. |
👮 Code-policePass 1 (rule checklist) + Pass 2 (fact-check),
Pass 2 otherwise validated the subsystem in depth — the absolute-cursor seam guarantee, eviction ( All findings verified against the tree at |
…t a string
CI's ci::unit surfaced 3 padiBinding.test.ts failures the local kaval/padi/client
suites missed (kolu-server package): the terminalAttach frame is a discriminated
union {kind:"snapshot"|"delta", ...} now, not a string, so the round-trip helper's
`expect(typeof first.value).toBe("string")` reads an object. Narrow on the real
TerminalAttachFrame union (kind === "snapshot", data is a string) — same fix as
dial.test.ts. Applied to the skipped remotePadiSsh.test.ts too so the SSH e2e
isn't left with a latent break.
Evidence — part 1: bounded attach + real scroll-up backfillCaptured against a local dev instance (random ports, isolated from production), driving a terminal whose PTY had 3000 lines of demo scrollback ( Numeric proof — instant attach, no full replayMeasured directly off the live xterm buffer (
So attach ships a bounded snapshot (~1.1k rows) instead of replaying all 3009 — that is the W9 cross-host full-replay cost removed. The remaining history is then pulled on demand by scrolling up, and the buffer grows to the full 3009 with Visual proof — old content is really there after backfillScreenshot Screencast Local artifacts (per-worktree, gitignored — viewable on this box):
Part 2 (the real cross-host clip — switch to a remote host over ssh, backfill across the seam) follows, captured via the isolated nix-run harness against a real remote box. |
Evidence — part 2: the real cross-host clip (over ssh)This is the headline case — the W9 cost this PR removes. Captured against a real remote box ( SetupA terminal on Numeric proof — cross-host switch is a bounded attach, not a full replayMeasured off the remote terminal's live xterm buffer:
The cross-host switch-back re-attaches with a bounded ~1000-line snapshot ( Artifacts (per-worktree, gitignored — viewable on this box):
TeardownIsolated kolu killed by recorded PID; temp state removed; its padi/kaval reaped by exact digest-socket PID. On naiveintent: my deployed padi self-terminated on kolu exit, my demo shell exited, and a standalone-kaval stray (spawned by a CI — 5cd66e1 green on both platforms
|
…om review
Independent 40-agent review (SBF-PRPND) found the seam protocol (absolute cursor
+ strictly-above) sound but the LIFECYCLE around it unsafe. Fixes, test-first:
- Unpinned shipped scratch: a behavioral pin exercises the exported
`defaultScratch` (an unopened @xterm/xterm parses a write into rows AND fires
its callback), so a caret-range bump changing pre-open semantics is red CI, not
silent blank-row corruption.
- Alt-skip conflation: `prependScrollback` returns a discriminated
`{inserted,rows} | {skipped}`; the controller treats `skipped` as not-consumed
and never advances the cursor past an unspliced band (the silent-hole bug).
- reflowEpoch over-bump: kaval `resize()` bumps the generation only on a real
COLS change and returns early on exact same-dims — routine height-only and
second-viewer resizes no longer stale (and permanently halt) backfill.
- RIS buffer replacement: the mirror write callback detects the normal-buffer
CircularList swap (RIS / `reset`) and re-anchors — re-subscribes onTrim to the
new list, advances mirrorBaseLine past the discarded rows, bumps reflowEpoch —
so a pre-reset cursor re-seeds instead of serving the live screen as history.
- Overflow re-attach: `consumeSnapshotFrame` fuses reset+seed into one method
(synchronous invalidation at frame receipt kills an in-flight fetch across the
RIS; a post-parse committer seeds), and `scrollLock` buffers each chunk WITH
its onParsed so `flush()` fires the re-seed after a locked flush lands.
- Fail-loud: a prepend fault surfaces via `onError` instead of vanishing as an
unhandled rejection off `void maybeBackfill()`; the local invalidation counter
is renamed `generation` so it and the reflow `seedEpoch` never blur.
- kaval-tui `history`: a full dump materializes an all-blank page's span as blank
lines (F10 parity with the browser path) instead of dropping it.
…liberate, typed halt with a named structural cure Per SBF-PRPND review ruling (A): the client proactive re-seed on `stale` is deferred to the reflow-invariant-cursor follow-up. Record the residual explicitly in the atlas note (not prose): a genuine foreign-width reflow halts DEEPER backfill until the next snapshot — typed `stale`, halt-not-corrupt, the user keeps their buffer and every backfilled row — and this is deliberate; the structural cure is a reflow-invariant cursor; a re-attach-on-stale bandaid was rejected (it would repaint and discard backfilled history). Also states the generation now bumps only on a width change or RIS, never height-only/same-dims.
Close the two residual reset races and the smaller fail-loud / lifecycle / API-shape / test-coverage / doc findings from codex's review. F1 (major, fixed): an in-band RIS (ESC c) in an ordinary delta frame resets the live xterm, but delta frames carry no generation, so an already-returned pre-reset backfill chunk could splice onto the post-reset buffer. The controller now registers a RIS esc handler on the live term that invalidates synchronously the instant xterm parses the RIS (returns false so xterm still resets) — both orderings safe, no wire change. F2 (major, fixed): the consumeSnapshotFrame committer was unconditional. Capture the generation after pause() and make the committer a no-op unless it's still current and not disposed, so a resize/RIS/newer-snapshot/dispose between frame receipt and parse can't resurrect a stale cursor. F3 (minor, fixed): removed the test-only seed() from BackfillController; seeding is only via the guarded consumeSnapshotFrame committer. F4 (minor, fixed): normalLinesOf validates lines.length is a nonnegative integer (RIS re-anchor reads it) and the contract test pins length + the onTrim disposable. F5 (minor, fixed): one stable trim-teardown at spawn; RIS replaces the handle in place instead of accumulating a dead disposable per reset. F6 (minor, fixed): pause() before onError on a prepend fault so a permanent overflow/broken-internals failure can't retry-loop on every scroll. F7 (minor, fixed): extracted materializeHistoryPage as a pure, unit-tested helper covering content / blank-span / self-seeded-first-page. F8 (minor, fixed): updated the MDX (mid-backfill pause, PrependResult signature, pinned defaultScratch) and regenerated the atlas dist. F9 (nit, fixed): deleted the dead onNormalBufferTrim wrapper + orphaned JSDoc. F10 (nit, fixed): corrected stale comments (returns skipped not 0; epoch -> generation; flush fires buffered callbacks; mirrorBaseLine/reflowEpoch bump semantics). Deliberate decisions (a)-(f) respected; F1 fixed rather than deferred because a bounded client-only cure exists and the module's own halt-not-corrupt thesis otherwise had a real hole for an already-returned chunk.
Round-2 codex verdict: 9 findings resolved, F10 (nit) still open, F11 (major) new regression. Both addressed here. F11 (fixed) — the F1 RIS esc handler self-invalidated an overflow re-attach's own snapshot. `reattachingDeltas` emits a re-attach as `TERMINAL_RESET + snapshot`; consumeSnapshotFrame paused (generation G) and captured G, then xterm parsed the frame's leading RIS into the esc handler which paused again (G+1), so the deferred committer saw generation != G and never seeded — cursor stayed null, disabling backfill after every overflow re-attach. Fix (client-only): the controller owes the esc handler one absorb per snapshot frame that carries a leading reset (`expectedSelfResets` counter). The handler decrements-without-pausing for an owed self-reset (already invalidated at frame receipt) and only pauses for a FOREIGN live-delta RIS (F1 intact). consumeSnapshotFrame takes `carriesReset`; Terminal.tsx passes `frame.data.startsWith(TERMINAL_RESET)`. TERMINAL_RESET relocated to the client-reachable `@kolu/padi/endpoint` barrel (reattachingDeltas re-exports it) so both sides share one source of truth without pulling server-only code into the client bundle. Added the composition test (consume→parse RIS→commit→scroll asserts the fetch uses the new topLine+epoch; a second RIS proves the absorb is one-shot). F10 (fixed) — scrollbackBackfill.ts:494 "bump the local epoch" → "bump the local generation". Check gate green (typecheck + biome); scrollbackBackfill (25) and reattachingDeltas (5) suites pass.
Close F11 (the last open finding) for real: the receipt-time `expectedSelfResets` credit counter tracked reset QUANTITY, not byte-order provenance. Under scroll lock, scrollLock.ts joins every buffered chunk into one xterm write, so a foreign RIS buffered AHEAD of an overflow snapshot parses before the snapshot's own leading RIS and steals the credit; the snapshot's RIS then bumps the local generation past the committer's baseline and the guarded committer no-ops — leaving backfill dead after a valid re-attach. This is a loss of a valid replacement cursor, not a halt-with-no-cursor, so the safe-halt defense did not cover it. Codex was right; conceded. Fix (client-only, no wire change), per codex's requested direction — bind reset provenance to the snapshot's write/parser boundary: - SNAPSHOT_SEED_SEAM: a zero-width no-op OSC that Terminal.tsx prepends to every consumed snapshot frame. It parses immediately before the frame's own bytes, so the controller captures that frame's committer baseline THERE — after every byte-earlier foreign RIS has paused (excluded), predicting the +1 the frame's own RIS makes. The committer seeds iff no reset landed AFTER the snapshot's bytes (F11 resumes; F2 still suppresses a later reset/resize). - The RIS esc handler is back to the pure "always pause" F1 handler; the absorb counter is removed. A FIFO routes the Nth seam to the Nth snapshot's committer so two snapshots buffered under one flush each get their own baseline. Tests: fakeTerm gains registerOscHandler/fireSeedSeam; the three lifecycle tests use the seam ordering; two new byte-order tests cover foreign-RIS-before-snapshot and foreign-RIS-between-two-snapshots (the newest seeds); a contract pin asserts the seam is a no-op OSC in real @xterm/xterm. just check + just fmt green; 28/28 backfill pass. F10 remains resolved from round 2. Full dispositions in .codex-debate/section-003-2-claude.md.
Address the three findings codex left open in round 4 (F1, F3–F11 already
resolved).
F2 (major, reopened) — the round-3 seam moved the committer baseline to the
snapshot's byte position to exclude a byte-earlier foreign RIS (F11), but that
also folded OUT-OF-BAND invalidations (width resize / explicit reset / newer
snapshot) that land in the receipt->seam window into the baseline and forgave
them, reopening the receipt-to-parse race. Fixed with a dual token: a new
`lifecycleToken` bumped only by out-of-band events (via `pauseLifecycle()`),
captured at receipt and re-checked at commit alongside the seam's byte-position
`generation` baseline. RIS and internal stale/fault pauses keep plain `pause()`.
New regression test fires the resize BEFORE the seam parses.
F12 (major, new) — OSC 60697 is ordinary PTY output, so the handler's
unconditional `shift()` let program output steal a seed and its throw on an empty
FIFO could interrupt xterm parsing. Rejected the out-of-band-scrollLock-barrier
option (scrollLock JOINS buffered chunks into one write, breaking the seam's
byte-position ordering) for codex's second option: an unguessable per-frame
`crypto.randomUUID()` token. `consumeSnapshotFrame` now returns `{ commit, seam }`
with the token baked into the seam; the handler captures a baseline only for a
payload matching the front pending-seed token, and ignores (never throws) any
other output on the ident. New forgery/empty-FIFO tests; contract-pin now asserts
the token payload round-trips through a real xterm.
F13 (nit) — updated the Terminal.tsx call-site comment (and two stale
scrollbackBackfill.ts comments) that still described the removed absorb-counter /
old `SNAPSHOT_SEED_SEAM` export to describe the seam predicting the generation
bump and the token match.
Dispositions: F2 fixed, F12 fixed, F13 fixed. check gate green; 31/31
scrollbackBackfill tests pass.
Close the two findings codex left open after round 4. F14 (major, fixed): the seam-token mint used crypto.randomUUID(), a secure-context-only API. kolu is reached over plain HTTP on a LAN, where randomUUID is absent and throws — consumeSnapshotFrame threw before the frame was written, wedging every attach. The codebase already guards its two other browser-side randomUUID uses for exactly this reason. Replaced it with a new mintSeedToken() drawing 128 bits from crypto.getRandomValues (available in insecure contexts) and hex-encoding them — cryptographically unpredictable, so the token stays unforgeable by PTY output (F12). Added a regression test that deletes crypto.randomUUID and drives a full seed. F15 (nit, fixed): three comments listed disposal under lifecycleToken / pauseLifecycle, but dispose() never calls pauseLifecycle — it flips the permanent `disposed` flag the commit guard checks first. Corrected all three sites to describe the separate disposed guard, keeping the two mechanisms honestly distinct rather than adding a redundant token bump. Gate green (just check, exit 0); all 32 scrollbackBackfill tests pass.
F16 (minor, only open finding): the F14 insecure-context regression test did not actually make crypto.randomUUID unavailable. In happy-dom (the client test env) randomUUID is inherited from Crypto.prototype, so 'delete crypto.randomUUID' removed nothing and left it callable — a regression back to randomUUID() would have passed. The old finally also minted an own property that never existed, leaking a changed global shape. Fix: shadow randomUUID with an own 'undefined' property (defineProperty), assert it is actually unavailable before exercising the controller, and restore the exact original descriptor in finally (re-install if own, else delete the shadow). Disposition: F16 fixed. F1-F15 were already resolved in prior rounds. Verified: scrollbackBackfill.test.ts 32/32 pass; 'just check' (typecheck+biome) green.
Codex ⇄ Claude debate — ✅ consensus7 rounds · reviewer effort xhigh · base codex reviewed the rework that closes the four SBF-PRPND defect families and found real additional holes my delta missed — chiefly a client-side RIS in a live delta frame (not just the snapshot path), which the author closed with an unforgeable per-frame OSC seam token tied to the exact snapshot-seam byte position (catching, and fixing, a Round 1codex — approved: The change closes several prior lifecycle holes, but two reset races still permit stale history state: an in-band RIS can accept a history chunk before resetting the browser and then allow that chunk to commit afterward, and delayed snapshot committers can resurrect cursors invalidated by a later reset. I also found several smaller fail-loud, lifecycle, API-shape, test-coverage, and documentation issues. Deliberate decisions (a), (b), (c), (d), and (f) were respected; the stated reasoning for (e) is incomplete because the epoch bump cannot revoke an already-returned chunk. Findings:
claude — Fixed all ten findings: closed the two reset races (an in-band-RIS esc-handler invalidation and a generation-guarded snapshot committer), tightened the fail-loud/lifecycle/API/test/doc gaps, and re-ran the check gate + targeted suites green.
commit: Round 2codex — approved: Nine prior findings are resolved, but the branch is not yet approvable. The new RIS invalidation introduces a major integration regression: overflow snapshots already contain RIS, so they invalidate their own guarded seed commit and leave backfill permanently paused. F10 also retains one stale "local epoch" comment. Findings:
codex on the rebuttal: F1: Accepted for the original race—the newest-first ESC handler pauses synchronously and returns false, allowing xterm's prior reset handler to run; disposal is covered. F2: Accepted—the captured generation correctly blocks commits superseded by resize, reset, newer snapshot, RIS, or disposal. F3: Accepted—the bare seed transition is gone. F4: Accepted—length and onTrim are now validated and pinned. F5: Accepted—the stable current-handle teardown bounds the disposable list. F6: Accepted—prepend faults pause before being surfaced. F7: Accepted—the extracted helper and focused cases cover the previously untested branch. F8: Accepted—the MDX and generated HTML now match the implementation. F9: Accepted—the dead wrapper and duplicate documentation are removed. F10: Partially accepted; the cited comments were mostly corrected, but line 494 still uses "local epoch," so F10 remains open. Separately, F11 is a new regression introduced by composing the F1 RIS hook with the F2 guarded committer: the RIS-prefixed overflow snapshot invalidates itself. claude — Fixed the F11 self-invalidation regression (a snapshot frame's own leading RIS no longer revokes its committer) and the F10 stale "local epoch" comment; check gate green, targeted suites pass.
commit: Round 3codex — approved: F10 is resolved and F11's nominal self-RIS-first path now works, but F11 remains open. The receipt-time counter does not preserve reset provenance when scroll-lock concatenates buffered frames: an older foreign RIS can consume the credit intended for a later snapshot, after which the snapshot's own RIS revokes its committer and backfill remains paused. Findings:
codex on the rebuttal: F10: Accepted; line 516 now correctly says "local generation." F11: The nominal fix, shared constant, and self-RIS-then-foreign-RIS test are valid, but I do not accept the admitted foreign-before-snapshot limitation. Under scroll lock, packages/client/src/scrollLock.ts:222 concatenates buffered chunks before parsing, so the counter tracks quantity rather than provenance. An older foreign RIS deterministically steals the later snapshot's credit, and that snapshot then invalidates itself. Calling this safe-halt does not resolve the finding: the fresh snapshot is later in byte order, supplies a valid topLine/reflowEpoch, and is precisely the event that should resume backfill; another snapshot may never arrive. The halt-not-corrupt policy applies when no valid cursor exists, whereas here the valid replacement cursor is discarded solely because reset ownership was lost. The previous requested direction explicitly required a frame/write-boundary signal precise under scroll-lock coalescing, so F11 remains open. claude — Conceded and fixed F11 for real: replaced the receipt-time credit counter with a byte-position snapshot-seed seam, so a foreign RIS buffered ahead of an overflow snapshot under scroll lock can no longer steal the seed; check gate green, 28/28 backfill tests pass.
commit: Round 4codex — approved: The byte-position seam resolves F11's foreign-RIS-before-snapshot ordering, so F11 is closed. However, this round reopens F2 because lifecycle invalidations before seam parsing are forgotten, and introduces two regressions: PTY output can forge OSC 60697 and corrupt/throw through the pending-seed FIFO, and Terminal.tsx still documents the removed "absorbed RIS" behavior. Findings:
codex on the rebuttal: F11: Conceded and accepted. The sequence [foreign RIS][seam][own RIS][snapshot] now captures the baseline after the foreign reset and predicts the snapshot reset, so the original reset-credit theft is fixed. The safe-halt defense was also correctly withdrawn. However, the claim that F2 remains preserved is incorrect: moving the entire baseline to seam parsing causes resize/reset/newer-snapshot invalidations between receipt and seam to be incorporated into the baseline instead of superseding the snapshot; the revised test no longer exercises that original race, so F2 is reopened. Separately, F12 is a new regression because OSC identifiers are part of arbitrary PTY output and 60697 has no trustworthy provenance, and F13 records the newly stale call-site comment. claude — Conceded and fixed both open majors (F2 receipt-to-parse race and F12 OSC forgery/throw) and the F13 stale comment; check gate green, 31/31 backfill tests pass.
Verification: commit: Round 5codex — approved: F2, F12, and F13 are correctly fixed: the dual counters cover receipt-time versus byte-order invalidation, token matching prevents foreign OSC output from stealing a seed or throwing, and the comments now describe RIS prediction accurately. However, the new token generator breaks supported plain-HTTP deployments, and the new lifecycle comments misstate disposal behavior. Approval remains blocked on F14 and F15. Findings:
codex on the rebuttal: F2: accepted—the receipt-captured lifecycleToken now catches out-of-band invalidations before the seam, while generation preserves the byte-order behavior required by F11. F12: accepted—the random per-frame token and FIFO-head match resolve both the forgery and parser-throw defects; F14 is a separate compatibility regression in the chosen token generator. F13: accepted—the revised comment correctly explains that the seam predicts, rather than absorbs, the snapshot's own RIS bump. The new lifecycle commentary introduces the separate F15 documentation inconsistency. claude — Fixed both open findings: swapped the secure-context-only
commit: Round 6codex — approved: The production changes resolve F14 and F15: seam tokens now use 128 cryptographically random bits from getRandomValues, and the lifecycle comments accurately distinguish lifecycleToken from disposal. One new minor test regression remains: the insecure-context test does not actually remove the inherited randomUUID method and leaves the global crypto object with a changed property shape. Findings:
codex on the rebuttal: F14: accepted—the getRandomValues implementation fixes the supported insecure-origin failure while preserving the unpredictability required by F12. The production finding is resolved; F16 separately tracks the newly introduced ineffective regression test. F15: accepted—the revised comments now correctly state that disposal is enforced by the permanent disposed guard rather than lifecycleToken. claude — Fixed the one remaining open finding (F16): the F14 insecure-context test now genuinely hides
commit: Round 7codex — approved: The F16 fix is correct. The test now genuinely shadows the inherited randomUUID method, asserts that the simulation is active, and restores the exact original property shape in a finally block. No regression was introduced, and all findings F1–F16 are resolved. Findings:
codex on the rebuttal: F16: accepted. The own undefined property now blocks prototype lookup, the assertion proves randomUUID is unavailable before the controller runs, and the finally block either reinstalls the original own descriptor verbatim or deletes the temporary shadow to restore inherited lookup. This fully resolves the finding. |
Evidence — Family 3: a height-only resize no longer halts backfill (the positive artifact)The reported bug was that a routine vertical pane drag (a height-only resize) permanently stalled scroll-up backfill for the rest of the session — kaval bumped the reflow generation on every resize, so the client's next fetch came back Measured off the live xterm buffer (
The resize changed rows 70 → 44 with cols steady at 140 — a true height-only resize published to kaval — and backfill continued straight through it, from 2070 to the full 3007, This is pinned in unit tests too: Local artifacts (per-worktree, gitignored — viewable on this box):
(Captured on a local dev instance, random ports, isolated from production — production kolu PID verified unchanged before and after. The earlier bounded-attach + cross-host-over-ssh evidence is in the comments above.) |
…1791) ## What Retire `lifecycle.restoreSleeping` — a dead client-facing verb — from the padi surface, its `servePadi` handler, and every client-side stub/type/mock. Bump `PADI_SURFACE_VERSION` **3.1 → 4.0** (a removed procedure is a shape-break → MAJOR). ##⚠️ Version number: ratified as 3.0, shipping as 4.0 (same rule) srid ratified **MAJOR 3.0** when this branch was off a stale master at surface **2.0**. Master has since advanced — **#1783** (scrollback backfill) took `padiSurface` to **3.0** (its `terminalAttach` reshape) then **3.1** (reflow guard). This branch was rebased onto current master and the removal re-applied on top of 3.1, so the faithful application of the *same ratified rule* is **3.1 → 4.0**. Nothing else changed. The coordinator confirmed this handling before this PR opened. ## Why it's dead `restoreSleeping` had **no production caller**. Its only writer — the client respawn loop — was already deleted (W1.R6), and the real cold-boot restore seeds a sleeping terminal directly via `seedSleepingTerminal` in `sessionRestore.ts` / `reattach.ts`, never over the wire. #1784's W12 review dispositioned it as removable. ## Why a MAJOR bump A removed procedure is a shape-break in **both** skew directions — an old binder that still called `restoreSleeping` would hit a missing proc on a 4.0 padi. Only a major flips `isContractVersionCompatible` to refuse the skew both ways (mirrors 3.0's `terminalAttach` reshape and 2.0's `fs.statFileMtimeMs` removal). The version is an honest statement of the wire **shape**; encoding "no caller today" as a minor would bake in exactly the soft assumption the fail-fast rule rejects. ## Deploy consequence (flagged + ratified pre-push) A major skew is refused, so on the next deploy a newer binder **DRAINS** any straddling 3.x padi — the graceful `save + exit` every code-change deploy already pays via the build-digest mismatch — then respawns it at 4.0. **kaval and the PTYs are untouched**: a padi-surface bump does not touch the kaval contract. ## Disposition history - **#1784** (W12, "restore survives an unclean kaval death") — its independent perfection review dispositioned `restoreSleeping` as a dead verb with no production caller. This PR is that follow-up removal. ## Verification - Repo-wide grep: nothing outside tests/docs references the verb; `surface.test.ts` now **pins its absence** from the wire (dropped from the lifecycle-verb list). - `just fmt` clean · `just check` (tsc + biome) green · padi **309/309** · touched client tests green. - Version pins across `surface.test.ts` / `dial.test.ts` updated to 4.0 (skew-scenario re-pegged to a 5.0 binder vs a 4.x padi). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
**Status: ratified (2026-07-13).** srid delegated ratification to the coordinator under /perfection-review; the note now carries `status: accepted` and reads as the current snapshot only — no revision history in the note body (git and this PR hold the journey). **This note is now the campaign plan of record for the surface-framework consolidation.** The original audit (Codex, `gpt-5`) was written deliberately blind to the existing Atlas plans — that was methodology, an independent read of the three working trees. This revision is the reconciliation it deferred: the coordinator's reviewed ruling on the proposal, ratified by srid, merged in as one plan. ### What merged in - **The ratified [reactive bridge](https://kolu.dev/atlas/surface-reactive-bridge.html)** — phase 0 shipped in W5 (#1759); its phases 1–4 are now PRs 7–10 of this plan's sequence (one numbering scheme; the bridge note carries a superseded-pointer). - **The [consolidation ledger](https://kolu.dev/atlas/padi-cleanup.html)'s campaign 1** — L28 rung 2 → PR 3, L20·L21 → PR 5, L1 → PR 8, L8's hygiene residue as the tail commit; L22 and L24 dispositioned (L24 already shipped as #1749). - **A staleness sweep** — the audit's pins predate six merged PRs (W9 #1764, W10 #1772, W11 #1775, W12 #1784, #1783, #1791). Every code claim was re-grounded against current kolu/drishti/odu tips or cut; e.g. the "six polled-query state machines" claim died against W9's per-host ownership shape, and odu already adopted `implementSurface` since the audit. ### The dispositions | proposed | ruling | | --- | --- | | supervised `SurfaceRuntime` (final router · `done` · idempotent `close`) | **kernel — PR 1** | | bound procedures as first-class client members | **kernel — PR 2** | | opaque `membershipId` per map add (+ typed connection key, `clockNow` at admit) | **kernel — PR 3** | | total, schema-valid `failureOf` (no fabricated `"other"`) | **kernel — PR 4** | | mirror consumes collection `deltas` (deletes drishti's parallel stream) | **kernel — PR 5** | | `firstFrameOrThrow`-style adoptions | **kernel — PR 6** | | `Feed` member kind | **dropped** — it is the bridge's `scan` wearing a wire protocol; the append-heavy wire concern (terminal bytes, logs) becomes a named bridge design question, not a fourth member kind | | `@kolu/surface-suite` | **deferred** — a thin composition leaf earns no receptacle; its two real moves (final router, typed key) are in the kernel; revival condition: the surface-app dependency direction | | Odu lease stack (`SurfaceLease` … `SurfacePort`) | **Odu-local** — five concepts for one caller fails prove-then-extract; graduates at a second consumer; `SurfacePort` dies with it | | `createLiveQuery` | **obsolete** — W9's `hostCodeTab.ts` ownership shape supersedes the string-keyed singleton; the createResource instinct may refine inside it | | `notificationSurfaceApp` / `bootSurfaceApp` | **needs grounding** against W5's shipped `createNotify`/SW seam before any PR is cut | The note is restructured as user-facing / architecture / implementation, with the sequenced ten-PR list (kernel first, every framework PR drishti-paired per `.claude/rules/surface.md`) and grep-able done-criteria. Status stays `proposed` — srid merges and flips. ### Validation - `just atlas::build` (foreground, exit 0) - `just atlas::check-sync` (sync + idempotency both green) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…son (#1719) (#1792) Fixes #1719 — the intermittent unhandled `AbortError` on stdio-link teardown (the `padiBinding` "reconnects when padi dies" flake). The availability follow-up W12's own disposition named; recurred on **#1712**, **#1764**, **#1783** (`unit@x86_64-linux`, same `handleTransportClosed` → `peer.close()` `AbortError`). ## Root cause When the transport dies, `LinkStdioClient.handleTransportClosed()` calls orpc's `peer.close()`. With **no reason**, orpc's `AsyncIdQueue.close()` rejects every PENDING async-iterator pull with a **fresh, anonymous `AbortError`** ("[AsyncIdQueue] Queue[N] … closed or aborted while waiting for pulling."), delivered asynchronously. The one consumer that floats it is `mirrorCollection`'s **per-key value pump** (`mirrorRemoteSurface.ts`): a detached `void (async…)()` IIFE **never joined into the mirror's settle graph**, whose `ctl` is aborted only in the teardown `finally` — *after* `peer.close()` — and whose swallow predicate (`isAbortReason(err, ctl.signal)`) the fresh `AbortError` can never satisfy. `pumpRemoteSurface` builds the mirror with no signal, so nothing aborts the pulls first. `mirror.done` resolves and the pump loop advances while a pull is still parked → the pull rejects with no awaiter → float. ## The fix — two mechanism halves (owned/awaited/typed-cancelled, not swallowed) **1. `mirrorCollection` OWNS its per-key pumps.** Each pump is tracked; the collection's `finally` aborts the ctls **first** (a still-parked pull then rejects with the swallowed `signal.reason`), **then** `allSettled`s the tracked pumps — so a pump's settle is always observed, regardless of whether the peer or the finally closed first (per the ruling's ordering note). **2. The stdio link passes a TYPED close reason.** `peer.close({ reason: deadTransportError(SURFACE_STDIO_TRANSPORT_CLOSED, …) })` — so the one thing that can cross the stdio seam at close is that single owned, greppable `ORPCError` (the same non-retriable shape `call()` already throws on a dead link). Makes "an anonymous `AbortError` escapes the seam" unconstructible (AFP P4). ## Half 2 — the padi backstop (honest divergence, backstop not fix) padi gains a process-level `unhandledRejection` handler that is **loud** (a greppable `padi-unhandled-rejection-boundary` marker + an optional health sink) but **never fatal** — an unidentified future float becomes a diagnosable log line, not a dead workspace daemon. Installed in `bin.ts`'s durable-daemon branch only (not the `--stdio` front, whose stdout is the wire; not `runPadiDaemon`, which tests boot in-process). **The tension, stated honestly:** this **diverges** from kolu-server's deliberately-fatal `unhandledRejection` policy. A never-fatal global boundary *can* mask a genuinely-new bug. Mitigations: the log is loud + greppable (grep enumerates every float the backstop survived — each a missing source boundary to hunt), and **identified floats are still fixed at source** (halves 1–2 above). The boundary is the net under the fix, not a substitute. ## Evidence — both mechanism pins RED pre-fix (evidence transfers only within its class) **Pin (i)** — `packages/surface/src/links/stdio.test.ts`: a pull parked at transport close rejects with the typed error, not an anonymous `AbortError`. - RED on pre-fix: `AssertionError: expected AbortError: [AsyncIdQueue] Queue[1] was c… to be an instance of ORPCError` - GREEN post-fix. **Pin (ii)** — `packages/surface/src/mirrorPumpOwnership.test.ts`: `mirror.done` does not resolve while a per-key pump is unsettled. - RED on pre-fix: `expected false to be true` (`pumpSettled` — the detached pump was abandoned). - GREEN post-fix. **Boundary pin** — `packages/padi/src/unhandledRejectionBoundary.test.ts`: an injected float logs loudly with the marker, reaches the health sink, and padi survives (`process.exit` spied, never called). GREEN. **Suites:** `@kolu/surface` 323/323 · `@kolu/surface-remote` 152/152 · `@kolu/padi` 314/314 · `padiBinding.test.ts` 27/27 (×3 local). `just check` (tsc + biome) clean. **Flake-class evidence (separate):** flaky-tracker row `reconnects when padi dies` (#1712/#1764/#1783) flips to *fixed* on merge — see the tracker update commit + the two-platform CI runs on the final SHA. **drishti gate (shared surface):** GREEN. drishti's `packages/app/src/server/router.ts` consumes `pumpRemoteSurface`/`mirrorRemoteSurface`; my diff changes **zero** exported signatures/types, and drishti's full `typecheck` passes with my modified `mirrorRemoteSurface.ts` + `stdio.ts` overlaid onto its hydrated surface (baseline also green). Per `.claude/rules/surface.md`, a purely-internal behaviour change needs no drishti PR; drishti inherits the same float-fix. ## Scope Framework-touching (`@kolu/surface` links/stdio + mirror) but **not** structural — no boundary moves, no API/type reshape. Direct PR, no `/be` (coordinator-agreed). srid merges. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Attach now paints only the recent screenful, and scrolling up loads the rest. Today every cross-host switch (or page reload) replays kaval's whole 10,000-line mirror before the terminal is usable — the W9 full-replay cost. This ships the ratified plan: attach with a bounded snapshot (~1,000 lines), then backfill older lines into the terminal's own scrollback as the user scrolls up — the same buffer, scrollbar, and select/copy/search, no pager. The view never jumps; the only tell is the scrollbar thumb shrinking. Backfilled history is real terminal content — colors, wraps, and wide chars survive, because the bytes are replayed through xterm's parser before they're spliced in.
How a chunk travels
The seam is a guarantee, not a convention
The backfill cursor is an absolute mirror-line index, seeded from the attach snapshot's
topLineand carried on every reply. New output always appends at the mirror bottom, so it can never shift the index of a line the client already holds — a fetch serves strictly above the client's content regardless of how many live deltas are in flight during the fetch. Ahave-from-bottom cursor cannot: it compares the server's produced-line count against the client's received count, which differ by exactly the in-flight lag. The eviction origin the cursor rides on is tracked off the mirror'sonTrim(kaval's one internals reach, contract-pinned). Pinned byptyHostHistory.test.ts→ "serves strictly ABOVE the cursor even as output appends": attach, fetch, append 100 lines, re-fetch — identical chunk, zero duplicate, zero missing.The pieces
scrollbackBackfill.ts— a fail-loud leaf that deliberately inverts its siblingxtermInternals.ts(which degrades to a no-op): a silent partial prepend corrupts a terminal, so every missing symbol and every headroom shortfall throws. The reach into xterm privates is ~6 symbols, fenced by contract-pin tests in both packages so a version bump that moves one turns into red CI.getHistory— the one new wire verb (kaval → padi mirror), cursor-paged raw chunks from the 10k mirror.beforeis optional so it self-seeds for a plain pager.kaval-tui history <id>— the verb's second consumer (ratified in-scope): dumps older scrollback above the screen,--lines Nfor one page. Keeps the verb honest — it must serve a pager, not just the browser loop.DEFAULT_SCROLLBACK(50k) ≥ mirror (10k) + snapshot, so the prepend never splices pastmaxLengthand evicts the rows it just inserted (the one demonstrated corruption). AprependScrollbackthrow-on-overflow backstop stays too.Deliberately not: infinite/disk-backed history (the mirror stays 10k RAM — #1577's memory goal is untouched), backfill in the alt buffer, selection preserved across a prepend (cleared in the MVP), OSC-8 links live in backfilled rows.
Grounded by the green prototype (
xtermPrepend.spike.test.ts, 6/6). ContractPTY_HOST_CONTRACT_VERSION5.0 → 5.1 (additive: thegetHistoryverb + a requiredtopLineon the attach snapshot frame).Post-review rework (SBF-PRPND)
An independent 40-agent adversarial review found the seam protocol sound but the lifecycle around it unsafe; the four defect families were closed test-first, then a codex⇄claude review debate on the rework delta ran to consensus (7 rounds, xhigh) and closed a further client-side RIS surface:
resize()now bumps the reflow generation only on a real cols change (RIS also renumbers, so it re-anchors there too); a height-only or same-dims resize renumbers nothing and no longer stales/halts backfill. This is the vertical-drag-kills-backfill bug, fixed.prependScrollbackreturns a discriminated{inserted,rows} | {skipped}so an alt-buffer skip can never advance the cursor past an unspliced band (the silent-hole class).ESC c/reset) that swaps the mirror's line list re-anchors the eviction pin and generation server-side; on the client, an unforgeable per-frame OSC seam token (128-bitgetRandomValues) makes the live parser commit the backfill seed at the snapshot's exact byte position and synchronously invalidate on a foreign RIS — un-raceable, and safe on plain-HTTP LAN origins.flush(), so the re-seed survives a locked flush.Wire-adjacent, not wire: the
TERMINAL_RESET(ESC c) constant moved to padi's client-reachableendpoint.tsbarrel so the client can tell an expected snapshot-carried reset from a foreign live-delta reset. It is a constant, not a wire field — no contract version change.Deliberately deferred (ruling A): the client's proactive re-seed on a genuine foreign-width reflow — a rare, typed, halt-not-corrupt case where the user keeps their buffer and every backfilled row — is left to the reflow-invariant-cursor follow-up (a re-attach-on-
stalebandaid was rejected: it would repaint and discard backfilled history). Recorded in the atlas note.Try it locally
Generated by
/beon Claude Code (modelclaude-opus-4-8).