@aragon-agent/cli 0.5.6 → 0.5.9

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 (156) hide show
  1. package/CHANGELOG.md +356 -0
  2. package/README.md +1429 -1105
  3. package/dist/agent/coalesce.d.ts +13 -0
  4. package/dist/agent/coalesce.js +50 -11
  5. package/dist/agent/controller.d.ts +141 -2
  6. package/dist/agent/controller.js +396 -4
  7. package/dist/agent/entry-limits.d.ts +65 -0
  8. package/dist/agent/entry-limits.js +133 -0
  9. package/dist/agent/headless.d.ts +8 -0
  10. package/dist/agent/headless.js +40 -0
  11. package/dist/agent/reducer.d.ts +253 -3
  12. package/dist/agent/reducer.js +553 -28
  13. package/dist/agent/retry-view.d.ts +68 -0
  14. package/dist/agent/retry-view.js +95 -0
  15. package/dist/agent/system-prompt.d.ts +11 -0
  16. package/dist/agent/system-prompt.js +2 -0
  17. package/dist/cli.js +122 -4
  18. package/dist/commands/builtins.d.ts +1 -1
  19. package/dist/commands/builtins.js +313 -2
  20. package/dist/commands/perf.d.ts +45 -0
  21. package/dist/commands/perf.js +78 -0
  22. package/dist/config/cli-commands.d.ts +53 -2
  23. package/dist/config/cli-commands.js +136 -3
  24. package/dist/config/env.d.ts +6 -1
  25. package/dist/config/env.js +131 -1
  26. package/dist/config/load.d.ts +63 -0
  27. package/dist/config/load.js +125 -2
  28. package/dist/config/schema.d.ts +273 -10
  29. package/dist/config/schema.js +250 -7
  30. package/dist/config/store.js +20 -1
  31. package/dist/fast/critique.d.ts +43 -0
  32. package/dist/fast/critique.js +62 -0
  33. package/dist/fast/digest.d.ts +42 -0
  34. package/dist/fast/digest.js +85 -0
  35. package/dist/fast/frames.d.ts +99 -0
  36. package/dist/fast/frames.js +180 -0
  37. package/dist/fast/limits.d.ts +98 -0
  38. package/dist/fast/limits.js +98 -0
  39. package/dist/fast/prompt.d.ts +72 -0
  40. package/dist/fast/prompt.js +94 -0
  41. package/dist/fast/resolve.d.ts +43 -0
  42. package/dist/fast/resolve.js +105 -0
  43. package/dist/fast/review-call.d.ts +99 -0
  44. package/dist/fast/review-call.js +130 -0
  45. package/dist/fast/reviewer.d.ts +248 -0
  46. package/dist/fast/reviewer.js +664 -0
  47. package/dist/fast/types.d.ts +128 -0
  48. package/dist/fast/types.js +14 -0
  49. package/dist/fast/wiring.d.ts +170 -0
  50. package/dist/fast/wiring.js +316 -0
  51. package/dist/logging/install.d.ts +15 -1
  52. package/dist/logging/install.js +37 -4
  53. package/dist/logging/logger.d.ts +1 -1
  54. package/dist/session/persist.js +57 -1
  55. package/dist/team/normalize.d.ts +26 -1
  56. package/dist/team/normalize.js +23 -3
  57. package/dist/team/prompt.d.ts +11 -0
  58. package/dist/team/prompt.js +10 -0
  59. package/dist/team/report.d.ts +18 -0
  60. package/dist/team/report.js +27 -1
  61. package/dist/team/runtime.d.ts +13 -2
  62. package/dist/team/runtime.js +33 -6
  63. package/dist/team/subagent.d.ts +19 -1
  64. package/dist/team/subagent.js +72 -10
  65. package/dist/team/task-tool.d.ts +26 -0
  66. package/dist/team/task-tool.js +48 -4
  67. package/dist/team/types.d.ts +80 -1
  68. package/dist/tools/bash-tool.js +19 -3
  69. package/dist/tools/diff.d.ts +25 -5
  70. package/dist/tools/diff.js +72 -41
  71. package/dist/tools/file-change-store.d.ts +34 -0
  72. package/dist/tools/file-change-store.js +57 -0
  73. package/dist/tools/fs-tools.d.ts +26 -0
  74. package/dist/tools/fs-tools.js +62 -5
  75. package/dist/tools/index.d.ts +31 -0
  76. package/dist/tools/index.js +7 -1
  77. package/dist/tools/patch.d.ts +115 -0
  78. package/dist/tools/patch.js +453 -0
  79. package/dist/tools/terminal-output.d.ts +107 -0
  80. package/dist/tools/terminal-output.js +198 -0
  81. package/dist/tools/tool-output-store.d.ts +68 -0
  82. package/dist/tools/tool-output-store.js +102 -0
  83. package/dist/ui/ActivityLine.d.ts +48 -0
  84. package/dist/ui/ActivityLine.js +22 -0
  85. package/dist/ui/App.d.ts +1 -4
  86. package/dist/ui/App.js +357 -44
  87. package/dist/ui/BottomStatusRow.d.ts +43 -0
  88. package/dist/ui/BottomStatusRow.js +14 -0
  89. package/dist/ui/Composer.d.ts +1 -6
  90. package/dist/ui/Composer.js +2 -2
  91. package/dist/ui/Markdown.d.ts +7 -1
  92. package/dist/ui/Markdown.js +15 -48
  93. package/dist/ui/PromptInput.d.ts +1 -15
  94. package/dist/ui/PromptInput.js +1 -38
  95. package/dist/ui/StatusBar.d.ts +50 -0
  96. package/dist/ui/StatusBar.js +7 -3
  97. package/dist/ui/TeamPanel.d.ts +5 -4
  98. package/dist/ui/TeamPanel.js +1 -1
  99. package/dist/ui/TodoPanel.d.ts +5 -4
  100. package/dist/ui/Transcript.d.ts +80 -5
  101. package/dist/ui/Transcript.js +179 -11
  102. package/dist/ui/activity-phrases.d.ts +33 -0
  103. package/dist/ui/activity-phrases.js +50 -0
  104. package/dist/ui/entries/AssistantEntry.d.ts +38 -1
  105. package/dist/ui/entries/AssistantEntry.js +34 -3
  106. package/dist/ui/entries/DiffView.d.ts +67 -0
  107. package/dist/ui/entries/DiffView.js +103 -0
  108. package/dist/ui/entries/FastCard.d.ts +53 -0
  109. package/dist/ui/entries/FastCard.js +87 -0
  110. package/dist/ui/entries/RetryCard.d.ts +56 -0
  111. package/dist/ui/entries/RetryCard.js +60 -0
  112. package/dist/ui/entries/TeamCard.d.ts +9 -1
  113. package/dist/ui/entries/TeamCard.js +28 -2
  114. package/dist/ui/entries/TodoCard.d.ts +9 -1
  115. package/dist/ui/entries/TodoCard.js +23 -1
  116. package/dist/ui/entries/ToolCard.d.ts +50 -1
  117. package/dist/ui/entries/ToolCard.js +104 -7
  118. package/dist/ui/entries/ToolPreview.d.ts +2 -2
  119. package/dist/ui/entries/UserEntry.d.ts +9 -1
  120. package/dist/ui/entries/UserEntry.js +17 -1
  121. package/dist/ui/exit-snapshot.d.ts +7 -0
  122. package/dist/ui/glyphs.d.ts +9 -0
  123. package/dist/ui/glyphs.js +2 -0
  124. package/dist/ui/layout/AppShell.d.ts +13 -22
  125. package/dist/ui/layout/AppShell.js +3 -38
  126. package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
  127. package/dist/ui/layout/MeasuredEntry.js +30 -0
  128. package/dist/ui/layout/ScrollViewport.js +8 -2
  129. package/dist/ui/layout/viewport-geometry.d.ts +28 -0
  130. package/dist/ui/layout/viewport-geometry.js +27 -0
  131. package/dist/ui/layout/virtual-window.d.ts +100 -0
  132. package/dist/ui/layout/virtual-window.js +308 -0
  133. package/dist/ui/live-clamp.d.ts +42 -0
  134. package/dist/ui/live-clamp.js +74 -0
  135. package/dist/ui/markdown-blocks.d.ts +25 -1
  136. package/dist/ui/markdown-blocks.js +43 -0
  137. package/dist/ui/overlays/HelpOverlay.js +16 -2
  138. package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
  139. package/dist/ui/overlays/SettingsScreen.js +147 -1
  140. package/dist/ui/render-cache.d.ts +81 -0
  141. package/dist/ui/render-cache.js +195 -0
  142. package/dist/ui/render-governor.d.ts +54 -0
  143. package/dist/ui/render-governor.js +78 -0
  144. package/dist/ui/transcript-text.d.ts +11 -0
  145. package/dist/ui/transcript-text.js +32 -0
  146. package/dist/ui/use-height-store.d.ts +31 -0
  147. package/dist/ui/use-height-store.js +76 -0
  148. package/dist/ui/use-render-governor.d.ts +39 -0
  149. package/dist/ui/use-render-governor.js +57 -0
  150. package/dist/ui/use-startup-notices.d.ts +9 -1
  151. package/dist/ui/use-startup-notices.js +35 -1
  152. package/dist/ui/use-wheel-routing.d.ts +16 -32
  153. package/dist/ui/use-wheel-routing.js +21 -38
  154. package/package.json +2 -2
  155. package/dist/ui/layout/regions.d.ts +0 -55
  156. package/dist/ui/layout/regions.js +0 -52
@@ -10,17 +10,32 @@
10
10
  * - `agent_end` after a turn that produced no `turn_end` and no error notice
11
11
  * synthesizes a generic error notice (a swallowed throw is never a no-op).
12
12
  */
13
+ import process from 'node:process';
13
14
  import { computeCost } from './usage.js';
15
+ import { ENTRY_LIMITS, appendBounded, entryRetain, trimEntries } from './entry-limits.js';
14
16
  /** Default auto-dismiss window for a toast. */
15
17
  export const DEFAULT_TOAST_TTL_MS = 2500;
16
- export function initialViewState() {
18
+ /**
19
+ * The initial view state, optionally seeded from config.
20
+ *
21
+ * THE DEFAULT IS `false`, NOT `true`, AND THAT IS THE WHOLE SAFETY ARGUMENT
22
+ * (D-2). There is exactly one production call site — `App`'s `useReducer`, which
23
+ * passes `cfg.showThinking`. Every other caller is a test. A future call site
24
+ * that forgets the seed fails CLOSED, toward the product default, rather than
25
+ * silently restoring the reasoning firehose for one code path with nothing
26
+ * reporting it.
27
+ *
28
+ * The seed is a PARAMETER rather than a post-mount dispatch so that `/resume`
29
+ * cannot flash a restored thinking block on frame 1.
30
+ */
31
+ export function initialViewState(seed = {}) {
17
32
  return {
18
33
  entries: [],
19
34
  status: 'idle',
20
35
  usageTotal: { inputTokens: 0, outputTokens: 0, costUsd: 0 },
21
36
  contextTokens: 0,
22
37
  overlay: null,
23
- thinkingVisible: true,
38
+ thinkingVisible: seed.thinkingVisible ?? false,
24
39
  toasts: [],
25
40
  expandedToolIds: {},
26
41
  // Seeded to the default posture; the App overrides it once on mount from
@@ -29,13 +44,18 @@ export function initialViewState() {
29
44
  pendingAgentMode: null,
30
45
  team: null,
31
46
  todos: null,
47
+ retry: null,
48
+ fast: null,
32
49
  seq: 0,
33
50
  teamEntryId: undefined,
34
51
  todoEntryId: undefined,
52
+ retryEntryId: undefined,
53
+ fastEntryId: undefined,
35
54
  streamingId: undefined,
36
55
  turnProduced: false,
37
56
  errorNoticed: false,
38
57
  aborted: false,
58
+ droppedEntries: 0,
39
59
  };
40
60
  }
41
61
  // ---------------------------------------------------------------------------
@@ -77,12 +97,18 @@ export function buildToolPreview(result) {
77
97
  }
78
98
  return preview;
79
99
  }
100
+ /** The first line of a stored preview, without its trailing newline. */
101
+ function firstLine(text) {
102
+ const at = text.indexOf('\n');
103
+ return at === -1 ? text : text.slice(0, at);
104
+ }
80
105
  /**
81
106
  * Map one core `AgentEvent` to view actions. `cost` (the active model's cost
82
107
  * table) lets `turn_end` accumulate USD cost; omit it in tests that only assert
83
- * token totals.
108
+ * token totals. `patches` is the CLI-local diff side channel (§3.3.7); omitting
109
+ * it — every existing test, and headless mode — produces byte-identical actions.
84
110
  */
85
- export function reduceEvent(event, cost) {
111
+ export function reduceEvent(event, cost, patches) {
86
112
  switch (event.type) {
87
113
  case 'agent_start':
88
114
  return [{ type: 'runStart' }];
@@ -100,16 +126,27 @@ export function reduceEvent(event, cost) {
100
126
  return [{ type: 'runEnd' }];
101
127
  case 'tool_execution_start':
102
128
  return [{ type: 'toolExecStart', toolCallId: event.toolCallId }];
103
- case 'tool_execution_end':
129
+ case 'tool_execution_end': {
130
+ const patch = patches?.take(event.toolCallId);
131
+ const preview = buildToolPreview(event.result);
104
132
  return [
105
133
  {
106
134
  type: 'toolExecEnd',
107
135
  toolCallId: event.toolCallId,
108
136
  isError: event.isError,
109
137
  duration: event.duration,
110
- preview: buildToolPreview(event.result),
138
+ // AN ENTRY CARRIES A PATCH OR A FULL PREVIEW, NEVER BOTH (D-19). This
139
+ // is not a budget compromise, it is deleting a duplicate: `ToolCard`
140
+ // renders `DiffView` INSTEAD of `ToolPreview` when a patch exists, so
141
+ // the stored preview is never drawn on that card — and for `edit_file`
142
+ // it is literally the same diff, as text. Keeping the first line
143
+ // preserves the one thing outside `DiffView` that still reads it,
144
+ // `entryRevision`'s `preview?.length` term.
145
+ preview: patch ? firstLine(preview) : preview,
146
+ ...(patch ? { patch } : {}),
111
147
  },
112
148
  ];
149
+ }
113
150
  case 'message_update':
114
151
  return reduceStreamEvent(event.streamEvent);
115
152
  case 'code_execution_start':
@@ -165,6 +202,23 @@ function reduceStreamEvent(se) {
165
202
  case 'done':
166
203
  // Authoritative usage is redundant with `turn_end`; nothing to add.
167
204
  return [];
205
+ // --- API retry (llm-api-retry-backoff §6.4) ---------------------------
206
+ case 'retry_scheduled':
207
+ return [
208
+ {
209
+ type: 'retryScheduled',
210
+ attempt: se.attempt,
211
+ maxRetries: se.maxRetries,
212
+ delayMs: se.delayMs,
213
+ resumeAt: se.resumeAt,
214
+ errorType: se.errorType,
215
+ message: se.message,
216
+ },
217
+ ];
218
+ case 'retry_attempt':
219
+ return [{ type: 'retryAttempt', attempt: se.attempt, maxRetries: se.maxRetries }];
220
+ case 'stream_restart':
221
+ return [{ type: 'streamRestart', discardedToolCallIds: se.discardedToolCallIds }];
168
222
  default:
169
223
  return [];
170
224
  }
@@ -201,6 +255,131 @@ function nextId(state) {
201
255
  function mapEntry(entries, id, fn) {
202
256
  return entries.map((e) => (e.id === id ? fn(e) : e));
203
257
  }
258
+ /**
259
+ * Seal `thinkingMs`, once, as a spreadable patch (§3.1.2).
260
+ *
261
+ * Returns an EMPTY object when there is nothing to seal — no reasoning happened,
262
+ * or the clock has already stopped — so the three call sites can spread it
263
+ * unconditionally and none of them can re-seal a settled duration.
264
+ */
265
+ function sealThinkingMs(entry) {
266
+ if (entry.thinkingStartedAt === undefined || entry.thinkingMs !== undefined)
267
+ return {};
268
+ return { thinkingMs: Math.max(0, Date.now() - entry.thinkingStartedAt) };
269
+ }
270
+ /**
271
+ * Append one entry and ring-trim the result (tui-render-performance L1).
272
+ *
273
+ * TRIMMING HAPPENS ONLY AT APPEND POINTS, and that is the whole reason this is a
274
+ * helper rather than a line in `viewReducer`. A mid-turn trim triggered by an
275
+ * UPDATE action could drop the entry `streamingId` / `teamEntryId` /
276
+ * `todoEntryId` points at while it is still being written to; appending is the
277
+ * only moment at which the tail is by definition the newest thing.
278
+ *
279
+ * I-L1-1 — the trim must never drop a live target. With `retain >= 200` and a
280
+ * trim that only ever removes from the head this is structurally true, but a
281
+ * future clamp change would break it SILENTLY (the `mapEntry` calls would simply
282
+ * stop matching), so development builds assert it.
283
+ */
284
+ function appendEntry(state, entries, entry) {
285
+ const trimmed = trimEntries([...entries, entry], entryRetain());
286
+ if (trimmed.dropped > 0 && process.env.NODE_ENV !== 'production') {
287
+ // `retryEntryId` IS PART OF THIS LIST. A fourth live card that is not in it is
288
+ // a fourth card I-L1-1 does not cover — the trim would silently turn its
289
+ // `mapEntry` into a no-op instead of failing loudly.
290
+ const live = [
291
+ state.streamingId,
292
+ state.teamEntryId,
293
+ state.todoEntryId,
294
+ state.retryEntryId,
295
+ // `fastEntryId` IS PART OF THIS LIST for the reason the note above gives:
296
+ // a fifth live card left out of it is a card I-L1-1 does not cover.
297
+ state.fastEntryId,
298
+ ].filter((id) => typeof id === 'string');
299
+ for (const id of live) {
300
+ if (!trimmed.entries.some((e) => e.id === id)) {
301
+ throw new Error(`trimEntries dropped a live entry (${id}) - see I-L1-1`);
302
+ }
303
+ }
304
+ }
305
+ return {
306
+ entries: trimmed.entries,
307
+ droppedEntries: state.droppedEntries + trimmed.dropped,
308
+ };
309
+ }
310
+ /**
311
+ * Settle the turn's retry card, if it has one (llm-api-retry-backoff §6.4).
312
+ *
313
+ * SETTLING IS DERIVED HERE, NOT SENT BY CORE. Core knows it scheduled a retry; it
314
+ * does not know whether the turn eventually succeeded, was interrupted, or ran out
315
+ * of budget — that is the consumer's own timeline.
316
+ *
317
+ * FOUR PATHS REACH THIS, and the fourth is what makes the set exhaustive rather
318
+ * than enumerated: `turnEnd` (recovered), an error `notice` (exhausted),
319
+ * `abortMark` (interrupted) and `runEnd` (interrupted). The rule is "every path
320
+ * that reaches `status: 'idle'` settles the card", because a card left at
321
+ * `phase: 'waiting'` pins `Transcript`'s MONOTONIC settled boundary and re-renders
322
+ * the tail every frame for the rest of the session (R-10).
323
+ *
324
+ * Returning `retry: null` / `retryEntryId: undefined` unconditionally is
325
+ * deliberate: both are already that when there is no card, so every caller can
326
+ * spread the result without a branch of its own.
327
+ */
328
+ function settleRetryCard(state, entries, phase) {
329
+ if (!state.retryEntryId)
330
+ return { entries, retry: null, retryEntryId: undefined };
331
+ const now = Date.now();
332
+ return {
333
+ entries: mapEntry(entries, state.retryEntryId, (e) => e.kind === 'retry'
334
+ ? {
335
+ ...e,
336
+ phase,
337
+ totalRetries: e.attempt,
338
+ elapsedMs: Math.max(0, now - e.startedAt),
339
+ resumeAt: undefined,
340
+ }
341
+ : e),
342
+ retry: null,
343
+ retryEntryId: undefined,
344
+ };
345
+ }
346
+ /**
347
+ * EVERY PATH THAT REACHES `status: 'idle'` RELEASES THE LIVE TAIL (D-35 / AC-40).
348
+ *
349
+ * `runEnd` finalizes only the streaming ASSISTANT entry and `abortMark` maps only
350
+ * assistant entries, so neither touches a tool entry stranded at
351
+ * `status: 'running'` -- the engine's own idle watchdog, a provider error mid-call
352
+ * and `Esc` all produce one. Today that costs a single spinner row. With a tail
353
+ * attached it costs a permanent multi-row card whose stall counter is frozen at
354
+ * its last value, because the 200 ms ticker that feeds `nowSec` is torn down the
355
+ * instant `state.status` leaves `running` -- and, worse, `computeSettledCount`
356
+ * breaks on a non-settled tool entry, so that card pins `Transcript`'s MONOTONIC
357
+ * settled boundary and re-renders the tail every frame for the rest of the
358
+ * session. The rule and the stakes are `settleRetryCard`'s, verbatim.
359
+ *
360
+ * THE STATUS IS DELIBERATELY NOT CHANGED. That is a different decision with
361
+ * different consequences for `bashBadge` and for the run's own bookkeeping, and
362
+ * it is not this round's to make. Only the tail is released.
363
+ *
364
+ * `liveSeq` is BUMPED rather than cleared, and that is not bookkeeping: clearing
365
+ * `live` is a non-append mutation, and with the status untouched nothing else in
366
+ * the tool revision moves -- so without the bump the height cache would keep
367
+ * serving the multi-row measurement for a card that now draws one row (I-L3-1).
368
+ * Entries with no tail are returned by identity, which is what keeps
369
+ * `EntryView`'s memo boundary intact for the rest of the transcript.
370
+ */
371
+ function releaseLiveTails(entries) {
372
+ let touched = false;
373
+ const next = entries.map((e) => {
374
+ if (e.kind !== 'tool' || !e.live)
375
+ return e;
376
+ if (e.status !== 'running' && e.status !== 'pending')
377
+ return e;
378
+ touched = true;
379
+ return { ...e, live: undefined, lastOutputAt: undefined, liveSeq: (e.liveSeq ?? 0) + 1 };
380
+ });
381
+ return touched ? next : entries;
382
+ }
204
383
  function findToolEntryId(state, toolCallId) {
205
384
  for (let i = state.entries.length - 1; i >= 0; i -= 1) {
206
385
  const e = state.entries[i];
@@ -217,12 +396,16 @@ export function viewReducer(state, action) {
217
396
  return {
218
397
  ...state,
219
398
  seq,
220
- entries: [...state.entries, entry],
399
+ ...appendEntry(state, state.entries, entry),
221
400
  streamingId: undefined,
222
401
  // A new turn gets its own card, in that turn's position in the
223
402
  // transcript. The PREVIOUS card stays exactly where it is — it is
224
403
  // history, and history is not retracted.
225
404
  todoEntryId: undefined,
405
+ // Same rule for the retry card. `runStart` clears NONE of the three (it
406
+ // never has), and `runEnd`'s settle rule below is what makes that safe
407
+ // rather than merely lucky.
408
+ retryEntryId: undefined,
226
409
  turnProduced: false,
227
410
  errorNoticed: false,
228
411
  aborted: false,
@@ -246,14 +429,23 @@ export function viewReducer(state, action) {
246
429
  thinkingOpen: false,
247
430
  streaming: true,
248
431
  };
249
- return { ...state, seq, entries: [...state.entries, entry], streamingId: id };
432
+ return { ...state, seq, ...appendEntry(state, state.entries, entry), streamingId: id };
250
433
  }
251
434
  case 'thinkingStart': {
252
435
  if (!state.streamingId)
253
436
  return state;
254
437
  return {
255
438
  ...state,
256
- entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, thinking: e.thinking ?? '', thinkingOpen: true } : e),
439
+ entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
440
+ ? {
441
+ ...e,
442
+ thinking: e.thinking ?? '',
443
+ thinkingOpen: true,
444
+ // `Date.now()` in the reducer is not a new impurity class: the
445
+ // retry card's `startedAt` already reads the clock here.
446
+ thinkingStartedAt: e.thinkingStartedAt ?? Date.now(),
447
+ }
448
+ : e),
257
449
  };
258
450
  }
259
451
  case 'thinkingDelta': {
@@ -262,7 +454,7 @@ export function viewReducer(state, action) {
262
454
  return {
263
455
  ...state,
264
456
  entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
265
- ? { ...e, thinking: (e.thinking ?? '') + action.delta }
457
+ ? { ...e, thinking: appendBounded(e.thinking ?? '', action.delta, ENTRY_LIMITS.thinking) }
266
458
  : e),
267
459
  };
268
460
  }
@@ -271,7 +463,16 @@ export function viewReducer(state, action) {
271
463
  return state;
272
464
  return {
273
465
  ...state,
274
- entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, text: e.text + action.delta } : e),
466
+ entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
467
+ ? {
468
+ ...e,
469
+ text: appendBounded(e.text, action.delta, ENTRY_LIMITS.text),
470
+ // FIRST TEXT DELTA ONLY. Thinking is over the moment the answer
471
+ // starts; re-sealing on every delta would make the number climb
472
+ // with the answer's length.
473
+ ...sealThinkingMs(e),
474
+ }
475
+ : e),
275
476
  };
276
477
  }
277
478
  case 'toolCallStart': {
@@ -285,7 +486,7 @@ export function viewReducer(state, action) {
285
486
  argsRaw: '',
286
487
  status: 'pending',
287
488
  };
288
- return { ...state, seq, entries: [...state.entries, entry] };
489
+ return { ...state, seq, ...appendEntry(state, state.entries, entry) };
289
490
  }
290
491
  case 'toolCallDelta': {
291
492
  const id = findToolEntryId(state, action.toolCallId);
@@ -293,7 +494,9 @@ export function viewReducer(state, action) {
293
494
  return state;
294
495
  return {
295
496
  ...state,
296
- entries: mapEntry(state.entries, id, (e) => e.kind === 'tool' ? { ...e, argsRaw: e.argsRaw + action.argsDelta } : e),
497
+ entries: mapEntry(state.entries, id, (e) => e.kind === 'tool'
498
+ ? { ...e, argsRaw: appendBounded(e.argsRaw, action.argsDelta, ENTRY_LIMITS.argsRaw) }
499
+ : e),
297
500
  };
298
501
  }
299
502
  case 'toolCallEnd': {
@@ -316,6 +519,28 @@ export function viewReducer(state, action) {
316
519
  entries: mapEntry(state.entries, id, (e) => e.kind === 'tool' ? { ...e, status: 'running' } : e),
317
520
  };
318
521
  }
522
+ /**
523
+ * THE STATUS GUARD IS WHAT KEEPS A LATE CHUNK FROM RESURRECTING A SETTLED
524
+ * CARD. The child's last write and its `close` event race, so a chunk can
525
+ * legitimately arrive after `tool_execution_end`; without the guard it would
526
+ * reattach a tail to a card whose authoritative preview is already drawn.
527
+ */
528
+ case 'toolOutputDelta': {
529
+ const id = findToolEntryId(state, action.toolCallId);
530
+ if (!id)
531
+ return state;
532
+ return {
533
+ ...state,
534
+ entries: mapEntry(state.entries, id, (e) => e.kind === 'tool' && (e.status === 'running' || e.status === 'pending')
535
+ ? {
536
+ ...e,
537
+ live: action.rows,
538
+ liveSeq: (e.liveSeq ?? 0) + 1,
539
+ lastOutputAt: action.at,
540
+ }
541
+ : e),
542
+ };
543
+ }
319
544
  case 'toolExecEnd': {
320
545
  const id = findToolEntryId(state, action.toolCallId);
321
546
  if (!id)
@@ -329,6 +554,13 @@ export function viewReducer(state, action) {
329
554
  isError: action.isError,
330
555
  durationMs: action.duration,
331
556
  preview: action.preview,
557
+ ...(action.patch ? { patch: action.patch } : {}),
558
+ // THE TAIL IS NEVER MERGED INTO `preview` (D-23): the
559
+ // authoritative result already contains everything it held, in
560
+ // full, and concatenating them would double the output and break
561
+ // `bashBadge`'s last-line parse (`ToolPreview.tsx:35-54`).
562
+ live: undefined,
563
+ lastOutputAt: undefined,
332
564
  }
333
565
  : e),
334
566
  };
@@ -336,12 +568,20 @@ export function viewReducer(state, action) {
336
568
  case 'turnEnd': {
337
569
  const entries = state.streamingId
338
570
  ? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
339
- ? { ...e, streaming: false, thinkingOpen: false, usage: action.usage }
571
+ ? {
572
+ ...e,
573
+ streaming: false,
574
+ thinkingOpen: false,
575
+ usage: action.usage,
576
+ // The turn that thought and then called a tool without emitting
577
+ // any text never saw a `textDelta`, so this is where its clock
578
+ // is sealed.
579
+ ...sealThinkingMs(e),
580
+ }
340
581
  : e)
341
582
  : state.entries;
342
583
  return {
343
584
  ...state,
344
- entries,
345
585
  streamingId: undefined,
346
586
  turnProduced: true,
347
587
  contextTokens: action.usage.inputTokens + action.usage.outputTokens,
@@ -350,50 +590,91 @@ export function viewReducer(state, action) {
350
590
  outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
351
591
  costUsd: state.usageTotal.costUsd + action.costDelta,
352
592
  },
593
+ // The turn produced an answer, so whatever it had to retry, it recovered.
594
+ ...settleRetryCard(state, entries, 'recovered'),
353
595
  };
354
596
  }
355
597
  case 'runEnd': {
356
598
  // Finalize any still-streaming entry.
357
599
  let entries = state.streamingId
358
- ? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false } : e)
600
+ ? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false, ...sealThinkingMs(e) } : e)
359
601
  : state.entries;
360
602
  let seq = state.seq;
603
+ let droppedEntries = state.droppedEntries;
361
604
  // Silent-failure guard (R2): a swallowed throw shows nothing otherwise.
362
605
  // A user abort is an expected empty run, not a failure — skip the guard.
363
606
  if (!state.turnProduced && !state.errorNoticed && !state.aborted) {
364
607
  const next = nextId({ ...state, seq });
365
608
  seq = next.seq;
366
- entries = [
367
- ...entries,
368
- { id: next.id, kind: 'notice', level: 'error', text: GENERIC_RUN_FAILURE },
369
- ];
609
+ const appended = appendEntry(state, entries, {
610
+ id: next.id,
611
+ kind: 'notice',
612
+ level: 'error',
613
+ text: GENERIC_RUN_FAILURE,
614
+ });
615
+ entries = appended.entries;
616
+ droppedEntries = appended.droppedEntries;
370
617
  }
371
618
  // The card may now reach `<Static>` (C-5 / I-6): the turn that owns it is
372
619
  // over, so nothing will rewrite it.
373
620
  if (state.todoEntryId) {
374
621
  entries = mapEntry(entries, state.todoEntryId, (e) => e.kind === 'todo' ? { ...e, live: false } : e);
375
622
  }
376
- return { ...state, entries, seq, status: 'idle', streamingId: undefined };
623
+ return {
624
+ ...state,
625
+ seq,
626
+ droppedEntries,
627
+ status: 'idle',
628
+ streamingId: undefined,
629
+ // THE TERMINAL BACKSTOP, and it is not redundant with `abortMark`
630
+ // (llm-api-retry-backoff P1-8 / AC-25b). `abortMark` is dispatched only
631
+ // from the Esc handler, but the ENGINE also aborts itself: the idle
632
+ // watchdog calls `Agent.abort()` directly, the run unwinds, and the UI
633
+ // sees `agent_end` -> `runEnd` with no `abortMark`, no `turn_end` and no
634
+ // error notice. A card left at `phase: 'waiting'` then pins
635
+ // `Transcript`'s monotonic boundary and re-renders the tail every frame
636
+ // for the rest of the session. By the time this runs the three other
637
+ // settle paths have already cleared `retryEntryId`, so this only fires on
638
+ // a genuinely unsettled card.
639
+ //
640
+ // `releaseLiveTails` rides the same argument one entry kind over (D-35):
641
+ // a tool card stranded at `running` by this very path would otherwise
642
+ // keep a tail and a frozen stall clock for the rest of the session.
643
+ ...settleRetryCard(state, releaseLiveTails(entries), 'interrupted'),
644
+ };
377
645
  }
378
646
  case 'notice': {
379
647
  const { id, seq } = nextId(state);
380
648
  const entry = { id, kind: 'notice', level: action.level, text: action.text };
649
+ const appended = appendEntry(state, state.entries, entry);
650
+ // A terminal error notice is what an EXHAUSTED ladder looks like from here:
651
+ // the retry wrapper forwards the provider's own error once it stops
652
+ // retrying, and that error always precedes any `turn_end` because a failed
653
+ // turn has none. A non-error notice leaves the card alone.
654
+ const settled = action.level === 'error'
655
+ ? settleRetryCard(state, appended.entries, 'exhausted')
656
+ : { entries: appended.entries };
381
657
  return {
382
658
  ...state,
383
659
  seq,
384
- entries: [...state.entries, entry],
660
+ droppedEntries: appended.droppedEntries,
385
661
  errorNoticed: action.level === 'error' ? true : state.errorNoticed,
662
+ ...settled,
386
663
  };
387
664
  }
388
665
  case 'abortMark': {
389
666
  // Mark the run aborted even when no assistant entry is streaming (e.g. an
390
667
  // abort during a tool call) so the runEnd failure guard stays suppressed.
391
- if (!state.streamingId)
392
- return { ...state, aborted: true };
668
+ const marked = state.streamingId
669
+ ? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false, aborted: true } : e)
670
+ : state.entries;
393
671
  return {
394
672
  ...state,
395
673
  aborted: true,
396
- entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false, aborted: true } : e),
674
+ // An abort during a tool call is the COMMON way a tail is stranded --
675
+ // `Esc` while `npm test` runs -- so this is the clause manual row 9
676
+ // exercises (D-35 / AC-40).
677
+ ...settleRetryCard(state, releaseLiveTails(marked), 'interrupted'),
397
678
  };
398
679
  }
399
680
  // `teamEntryId` is dropped with the entries it pointed at. Keeping it would
@@ -403,6 +684,11 @@ export function viewReducer(state, action) {
403
684
  return {
404
685
  ...state,
405
686
  entries: [],
687
+ // The count describes what the RETAIN RING removed from the visible
688
+ // transcript. `/clear` empties it by intent, so carrying the number
689
+ // over would make the exported transcript claim a loss that did not
690
+ // happen.
691
+ droppedEntries: 0,
406
692
  streamingId: undefined,
407
693
  expandedToolIds: {},
408
694
  teamEntryId: undefined,
@@ -411,11 +697,18 @@ export function viewReducer(state, action) {
411
697
  // the conversation, so the model still believes in the plan, and a panel
412
698
  // that disagrees with the model is worse than no panel.
413
699
  todoEntryId: undefined,
700
+ // Same reasoning for the retry card: the entry is gone, so the id must go
701
+ // with it or `retryScheduled` would `mapEntry` an id that no longer
702
+ // exists. `retry` goes too — unlike `todos` it is not a belief the model
703
+ // holds, it is a projection of an entry that has just been erased.
704
+ retryEntryId: undefined,
705
+ retry: null,
414
706
  };
415
707
  case 'resetConversation':
416
708
  return {
417
709
  ...state,
418
710
  entries: [],
711
+ droppedEntries: 0,
419
712
  streamingId: undefined,
420
713
  expandedToolIds: {},
421
714
  contextTokens: 0,
@@ -426,6 +719,8 @@ export function viewReducer(state, action) {
426
719
  // the STORE; this clears the mirror.
427
720
  todos: null,
428
721
  todoEntryId: undefined,
722
+ retryEntryId: undefined,
723
+ retry: null,
429
724
  };
430
725
  case 'setOverlay':
431
726
  return { ...state, overlay: action.overlay };
@@ -462,6 +757,10 @@ export function viewReducer(state, action) {
462
757
  const runs = action.specs.map((spec) => ({
463
758
  label: spec.label,
464
759
  description: spec.description,
760
+ // Already downgraded by the normalizer when the tier was unavailable, so
761
+ // the card and the report agree from the first frame (fast-model-tier
762
+ // §3.4).
763
+ tier: spec.tier,
465
764
  phase: 'queued',
466
765
  turns: 0,
467
766
  toolCalls: 0,
@@ -481,7 +780,7 @@ export function viewReducer(state, action) {
481
780
  return {
482
781
  ...state,
483
782
  seq,
484
- entries: [...state.entries, entry],
783
+ ...appendEntry(state, state.entries, entry),
485
784
  teamEntryId: id,
486
785
  team: {
487
786
  dispatchId: action.dispatchId,
@@ -535,6 +834,12 @@ export function viewReducer(state, action) {
535
834
  }
536
835
  // --- Todo planning (todo-plan-execution §5.2) -------------------------
537
836
  case 'todoUpdate': {
837
+ // TYPED AS THE TODO MEMBER, NOT AS `Entry`. The rewrite below spreads this
838
+ // and overrides `live: boolean`; while the return type was the whole union
839
+ // that spread also produced a TOOL entry carrying `live: boolean`, which
840
+ // stopped type-checking the moment the tool member gained its own `live`
841
+ // (agent-activity-presentation-live). Narrowing here is what keeps the
842
+ // override honest rather than accidentally legal.
538
843
  const entry = (id) => ({
539
844
  id,
540
845
  kind: 'todo',
@@ -558,7 +863,7 @@ export function viewReducer(state, action) {
558
863
  ...state,
559
864
  seq,
560
865
  todos: action.snapshot,
561
- entries: [...state.entries, entry(id)],
866
+ ...appendEntry(state, state.entries, entry(id)),
562
867
  todoEntryId: id,
563
868
  };
564
869
  }
@@ -566,21 +871,241 @@ export function viewReducer(state, action) {
566
871
  // live projection goes away, which is what unmounts the rail.
567
872
  case 'todoCleared':
568
873
  return { ...state, todos: null, todoEntryId: undefined };
874
+ // --- Fast model tier (fast-model-tier §5.4) ---------------------------
875
+ case 'fastStart': {
876
+ const { id, seq } = nextId(state);
877
+ const entry = {
878
+ id,
879
+ kind: 'fast',
880
+ reviewIndex: action.index,
881
+ model: action.model,
882
+ status: 'running',
883
+ turn: action.turn,
884
+ live: true,
885
+ };
886
+ return {
887
+ ...state,
888
+ seq,
889
+ ...appendEntry(state, state.entries, entry),
890
+ fastEntryId: id,
891
+ };
892
+ }
893
+ case 'fastEnd': {
894
+ const { review } = action;
895
+ const settle = (e) => e.kind === 'fast'
896
+ ? {
897
+ ...e,
898
+ status: review.kind,
899
+ ...(review.text !== undefined ? { text: review.text } : {}),
900
+ ...(review.detail !== undefined ? { detail: review.detail } : {}),
901
+ durationMs: review.durationMs,
902
+ // SETTLED. `Transcript`'s boundary is monotonic, so a card that
903
+ // never settles is re-rendered on every frame for the rest of the
904
+ // session (C-5).
905
+ live: false,
906
+ }
907
+ : e;
908
+ // Rewrite the card this review opened when there is one; otherwise append
909
+ // a settled card. THE SECOND BRANCH IS NOT DEFENSIVE PADDING: a review
910
+ // that is dropped as `stranded` is emitted from `agent_end` and never had
911
+ // a `review_start`, and a run restored from `/resume` has no live id at
912
+ // all — "it did nothing" must never be the observable outcome (R-8).
913
+ if (state.fastEntryId) {
914
+ return {
915
+ ...state,
916
+ entries: mapEntry(state.entries, state.fastEntryId, settle),
917
+ fastEntryId: undefined,
918
+ };
919
+ }
920
+ const { id, seq } = nextId(state);
921
+ const entry = {
922
+ id,
923
+ kind: 'fast',
924
+ reviewIndex: review.index,
925
+ model: review.model,
926
+ status: review.kind,
927
+ ...(review.text !== undefined ? { text: review.text } : {}),
928
+ ...(review.detail !== undefined ? { detail: review.detail } : {}),
929
+ turn: review.turn,
930
+ durationMs: review.durationMs,
931
+ live: false,
932
+ };
933
+ return { ...state, seq, ...appendEntry(state, state.entries, entry) };
934
+ }
935
+ case 'fastUsage':
936
+ // `usageTotal` AND NOTHING ELSE, exactly as `teamUsage` (§3.6 / AC-18). In
937
+ // particular NOT `contextTokens`: that gauge shows the LEAD's context
938
+ // occupancy against the lead model's window, and a review is a separate
939
+ // conversation with a different model entirely.
940
+ return {
941
+ ...state,
942
+ usageTotal: {
943
+ inputTokens: state.usageTotal.inputTokens + action.usage.inputTokens,
944
+ outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
945
+ costUsd: state.usageTotal.costUsd + action.costDelta,
946
+ },
947
+ };
948
+ case 'fastTier':
949
+ return { ...state, fast: action.snapshot };
950
+ // --- API retry (llm-api-retry-backoff §6.4) ---------------------------
951
+ case 'retryScheduled': {
952
+ const snapshot = {
953
+ attempt: action.attempt,
954
+ maxRetries: action.maxRetries,
955
+ resumeAt: action.resumeAt,
956
+ phase: 'waiting',
957
+ errorType: action.errorType,
958
+ };
959
+ // ONE CARD PER TURN, REWRITTEN IN PLACE. One `mapEntry` on the KNOWN id,
960
+ // never a scan — the same discipline `todoUpdate` follows.
961
+ if (state.retryEntryId) {
962
+ const known = state.retryEntryId;
963
+ return {
964
+ ...state,
965
+ retry: snapshot,
966
+ entries: mapEntry(state.entries, known, (e) => e.kind === 'retry'
967
+ ? {
968
+ ...e,
969
+ attempt: action.attempt,
970
+ maxRetries: action.maxRetries,
971
+ errorType: action.errorType,
972
+ message: action.message,
973
+ delayMs: action.delayMs,
974
+ resumeAt: action.resumeAt,
975
+ phase: 'waiting',
976
+ }
977
+ : e),
978
+ };
979
+ }
980
+ const { id, seq } = nextId(state);
981
+ const entry = {
982
+ id,
983
+ kind: 'retry',
984
+ attempt: action.attempt,
985
+ maxRetries: action.maxRetries,
986
+ errorType: action.errorType,
987
+ message: action.message,
988
+ delayMs: action.delayMs,
989
+ resumeAt: action.resumeAt,
990
+ phase: 'waiting',
991
+ // The clock the settled card's `elapsedMs` is measured from: the FIRST
992
+ // retry of the turn, not the last.
993
+ startedAt: Date.now(),
994
+ };
995
+ return {
996
+ ...state,
997
+ seq,
998
+ retry: snapshot,
999
+ ...appendEntry(state, state.entries, entry),
1000
+ retryEntryId: id,
1001
+ };
1002
+ }
1003
+ case 'retryAttempt': {
1004
+ if (!state.retryEntryId)
1005
+ return state;
1006
+ const known = state.retryEntryId;
1007
+ return {
1008
+ ...state,
1009
+ // `resumeAt` is CLEARED: the wait is over, so a countdown would tick
1010
+ // downward past zero while the request is already in flight.
1011
+ retry: state.retry ? { ...state.retry, phase: 'retrying', resumeAt: undefined } : null,
1012
+ entries: mapEntry(state.entries, known, (e) => e.kind === 'retry' ? { ...e, phase: 'retrying', resumeAt: undefined } : e),
1013
+ };
1014
+ }
1015
+ /**
1016
+ * Rewind the view for a mid-stream restart.
1017
+ *
1018
+ * THE ONLY ACTION IN THIS REDUCER THAT SHRINKS `entries`, and it is safe for
1019
+ * one specific reason (§6.4 / AC-22). `Transcript` clamps its settled boundary
1020
+ * with a MONOTONIC high-water mark: if `highWater` ever exceeded
1021
+ * `entries.length` the clamp would mark EVERYTHING settled — including the
1022
+ * still-streaming assistant entry — and `<Static>` would duplicate it.
1023
+ *
1024
+ * It cannot, and the ORDERING is why: the entries removed here are
1025
+ * `kind: 'tool'` cards created by `toolCallStart` AFTER `turnStart` created the
1026
+ * streaming assistant entry, so they sit strictly above it in the array; and
1027
+ * the boundary scan already breaks at that entry. Removing only entries above
1028
+ * the boundary is the invariant. A future action that removed a SETTLED entry
1029
+ * would break `<Static>` and nothing would report it.
1030
+ */
1031
+ case 'streamRestart': {
1032
+ const discarded = new Set(action.discardedToolCallIds);
1033
+ const entries = state.entries.filter((e) => !(e.kind === 'tool' && discarded.has(e.toolCallId)));
1034
+ return {
1035
+ ...state,
1036
+ entries: state.streamingId
1037
+ ? mapEntry(entries, state.streamingId, (e) => e.kind === 'assistant'
1038
+ ? {
1039
+ ...e,
1040
+ text: '',
1041
+ thinking: undefined,
1042
+ thinkingOpen: false,
1043
+ // A restarted stream re-thinks from scratch. Carrying the old
1044
+ // clock forward would report a duration for reasoning that
1045
+ // was discarded.
1046
+ thinkingStartedAt: undefined,
1047
+ thinkingMs: undefined,
1048
+ }
1049
+ : e)
1050
+ : entries,
1051
+ };
1052
+ }
1053
+ /**
1054
+ * The 1 Hz countdown tick. NO VALUE CHANGES — the card is refreshed by
1055
+ * IDENTITY so that React re-renders exactly one entry.
1056
+ *
1057
+ * A FLAT NONCE ON `ViewState` WOULD NOT WORK, and this is the trap worth
1058
+ * naming: `EntryView` is `React.memo`'d with an explicit comparator whose
1059
+ * first clause is `a.entry === b.entry`, so a state field the card does not
1060
+ * receive as a prop changes nothing at all — the countdown would freeze at
1061
+ * whatever second it was first drawn on, and the transcript would keep
1062
+ * re-rendering around it for no benefit. Rewriting the entry is what breaks
1063
+ * that boundary, and `EntryView` reads the clock for this kind only.
1064
+ *
1065
+ * `entryRevision` is deliberately NOT extended: the card is one row in every
1066
+ * phase, so the cached height stays correct and no re-measure is wanted.
1067
+ *
1068
+ * Returning `state` unchanged when nothing is waiting is what keeps a stray
1069
+ * tick from costing a render at all.
1070
+ */
1071
+ case 'retryTick': {
1072
+ if (state.retry?.phase !== 'waiting' || !state.retryEntryId)
1073
+ return state;
1074
+ const known = state.retryEntryId;
1075
+ return {
1076
+ ...state,
1077
+ entries: mapEntry(state.entries, known, (e) => (e.kind === 'retry' ? { ...e } : e)),
1078
+ };
1079
+ }
569
1080
  case 'restoreEntries': {
570
1081
  // Adopt a seq beyond any restored id to keep future ids unique.
571
1082
  const maxSeq = action.entries.reduce((acc, e) => {
572
1083
  const n = Number.parseInt(e.id.replace(/^e/, ''), 10);
573
1084
  return Number.isFinite(n) ? Math.max(acc, n) : acc;
574
1085
  }, state.seq);
1086
+ // A session file can be longer than the retain ring; trimming it here is
1087
+ // the one append-free place the bound still has to hold, and the count
1088
+ // starts from this restore rather than inheriting the previous session's.
1089
+ const restored = trimEntries([...action.entries], entryRetain());
575
1090
  return {
576
1091
  ...state,
577
- entries: [...action.entries],
1092
+ entries: restored.entries,
1093
+ droppedEntries: restored.dropped,
578
1094
  seq: maxSeq,
579
1095
  teamEntryId: undefined,
580
1096
  // As for `team`: the restored cards are history and no turn owns them.
581
1097
  // The LIST itself arrives separately, through `controller.restoreTodos`
582
1098
  // and the store's own event (§3.13).
583
1099
  todoEntryId: undefined,
1100
+ // Same for the retry card. `normalizeLoadedEntries` has already forced any
1101
+ // in-flight phase to `interrupted`, so nothing restored is live.
1102
+ retryEntryId: undefined,
1103
+ retry: null,
1104
+ // Same again for the fast card: `normalizeLoadedEntries` has already
1105
+ // forced `live: false`, so nothing restored is live. `state.fast` is
1106
+ // NOT cleared — it is the session's own tier projection, which a
1107
+ // `/resume` does not change.
1108
+ fastEntryId: undefined,
584
1109
  };
585
1110
  }
586
1111
  default: