@floegence/floeterm-terminal-web 0.5.18 → 0.5.20

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.
Files changed (32) hide show
  1. package/README.md +33 -0
  2. package/dist/core/PagedTerminalOutputCoordinator.d.ts +29 -1
  3. package/dist/core/PagedTerminalOutputCoordinator.d.ts.map +1 -1
  4. package/dist/core/PagedTerminalOutputCoordinator.js +412 -16
  5. package/dist/core/PagedTerminalOutputCoordinator.js.map +1 -1
  6. package/dist/core/TerminalCore.d.ts +6 -2
  7. package/dist/core/TerminalCore.d.ts.map +1 -1
  8. package/dist/core/TerminalCore.js +168 -36
  9. package/dist/core/TerminalCore.js.map +1 -1
  10. package/dist/fabric/BeamtermFabricRenderer.d.ts.map +1 -1
  11. package/dist/fabric/BeamtermFabricRenderer.js +3 -0
  12. package/dist/fabric/BeamtermFabricRenderer.js.map +1 -1
  13. package/dist/index.d.ts +6 -4
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -2
  16. package/dist/index.js.map +1 -1
  17. package/dist/internal/TerminalInitializationScheduler.d.ts +24 -8
  18. package/dist/internal/TerminalInitializationScheduler.d.ts.map +1 -1
  19. package/dist/internal/TerminalInitializationScheduler.js +61 -28
  20. package/dist/internal/TerminalInitializationScheduler.js.map +1 -1
  21. package/dist/manager/TerminalInstanceController.d.ts +0 -2
  22. package/dist/manager/TerminalInstanceController.d.ts.map +1 -1
  23. package/dist/manager/TerminalInstanceController.js +38 -54
  24. package/dist/manager/TerminalInstanceController.js.map +1 -1
  25. package/dist/sessions/TerminalSessionsCoordinator.d.ts +6 -0
  26. package/dist/sessions/TerminalSessionsCoordinator.d.ts.map +1 -1
  27. package/dist/sessions/TerminalSessionsCoordinator.js +66 -16
  28. package/dist/sessions/TerminalSessionsCoordinator.js.map +1 -1
  29. package/dist/types.d.ts +10 -1
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/types.js.map +1 -1
  32. package/package.json +1 -1
@@ -1,9 +1,11 @@
1
+ import { scheduleUiTurn } from '../internal/scheduleUiTurn';
1
2
  const DEFAULT_POLICY = {
2
3
  maxRetainedLiveChunks: 2048,
3
4
  maxRetainedLiveBytes: 8 * 1024 * 1024,
4
5
  retryDelaysMs: [250, 1000, 4000],
5
6
  maxWriteBatchBytes: 256 * 1024,
6
7
  };
8
+ const DEFAULT_PREPARED_HISTORY_MAX_BYTES = 32 * 1024 * 1024;
7
9
  const normalizePositiveInteger = (value, fallback) => (typeof value === 'number' && Number.isSafeInteger(value) && value > 0
8
10
  ? value
9
11
  : fallback);
@@ -33,6 +35,213 @@ class HistoryContractError extends Error {
33
35
  this.firstRetainedSequence = firstRetainedSequence;
34
36
  }
35
37
  }
38
+ const createAbortError = () => {
39
+ if (typeof DOMException !== 'undefined')
40
+ return new DOMException('Operation aborted', 'AbortError');
41
+ const error = new Error('Operation aborted');
42
+ error.name = 'AbortError';
43
+ return error;
44
+ };
45
+ const throwIfAborted = (signal) => {
46
+ if (signal?.aborted)
47
+ throw createAbortError();
48
+ };
49
+ const waitForPreparedPage = (promise, signal) => {
50
+ if (!signal)
51
+ return promise;
52
+ if (signal.aborted)
53
+ return Promise.reject(createAbortError());
54
+ return new Promise((resolve, reject) => {
55
+ const onAbort = () => reject(createAbortError());
56
+ signal.addEventListener('abort', onAbort, { once: true });
57
+ promise.then(value => {
58
+ signal.removeEventListener('abort', onAbort);
59
+ resolve(value);
60
+ }, error => {
61
+ signal.removeEventListener('abort', onAbort);
62
+ reject(error);
63
+ });
64
+ });
65
+ };
66
+ const yieldHistoryPreparation = async (signal, yieldControl) => {
67
+ throwIfAborted(signal);
68
+ if (yieldControl) {
69
+ await yieldControl();
70
+ throwIfAborted(signal);
71
+ return;
72
+ }
73
+ await new Promise((resolve, reject) => {
74
+ const cancelTurn = scheduleUiTurn(() => {
75
+ signal?.removeEventListener('abort', onAbort);
76
+ resolve();
77
+ });
78
+ const onAbort = () => {
79
+ cancelTurn();
80
+ reject(createAbortError());
81
+ };
82
+ signal?.addEventListener('abort', onAbort, { once: true });
83
+ });
84
+ };
85
+ const clonePreparedChunk = (chunk) => {
86
+ const immutableData = new Uint8Array(chunk.data);
87
+ return Object.freeze({
88
+ get data() {
89
+ return new Uint8Array(immutableData);
90
+ },
91
+ ...(chunk.sequence !== undefined ? { sequence: chunk.sequence } : {}),
92
+ ...(chunk.timestampMs !== undefined ? { timestampMs: chunk.timestampMs } : {}),
93
+ });
94
+ };
95
+ export const preparePagedTerminalHistory = async (options) => {
96
+ const requestedStartSequence = normalizeSequence(options.startSequence ?? 1, 'startSequence');
97
+ const maxBytes = normalizePositiveInteger(options.maxBytes, DEFAULT_PREPARED_HISTORY_MAX_BYTES);
98
+ let startSequence = Math.max(1, requestedStartSequence);
99
+ let cursor;
100
+ let historyGeneration;
101
+ let snapshotEndSequence;
102
+ let firstRetainedSequence;
103
+ let coveredThroughSequence = Math.max(0, startSequence - 1);
104
+ let byteLength = 0;
105
+ let pageCount = 0;
106
+ let chunks = [];
107
+ let rebaseAttempts = 0;
108
+ const requestSignal = options.signal ?? new AbortController().signal;
109
+ while (true) {
110
+ throwIfAborted(options.signal);
111
+ const page = await waitForPreparedPage(options.fetchPage({
112
+ startSequence,
113
+ endSequence: snapshotEndSequence,
114
+ historyGeneration,
115
+ cursor,
116
+ maxBytes: Math.max(1, maxBytes - byteLength),
117
+ signal: requestSignal,
118
+ }), options.signal);
119
+ throwIfAborted(options.signal);
120
+ pageCount += 1;
121
+ if (!Object.prototype.hasOwnProperty.call(page, 'coveredThroughSequence')) {
122
+ throw new HistoryContractError('history_contract_missing', 'coveredThroughSequence is required');
123
+ }
124
+ if (!Object.prototype.hasOwnProperty.call(page, 'snapshotEndSequence')) {
125
+ throw new HistoryContractError('history_contract_missing', 'snapshotEndSequence is required');
126
+ }
127
+ if (!Object.prototype.hasOwnProperty.call(page, 'historyGeneration')) {
128
+ throw new HistoryContractError('history_contract_missing', 'historyGeneration is required');
129
+ }
130
+ if (!Object.prototype.hasOwnProperty.call(page, 'firstRetainedSequence')
131
+ && !Object.prototype.hasOwnProperty.call(page, 'firstAvailableSequence')) {
132
+ throw new HistoryContractError('history_contract_missing', 'firstRetainedSequence is required');
133
+ }
134
+ const pageCoverage = normalizeSequence(page.coveredThroughSequence, 'coveredThroughSequence');
135
+ const pageSnapshotEnd = normalizeSequence(page.snapshotEndSequence, 'snapshotEndSequence');
136
+ const pageGeneration = normalizeSequence(page.historyGeneration, 'historyGeneration');
137
+ const pageFirstRetained = normalizeSequence(page.firstRetainedSequence ?? page.firstAvailableSequence, 'firstRetainedSequence');
138
+ const generationChanged = historyGeneration !== undefined && pageGeneration !== historyGeneration;
139
+ const snapshotChanged = snapshotEndSequence !== undefined && pageSnapshotEnd !== snapshotEndSequence;
140
+ const retentionAdvanced = firstRetainedSequence !== undefined
141
+ && pageFirstRetained > firstRetainedSequence;
142
+ if (historyGeneration !== undefined
143
+ && (page.historyReset || page.historyTruncated || generationChanged || snapshotChanged || retentionAdvanced)) {
144
+ if (rebaseAttempts >= 2) {
145
+ throw new HistoryContractError('history_contract_invalid', 'terminal history changed repeatedly while preparing a stable snapshot');
146
+ }
147
+ rebaseAttempts += 1;
148
+ startSequence = Math.max(1, pageFirstRetained);
149
+ cursor = undefined;
150
+ historyGeneration = undefined;
151
+ snapshotEndSequence = undefined;
152
+ firstRetainedSequence = undefined;
153
+ coveredThroughSequence = startSequence - 1;
154
+ byteLength = 0;
155
+ chunks = [];
156
+ await yieldHistoryPreparation(options.signal, options.yieldControl);
157
+ continue;
158
+ }
159
+ if (pageCoverage < coveredThroughSequence) {
160
+ throw new HistoryContractError('history_contract_invalid', 'coveredThroughSequence regressed');
161
+ }
162
+ if (pageCoverage > pageSnapshotEnd) {
163
+ throw new HistoryContractError('history_contract_invalid', 'coveredThroughSequence exceeded snapshotEndSequence');
164
+ }
165
+ let previousChunkSequence = coveredThroughSequence;
166
+ for (const chunk of page.chunks) {
167
+ const sequence = normalizeSequence(chunk.sequence, 'chunk.sequence');
168
+ if (sequence <= previousChunkSequence || sequence > pageCoverage) {
169
+ throw new HistoryContractError('history_contract_invalid', 'prepared history chunks must have strictly increasing sequences within page coverage');
170
+ }
171
+ previousChunkSequence = sequence;
172
+ }
173
+ if (pageCoverage > Math.max(0, pageFirstRetained - 1)
174
+ && previousChunkSequence !== pageCoverage) {
175
+ throw new HistoryContractError('history_contract_invalid', 'prepared history page did not include its covered terminal sequence');
176
+ }
177
+ historyGeneration = pageGeneration;
178
+ snapshotEndSequence = pageSnapshotEnd;
179
+ firstRetainedSequence = pageFirstRetained;
180
+ if (pageFirstRetained > startSequence) {
181
+ coveredThroughSequence = Math.max(coveredThroughSequence, pageFirstRetained - 1);
182
+ }
183
+ const pageBytes = page.chunks.reduce((sum, chunk) => sum + chunk.data.byteLength, 0);
184
+ const remainingBytes = maxBytes - byteLength;
185
+ if (pageBytes <= remainingBytes) {
186
+ chunks.push(...page.chunks.map(clonePreparedChunk));
187
+ byteLength += pageBytes;
188
+ coveredThroughSequence = Math.max(coveredThroughSequence, pageCoverage);
189
+ }
190
+ else {
191
+ for (const chunk of page.chunks) {
192
+ const sequence = normalizeSequence(chunk.sequence, 'chunk.sequence');
193
+ if (byteLength + chunk.data.byteLength > maxBytes)
194
+ break;
195
+ chunks.push(clonePreparedChunk(chunk));
196
+ byteLength += chunk.data.byteLength;
197
+ coveredThroughSequence = Math.max(coveredThroughSequence, sequence);
198
+ }
199
+ return Object.freeze({
200
+ chunks: Object.freeze(chunks),
201
+ requestedStartSequence,
202
+ firstRetainedSequence,
203
+ coveredThroughSequence,
204
+ snapshotEndSequence,
205
+ historyGeneration,
206
+ byteLength,
207
+ pageCount,
208
+ complete: false,
209
+ });
210
+ }
211
+ if (!page.hasMore) {
212
+ return Object.freeze({
213
+ chunks: Object.freeze(chunks),
214
+ requestedStartSequence,
215
+ firstRetainedSequence,
216
+ coveredThroughSequence,
217
+ snapshotEndSequence,
218
+ historyGeneration,
219
+ byteLength,
220
+ pageCount,
221
+ complete: coveredThroughSequence >= snapshotEndSequence,
222
+ });
223
+ }
224
+ if (byteLength >= maxBytes) {
225
+ return Object.freeze({
226
+ chunks: Object.freeze(chunks),
227
+ requestedStartSequence,
228
+ firstRetainedSequence,
229
+ coveredThroughSequence,
230
+ snapshotEndSequence,
231
+ historyGeneration,
232
+ byteLength,
233
+ pageCount,
234
+ complete: false,
235
+ });
236
+ }
237
+ if (page.nextCursor === undefined) {
238
+ throw new HistoryContractError('history_contract_invalid', 'nextCursor is required when hasMore is true');
239
+ }
240
+ cursor = page.nextCursor;
241
+ startSequence = pageCoverage + 1;
242
+ await yieldHistoryPreparation(options.signal, options.yieldControl);
243
+ }
244
+ };
36
245
  class PagedTerminalOutputCoordinator {
37
246
  constructor(options) {
38
247
  this.active = true;
@@ -51,6 +260,7 @@ class PagedTerminalOutputCoordinator {
51
260
  this.lastError = null;
52
261
  this.failure = null;
53
262
  this.recoveryStartSequence = 1;
263
+ this.explicitAttachFence = false;
54
264
  this.recoveryKind = 'initial';
55
265
  this.recoveryRunning = false;
56
266
  this.needsRebase = false;
@@ -62,6 +272,8 @@ class PagedTerminalOutputCoordinator {
62
272
  this.activeWriters = 0;
63
273
  this.writerQuiescenceWaiters = [];
64
274
  this.pausedRecoveryPending = false;
275
+ this.droppedLiveThroughSequence = 0;
276
+ this.preparedHistory = null;
65
277
  this.options = options;
66
278
  this.policy = {
67
279
  maxRetainedLiveChunks: normalizePositiveInteger(options.policy?.maxRetainedLiveChunks, DEFAULT_POLICY.maxRetainedLiveChunks),
@@ -72,9 +284,13 @@ class PagedTerminalOutputCoordinator {
72
284
  maxWriteBatchBytes: normalizePositiveInteger(options.policy?.maxWriteBatchBytes, DEFAULT_POLICY.maxWriteBatchBytes),
73
285
  };
74
286
  }
75
- async attach(startSequence = 1) {
287
+ async attach(startSequence = 1, snapshotEndSequence, options) {
288
+ const attachGeneration = this.beginAttach(startSequence);
289
+ await this.completeAttach(attachGeneration, snapshotEndSequence, options);
290
+ }
291
+ beginAttach(startSequence = 1) {
76
292
  if (this.disposed)
77
- return;
293
+ return this.generation;
78
294
  this.cancelRecovery();
79
295
  this.resolveBaselineWaiters();
80
296
  this.retainedLive = [];
@@ -88,10 +304,37 @@ class PagedTerminalOutputCoordinator {
88
304
  this.scheduledThroughSequence = this.coveredThroughSequence;
89
305
  this.recoveryKind = 'initial';
90
306
  this.recoveryStartSequence = Math.max(0, Math.floor(startSequence));
307
+ this.recoveryEndSequence = undefined;
308
+ this.explicitAttachFence = false;
309
+ this.droppedLiveThroughSequence = 0;
310
+ this.needsRebase = false;
91
311
  this.retryAttempt = 0;
92
312
  this.failure = null;
93
313
  this.lastError = null;
94
314
  this.historyRebasePrepared = false;
315
+ this.preparedHistory = null;
316
+ this.setState('idle');
317
+ return this.generation;
318
+ }
319
+ async completeAttach(attachGeneration, snapshotEndSequence, options = {}) {
320
+ if (this.disposed || !this.isCurrent(attachGeneration))
321
+ return;
322
+ this.explicitAttachFence = snapshotEndSequence !== undefined;
323
+ this.recoveryEndSequence = normalizeSequence(snapshotEndSequence, 'snapshotEndSequence', true);
324
+ this.preparedHistory = this.acceptPreparedHistory(options.preparedHistory);
325
+ if (this.explicitAttachFence && this.recoveryEndSequence === 0) {
326
+ if (this.needsRebase) {
327
+ this.prepareRetainedLiveRebase();
328
+ await this.runRecovery();
329
+ return;
330
+ }
331
+ this.baselineReady = true;
332
+ this.resolveBaselineWaiters();
333
+ this.emitState();
334
+ this.setState('live');
335
+ this.drainRetainedLive();
336
+ return;
337
+ }
95
338
  await this.runRecovery();
96
339
  }
97
340
  waitForBaseline() {
@@ -163,10 +406,14 @@ class PagedTerminalOutputCoordinator {
163
406
  this.baselineReady = false;
164
407
  this.coveredThroughSequence = Math.max(0, Math.floor(startSequence) - 1);
165
408
  this.scheduledThroughSequence = this.coveredThroughSequence;
409
+ this.recoveryEndSequence = undefined;
410
+ this.explicitAttachFence = false;
411
+ this.droppedLiveThroughSequence = 0;
166
412
  this.retryAttempt = 0;
167
413
  this.failure = null;
168
414
  this.lastError = null;
169
415
  this.historyRebasePrepared = false;
416
+ this.preparedHistory = null;
170
417
  this.setState('idle');
171
418
  this.resolveBaselineWaiters();
172
419
  }
@@ -203,6 +450,7 @@ class PagedTerminalOutputCoordinator {
203
450
  this.pendingLiveWrites = [];
204
451
  this.liveWriteScheduled = false;
205
452
  this.pausedRecoveryPending = false;
453
+ this.preparedHistory = null;
206
454
  this.setState('disposed');
207
455
  this.resolveBaselineWaiters();
208
456
  }
@@ -211,9 +459,69 @@ class PagedTerminalOutputCoordinator {
211
459
  return;
212
460
  this.recoveryKind = 'catch-up';
213
461
  this.recoveryStartSequence = Math.max(0, Math.floor(startSequence));
462
+ const firstRetainedSequence = this.firstRetainedLiveSequence();
463
+ this.recoveryEndSequence = firstRetainedSequence === undefined
464
+ ? undefined
465
+ : Math.max(0, firstRetainedSequence - 1);
214
466
  this.retryAttempt = 0;
215
467
  void this.runRecovery();
216
468
  }
469
+ acceptPreparedHistory(preparedHistory) {
470
+ if (!preparedHistory || this.recoveryEndSequence === undefined)
471
+ return null;
472
+ try {
473
+ const requestedStart = normalizeSequence(preparedHistory.requestedStartSequence, 'preparedHistory.requestedStartSequence');
474
+ const firstRetained = normalizeSequence(preparedHistory.firstRetainedSequence, 'preparedHistory.firstRetainedSequence');
475
+ const coveredThrough = normalizeSequence(preparedHistory.coveredThroughSequence, 'preparedHistory.coveredThroughSequence');
476
+ const snapshotEnd = normalizeSequence(preparedHistory.snapshotEndSequence, 'preparedHistory.snapshotEndSequence');
477
+ normalizeSequence(preparedHistory.historyGeneration, 'preparedHistory.historyGeneration');
478
+ normalizeSequence(preparedHistory.byteLength, 'preparedHistory.byteLength');
479
+ normalizeSequence(preparedHistory.pageCount, 'preparedHistory.pageCount');
480
+ const expectedStart = Math.max(1, this.recoveryStartSequence);
481
+ if (requestedStart > expectedStart)
482
+ return null;
483
+ if (firstRetained > coveredThrough + 1)
484
+ return null;
485
+ if (coveredThrough < expectedStart - 1 || coveredThrough > snapshotEnd)
486
+ return null;
487
+ if (snapshotEnd > this.recoveryEndSequence || coveredThrough > this.recoveryEndSequence)
488
+ return null;
489
+ if (!Array.isArray(preparedHistory.chunks))
490
+ return null;
491
+ let previousChunkSequence = Math.max(0, firstRetained - 1);
492
+ let preparedBytes = 0;
493
+ for (const chunk of preparedHistory.chunks) {
494
+ if (!(chunk.data instanceof Uint8Array))
495
+ return null;
496
+ const sequence = normalizeSequence(chunk.sequence, 'preparedHistory.chunk.sequence');
497
+ if (sequence <= previousChunkSequence || sequence > coveredThrough)
498
+ return null;
499
+ previousChunkSequence = sequence;
500
+ preparedBytes += chunk.data.byteLength;
501
+ }
502
+ if (preparedBytes !== preparedHistory.byteLength)
503
+ return null;
504
+ if (coveredThrough >= Math.max(1, firstRetained)
505
+ && previousChunkSequence !== coveredThrough)
506
+ return null;
507
+ if (preparedHistory.complete !== (coveredThrough >= snapshotEnd))
508
+ return null;
509
+ if (firstRetained > expectedStart) {
510
+ this.options.clear?.();
511
+ this.options.onHistoryTruncated?.('history-evicted');
512
+ }
513
+ return preparedHistory;
514
+ }
515
+ catch {
516
+ return null;
517
+ }
518
+ }
519
+ discardPreparedHistoryForFullRecovery() {
520
+ this.preparedHistory = null;
521
+ this.coveredThroughSequence = Math.max(0, this.recoveryStartSequence - 1);
522
+ this.scheduledThroughSequence = this.coveredThroughSequence;
523
+ this.historyRebasePrepared = false;
524
+ }
217
525
  async runRecovery() {
218
526
  if (this.disposed || this.recoveryRunning)
219
527
  return;
@@ -230,13 +538,26 @@ class PagedTerminalOutputCoordinator {
230
538
  await this.writeChain;
231
539
  if (!this.isRecoveryCurrent(generation, recoverySerial, controller))
232
540
  return;
233
- const historyChunks = [];
541
+ const preparedHistory = this.preparedHistory;
542
+ const historyChunks = preparedHistory
543
+ ? preparedHistory.chunks.map(item => ({ ...item, data: new Uint8Array(item.data), source: 'history' }))
544
+ : [];
234
545
  let cursor;
235
- let startSequence = this.recoveryStartSequence;
236
- let snapshotEnd;
237
- let historyGeneration;
238
- let coveredEnd = this.coveredThroughSequence;
239
- let firstPage = true;
546
+ let startSequence = preparedHistory
547
+ ? Math.max(1, preparedHistory.coveredThroughSequence
548
+ + (preparedHistory.coveredThroughSequence < (this.recoveryEndSequence ?? 0) ? 1 : 0))
549
+ : this.recoveryStartSequence;
550
+ let snapshotEnd = this.recoveryEndSequence;
551
+ let historyGeneration = preparedHistory?.historyGeneration;
552
+ let coveredEnd = preparedHistory?.coveredThroughSequence ?? this.coveredThroughSequence;
553
+ let firstPage = preparedHistory === null;
554
+ const preparedBaseline = preparedHistory === null
555
+ ? undefined
556
+ : Math.max(Math.max(1, this.recoveryStartSequence) - 1, preparedHistory.firstRetainedSequence - 1);
557
+ if (preparedBaseline !== undefined) {
558
+ this.coveredThroughSequence = preparedBaseline;
559
+ this.scheduledThroughSequence = preparedBaseline;
560
+ }
240
561
  do {
241
562
  const page = await this.options.fetchPage({
242
563
  startSequence,
@@ -250,29 +571,78 @@ class PagedTerminalOutputCoordinator {
250
571
  const pageSnapshotEnd = normalizeSequence(page.snapshotEndSequence, 'snapshotEndSequence', true);
251
572
  const pageGeneration = normalizeSequence(page.historyGeneration, 'historyGeneration', true);
252
573
  const firstRetained = normalizeSequence(page.firstRetainedSequence ?? page.firstAvailableSequence, 'firstRetainedSequence', true);
574
+ if (preparedHistory !== null
575
+ && (pageSnapshotEnd === undefined || pageGeneration === undefined || firstRetained === undefined)) {
576
+ this.discardPreparedHistoryForFullRecovery();
577
+ this.recoveryRunning = false;
578
+ await this.runRecovery();
579
+ return;
580
+ }
253
581
  const generationChanged = historyGeneration !== undefined
254
582
  && pageGeneration !== undefined
255
583
  && pageGeneration !== historyGeneration;
256
584
  const effectivePageStart = Math.max(1, startSequence);
257
585
  const retentionAdvanced = firstRetained !== undefined
258
- && firstRetained > effectivePageStart;
586
+ && (firstRetained > effectivePageStart
587
+ || (preparedHistory !== null && firstRetained > preparedHistory.firstRetainedSequence));
259
588
  const generationNeedsRebase = page.historyReset || generationChanged;
260
589
  const retentionNeedsRebase = page.historyTruncated || retentionAdvanced;
590
+ const fencedRangeFullyEvicted = this.recoveryEndSequence !== undefined
591
+ && firstRetained !== undefined
592
+ && firstRetained > this.recoveryEndSequence;
261
593
  if (!this.historyRebasePrepared && (generationNeedsRebase || retentionNeedsRebase)) {
594
+ this.preparedHistory = null;
595
+ if (fencedRangeFullyEvicted) {
596
+ this.options.clear?.();
597
+ this.options.onHistoryTruncated?.('history-evicted');
598
+ if (preparedHistory !== null)
599
+ historyChunks.length = 0;
600
+ if (generationNeedsRebase) {
601
+ this.historyRebasePrepared = true;
602
+ this.recoveryRunning = false;
603
+ await this.runRecovery();
604
+ return;
605
+ }
606
+ }
607
+ else {
608
+ this.prepareHistoryGenerationRebase(firstRetained);
609
+ this.recoveryRunning = false;
610
+ await this.runRecovery();
611
+ return;
612
+ }
613
+ }
614
+ if (this.historyRebasePrepared && generationNeedsRebase) {
615
+ throw new HistoryContractError('history_contract_invalid', 'history generation reset persisted after rebase');
616
+ }
617
+ let coverage;
618
+ try {
619
+ coverage = this.validatePage(page, coveredEnd);
620
+ }
621
+ catch (error) {
622
+ if (preparedHistory === null)
623
+ throw error;
624
+ this.preparedHistory = null;
262
625
  this.prepareHistoryGenerationRebase(firstRetained);
263
626
  this.recoveryRunning = false;
264
627
  await this.runRecovery();
265
628
  return;
266
629
  }
267
- if (this.historyRebasePrepared && generationNeedsRebase) {
268
- throw new HistoryContractError('history_contract_invalid', 'history generation reset persisted after rebase');
630
+ if (snapshotEnd !== undefined && coverage > snapshotEnd) {
631
+ throw new HistoryContractError('history_contract_invalid', 'coveredThroughSequence exceeded the recovery fence');
269
632
  }
270
- const coverage = this.validatePage(page, coveredEnd);
271
633
  if (firstPage) {
272
- snapshotEnd = pageSnapshotEnd;
634
+ if (this.recoveryEndSequence !== undefined) {
635
+ if (pageSnapshotEnd !== undefined && pageSnapshotEnd !== this.recoveryEndSequence) {
636
+ throw new HistoryContractError('history_contract_invalid', 'snapshotEndSequence does not match the requested recovery fence');
637
+ }
638
+ snapshotEnd = this.recoveryEndSequence;
639
+ }
640
+ else {
641
+ snapshotEnd = pageSnapshotEnd;
642
+ }
273
643
  historyGeneration = pageGeneration;
274
644
  const effectiveStart = Math.max(1, startSequence);
275
- if (firstRetained !== undefined && firstRetained > effectiveStart) {
645
+ if (!fencedRangeFullyEvicted && firstRetained !== undefined && firstRetained > effectiveStart) {
276
646
  if (!this.historyRebasePrepared) {
277
647
  this.options.clear?.();
278
648
  this.options.onHistoryTruncated?.('history-evicted');
@@ -284,6 +654,13 @@ class PagedTerminalOutputCoordinator {
284
654
  }
285
655
  else {
286
656
  if (snapshotEnd !== undefined && pageSnapshotEnd !== undefined && pageSnapshotEnd !== snapshotEnd) {
657
+ if (preparedHistory !== null) {
658
+ this.preparedHistory = null;
659
+ this.prepareHistoryGenerationRebase(firstRetained);
660
+ this.recoveryRunning = false;
661
+ await this.runRecovery();
662
+ return;
663
+ }
287
664
  throw new HistoryContractError('history_contract_invalid', 'snapshotEndSequence changed during pagination');
288
665
  }
289
666
  }
@@ -300,6 +677,9 @@ class PagedTerminalOutputCoordinator {
300
677
  } while (!controller.signal.aborted);
301
678
  if (!this.isRecoveryCurrent(generation, recoverySerial, controller))
302
679
  return;
680
+ if (this.recoveryEndSequence !== undefined && coveredEnd < this.recoveryEndSequence) {
681
+ throw new HistoryContractError('history_coverage_incomplete', 'terminal history coverage did not reach the recovery fence');
682
+ }
303
683
  if (this.needsRebase) {
304
684
  this.prepareRetainedLiveRebase();
305
685
  this.recoveryRunning = false;
@@ -332,6 +712,7 @@ class PagedTerminalOutputCoordinator {
332
712
  this.retryAttempt = 0;
333
713
  this.lastError = null;
334
714
  this.failure = null;
715
+ this.preparedHistory = null;
335
716
  this.setState('live');
336
717
  this.recoveryRunning = false;
337
718
  this.drainRetainedLive();
@@ -380,8 +761,8 @@ class PagedTerminalOutputCoordinator {
380
761
  for (const item of this.retainedLive) {
381
762
  const sequence = this.chunkSequence(item);
382
763
  if (sequence !== undefined && sequence <= coveredEnd) {
383
- if (!selected.has(sequence))
384
- selected.set(sequence, item);
764
+ // A live copy preserves terminal query/response semantics that history replay suppresses.
765
+ selected.set(sequence, item);
385
766
  }
386
767
  else {
387
768
  remaining.push(item);
@@ -467,6 +848,10 @@ class PagedTerminalOutputCoordinator {
467
848
  if (!removed)
468
849
  break;
469
850
  this.retainedLiveBytes -= removed.data.byteLength;
851
+ const removedSequence = this.chunkSequence(removed);
852
+ if (removedSequence !== undefined) {
853
+ this.droppedLiveThroughSequence = Math.max(this.droppedLiveThroughSequence, removedSequence);
854
+ }
470
855
  this.needsRebase = true;
471
856
  }
472
857
  this.emitState();
@@ -476,6 +861,9 @@ class PagedTerminalOutputCoordinator {
476
861
  this.historyRebasePrepared = false;
477
862
  this.recoveryKind = this.baselineReady ? 'catch-up' : 'initial';
478
863
  this.recoveryStartSequence = 0;
864
+ const firstRetainedSequence = this.firstRetainedLiveSequence();
865
+ this.recoveryEndSequence = Math.max(this.droppedLiveThroughSequence, firstRetainedSequence === undefined ? 0 : firstRetainedSequence - 1) || undefined;
866
+ this.droppedLiveThroughSequence = 0;
479
867
  this.coveredThroughSequence = 0;
480
868
  this.scheduledThroughSequence = 0;
481
869
  this.options.clear?.();
@@ -502,6 +890,14 @@ class PagedTerminalOutputCoordinator {
502
890
  const sequence = this.chunkSequence(chunk);
503
891
  if (sequence !== undefined && sequence <= this.scheduledThroughSequence)
504
892
  return;
893
+ if (sequence !== undefined
894
+ && this.explicitAttachFence
895
+ && this.scheduledThroughSequence === 0
896
+ && sequence > 1) {
897
+ this.retainLive(chunk);
898
+ this.beginCatchUp(1);
899
+ return;
900
+ }
505
901
  if (sequence !== undefined
506
902
  && this.scheduledThroughSequence > 0
507
903
  && sequence > this.scheduledThroughSequence + 1) {