@cplieger/web-terminal-engine 3.5.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/store.ts CHANGED
@@ -13,15 +13,219 @@
13
13
  // from a zombie socket) incapable of duplicating a row.
14
14
 
15
15
  import type { ScreenMessage, ScrollMessage, WireRun } from "./types.js";
16
+ import { interiorGaps, retainedIntervals, type Interval } from "./intervals.js";
16
17
 
17
- /** Maximum lines retained client-side. Older lines are evicted from the top. */
18
- const MAX_LINES = 5000;
18
+ /**
19
+ * The tail cap held while paging is unconfirmed — the legacy default, so a
20
+ * pairing with a server that cannot serve history back never has its reachable
21
+ * history cut.
22
+ *
23
+ * This is the SOURCE of the default cap, not a second name for it: `MAX_LINES`
24
+ * derives from it below. The two were independent literals, so editing this one
25
+ * — the exported, documented, obviously-intended knob — changed nothing except
26
+ * which assertion failed.
27
+ */
28
+ export const COMPATIBILITY_TAIL_CAP = 5000;
19
29
 
20
- /** The live screen window: a fixed `height`-row block at the tail of the buffer. */
30
+ /**
31
+ * Maximum lines retained client-side. Older lines are evicted from the top.
32
+ * Identical to the compatibility cap by construction: the depth a client holds
33
+ * when nothing has told it to hold less IS the pre-flip cap.
34
+ */
35
+ const MAX_LINES = COMPATIBILITY_TAIL_CAP;
36
+
37
+ /**
38
+ * SNAPSHOT_VERSION is the shape version of a persisted store snapshot. Bump it
39
+ * on any change to StoreSnapshot's fields or their meaning; fromSnapshot rejects
40
+ * anything that does not match, so an older snapshot is discarded rather than
41
+ * misread. There is deliberately no migration path: a discarded snapshot costs
42
+ * one full resume, which is exactly the behavior of not having persisted at all.
43
+ */
44
+ const SNAPSHOT_VERSION = 1;
45
+
46
+ /**
47
+ * A persisted line store, as plain data: no class instances, no functions, no
48
+ * cycles, so it survives structuredClone and can go straight into IndexedDB
49
+ * without a bespoke serializer (WireRun is primitives only — see types.ts).
50
+ *
51
+ * What is deliberately NOT here, and must not be added without re-reading why:
52
+ *
53
+ * - The live window (`WindowState`). It is last session's geometry, and it is
54
+ * load-bearing in two places that would silently misbehave under a stale
55
+ * value: `inWindow()` is what exempts live-window rows from the staleness
56
+ * guard and from cap eviction. The first screen frame re-establishes it.
57
+ * - `serverOldest`. It is the server's retained-history bound from a resumeAck,
58
+ * and the next resumeAck supplies the current one. A stale value would make
59
+ * the "earlier output trimmed" marker report on a range that no longer exists.
60
+ * - The alternate screen (`alt`, `altRows`). Ephemeral by definition: an alt
61
+ * grid is not history and is never addressed by absolute index.
62
+ */
63
+ export interface StoreSnapshot {
64
+ /** Shape version; must equal SNAPSHOT_VERSION or the snapshot is discarded. */
65
+ v: number;
66
+ /**
67
+ * The server boot epoch these lines belong to, as last reported by a
68
+ * resumeAck, or 0 when the client never learned one.
69
+ *
70
+ * This is the field that makes persistence safe rather than actively harmful.
71
+ * Absolute line indices are only meaningful within one server process: a
72
+ * restarted server begins again at 0. Restart detection is otherwise
73
+ * in-memory — the first resumeAck of a page load records the epoch with
74
+ * nothing to compare against — so a hydrated store plus a restarted server
75
+ * would present stale content as live AND then silently drop the new
76
+ * session's output, whose low indices fall at or below the hydrated
77
+ * everEvictedThrough and are refused by apply-line guard 2.
78
+ *
79
+ * The consumer must therefore hand this to the connection layer before
80
+ * connecting (see connection.adoptPersistedEpoch), so the first resumeAck
81
+ * has something to compare against and the existing restart path fires.
82
+ */
83
+ serverEpoch: number;
84
+ /** Lowest absolute index in `lines`.
85
+ *
86
+ * Write-side metadata: `fromSnapshot` derives the bounds from the pairs it
87
+ * actually accepted rather than trusting these, so a corrupt value here changes
88
+ * nothing on the way back in. They are carried because a consumer's storage
89
+ * layer wants them without parsing the payload (the keeper records `highest` as
90
+ * its save watermark), and they are deliberately NOT validated against the pairs
91
+ * — a check with no failure mode behind it is worse than none. */
92
+ oldest: number;
93
+ /** Highest absolute index in `lines`. Write-side metadata; see `oldest`. */
94
+ highest: number;
95
+ /**
96
+ * The retained lines as [absoluteIndex, runs] pairs, ascending. Pairs rather
97
+ * than an object keyed by index, because object keys stringify and absolute
98
+ * indices are numbers; pairs clone as-is and read plainly in a debugger.
99
+ */
100
+ lines: [number, WireRun[]][];
101
+ }
102
+
103
+ /**
104
+ * isWireRunArray reports whether a value decoded from OUTSIDE this program is a
105
+ * usable run array.
106
+ *
107
+ * This is the only path into a store that admits externally-sourced run objects:
108
+ * the wire decoder constructs each WireRun field by field from typed cursor reads,
109
+ * so `t` is a string by construction and applyLine's own `Array.isArray(runs)`
110
+ * check inherits that guarantee. A snapshot inherits nothing, and the difference
111
+ * is not academic — a run whose `t` is not a string reaches the renderer's
112
+ * per-character loop and throws, the row stays queued because the drain only
113
+ * clears a row after a successful build, and every row above it is blocked. That
114
+ * failure is permanent and self-perpetuating (the store hydrated, so the entry is
115
+ * never dropped, so the next reload breaks the same way), where every other
116
+ * rejection here degrades to "nothing restored".
117
+ *
118
+ * Optional numeric fields are checked when present rather than defaulted: a
119
+ * string in `f` would flow into a colour lookup, and silently coercing it would
120
+ * hide a producer bug instead of rejecting a payload this store cannot trust.
121
+ */
122
+ function isWireRunArray(value: unknown): value is WireRun[] {
123
+ if (!Array.isArray(value)) {
124
+ return false;
125
+ }
126
+ for (const run of value) {
127
+ if (typeof run !== "object" || run === null) {
128
+ return false;
129
+ }
130
+ const r = run as Record<string, unknown>;
131
+ if (typeof r["t"] !== "string") {
132
+ return false;
133
+ }
134
+ for (const field of ["f", "b", "uc", "a"]) {
135
+ const v = r[field];
136
+ if (v !== undefined && (typeof v !== "number" || !Number.isFinite(v))) {
137
+ return false;
138
+ }
139
+ }
140
+ if (r["u"] !== undefined && typeof r["u"] !== "string") {
141
+ return false;
142
+ }
143
+ }
144
+ return true;
145
+ }
146
+
147
+ /**
148
+ * evictionBatch returns how many lines an at-cap eviction pass NOMINALLY frees
149
+ * (the hysteresis band), derived from the cap so tiny test stores keep
150
+ * one-at-a-time eviction (a batch of 1 IS the pre-batching behavior) while the
151
+ * real 5000-line store frees 256 rows per pass. A pass can free fewer when the
152
+ * retained history above the cap is smaller than the batch: headroom is taken
153
+ * from evictable HISTORY only, never from the protected live window, so a cap
154
+ * close to the screen height degrades toward per-line eviction (see the
155
+ * operating-range note on the consumer option docs).
156
+ *
157
+ * Why evictions are batched at all: every evicted line removes a DOM row from
158
+ * the TOP of the scroller, which shifts every row below it and invalidates the
159
+ * whole scrolled-contents layer. At the cap, one-at-a-time eviction turns a
160
+ * streaming session into one whole-layer invalidation PER LINE — on WebKit
161
+ * (which re-rasterizes the resident tiles each time) that is the single largest
162
+ * compositor cost this client can trigger, and on a memory-pressured iPhone it
163
+ * is churn the page cannot afford (see the web-terminal-ui steering doc, "iOS
164
+ * memory ceiling"). Batching keeps appends eviction-free between passes, so the
165
+ * layer shifts once per batch instead of once per line. The retained count is
166
+ * hard-bounded at `max(maxLines, live-window height)` after every apply — the
167
+ * window is never evicted (a cap at or below the screen height keeps the full
168
+ * screen with zero scrollback), and everything else stays under the cap
169
+ * whatever shape the frames arrive in (enforceCap's skip-over-window walk
170
+ * also bounds content committed above a stale window, e.g. a resume replay).
171
+ */
172
+ function evictionBatch(maxLines: number): number {
173
+ return Math.min(256, Math.max(1, Math.floor(maxLines / 16)));
174
+ }
175
+
176
+ /**
177
+ * Demand-paged-scrollback residency constants (docs/paged-scrollback.md §3).
178
+ *
179
+ * The store holds a small RESIDENT TAIL plus a disposable BROWSE CACHE of
180
+ * pages fetched on demand. The two budgets are enforced by separate mechanisms
181
+ * that can never touch each other's lines: live output cannot evict browse
182
+ * cache, and browse pressure cannot evict the tail.
183
+ */
184
+
185
+ /**
186
+ * The resident-tail target once the server has DECLARED it serves paging. The
187
+ * compatibility value below is what the tail holds until then.
188
+ */
189
+ export const RESIDENT_TAIL_CAP = 1500;
190
+
191
+ /** Lines of paged-in history retained as disposable cache. Engine-internal. */
192
+ export const BROWSE_CACHE_CAP = 2500;
193
+
194
+ /**
195
+ * How close the viewport must come to an absent range's edge before a fetch is
196
+ * triggered, and the radius of the eviction exemption around the reader. It is
197
+ * both because they are the same idea: the band worth prefetching is the band
198
+ * worth not evicting.
199
+ */
200
+ export const PREFETCH_THRESHOLD = 500;
201
+
202
+ /** Lines requested per page, and the ceiling on one eviction pass's removals. */
203
+ export const PAGE_SIZE = 1000;
204
+
205
+ /**
206
+ * The viewport exemption can never pin browse membership above its cap. Checked
207
+ * statically here rather than left as a comment, because the eviction loop's
208
+ * termination argument at the page-apply target rests on it: if the exemption
209
+ * could cover the whole cache, a pass with work to do could find no victim.
210
+ */
211
+ if (2 * PREFETCH_THRESHOLD + 1 >= BROWSE_CACHE_CAP) {
212
+ throw new Error(
213
+ `paged-scrollback invariant violated: the viewport exemption (${2 * PREFETCH_THRESHOLD + 1} lines) ` +
214
+ `must stay below browseCacheCap (${BROWSE_CACHE_CAP})`,
215
+ );
216
+ }
217
+
218
+ /** The live screen window: a fixed `height`-row block at the tail of the buffer.
219
+ * Describes the MAIN screen even while the alternate screen is active: the alt
220
+ * grid's geometry lives in the store's altRows, so during an alt session that
221
+ * outlived a resize, `base`/`height` report the main region alt exit restores,
222
+ * not the current terminal size. */
21
223
  export interface WindowState {
22
- /** Absolute index of window row 0. */
224
+ /** Absolute index of window row 0 (the MAIN screen's; frozen during alt). */
23
225
  base: number;
24
- /** Number of rows in the window (terminal height). */
226
+ /** Number of rows in the window — the MAIN screen's height, which equals
227
+ * the terminal height except during an alt session that resized (then the
228
+ * alt grid's height lives in altRows and this keeps the restore target). */
25
229
  height: number;
26
230
  /** Cursor row within the window (0..height-1). */
27
231
  cursorRow: number;
@@ -98,11 +302,173 @@ export class LineStore {
98
302
  private altDirty = false;
99
303
  private resetPending = false;
100
304
 
305
+ // --- demand-paged scrollback (docs/paged-scrollback.md §5.1, §5.3) ---
306
+
307
+ /**
308
+ * The RESIDENT TAIL cap, and the one deliberately mutable piece of residency
309
+ * policy: the capability the server declares in its resumeAck decides it at
310
+ * runtime. It starts at the compatibility value and flips ONE-WAY to the
311
+ * supported target through `applyResumeAck`. Every residency reader uses this
312
+ * value — the tail-budget gate, `evictionBatch`, and `snapshot`'s default
313
+ * bound — so a flip moves all of them together.
314
+ */
315
+ private effectiveTailCap: number;
316
+
317
+ /** The post-flip target: what the tail shrinks to once paging is declared. */
318
+ private readonly supportedTarget: number;
319
+
320
+ /** The pre-flip cap: what the tail holds while paging is unconfirmed. */
321
+ private readonly compatibilityCap: number;
322
+
323
+ /**
324
+ * The keys currently held as BROWSE CACHE — a subset of `lines`' keys, and
325
+ * the cache's whole model: membership, size and eviction candidates all read
326
+ * it directly. It drives cap accounting, eviction and snapshot exclusion. It
327
+ * is NOT a guard-2 bypass — re-delivery into a retained index passes only
328
+ * through applyLine's idempotence, or arrives under a fresh
329
+ * `solicitedPending`.
330
+ *
331
+ * A SET of keys rather than a set of intervals, and no companion counter.
332
+ * Both alternatives were built first and both leaked defects that this shape
333
+ * cannot express:
334
+ *
335
+ * - INTERVALS described ranges the store need not hold, so every question
336
+ * ("is the reader on cache?", "how many cache lines?", "which to evict?")
337
+ * became a walk over a numeric SPAN. The replay-jump band is a hull from
338
+ * `oldest` through the client's `haveThrough`, so that span is as wide as
339
+ * the client is behind: a 500k hull measured 2.3 s of main-thread work for
340
+ * ten real rows, and 80M measured 1.2 s in the one query that still walked
341
+ * it. Keys are bounded by residency, so every question here is O(cache).
342
+ * - A COMPANION COUNT (`browseCount`) had to be maintained by arithmetic at
343
+ * every mutation, and was wrong at four of them — it reached -96 on a third
344
+ * successive jump ack. `browse.size` cannot drift because it is not
345
+ * maintained at all.
346
+ *
347
+ * The one invariant is `browse ⊆ lines.keys()`, and it holds because `forget`
348
+ * is the sole deletion path and deletes from both.
349
+ */
350
+ private browse = new Set<number>();
351
+
352
+ /**
353
+ * The window of the request currently in flight, or null. Lines inside it are
354
+ * applyable even below prior evictions — paging is exactly a legitimate
355
+ * re-fetch below the stale-re-send watermark, which is why the watermark
356
+ * doctrine needed a solicited-range exception rather than a weaker guard.
357
+ * Exactly one slot, matching single-flight: there is nothing to expire.
358
+ */
359
+ private solicitedPending: Interval | null = null;
360
+
361
+ /**
362
+ * The lowest index still worth requesting. Raised by a clamped or empty
363
+ * reply (the server proving nothing at or below it survives), lowered only by
364
+ * a resumeAck reporting an older retained edge — which within one epoch is
365
+ * the repair path for a mis-correlated clamp.
366
+ */
367
+ private pagingFloor = 0;
368
+
369
+ /** When browse cache was last created or read, for the consumer's TTL. */
370
+ private browseActivityMs = 0;
371
+
101
372
  /**
102
- * @param maxLines retained-line cap; defaults to MAX_LINES. Injectable so
103
- * eviction is testable without allocating thousands of rows.
373
+ * Whether the last resumeAck declared paging. Kept because the RENDERER needs
374
+ * it and cannot derive it: §5.4's top-of-store marker says something different
375
+ * about the history above what is held depending on whether a fetch can bring
376
+ * it back.
377
+ *
378
+ * A BOOLEAN, false until an ack says otherwise, not a tri-state with a
379
+ * pre-ack "unknown". The pre-ack instant and a declared `unsupported` want the
380
+ * same answer — assert only what the client's own bookkeeping proves — so a
381
+ * third state would be a distinction nothing reads.
104
382
  */
105
- constructor(private readonly maxLines: number = MAX_LINES) {}
383
+ private paging = false;
384
+
385
+ /**
386
+ * The highest index the APPLICATION erased (ED3), or -1. A second watermark
387
+ * beside `everEvictedThrough` because the two answer different questions and
388
+ * only one of them is a trim.
389
+ *
390
+ * `everEvictedThrough` means "we dropped this to stay inside a memory budget",
391
+ * and it drives the "earlier output trimmed" affordance. ED3 is not that: the
392
+ * app discarded its own scrollback, so a marker would be permanent noise on
393
+ * kiro-cli (which clears on every resize redraw), which is why ED3 deliberately
394
+ * leaves it alone. That left NOTHING refusing an erased index, and a page reply
395
+ * whose data timeout had already released single-flight comes back
396
+ * UNCORRELATED — through the ordinary scroll path, classified TAIL, so outside
397
+ * the cache budget and the TTL both. It never leaves.
398
+ *
399
+ * Refusing an erased index is unconditionally safe within an epoch: the server's
400
+ * ring `Clear()` preserves `committed`, so an erased absolute index is never
401
+ * reused, and no legitimate later frame can carry one. The companion reprint an
402
+ * app emits in the same tick lands at NEW indices above everything the client
403
+ * held, which is why this watermark tracks what was actually DROPPED rather than
404
+ * the cleared bound — the bound would also refuse that reprint.
405
+ */
406
+ private erasedThrough = -1;
407
+
408
+ /**
409
+ * Memo for `retainedRanges()`, invalidated by every key-set mutation.
410
+ *
411
+ * Coalescing the keys costs a copy and a sort of the whole residency, and the
412
+ * scroll seam asks for it on EVERY scroll event (the fetch trigger reads the
413
+ * gaps near the viewport) while the renderer asks again on every flush (the
414
+ * gap markers are re-derived, not tracked). Measured at 4500 retained rows:
415
+ * 95 us per call, so 5.7 ms per 60-event scroll frame — a third of a frame
416
+ * budget, on the phone this whole feature exists for, during the one
417
+ * interaction it exists to serve. The key set changes far less often than it
418
+ * is asked about, so the answer is computed once per change instead of once
419
+ * per question.
420
+ *
421
+ * Invalidation is sound for the same reason the browse set is: insertion has
422
+ * one owner (`applyLine`) and deletion has one owner (`forget`), so there are
423
+ * exactly two places to clear it, plus `reset`. The snapshot hydrate writes
424
+ * keys directly and deliberately clears NOTHING: it builds a store whose memo
425
+ * is still unset, and an invalidation there is unreachable by construction
426
+ * (verified by mutation — the test that covers it fails only if a future edit
427
+ * reads the geometry before the keys land).
428
+ */
429
+ private rangesMemo: Interval[] | null = null;
430
+
431
+ /**
432
+ * @param maxLines retained-line cap, or OMITTED for the engine's choice.
433
+ * Optional rather than defaulted, because the two are not
434
+ * the same statement: "omitted" selects the engine's own
435
+ * post-flip target, and expressing that as a default VALUE
436
+ * made the consumer's explicit `5000` indistinguishable from
437
+ * silence — a UI passing `scrollbackLines: 5000` had its cap
438
+ * silently replaced by the small target on the first paging
439
+ * ack. It also made every call site spell the distinction
440
+ * itself (`x !== undefined ? new LineStore(x) : new
441
+ * LineStore()`), which is the shape a sentinel forces. A
442
+ * HISTORY
443
+ * budget floored at the live screen: the current window's
444
+ * rows are never evicted, so a cap at or below the
445
+ * terminal height keeps the full screen and simply
446
+ * retains no scrollback. Injectable so eviction is
447
+ * testable without allocating thousands of rows, and
448
+ * consumer-tunable through the renderer/UI plumb.
449
+ */
450
+ constructor(maxLines?: number) {
451
+ // Constructor-knob semantics (docs/paged-scrollback.md §5.3): an OMITTED
452
+ // cap means "engine's choice", so it holds the legacy value until paging is
453
+ // declared and then flips to the small target. A SUPPLIED cap is an
454
+ // explicit memory decision by the consumer and holds in EVERY capability
455
+ // state — the flip is then a no-op. Either way the tail never exceeds what
456
+ // the consumer asked for, and never drops below what a non-paging server
457
+ // can serve back.
458
+ this.compatibilityCap = maxLines ?? MAX_LINES;
459
+ this.supportedTarget = maxLines ?? RESIDENT_TAIL_CAP;
460
+ this.effectiveTailCap = this.compatibilityCap;
461
+ }
462
+
463
+ /** Lines held in the resident tail (everything not classified browse). */
464
+ private get tailCount(): number {
465
+ return this.lines.size - this.browse.size;
466
+ }
467
+
468
+ /** The effective resident-tail cap, floored at the live window's height. */
469
+ private get tailBound(): number {
470
+ return Math.max(this.effectiveTailCap, this.win.height);
471
+ }
106
472
 
107
473
  /** Highest absolute index held, or -1 if empty. Used as resume `haveThrough`. */
108
474
  highestIndex(): number {
@@ -183,6 +549,44 @@ export class LineStore {
183
549
  * changed window row at its absolute index (base + y).
184
550
  */
185
551
  applyScreen(msg: ScreenMessage): void {
552
+ // ED3 FIRST, BEFORE the screen-mode dispatch. The signal is a property of the
553
+ // SESSION, not of the buffer the frame happens to describe. The server raises
554
+ // a pending flag in the PTY path and attaches it to whatever frame it builds
555
+ // next, with NO alt gate — and it force-appends a row so the flag can never be
556
+ // dropped, which on an alt tick forces an ALT payload carrying it.
557
+ //
558
+ // Handling it inside the main-screen branch made every alt-active ED3 a total
559
+ // no-op: not one of §5.5's three effects ran. `clear; vim foo` typed as one
560
+ // line is enough (clear's terminfo emits CSI 3 J, vim's smcup follows in the
561
+ // same PTY burst, both inside one flush interval), as is `reset` inside any
562
+ // full-screen app. The erased transcript stayed resident and — because the
563
+ // server's ring Clear() preserves `committed`, so post-ED3 lines keep
564
+ // numbering from where the erased ones stopped — it spliced CONTIGUOUSLY into
565
+ // later output with no index gap, no gap marker and no trim marker. The
566
+ // in-flight window also stayed open, so a reply already on the wire put the
567
+ // erased rows back through the one path exempt from guard 2.
568
+ //
569
+ // `msg.base` is the correct bound on either buffer: in alt it is
570
+ // `committedBefore` (alt accrues no history, so the base stays frozen), which
571
+ // is one past the newest committed line, so "below base" is the whole
572
+ // scrollback either way.
573
+ if (msg.scrollbackCleared) {
574
+ this.applyScrollbackCleared(msg.base);
575
+ }
576
+ // A ROWS-LESS screen frame is a SIGNAL, not geometry, and must not redefine
577
+ // any buffer's shape. The connection layer forwards a pre-ack ED3 exactly that
578
+ // way (its rows are suppressed because the resume batch will repaint them),
579
+ // and no real terminal has a zero-height screen. On the MAIN path, taking the
580
+ // window from it set `height = 0`, which put the window bottom at `base - 1`
581
+ // and handed truncateBelowWindow every retained row at or above `base` as
582
+ // stranded: measured wiping a 3024-line store to 0 rows, and silently, because
583
+ // neither that path nor the ED3 drop advances the eviction watermark that
584
+ // would show a trim marker. On the ALT path it reached `enterAltIfNeeded(0)`
585
+ // and collapsed the alt grid to zero rows until the next full frame. The
586
+ // signal above is the whole payload of such a frame.
587
+ if (msg.rows.length === 0) {
588
+ return;
589
+ }
186
590
  if (msg.altActive) {
187
591
  this.enterAltIfNeeded(msg.rows.length);
188
592
  for (const y of msg.changed) {
@@ -196,12 +600,6 @@ export class LineStore {
196
600
  return;
197
601
  }
198
602
  this.exitAltIfNeeded();
199
- if (msg.scrollbackCleared) {
200
- // ED3 (erase scrollback): the app cleared its saved lines (kiro-cli does
201
- // this on every resize redraw). Drop our matching history below the live
202
- // window so the previous frame doesn't accumulate beneath the new one.
203
- this.dropHistoryBelow(msg.base);
204
- }
205
603
  this.updateWindow(msg);
206
604
  for (const y of msg.changed) {
207
605
  const row = msg.rows[y];
@@ -210,13 +608,41 @@ export class LineStore {
210
608
  }
211
609
  }
212
610
  this.truncateBelowWindow();
611
+ // Re-check the cap AFTER the window settled: a screen frame can LOWER the
612
+ // retention bound without applying a single line — a height shrink whose
613
+ // rows are byte-identical short-circuits applyLine on the idempotency
614
+ // guard, and applyLine is otherwise the only enforceCap caller. The R3
615
+ // adversarial rounds' interleaved property test failed on exactly this
616
+ // shape (~60% of seeds) until the bound was re-checked per FRAME, not
617
+ // only per content change.
618
+ this.enforceCap();
213
619
  }
214
620
 
215
621
  /** Apply a decoded scroll/history frame: each line at firstIndex + i. */
216
622
  applyScroll(msg: ScrollMessage): void {
217
623
  if (this.alt) {
218
- // Protocol invariant: the server does not emit scroll frames while the
219
- // alternate screen is active. Drop rather than corrupt the abs store.
624
+ // History strictly below the frozen MAIN window is safe to store while
625
+ // the alternate screen is active: the alt display renders from altRows,
626
+ // never from the abs store, and lines below win.base can only be
627
+ // main-screen scrollback — a resume batch's replay, or a post-resume
628
+ // durable chunk that raced an alt flip (the 2026-08 write-ordering
629
+ // race's last residual, closed here). They surface at alt exit's store
630
+ // rebuild. Anything AT or ABOVE the frozen base still violates the
631
+ // protocol invariant (the server never emits live scroll during alt)
632
+ // and is dropped rather than corrupting the frozen window region. With
633
+ // no main window ever seen (a fresh tab attaching straight into an
634
+ // in-alt session) there is no region to protect, so everything is
635
+ // accepted exactly as on the main-screen path — the alt-exit full
636
+ // frame overwrites the window region either way. applyLine's guard
637
+ // set applies to whatever passes.
638
+ const base = this.win.height > 0 ? this.win.base : Number.POSITIVE_INFINITY;
639
+ for (let i = 0; i < msg.lines.length; i++) {
640
+ const abs = msg.firstIndex + i;
641
+ const row = msg.lines[i];
642
+ if (row !== undefined && abs < base) {
643
+ this.applyLine(abs, row);
644
+ }
645
+ }
220
646
  return;
221
647
  }
222
648
  for (let i = 0; i < msg.lines.length; i++) {
@@ -230,11 +656,589 @@ export class LineStore {
230
656
  /**
231
657
  * Record the server's retained-history bounds from a resumeAck so the
232
658
  * renderer can tell a genuine trim from a still-loading state.
659
+ *
660
+ * `committed` is deliberately NOT stored. The replay-jump prediction needs
661
+ * it, but it takes it as an argument from the ack being processed — reading a
662
+ * stored copy would risk predicting from a previous ack's value, which is the
663
+ * same class of defect as predicting from a mutated `highest`.
233
664
  */
234
665
  noteResumeBounds(_committed: number, oldestIndex: number): void {
235
666
  if (Number.isInteger(oldestIndex) && oldestIndex >= 0) {
236
667
  this.serverOldest = oldestIndex;
668
+ // Within one boot epoch a ring's oldest only RISES, so a report below the
669
+ // floor cannot be ordinary progress — it is the repair path for a floor
670
+ // raised by a mis-correlated clamp, and lowering it back is how a
671
+ // session that wrongly condemned its own history recovers.
672
+ if (oldestIndex < this.pagingFloor) {
673
+ this.pagingFloor = oldestIndex;
674
+ }
675
+ }
676
+ }
677
+
678
+ // --- demand-paged scrollback: public surface (docs/paged-scrollback.md) ---
679
+
680
+ /** Whether the last resumeAck declared demand paging (§5.4's `supported`). */
681
+ pagingDeclared(): boolean {
682
+ return this.paging;
683
+ }
684
+
685
+ /** The oldest index the server reported retaining, or -1 if unknown. */
686
+ serverOldestIndex(): number {
687
+ return this.serverOldest;
688
+ }
689
+
690
+ /** The lowest index still worth requesting (§4.5). */
691
+ pagingFloorIndex(): number {
692
+ return this.pagingFloor;
693
+ }
694
+
695
+ /** Lines currently classified as browse cache. */
696
+ browseCacheSize(): number {
697
+ return this.browse.size;
698
+ }
699
+
700
+ /** The effective resident-tail cap (post-flip once paging is declared). */
701
+ tailCap(): number {
702
+ return this.effectiveTailCap;
703
+ }
704
+
705
+ /** When browse cache was last created or refreshed, for the consumer's TTL. */
706
+ lastBrowseActivityMs(): number {
707
+ return this.browseActivityMs;
708
+ }
709
+
710
+ /**
711
+ * Record the window of a request about to go out, so its reply can apply
712
+ * below the stale-re-send watermark. Exactly one slot, matching
713
+ * single-flight — a second call replaces the first rather than accumulating.
714
+ */
715
+ noteSolicited(fromAbs: number, end: number): void {
716
+ if (!Number.isSafeInteger(fromAbs) || !Number.isSafeInteger(end) || end <= fromAbs) {
717
+ return;
718
+ }
719
+ this.solicitedPending = { lo: fromAbs, hi: end };
720
+ }
721
+
722
+ /** Release the in-flight window (reply applied, timed out, or socket gone). */
723
+ clearSolicited(): void {
724
+ this.solicitedPending = null;
725
+ }
726
+
727
+ /**
728
+ * The retained ranges, coalesced from the KEY SET. This is the authority for
729
+ * gap geometry — see intervals.ts for why it cannot be the browse set.
730
+ */
731
+ retainedRanges(): Interval[] {
732
+ this.rangesMemo ??= retainedIntervals(this.lines.keys());
733
+ // A COPY of the runs, not the memo itself: this is a public accessor and a
734
+ // caller that mutated the returned list would corrupt every later answer.
735
+ // The run count is small (one tail plus a cache run or two) where the key
736
+ // count is thousands, which is the whole reason the memo pays.
737
+ return this.rangesMemo.map((iv) => ({ ...iv }));
738
+ }
739
+
740
+ /**
741
+ * The absent edges within `threshold` lines of `abs`, nearest first: the
742
+ * trigger's input. Each entry is a gap the viewport is approaching, already
743
+ * clamped to what is worth requesting — an interior hole between two retained
744
+ * runs, or the bottom FRONTIER pseudo-gap `[pagingFloor, oldestHeld)`.
745
+ *
746
+ * The frontier is included only when there is something below to ask for:
747
+ * `oldestHeld > 0`, the floor does not already cover it, and the server still
748
+ * reports retaining something older. A gap whose high edge is at or below the
749
+ * floor is omitted, because the floor means the server has PROVEN nothing at
750
+ * or below it survives.
751
+ */
752
+ absentEdgesNear(abs: number, threshold: number): Interval[] {
753
+ const runs = this.retainedRanges();
754
+ if (runs.length === 0) {
755
+ return [];
756
+ }
757
+ const candidates = interiorGaps(runs);
758
+ const lowest = runs[0]?.lo ?? 0;
759
+ if (
760
+ lowest > 0 &&
761
+ lowest > this.pagingFloor &&
762
+ (this.serverOldest < 0 || lowest > this.serverOldest)
763
+ ) {
764
+ candidates.push({ lo: this.pagingFloor, hi: lowest });
765
+ }
766
+ // `gapHigh > pagingFloor`, deliberately NOT gapLow: after a ring exhaustion
767
+ // followed by a later tail trim, the frontier's low edge EQUALS the floor,
768
+ // and that reopened frontier must stay fetchable (§5.4).
769
+ const live = candidates.filter((g) => g.hi > this.pagingFloor && g.hi > g.lo);
770
+ const near = live.filter((g) => abs >= g.lo - threshold && abs <= g.hi + threshold);
771
+ near.sort((a, b) => Math.abs(abs - a.hi) - Math.abs(abs - b.hi));
772
+ return near;
773
+ }
774
+
775
+ /**
776
+ * Apply a correlated history page. The bulk entry point for paged-in lines,
777
+ * distinct from `applyScroll` in three ways that all matter: it classifies
778
+ * what it stores as BROWSE cache, it suppresses per-line cap enforcement in
779
+ * favour of one budget pass at the end, and it clips to the solicited window.
780
+ *
781
+ * `viewportAbs` comes from the renderer — the only layer that knows where the
782
+ * reader is — and decides which end of the cache is safe to evict. The store
783
+ * never guesses it.
784
+ */
785
+ applyHistoryScroll(msg: ScrollMessage, viewportAbs: number): void {
786
+ // The ALT GATE first, identical to applyScroll's prologue: the fetch
787
+ // controller is scrollback-UI-only, but a reply can still land during an
788
+ // alt flip. History strictly below the frozen main base is safe to store
789
+ // (it surfaces at alt exit's rebuild); at or above it would corrupt the
790
+ // frozen window region.
791
+ const altBase = this.alt
792
+ ? this.win.height > 0
793
+ ? this.win.base
794
+ : Number.POSITIVE_INFINITY
795
+ : Number.POSITIVE_INFINITY;
796
+
797
+ // Only the INTERSECTION with the in-flight window gets solicited treatment.
798
+ // A correlated frame wider than the current slot — a timed-out larger reply
799
+ // racing a shrunken retry — has its out-of-window lines routed through the
800
+ // ordinary guards instead, where they are typically refused below the
801
+ // watermark (§5.1). Without the clip, a stale oversized reply would apply
802
+ // content the client did not ask for on this attempt.
803
+ const s = this.solicitedPending;
804
+ if (s === null) {
805
+ // The question was RETRACTED, so the answer is dropped whole. Every one of
806
+ // the bulk path's concessions — admission below the stale-re-send
807
+ // watermark, suppressed per-line cap enforcement, disposable-cache
808
+ // classification — is paid for by a request the client currently has out.
809
+ //
810
+ // Downgrading it to the ordinary scroll path instead is NOT equivalent and
811
+ // was tried first: at the store level a page reply is indistinguishable
812
+ // from legitimate new content at the same indices (after ED3 an app
813
+ // reprints its transcript below the new window base, and that must apply),
814
+ // so the ordinary path admits the stale rows too. Only the CALLER knows
815
+ // this frame was correlated against a window that no longer exists, so
816
+ // only here can it be refused. The three cancellers are ED3, an epoch
817
+ // reset, and a closed socket; if the range still matters the trigger asks
818
+ // again, subject to the floor.
819
+ return;
820
+ }
821
+ const applied: number[] = [];
822
+ for (let i = 0; i < msg.lines.length; i++) {
823
+ const abs = msg.firstIndex + i;
824
+ const row = msg.lines[i];
825
+ if (row === undefined || abs >= altBase) {
826
+ continue;
827
+ }
828
+ if (abs < s.lo || abs >= s.hi) {
829
+ // Outside the solicited window: ordinary guards, ordinary
830
+ // classification (it is not this request's content).
831
+ this.applyLine(abs, row);
832
+ continue;
833
+ }
834
+ this.applyLine(abs, row, true);
835
+ if (this.lines.has(abs)) {
836
+ applied.push(abs);
837
+ }
838
+ }
839
+ if (applied.length === 0) {
840
+ // An empty or fully-refused reply changes no classification. The caller
841
+ // still owns the floor raise for an empty reply (§4.3).
842
+ this.enforceCap();
843
+ return;
844
+ }
845
+ // Exactly the keys this reply put in the store join the cache. Re-fetching a
846
+ // range already cached is a no-op per key, so overlap cannot inflate the
847
+ // cache size the way a reply-length increment would.
848
+ this.classifyAsBrowse(applied);
849
+ // ONE budget pass, at the page-apply target. The transient between the
850
+ // apply above and this pass is the reason the invariants are documented as
851
+ // holding at OPERATION boundaries, not inside a bulk apply.
852
+ this.enforceBrowseBudget(BROWSE_CACHE_CAP, viewportAbs);
853
+ this.enforceCap();
854
+ }
855
+
856
+ /**
857
+ * Move the given retained keys into the browse classification without
858
+ * touching a single line. This is a RECLASSIFICATION, not a trim: nothing is
859
+ * deleted, no DOM changes, and `everEvictedThrough` is untouched — which is
860
+ * what lets the cap flip and the replay jump hand a band to the cache budget
861
+ * instead of evicting it out from under the reader.
862
+ *
863
+ * It takes KEYS, not a range, and that is the point: every caller already
864
+ * knows exactly which indices it is reclassifying (the page's applied list,
865
+ * the flip's excess tail keys, the jump's keys below the send watermark), so
866
+ * no span loop exists anywhere in the cache model. The range-taking version
867
+ * this replaced had to walk `[lo, hi)` to find the retained members, and its
868
+ * band is routinely a hull tens of millions of indices wide.
869
+ *
870
+ * Unheld keys are skipped, so the subset invariant survives a caller passing
871
+ * an index the store does not have.
872
+ */
873
+ private classifyAsBrowse(keys: Iterable<number>): number {
874
+ let added = 0;
875
+ for (const abs of keys) {
876
+ if (this.lines.has(abs) && !this.browse.has(abs)) {
877
+ this.browse.add(abs);
878
+ added++;
879
+ }
880
+ }
881
+ if (added > 0) {
882
+ this.browseActivityMs = Date.now();
883
+ }
884
+ return added;
885
+ }
886
+
887
+ /**
888
+ * Evict browse cache down to `target`, far edge first.
889
+ *
890
+ * Removal is EXACTLY the overflow, capped at one page per step: a one-line
891
+ * overflow removes one line, not a page, because anything larger thrashes
892
+ * fetch/evict cycles at the cap. Victims are taken from whichever END of the
893
+ * cache is FARTHER from `viewportAbs`, and every line within
894
+ * `PREFETCH_THRESHOLD` of it is EXEMPT — an eviction must never create a hole
895
+ * the trigger immediately re-fetches, nor blank the rows under the reader.
896
+ *
897
+ * Direction is computed from the cache's own extremes, never from a range's
898
+ * edges. The version this replaced asked whether an interval began at or above
899
+ * the viewport to find its far side, which is false whenever the reader is
900
+ * INSIDE the cache — the normal deep-scroll shape — so it always took the low
901
+ * end: the direction an up-scrolling reader is heading, while the pages behind
902
+ * them were never freed.
903
+ *
904
+ * Termination: the exemption band is one contiguous range, so if any victim is
905
+ * available at the far end the pass makes progress, and every removal shrinks
906
+ * `browse`. When the reader's own neighbourhood is all that remains the pass
907
+ * stops and accepts the bounded overshoot rather than spinning — that stop
908
+ * rule is target-agnostic, which is what makes a smaller reclassify target
909
+ * safe too.
910
+ */
911
+ private enforceBrowseBudget(target: number, viewportAbs: number): void {
912
+ // §5.3's viewport exemption, and it is REACHABLE — an earlier comment here
913
+ // claimed arithmetic made it dead, which was measurably wrong.
914
+ //
915
+ // The shape that reaches it: the resume-ack pass uses the small
916
+ // PREFETCH_THRESHOLD target, and the band is 2 * PREFETCH_THRESHOLD + 1 wide,
917
+ // so a cache SPANNING the reader but smaller than the band is entirely
918
+ // exempt while still exceeding the target. Two retained runs either side of
919
+ // the reader's position do it: 400 rows at [0,400), 400 at [600,1000), the
920
+ // reader in the hole at 500, and a jump ack classifies all 800 against a
921
+ // target of 500. Nothing is evictable, the pass stops, and the store keeps
922
+ // 800 — the bounded overshoot the design prefers to blanking the rows under
923
+ // the reader. Under BROWSE_CACHE_CAP the same arithmetic never bites, which
924
+ // is what the dead-code claim was really about.
925
+ const exemptLo = viewportAbs - PREFETCH_THRESHOLD;
926
+ const exemptHi = viewportAbs + PREFETCH_THRESHOLD + 1;
927
+ const exempt = (abs: number) => abs >= exemptLo && abs < exemptHi;
928
+
929
+ while (this.browse.size > target) {
930
+ // The victim pool IS the browse set: bounded by the cache, not by
931
+ // residency and not by any numeric span. `size` is the count the loop
932
+ // tests, so the gate and the pool cannot disagree.
933
+ const keys = [...this.browse].sort((a, b) => a - b);
934
+ const budget = Math.min(this.browse.size - target, PAGE_SIZE);
935
+ // Distance from the reader decides which end goes; on a tie the older
936
+ // (lower) end goes, matching the tail cap's oldest-first bias. `keys` is
937
+ // non-empty because `size > target >= 0`.
938
+ const lowest = keys[0] ?? 0;
939
+ const highest = keys[keys.length - 1] ?? 0;
940
+ const fromTop = Math.abs(highest - viewportAbs) > Math.abs(viewportAbs - lowest);
941
+ const victims: number[] = [];
942
+ const walk = fromTop ? keys.reverse() : keys;
943
+ for (const abs of walk) {
944
+ if (victims.length >= budget || exempt(abs)) {
945
+ break; // the exemption is contiguous, so the rest of this end is too
946
+ }
947
+ victims.push(abs);
948
+ }
949
+ if (victims.length === 0) {
950
+ break; // the reader's own neighbourhood is all that is left: overshoot
951
+ }
952
+ for (const abs of victims) {
953
+ // forget() drops the line AND its classification. A cache drop is NOT a
954
+ // permanent trim, so everEvictedThrough deliberately stays where it is
955
+ // and the same index can be re-fetched later.
956
+ this.forget(abs);
957
+ }
958
+ this.recomputeBounds();
959
+ }
960
+ }
961
+
962
+ /**
963
+ * The resume-ack transition: ONE store operation carrying every decision the
964
+ * ack implies, in a fixed order (docs/paged-scrollback.md §4.5).
965
+ *
966
+ * It is one call rather than four because the order is load-bearing and the
967
+ * inputs live in three different layers — the connection decodes the ack, the
968
+ * store owns residency, the renderer owns the viewport. Split across
969
+ * callbacks, an implementer can interleave them; here they cannot.
970
+ *
971
+ * `committed`/`serverOldest` are NULLABLE AS A PAIR: a server too old to
972
+ * carry the ack's length-gated bounds tail sends neither, and inventing zeros
973
+ * would lower the floor and forge a jump. Such an ack still runs the epoch
974
+ * reset and the capability read, and skips the two steps that have no inputs.
975
+ */
976
+ applyResumeAck(ack: {
977
+ /** The boot epoch changed: everything retained is from another server. */
978
+ epochChanged: boolean;
979
+ /** One past the server's newest committed line, or null on an old ack. */
980
+ committed: number | null;
981
+ /** The server's oldest retained index, or null on an old ack. */
982
+ serverOldest: number | null;
983
+ /** The server DECLARED demand paging (ackFlags bit1). */
984
+ paging: boolean;
985
+ /** The `haveThrough` this socket SENT, which the reply is a function of. */
986
+ sentHaveThrough: number;
987
+ /** The `replayMax` this socket SENT (already clamped), or null. */
988
+ sentReplayMax: number | null;
989
+ /** Where the reader is, from the renderer. */
990
+ viewportAbs: number;
991
+ /**
992
+ * Whether the reader is at the live tail, from the renderer — which KNOWS,
993
+ * where the store could only ever guess.
994
+ *
995
+ * The store used to derive it from `viewportAbs >= win.base`, and every
996
+ * failure of that derivation was a real defect. It read a window descriptor
997
+ * that step 4 of this very transition RETIRES, so the answer was wrong for
998
+ * every predicted replay jump (r2's confirmed high, patched by capturing the
999
+ * value earlier — a fix that left the hazard in place). With no descriptor at
1000
+ * all, which is the DOMINANT shape here (this ack is the first frame of its
1001
+ * batch, so it precedes the window frame, and a hydrated store persists no
1002
+ * window), the fallback was wrong in both directions: "not following" strands
1003
+ * ~2000 lines over budget on the first attach over a snapshot, and
1004
+ * "following" would drain a reader who is demonstrably deep in history.
1005
+ *
1006
+ * The renderer has one unambiguous source (`scroll.isUserScrolledUp()`, plus
1007
+ * an armed restore, which means "in history at the anchor"), so it answers and
1008
+ * the store stops inferring. Retiring the window descriptor mid-transition can
1009
+ * no longer corrupt anything.
1010
+ */
1011
+ following: boolean;
1012
+ }): void {
1013
+ // (1) An epoch change resets everything, cap included: a different server
1014
+ // process shares no absolute indices with the old one.
1015
+ if (ack.epochChanged) {
1016
+ this.reset();
1017
+ }
1018
+ // (2) The capability, recorded for the renderer before anything reads it, and
1019
+ // stated in BOTH directions: an ack is the only thing that knows, so an
1020
+ // unsupported pairing must actively restore the compatibility cap rather than
1021
+ // relying on some earlier reset to have cleared it. (When paging IS declared,
1022
+ // step 3's flip owns the cap.)
1023
+ this.paging = ack.paging;
1024
+ if (!ack.paging) {
1025
+ this.effectiveTailCap = this.compatibilityCap;
1026
+ }
1027
+ // (2b) Bounds, when the ack carries them.
1028
+ if (ack.committed !== null && ack.serverOldest !== null) {
1029
+ this.noteResumeBounds(ack.committed, ack.serverOldest);
1030
+ }
1031
+ // (3) Capability: the cap flip, bookkeeping only.
1032
+ let reclassified = false;
1033
+ if (ack.paging) {
1034
+ reclassified = this.confirmPaging();
1035
+ }
1036
+ // (4) The replay JUMP, predicted from what this socket SENT — never from
1037
+ // the store's current `highest`, which a frame arriving between send and
1038
+ // ack could have moved, masking the jump and leaving the stranded band for
1039
+ // enforceCap to eat. Gated on `paging`: under an unsupported pairing the
1040
+ // band stays TAIL, which is today's behavior and bounded by the
1041
+ // compatibility cap. Reclassifying it there would hand disposable-cache
1042
+ // semantics to history no fetch can restore.
1043
+ if (ack.paging && ack.committed !== null && ack.serverOldest !== null) {
1044
+ const jumped = this.predictReplayJump(
1045
+ ack.committed,
1046
+ ack.serverOldest,
1047
+ ack.sentHaveThrough,
1048
+ ack.sentReplayMax,
1049
+ );
1050
+ reclassified = reclassified || jumped;
1051
+ }
1052
+ // (5) ONE budget pass, if either step moved anything into the cache. The two
1053
+ // steps used to be compared as bands to pick the wider one; with membership
1054
+ // as a key set there is nothing to compare — the containment test below
1055
+ // reads the set itself and has one answer regardless of which step grew it.
1056
+ if (reclassified) {
1057
+ // CONTAINMENT: a FOLLOWING viewport is outside every reclassified band by
1058
+ // definition — it is looking at the live tail, not at cache — so the
1059
+ // disposable band drains to the small target. A reader in HISTORY keeps the
1060
+ // full cache instead, and the TTL cleans up later.
1061
+ //
1062
+ // Following is the WHOLE question, and the store asked a second one twice,
1063
+ // in two different structures. It tested whether the row UNDER the reader
1064
+ // was cache — a proxy that fails in exactly the shape
1065
+ // paging exists for: a reader whose position is an index the store does not
1066
+ // currently hold. Both doors to that shape are ordinary — a hole inside the
1067
+ // cache, and an ARMED RESTORE anchor (§7.2/§7.3 of
1068
+ // scroll-position-fidelity.md — the renderer deliberately passes the position
1069
+ // the reader is REGAINING, whose row may since have been dropped). Either
1070
+ // way the reader is unambiguously reading history and the proxy answered
1071
+ // "no", draining their depth to the small target: measured 801 rows kept
1072
+ // instead of 2420. The range version got that case right by accident (a hull
1073
+ // spans holes) and the key version got it wrong by accident; asking only
1074
+ // what the design means removes both.
1075
+ this.enforceBrowseBudget(
1076
+ ack.following ? PREFETCH_THRESHOLD : BROWSE_CACHE_CAP,
1077
+ ack.viewportAbs,
1078
+ );
1079
+ this.enforceCap();
1080
+ }
1081
+ }
1082
+
1083
+ /**
1084
+ * The cap flip: set the effective tail cap to the supported target and
1085
+ * reclassify the excess tail as browse cache. Returns whether anything moved.
1086
+ *
1087
+ * The band keeps the newest `max(supportedTarget, windowHeight)` tail lines
1088
+ * and NEVER crosses `win.base` — a LIVE window row is never reclassified,
1089
+ * because the browse budget may evict it and the window must not be
1090
+ * evictable. (§5.2's replay-jump band deliberately DOES include the old
1091
+ * window rows, but only because it retires the descriptor first.)
1092
+ */
1093
+ private confirmPaging(): boolean {
1094
+ this.effectiveTailCap = this.supportedTarget;
1095
+ const keep = Math.max(this.supportedTarget, this.win.height);
1096
+ if (this.tailCount <= keep || this.oldest < 0) {
1097
+ return false;
1098
+ }
1099
+ // Walk the tail keys oldest-first and take all but the newest `keep`.
1100
+ const tailKeys = [...this.lines.keys()]
1101
+ .filter((k) => !this.browse.has(k))
1102
+ .sort((a, b) => a - b);
1103
+ const excess = tailKeys.length - keep;
1104
+ if (excess <= 0) {
1105
+ return false;
1106
+ }
1107
+ const winFloor = this.win.height > 0 ? this.win.base : Number.POSITIVE_INFINITY;
1108
+ return this.classifyAsBrowse(tailKeys.slice(0, excess).filter((k) => k < winFloor)) > 0;
1109
+ }
1110
+
1111
+ /**
1112
+ * Predict where the incoming replay will START, and if it lands above what
1113
+ * this socket told the server it had, reclassify the stranded band as browse
1114
+ * cache before any frame of the batch applies.
1115
+ *
1116
+ * Two causes produce the same shape and both are covered: the new bounded
1117
+ * replay's clamp, and the plain eviction gap EVERY server produces when its
1118
+ * ring has moved past the client's `haveThrough`. Scoping detection to the
1119
+ * clamp alone would miss the shape that already ships.
1120
+ *
1121
+ * The band deliberately includes the OLD window rows and RETIRES the window
1122
+ * descriptor, because the batch's own window frame re-establishes it at the
1123
+ * new base — those rows stop being window rows the moment it lands.
1124
+ *
1125
+ * The retirement leaves the same transition's later steps reading a window of
1126
+ * height 0, so state precisely what that costs rather than forbidding it: the
1127
+ * only window-derived bound they touch is `enforceCap`'s, which loses the live
1128
+ * screen's eviction protection for that one pass. It is harmless HERE and not
1129
+ * by luck — after the jump the tail holds only the keys above
1130
+ * `sentHaveThrough`, so `tailCount` is far below the bound and the pass returns
1131
+ * at its first gate; and were it to run, the walk is oldest-first while the
1132
+ * screen rows are the newest. A future step that evaluates a window bound with
1133
+ * consequences must re-establish the descriptor first.
1134
+ */
1135
+ private predictReplayJump(
1136
+ committed: number,
1137
+ serverOldest: number,
1138
+ sentHaveThrough: number,
1139
+ sentReplayMax: number | null,
1140
+ ): boolean {
1141
+ const base = sentHaveThrough + 1;
1142
+ let predicted = Math.max(base, serverOldest);
1143
+ if (sentReplayMax !== null) {
1144
+ predicted = Math.max(predicted, committed - sentReplayMax);
237
1145
  }
1146
+ if (predicted <= base || this.oldest < 0 || sentHaveThrough < this.oldest) {
1147
+ // No jump, or an empty/fresh store where the predicate fires vacuously.
1148
+ return false;
1149
+ }
1150
+ // The stranded band is every retained key at or below what this socket told
1151
+ // the server it had. Taken from the KEY SET, so a client reconnecting far
1152
+ // behind costs its residency, not the width of the range it is missing.
1153
+ const stranded: number[] = [];
1154
+ for (const abs of this.lines.keys()) {
1155
+ if (abs <= sentHaveThrough) {
1156
+ stranded.push(abs);
1157
+ }
1158
+ }
1159
+ this.classifyAsBrowse(stranded);
1160
+ this.win = emptyWindow();
1161
+ this.windowDirty = true;
1162
+ return true;
1163
+ }
1164
+
1165
+ /**
1166
+ * Raise the paging floor: the server proved nothing at or below `end`
1167
+ * survives, so no further request below it can succeed. Called for a clamped
1168
+ * reply (`firstIndex > fromAbs`) and for an empty one.
1169
+ */
1170
+ raisePagingFloor(end: number): void {
1171
+ if (Number.isSafeInteger(end) && end > this.pagingFloor) {
1172
+ this.pagingFloor = end;
1173
+ }
1174
+ }
1175
+
1176
+ /**
1177
+ * Drop the browse cache. The consumer owns the TTL that calls this (the
1178
+ * engine has no notion of tabs or visibility) and supplies both inputs.
1179
+ *
1180
+ * A VISIBLE page whose reader sits on browse rows SKIPS the drop: the TTL is
1181
+ * an inactivity signal, and a reader parked on a long stack trace is inactive
1182
+ * while looking straight at cache content — wiping it and re-fetching one RTT
1183
+ * later serves nobody. A HIDDEN page has no reader, so it drops
1184
+ * unconditionally; without that condition the skip would retain exactly the
1185
+ * deep-scrolled cache the hidden-page TTL exists to free.
1186
+ */
1187
+ dropBrowseCache(viewportAbs: number, pageVisible: boolean): void {
1188
+ if (this.browse.size === 0) {
1189
+ return;
1190
+ }
1191
+ if (pageVisible && this.browse.has(viewportAbs)) {
1192
+ return; // skip and re-arm; the consumer's timer retries
1193
+ }
1194
+ for (const abs of [...this.browse]) {
1195
+ this.forget(abs);
1196
+ }
1197
+ this.recomputeBounds();
1198
+ }
1199
+
1200
+ /**
1201
+ * Remove one retained line and its browse classification. The single owner of
1202
+ * every deletion in this store.
1203
+ *
1204
+ * It exists because classification lives in a second structure keyed by the
1205
+ * same indices, and a second structure is only as correct as its
1206
+ * least-maintained mutation path. Two paths did not maintain it — ED3's
1207
+ * `applyScrollbackCleared` and the resize `truncateBelowWindow`, the latter firing
1208
+ * on every soft-keyboard open — so deleting a browse-classified line left the
1209
+ * cache accounting too high, `tailCount` understated, and the tail cap
1210
+ * under-enforcing for the rest of the epoch. Funnelling every removal through
1211
+ * here removes the CLASS rather than those two instances, and is what makes
1212
+ * `browse ⊆ lines.keys()` a fact rather than a hope.
1213
+ *
1214
+ * Returns whether a line was actually held (mirroring Map.delete), so callers
1215
+ * can count real removals.
1216
+ */
1217
+ private forget(abs: number): boolean {
1218
+ const held = this.lines.delete(abs);
1219
+ if (held) {
1220
+ this.rangesMemo = null;
1221
+ this.evicted.add(abs);
1222
+ this.dirty.delete(abs);
1223
+ }
1224
+ this.browse.delete(abs);
1225
+ return held;
1226
+ }
1227
+
1228
+ /** Recompute oldest/highest after a bulk removal. Bounded by map size. */
1229
+ private recomputeBounds(): void {
1230
+ let min = -1;
1231
+ let max = -1;
1232
+ for (const k of this.lines.keys()) {
1233
+ if (min < 0 || k < min) {
1234
+ min = k;
1235
+ }
1236
+ if (k > max) {
1237
+ max = k;
1238
+ }
1239
+ }
1240
+ this.oldest = min;
1241
+ this.highest = max;
238
1242
  }
239
1243
 
240
1244
  /**
@@ -244,9 +1248,12 @@ export class LineStore {
244
1248
  */
245
1249
  reset(): void {
246
1250
  this.lines.clear();
1251
+ this.rangesMemo = null;
247
1252
  this.oldest = -1;
248
1253
  this.highest = -1;
249
1254
  this.everEvictedThrough = -1;
1255
+ this.erasedThrough = -1;
1256
+ this.paging = false;
250
1257
  this.serverOldest = -1;
251
1258
  this.win = emptyWindow();
252
1259
  this.alt = false;
@@ -256,6 +1263,197 @@ export class LineStore {
256
1263
  this.windowDirty = true;
257
1264
  this.altDirty = true;
258
1265
  this.resetPending = true;
1266
+ // CONTENT-derived paging state goes with the content: a new boot epoch shares
1267
+ // no absolute indices with the old one, so the cache, the in-flight window and
1268
+ // the floor are all meaningless at once (docs/paged-scrollback.md §5.5).
1269
+ this.browse.clear();
1270
+ this.solicitedPending = null;
1271
+ this.pagingFloor = 0;
1272
+ this.browseActivityMs = 0;
1273
+ // CAPABILITY is deliberately NOT reset here. It describes the SERVER on the
1274
+ // other end of the socket, not the content, and only a resumeAck can restate
1275
+ // it — so a reset that cleared it left the store waiting for an event that
1276
+ // may never come. That was safe for the documented caller (applyResumeAck's
1277
+ // epoch step, whose very next step re-reads the ack), and wrong for the two
1278
+ // public ones that predate paging: `render.resetScreen()` and
1279
+ // `render.resetScrollback()`, which a consumer is explicitly invited to call
1280
+ // on alt-screen entry, nowhere near a resume. Until the next reconnect that
1281
+ // left `pagingDeclared()` false while the transport was still perfectly able
1282
+ // to page, so §5.4's top marker asserted "earlier output trimmed" about
1283
+ // history the trigger was about to fetch, and the tail cap reverted to the
1284
+ // compatibility value — discarding the residency reduction that is the whole
1285
+ // point of the feature on the device it exists for. `applyResumeAck` now
1286
+ // states the capability explicitly instead of relying on a reset to clear it.
1287
+ }
1288
+
1289
+ /**
1290
+ * Serialize the newest retained lines as plain, structuredClone-safe data, for
1291
+ * a consumer that persists scrollback across a page discard (see
1292
+ * StoreSnapshot for what is deliberately excluded and why).
1293
+ *
1294
+ * `serverEpoch` is the boot epoch the caller last saw in a resumeAck; pass 0
1295
+ * if none is known. It is required rather than optional because a snapshot
1296
+ * without it cannot be hydrated safely, and an optional parameter is a
1297
+ * corruption bug waiting for the one caller who omits it.
1298
+ *
1299
+ * `maxLines` bounds the tail, defaulting to the store's own cap. A smaller
1300
+ * bound is the normal choice: the screen plus recent history is what a
1301
+ * returning user needs, and the cost of persisting is a repeated serialize on
1302
+ * a device that may already be under memory pressure. The depth that is not
1303
+ * kept is reported honestly after hydration, via the trim marker.
1304
+ *
1305
+ * Returns null when there is nothing worth persisting (an empty store), so a
1306
+ * caller cannot accidentally store an empty snapshot over a good one.
1307
+ */
1308
+ snapshot(serverEpoch: number, maxLines: number = this.effectiveTailCap): StoreSnapshot | null {
1309
+ if (this.oldest < 0 || this.highest < 0) {
1310
+ return null;
1311
+ }
1312
+ const cap = Number.isInteger(maxLines) && maxLines > 0 ? maxLines : this.effectiveTailCap;
1313
+ // Persist the LIVE TAIL only, excluding browse cache BY CLASSIFICATION
1314
+ // rather than by contiguity (docs/paged-scrollback.md §5.2). Walk down from
1315
+ // `highest` and stop at the first index that is absent OR a browse member:
1316
+ // a fetched page can sit FLUSH against the tail with no numeric gap, so a
1317
+ // contiguity test alone would serialize it. Excluding it is deliberate —
1318
+ // the cache is disposable by construction (recovery is one page fetch), and
1319
+ // persisting it would spend storage writes on data the design defines as
1320
+ // throwaway and hydrate interior holes into a fresh store.
1321
+ //
1322
+ // The result is that a hydrated store is always ONE contiguous tail, which
1323
+ // is what keeps its derived `everEvictedThrough = oldest - 1` honest.
1324
+ const tail: number[] = [];
1325
+ for (let abs = this.highest; abs >= 0 && tail.length < cap; abs--) {
1326
+ if (!this.lines.has(abs) || this.browse.has(abs)) {
1327
+ break;
1328
+ }
1329
+ tail.push(abs);
1330
+ }
1331
+ tail.reverse();
1332
+ const lines: [number, WireRun[]][] = [];
1333
+ let first = -1;
1334
+ let last = -1;
1335
+ for (const abs of tail) {
1336
+ const runs = this.lines.get(abs);
1337
+ if (runs !== undefined) {
1338
+ lines.push([abs, runs]);
1339
+ if (first < 0) {
1340
+ first = abs;
1341
+ }
1342
+ last = abs;
1343
+ }
1344
+ }
1345
+ if (lines.length === 0) {
1346
+ return null;
1347
+ }
1348
+ return {
1349
+ v: SNAPSHOT_VERSION,
1350
+ serverEpoch: Number.isFinite(serverEpoch) ? serverEpoch : 0,
1351
+ // Derived from what is actually being written, not from this.oldest: a
1352
+ // bounded tail starts above it.
1353
+ oldest: first,
1354
+ highest: last,
1355
+ lines,
1356
+ };
1357
+ }
1358
+
1359
+ /**
1360
+ * Rehydrate a store from a snapshot, or return null when the snapshot is
1361
+ * missing, malformed, or of a different shape version.
1362
+ *
1363
+ * Returning null rather than throwing (or returning a half-built store) is the
1364
+ * point: every failure mode here — a truncated write, a hand-edited value, a
1365
+ * snapshot from an older release — has the same correct handling, which is to
1366
+ * start empty and take a full resume.
1367
+ *
1368
+ * The hydrated store reports the depth it does NOT have.
1369
+ * `everEvictedThrough` is set to `oldest - 1`, which is what the eviction
1370
+ * bookkeeping would say had this client trimmed those lines itself: the
1371
+ * staleness guard then refuses a late re-delivery of a line below the tail
1372
+ * (it is not wanted; the tail is deliberate), and `hasTrimmedHistory()`
1373
+ * reports true so the renderer shows its "earlier output trimmed" affordance
1374
+ * instead of implying the history is complete.
1375
+ *
1376
+ * `maxLines` is the hydrated store's retained-line cap, and it also BOUNDS the
1377
+ * restore: a snapshot larger than the cap is trimmed to its newest `maxLines`
1378
+ * lines, because the cap is a memory budget (the renderer builds one DOM row
1379
+ * per retained line) and a restore that ignored it would blow the budget until
1380
+ * eviction caught up. The trimmed depth is reported through the usual channel:
1381
+ * `hasTrimmedHistory()`.
1382
+ *
1383
+ * The caller MUST pass `snap.serverEpoch` to the connection layer before
1384
+ * connecting; see StoreSnapshot.serverEpoch for what goes wrong otherwise.
1385
+ */
1386
+ static fromSnapshot(snap: unknown, maxLines?: number): LineStore | null {
1387
+ if (typeof snap !== "object" || snap === null) {
1388
+ return null;
1389
+ }
1390
+ const rec = snap as Record<string, unknown>;
1391
+ // Read the payload as unknown[] rather than through StoreSnapshot's typed
1392
+ // field: this data has been outside the program's memory, so its shape is an
1393
+ // assumption until checked, and typing it first would make the checks below
1394
+ // statically dead (the compiler would "know" each entry is a valid pair).
1395
+ if (rec["v"] !== SNAPSHOT_VERSION || !Array.isArray(rec["lines"])) {
1396
+ return null;
1397
+ }
1398
+ const raw = rec["lines"] as unknown[];
1399
+ if (raw.length === 0) {
1400
+ return null;
1401
+ }
1402
+ // Mirrors the constructor: an absent or invalid cap means "engine's choice"
1403
+ // and is FORWARDED as absent, not as a value. Resolved BEFORE the store is
1404
+ // constructed, because it has to be the store's own cap as well as the trim
1405
+ // bound — passing the raw value here gave `fromSnapshot(snap, 0)` a store
1406
+ // that trimmed at 5000 and then evicted almost everything on the next append.
1407
+ const cap =
1408
+ maxLines !== undefined && Number.isInteger(maxLines) && maxLines > 0 ? maxLines : undefined;
1409
+ const bound = cap ?? MAX_LINES;
1410
+ const store = new LineStore(cap);
1411
+ const pairs: [number, WireRun[]][] = [];
1412
+ let highest = -1;
1413
+ for (const entry of raw) {
1414
+ // One bad row must not poison the whole restore, and a partially applied
1415
+ // one is worse than none: bail out entirely and take a full resume.
1416
+ if (!Array.isArray(entry) || entry.length !== 2) {
1417
+ return null;
1418
+ }
1419
+ const abs: unknown = entry[0];
1420
+ const runs: unknown = entry[1];
1421
+ if (typeof abs !== "number" || !Number.isInteger(abs) || abs < 0) {
1422
+ return null;
1423
+ }
1424
+ if (!isWireRunArray(runs)) {
1425
+ return null;
1426
+ }
1427
+ if (abs <= highest) {
1428
+ return null; // not ascending, or duplicated: the index space is broken
1429
+ }
1430
+ pairs.push([abs, runs]);
1431
+ highest = abs;
1432
+ }
1433
+ // Bound the restore to the store's own cap. A snapshot may legitimately be
1434
+ // larger than the cap this client runs with (the same snapshot outlives a
1435
+ // consumer lowering its budget, and the persisted bound is a separate knob),
1436
+ // and hydrating over the cap would blow the memory budget the cap exists to
1437
+ // set — the renderer builds one DOM row per retained line. The whole payload
1438
+ // is validated first, so a corrupt head still rejects the restore rather
1439
+ // than being silently sliced off.
1440
+ const kept = pairs.length > bound ? pairs.slice(pairs.length - bound) : pairs;
1441
+ const oldest = kept[0]?.[0] ?? -1;
1442
+ if (oldest < 0) {
1443
+ return null;
1444
+ }
1445
+ for (const [abs, runs] of kept) {
1446
+ store.lines.set(abs, runs);
1447
+ }
1448
+ store.oldest = oldest;
1449
+ store.highest = highest;
1450
+ // Everything below the tail is gone as far as this store is concerned.
1451
+ store.everEvictedThrough = oldest - 1;
1452
+ // The renderer must build every hydrated row: nothing is on screen yet.
1453
+ for (const abs of store.lines.keys()) {
1454
+ store.dirty.add(abs);
1455
+ }
1456
+ return store;
259
1457
  }
260
1458
 
261
1459
  /** Drain accumulated changes for the renderer and clear the tracking sets. */
@@ -277,6 +1475,11 @@ export class LineStore {
277
1475
 
278
1476
  // --- internals ---
279
1477
 
1478
+ /** Whether an absolute index falls inside the current live window. */
1479
+ private inWindow(abs: number): boolean {
1480
+ return this.win.height > 0 && abs >= this.win.base && abs < this.win.base + this.win.height;
1481
+ }
1482
+
280
1483
  /**
281
1484
  * applyLine is the guarded core. It enforces the apply-line guard set
282
1485
  * (see the #web-terminal-engine steering doc, "Design rationale"):
@@ -284,13 +1487,35 @@ export class LineStore {
284
1487
  * cap-bounded. Returns nothing; effects are recorded in the dirty/evicted
285
1488
  * sets for the next drain.
286
1489
  */
287
- private applyLine(abs: number, runs: WireRun[]): void {
1490
+ private applyLine(abs: number, runs: WireRun[], bulk = false): void {
288
1491
  // Guard 1: a valid, non-negative integer index.
289
1492
  if (!Number.isInteger(abs) || abs < 0) {
290
1493
  return;
291
1494
  }
292
- // Guard 2: not below what we have permanently evicted (stale re-send).
293
- if (abs <= this.everEvictedThrough) {
1495
+ // Guard 2: not below what we have permanently evicted (stale re-send) —
1496
+ // EXCEPT inside the current live window, and EXCEPT inside the window of a
1497
+ // request we currently have in flight. The window is the terminal's
1498
+ // writable region and is never stale: under the real protocol its base
1499
+ // only advances, so a window index at or below everEvictedThrough is
1500
+ // unreachable — but a malformed or hostile frame whose base RETREATS
1501
+ // must degrade to a drawn screen, not to window rows silently dropped
1502
+ // forever (the property test drives exactly that shape). Same doctrine
1503
+ // as enforceCap's "the live window is never evictable".
1504
+ //
1505
+ // The SOLICITED exception is what makes demand paging possible at all:
1506
+ // a page is by definition a re-fetch of history below the watermark, so
1507
+ // the watermark alone cannot decide staleness any more. What keeps it
1508
+ // safe is that the exception is scoped to one in-flight request window —
1509
+ // an UNSOLICITED line below the watermark is still refused, exactly as
1510
+ // before (docs/paged-scrollback.md §5.1).
1511
+ if (abs <= this.everEvictedThrough && !this.inWindow(abs) && !this.isSolicited(abs)) {
1512
+ return;
1513
+ }
1514
+ // Guard 2b: not an index the APPLICATION erased. Same two exemptions, and
1515
+ // `erasedThrough` is why it is separable from guard 2 above — see its
1516
+ // declaration for why ED3 cannot advance the trim watermark, and why
1517
+ // refusing an erased index can never refuse legitimate content.
1518
+ if (abs <= this.erasedThrough && !this.inWindow(abs) && !this.isSolicited(abs)) {
294
1519
  return;
295
1520
  }
296
1521
  // Guard 3: a well-formed run array.
@@ -305,6 +1530,7 @@ export class LineStore {
305
1530
  return;
306
1531
  }
307
1532
  this.lines.set(abs, runs);
1533
+ this.rangesMemo = null;
308
1534
  this.evicted.delete(abs);
309
1535
  this.dirty.add(abs);
310
1536
  if (this.oldest < 0 || abs < this.oldest) {
@@ -313,36 +1539,61 @@ export class LineStore {
313
1539
  if (abs > this.highest) {
314
1540
  this.highest = abs;
315
1541
  }
316
- // Guard 10: enforce the cap by evicting from the oldest end.
317
- this.enforceCap();
1542
+ // Guard 10: enforce the cap by evicting from the oldest end. SUPPRESSED
1543
+ // during a bulk page apply, which runs one budget pass of its own at the
1544
+ // end: per-line enforcement there would trim the tail against a count the
1545
+ // apply is still in the middle of changing.
1546
+ if (!bulk) {
1547
+ this.enforceCap();
1548
+ }
1549
+ }
1550
+
1551
+ /** Whether `abs` lies inside the request currently in flight. */
1552
+ private isSolicited(abs: number): boolean {
1553
+ const s = this.solicitedPending;
1554
+ return s !== null && abs >= s.lo && abs < s.hi;
318
1555
  }
319
1556
 
320
- // dropHistoryBelow evicts all retained lines with an absolute index below
321
- // `base` — the scrollback history under the live window. Called when the
322
- // server signals ED3 (erase scrollback): an inline TUI (kiro-cli) clears its
323
- // saved lines on every resize redraw, so the client drops its matching
324
- // history to stop the previous frame accumulating beneath the new one. Window
325
- // rows (>= base) are kept and refreshed by the frame carrying the signal.
326
- // everEvictedThrough is left untouched: the app discarded the lines
327
- // deliberately (not a cap trim), so no "earlier output trimmed" marker fits.
328
- private dropHistoryBelow(base: number): void {
1557
+ // applyScrollbackCleared handles ED3 (erase scrollback) in full: the app told
1558
+ // the terminal to discard its saved lines, and an inline TUI (kiro-cli) does it
1559
+ // on every resize redraw. Window rows (>= base) are kept and refreshed by the
1560
+ // frame carrying the signal. everEvictedThrough is left untouched: the app
1561
+ // discarded the lines deliberately (not a cap trim), so no "earlier output
1562
+ // trimmed" marker fits.
1563
+ //
1564
+ // Dropping the lines is only ONE of the three effects §5.5 requires, and the
1565
+ // other two are what stop the erased history coming back:
1566
+ //
1567
+ // - The in-flight request window is CANCELLED. It is the store's standing
1568
+ // permission to admit lines below its stale-re-send watermark, so a reply
1569
+ // already in flight when the app erased its scrollback would otherwise
1570
+ // re-apply the very rows the app just discarded — through the one path that
1571
+ // is exempt from the staleness guard.
1572
+ // - The paging FLOOR snaps to the cleared bound: nothing at or below it is
1573
+ // worth requesting any more. Without it the trigger re-fetches the erased
1574
+ // range from a server that still holds it, and the user watches erased
1575
+ // output scroll back into view.
1576
+ //
1577
+ // Both run even when this client holds no line below `base`: an in-flight
1578
+ // request and a stale floor are not conditional on local residency.
1579
+ private applyScrollbackCleared(base: number): void {
1580
+ this.solicitedPending = null;
1581
+ this.raisePagingFloor(base);
329
1582
  if (this.oldest < 0 || this.oldest >= base) {
330
1583
  return;
331
1584
  }
332
1585
  // Bounded key-scan (like enforceCap), not an integer walk over [oldest, base).
1586
+ // Through forget() so a browse-classified line takes its classification with
1587
+ // it: an application clearing its scrollback deletes cache as readily as tail.
333
1588
  for (const abs of [...this.lines.keys()]) {
334
1589
  if (abs < base) {
335
- this.lines.delete(abs);
336
- this.evicted.add(abs);
337
- this.dirty.delete(abs);
1590
+ this.forget(abs);
1591
+ // The watermark tracks what was actually DROPPED, never the cleared
1592
+ // bound: the gap between them is where the app's own reprint lands.
1593
+ this.erasedThrough = Math.max(this.erasedThrough, abs);
338
1594
  }
339
1595
  }
340
- if (base > this.highest) {
341
- this.oldest = -1;
342
- this.highest = -1;
343
- } else {
344
- this.oldest = base;
345
- }
1596
+ this.recomputeBounds();
346
1597
  }
347
1598
 
348
1599
  // truncateBelowWindow evicts every retained line past the window's bottom row.
@@ -370,9 +1621,7 @@ export class LineStore {
370
1621
  let newHighest = -1;
371
1622
  for (const abs of [...this.lines.keys()]) {
372
1623
  if (abs > windowBottom) {
373
- this.lines.delete(abs);
374
- this.evicted.add(abs);
375
- this.dirty.delete(abs);
1624
+ this.forget(abs);
376
1625
  } else if (abs > newHighest) {
377
1626
  newHighest = abs;
378
1627
  }
@@ -386,38 +1635,157 @@ export class LineStore {
386
1635
  }
387
1636
 
388
1637
  private enforceCap(): void {
389
- while (this.lines.size > this.maxLines && this.oldest >= 0 && this.oldest < this.highest) {
390
- const victim = this.oldest;
391
- if (this.lines.delete(victim)) {
392
- this.everEvictedThrough = Math.max(this.everEvictedThrough, victim);
393
- this.evicted.add(victim);
394
- this.dirty.delete(victim);
395
- }
396
- // Advance oldest to the lowest remaining key. After evicting the current
397
- // oldest, contiguous history (the common case: live scroll-off, resume
398
- // replay, a cat-bigfile burst) leaves victim + 1 present -- an O(1) probe.
399
- // Only a hole at the boundary falls back to a full key scan. That scan is
400
- // bounded by map size (<= maxLines), NOT by the integer gap to the next
401
- // retained line, so a malformed/compromised frame whose base jumps far
402
- // ahead of a retained low index cannot make eviction walk billions of
403
- // indices (an algorithmic-complexity DoS that freezes the tab).
404
- if (this.lines.has(victim + 1)) {
405
- this.oldest = victim + 1;
406
- } else {
407
- let min = -1;
408
- for (const k of this.lines.keys()) {
409
- if (min < 0 || k < min) {
410
- min = k;
411
- }
1638
+ // AMENDMENT 1 (docs/paged-scrollback.md §5.3): the gate and the victim
1639
+ // target read `tailCount`, not `lines.size`. Browse cache lives under its
1640
+ // OWN budget, enforced at page-apply time, so live output must not be able
1641
+ // to evict a page the reader is browsing — at the default batch that would
1642
+ // be up to 93 cache lines per applied line.
1643
+ if (this.tailCount <= this.tailBound) {
1644
+ return;
1645
+ }
1646
+ const target = this.tailBound - evictionBatch(this.tailBound) + 1;
1647
+ // ONE oldest-first eviction walk that SKIPS the live window. The window
1648
+ // is never evictable — the screen is documented as a fixed,
1649
+ // always-present block, so the cap is a HISTORY budget floored at the
1650
+ // live screen (a cap at or below the terminal height keeps the full
1651
+ // screen with zero scrollback). Content ABOVE the window (a resume
1652
+ // replay delivered before its window frame; a malformed stream that
1653
+ // never advances the base) is ordinary evictable history too: the
1654
+ // cursor hops over the window and keeps walking, so the bound
1655
+ // size <= max(maxLines, window height) holds whatever shape the frames
1656
+ // arrive in. Evicting that band OLDEST-first keeps the NEWEST lines —
1657
+ // the ones adjacent to the incoming window — so after the window
1658
+ // advances the retained set converges back to one contiguous block
1659
+ // (R3 adversarial finding: the earlier newest-first tail trim parked a
1660
+ // permanent interior hole under the live screen that resume, which
1661
+ // replays only above haveThrough, could never backfill).
1662
+ // everEvictedThrough advances over every TAIL eviction, including the
1663
+ // hopped band — safe for the window frame that follows a replay because
1664
+ // apply-line guard 2 exempts the live window (see applyLine).
1665
+ const winBottom = this.win.height > 0 ? this.win.base + this.win.height - 1 : -1;
1666
+ const hasWindow = this.win.height > 0;
1667
+ let cursor = this.oldest;
1668
+ let hopped = false;
1669
+ // The `cursor < highest` half of the guard keeps the NEWEST line alive in
1670
+ // a scroll-only store (a cap-1 store must still show something). With a
1671
+ // protected window retained there is always content to show, so a line
1672
+ // ABOVE the window bottom — the tail invariant broken by a scroll frame,
1673
+ // a shape only a malformed stream produces — is evictable even when it is
1674
+ // the highest key; otherwise one such line parks the store at bound+1
1675
+ // (found by the interleaved property test).
1676
+ while (
1677
+ this.tailCount > target &&
1678
+ cursor >= 0 &&
1679
+ (cursor < this.highest || (hasWindow && cursor > winBottom))
1680
+ ) {
1681
+ if (hasWindow && cursor >= this.win.base && cursor <= winBottom) {
1682
+ // Hop the cursor over the protected window to the first retained key
1683
+ // above it. O(1) contiguous probe first; the scan fallback is
1684
+ // bounded by map size (the DoS note below applies here too).
1685
+ if (winBottom >= this.highest) {
1686
+ break; // the window is the tail: nothing evictable above it
1687
+ }
1688
+ cursor = this.lines.has(winBottom + 1) ? winBottom + 1 : this.minKeyAbove(winBottom);
1689
+ hopped = true;
1690
+ if (cursor < 0) {
1691
+ break;
1692
+ }
1693
+ continue;
1694
+ }
1695
+ // AMENDMENT 2: the hop predicate extends to BROWSE intervals. The walk
1696
+ // steps over them exactly the way it steps over the window, which is
1697
+ // what keeps `this.oldest` correct: it stays the GLOBAL minimum retained
1698
+ // key (browse included), and hopping arms the once-per-pass bounds
1699
+ // recompute below. Without this the cursor would delete cache lines as
1700
+ // if they were tail, and `oldestIndex()` — which the frontier and the
1701
+ // gap geometry both read — would go stale on the first trim of any
1702
+ // browse episode.
1703
+ if (this.browse.has(cursor)) {
1704
+ const next = this.minKeyAboveNonBrowse(cursor);
1705
+ hopped = true;
1706
+ if (next < 0) {
1707
+ break;
412
1708
  }
413
- this.oldest = min;
1709
+ cursor = next;
1710
+ continue;
1711
+ }
1712
+ const victim = cursor;
1713
+ if (this.forget(victim)) {
1714
+ // AMENDMENT 3: only TAIL victims advance everEvictedThrough. A browse
1715
+ // eviction is a cache drop, not a permanent trim, and must not teach
1716
+ // guard 2 to refuse a later re-fetch of the same index. (Browse lines
1717
+ // never reach here anyway — amendment 2 hops them — so this is the
1718
+ // invariant stated where a future edit would break it, and forget()
1719
+ // keeps the classification consistent if one ever does.)
1720
+ this.everEvictedThrough = Math.max(this.everEvictedThrough, victim);
414
1721
  }
415
1722
  if (this.lines.size === 0) {
416
1723
  this.oldest = -1;
417
1724
  this.highest = -1;
1725
+ return;
1726
+ }
1727
+ // Advance to the lowest remaining key above the victim. Contiguous
1728
+ // history (the common case: live scroll-off, resume replay, a
1729
+ // cat-bigfile burst) leaves victim + 1 present -- an O(1) probe. Only a
1730
+ // hole at the boundary falls back to a scan, and that scan is bounded
1731
+ // by map size (<= the retention bound), NOT by the integer gap to the
1732
+ // next retained line, so a malformed/compromised frame whose base jumps
1733
+ // far ahead of a retained low index cannot make eviction walk billions
1734
+ // of indices (an algorithmic-complexity DoS that freezes the tab).
1735
+ cursor = this.lines.has(victim + 1) ? victim + 1 : this.minKeyAbove(victim);
1736
+ if (!hopped) {
1737
+ // Classic head eviction: the cursor IS the minimum retained key.
1738
+ this.oldest = cursor;
1739
+ }
1740
+ if (cursor < 0) {
418
1741
  break;
419
1742
  }
420
1743
  }
1744
+ if (hopped) {
1745
+ // The cursor hopped the window or a browse interval, so the minimum
1746
+ // retained key is not the cursor: recompute both bounds once per pass
1747
+ // (bounded scan; once per PASS, never per victim — R3 perf finding).
1748
+ let min = -1;
1749
+ let max = -1;
1750
+ for (const k of this.lines.keys()) {
1751
+ if (min < 0 || k < min) {
1752
+ min = k;
1753
+ }
1754
+ if (k > max) {
1755
+ max = k;
1756
+ }
1757
+ }
1758
+ this.oldest = min;
1759
+ this.highest = max;
1760
+ }
1761
+ }
1762
+
1763
+ /**
1764
+ * The lowest retained NON-BROWSE key strictly above k, or -1. The eviction
1765
+ * walk uses it to step past a whole browse interval in one move rather than
1766
+ * key by key: a 2500-line cache would otherwise cost 2500 iterations of the
1767
+ * hop branch on every tail trim taken while the reader is deep in history.
1768
+ * Bounded by map size, like minKeyAbove.
1769
+ */
1770
+ private minKeyAboveNonBrowse(k: number): number {
1771
+ let min = -1;
1772
+ for (const key of this.lines.keys()) {
1773
+ if (key > k && !this.browse.has(key) && (min < 0 || key < min)) {
1774
+ min = key;
1775
+ }
1776
+ }
1777
+ return min;
1778
+ }
1779
+
1780
+ /** The lowest retained key strictly above k, or -1. Bounded by map size. */
1781
+ private minKeyAbove(k: number): number {
1782
+ let min = -1;
1783
+ for (const key of this.lines.keys()) {
1784
+ if (key > k && (min < 0 || key < min)) {
1785
+ min = key;
1786
+ }
1787
+ }
1788
+ return min;
421
1789
  }
422
1790
 
423
1791
  private updateWindow(msg: ScreenMessage): void {
@@ -437,10 +1805,17 @@ export class LineStore {
437
1805
  }
438
1806
 
439
1807
  private updateWindowCursor(msg: ScreenMessage): void {
440
- // In the alt screen the base is frozen but the cursor still moves.
1808
+ // In the alt screen the base AND height keep the MAIN window's values —
1809
+ // the alt grid's own height lives in altRows — and only the cursor tracks
1810
+ // the alt frame. This keeps the retention bound (max(maxLines, window
1811
+ // height)) and the guard-2 window exemption defined over the MAIN screen,
1812
+ // the region alt exit must restore, rather than over a phantom range
1813
+ // mixing the main base with the alt height (R4 review, all three models:
1814
+ // a smaller alt frame otherwise appeared to lower the bound below the
1815
+ // protected main rows, and the exemption described rows that were not the
1816
+ // writable screen).
441
1817
  const next: WindowState = {
442
1818
  ...this.win,
443
- height: msg.rows.length,
444
1819
  cursorRow: msg.cursor[0],
445
1820
  cursorCol: msg.cursor[1],
446
1821
  cursorStyle: msg.cursorStyle ?? 0,