@aragon-agent/cli 0.5.8 → 0.5.10

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 +1157 -747
  2. package/README.md +1552 -1163
  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/headless.d.ts +8 -0
  8. package/dist/agent/headless.js +40 -0
  9. package/dist/agent/reducer.d.ts +245 -3
  10. package/dist/agent/reducer.js +490 -17
  11. package/dist/agent/retry-view.d.ts +68 -0
  12. package/dist/agent/retry-view.js +95 -0
  13. package/dist/agent/system-prompt.d.ts +11 -0
  14. package/dist/agent/system-prompt.js +2 -0
  15. package/dist/boot/guard.d.ts +135 -0
  16. package/dist/boot/guard.js +218 -0
  17. package/dist/boot/rollback.d.ts +86 -0
  18. package/dist/boot/rollback.js +185 -0
  19. package/dist/cli.js +368 -3
  20. package/dist/commands/builtins.d.ts +1 -1
  21. package/dist/commands/builtins.js +426 -2
  22. package/dist/commands/registry.d.ts +17 -0
  23. package/dist/config/app-paths.d.ts +31 -0
  24. package/dist/config/app-paths.js +37 -0
  25. package/dist/config/cli-commands.d.ts +79 -2
  26. package/dist/config/cli-commands.js +185 -3
  27. package/dist/config/env.d.ts +15 -3
  28. package/dist/config/env.js +143 -3
  29. package/dist/config/load.d.ts +66 -0
  30. package/dist/config/load.js +107 -1
  31. package/dist/config/schema.d.ts +268 -2
  32. package/dist/config/schema.js +288 -1
  33. package/dist/config/store.js +32 -1
  34. package/dist/fast/critique.d.ts +43 -0
  35. package/dist/fast/critique.js +62 -0
  36. package/dist/fast/digest.d.ts +42 -0
  37. package/dist/fast/digest.js +85 -0
  38. package/dist/fast/frames.d.ts +99 -0
  39. package/dist/fast/frames.js +180 -0
  40. package/dist/fast/limits.d.ts +98 -0
  41. package/dist/fast/limits.js +98 -0
  42. package/dist/fast/prompt.d.ts +72 -0
  43. package/dist/fast/prompt.js +94 -0
  44. package/dist/fast/resolve.d.ts +43 -0
  45. package/dist/fast/resolve.js +105 -0
  46. package/dist/fast/review-call.d.ts +99 -0
  47. package/dist/fast/review-call.js +130 -0
  48. package/dist/fast/reviewer.d.ts +248 -0
  49. package/dist/fast/reviewer.js +664 -0
  50. package/dist/fast/types.d.ts +128 -0
  51. package/dist/fast/types.js +14 -0
  52. package/dist/fast/wiring.d.ts +170 -0
  53. package/dist/fast/wiring.js +316 -0
  54. package/dist/launcher.d.ts +14 -0
  55. package/dist/launcher.js +43 -0
  56. package/dist/logging/install.d.ts +15 -1
  57. package/dist/logging/install.js +37 -4
  58. package/dist/logging/logger.d.ts +1 -1
  59. package/dist/session/persist.js +57 -1
  60. package/dist/team/normalize.d.ts +26 -1
  61. package/dist/team/normalize.js +23 -3
  62. package/dist/team/prompt.d.ts +11 -0
  63. package/dist/team/prompt.js +10 -0
  64. package/dist/team/report.d.ts +18 -0
  65. package/dist/team/report.js +27 -1
  66. package/dist/team/runtime.d.ts +13 -2
  67. package/dist/team/runtime.js +33 -6
  68. package/dist/team/subagent.d.ts +19 -1
  69. package/dist/team/subagent.js +72 -10
  70. package/dist/team/task-tool.d.ts +26 -0
  71. package/dist/team/task-tool.js +48 -4
  72. package/dist/team/types.d.ts +80 -1
  73. package/dist/tools/bash-tool.js +19 -3
  74. package/dist/tools/diff.d.ts +25 -5
  75. package/dist/tools/diff.js +72 -41
  76. package/dist/tools/file-change-store.d.ts +34 -0
  77. package/dist/tools/file-change-store.js +57 -0
  78. package/dist/tools/fs-tools.d.ts +26 -0
  79. package/dist/tools/fs-tools.js +62 -5
  80. package/dist/tools/index.d.ts +31 -0
  81. package/dist/tools/index.js +7 -1
  82. package/dist/tools/patch.d.ts +115 -0
  83. package/dist/tools/patch.js +453 -0
  84. package/dist/tools/terminal-output.d.ts +107 -0
  85. package/dist/tools/terminal-output.js +198 -0
  86. package/dist/tools/tool-output-store.d.ts +68 -0
  87. package/dist/tools/tool-output-store.js +102 -0
  88. package/dist/ui/ActivityLine.d.ts +48 -0
  89. package/dist/ui/ActivityLine.js +22 -0
  90. package/dist/ui/App.d.ts +11 -4
  91. package/dist/ui/App.js +359 -37
  92. package/dist/ui/BottomStatusRow.d.ts +65 -0
  93. package/dist/ui/BottomStatusRow.js +17 -0
  94. package/dist/ui/Composer.d.ts +1 -6
  95. package/dist/ui/Composer.js +2 -2
  96. package/dist/ui/PromptInput.d.ts +1 -15
  97. package/dist/ui/PromptInput.js +1 -38
  98. package/dist/ui/StatusBar.d.ts +37 -0
  99. package/dist/ui/StatusBar.js +5 -3
  100. package/dist/ui/TeamPanel.d.ts +5 -4
  101. package/dist/ui/TeamPanel.js +1 -1
  102. package/dist/ui/TodoPanel.d.ts +5 -4
  103. package/dist/ui/Transcript.d.ts +26 -3
  104. package/dist/ui/Transcript.js +76 -10
  105. package/dist/ui/UpdateLine.d.ts +44 -0
  106. package/dist/ui/UpdateLine.js +129 -0
  107. package/dist/ui/activity-phrases.d.ts +33 -0
  108. package/dist/ui/activity-phrases.js +50 -0
  109. package/dist/ui/entries/AssistantEntry.d.ts +20 -0
  110. package/dist/ui/entries/AssistantEntry.js +3 -2
  111. package/dist/ui/entries/DiffView.d.ts +67 -0
  112. package/dist/ui/entries/DiffView.js +103 -0
  113. package/dist/ui/entries/FastCard.d.ts +53 -0
  114. package/dist/ui/entries/FastCard.js +87 -0
  115. package/dist/ui/entries/RetryCard.d.ts +56 -0
  116. package/dist/ui/entries/RetryCard.js +60 -0
  117. package/dist/ui/entries/TeamCard.js +6 -1
  118. package/dist/ui/entries/ToolCard.d.ts +38 -1
  119. package/dist/ui/entries/ToolCard.js +68 -5
  120. package/dist/ui/glyphs.d.ts +9 -0
  121. package/dist/ui/glyphs.js +2 -0
  122. package/dist/ui/layout/AppShell.d.ts +13 -22
  123. package/dist/ui/layout/AppShell.js +3 -38
  124. package/dist/ui/layout/virtual-window.d.ts +1 -1
  125. package/dist/ui/layout/virtual-window.js +109 -5
  126. package/dist/ui/overlays/HelpOverlay.js +16 -2
  127. package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
  128. package/dist/ui/overlays/SettingsScreen.js +147 -1
  129. package/dist/ui/transcript-text.js +27 -0
  130. package/dist/ui/use-wheel-routing.d.ts +16 -32
  131. package/dist/ui/use-wheel-routing.js +21 -38
  132. package/dist/update/classify-failure.d.ts +32 -0
  133. package/dist/update/classify-failure.js +60 -0
  134. package/dist/update/install-lock.d.ts +55 -0
  135. package/dist/update/install-lock.js +184 -0
  136. package/dist/update/install-source.d.ts +97 -0
  137. package/dist/update/install-source.js +218 -0
  138. package/dist/update/installer.d.ts +108 -0
  139. package/dist/update/installer.js +242 -0
  140. package/dist/update/limits.d.ts +111 -0
  141. package/dist/update/limits.js +111 -0
  142. package/dist/update/npm-view.d.ts +62 -0
  143. package/dist/update/npm-view.js +133 -0
  144. package/dist/update/registry.d.ts +52 -0
  145. package/dist/update/registry.js +161 -0
  146. package/dist/update/semver.d.ts +89 -0
  147. package/dist/update/semver.js +295 -0
  148. package/dist/update/service.d.ts +193 -0
  149. package/dist/update/service.js +636 -0
  150. package/dist/update/state.d.ts +103 -0
  151. package/dist/update/state.js +142 -0
  152. package/dist/update/types.d.ts +169 -0
  153. package/dist/update/types.js +68 -0
  154. package/package.json +3 -3
  155. package/dist/ui/layout/regions.d.ts +0 -55
  156. package/dist/ui/layout/regions.js +0 -52
@@ -15,14 +15,27 @@ import { computeCost } from './usage.js';
15
15
  import { ENTRY_LIMITS, appendBounded, entryRetain, trimEntries } from './entry-limits.js';
16
16
  /** Default auto-dismiss window for a toast. */
17
17
  export const DEFAULT_TOAST_TTL_MS = 2500;
18
- 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 = {}) {
19
32
  return {
20
33
  entries: [],
21
34
  status: 'idle',
22
35
  usageTotal: { inputTokens: 0, outputTokens: 0, costUsd: 0 },
23
36
  contextTokens: 0,
24
37
  overlay: null,
25
- thinkingVisible: true,
38
+ thinkingVisible: seed.thinkingVisible ?? false,
26
39
  toasts: [],
27
40
  expandedToolIds: {},
28
41
  // Seeded to the default posture; the App overrides it once on mount from
@@ -31,9 +44,13 @@ export function initialViewState() {
31
44
  pendingAgentMode: null,
32
45
  team: null,
33
46
  todos: null,
47
+ retry: null,
48
+ fast: null,
34
49
  seq: 0,
35
50
  teamEntryId: undefined,
36
51
  todoEntryId: undefined,
52
+ retryEntryId: undefined,
53
+ fastEntryId: undefined,
37
54
  streamingId: undefined,
38
55
  turnProduced: false,
39
56
  errorNoticed: false,
@@ -80,12 +97,18 @@ export function buildToolPreview(result) {
80
97
  }
81
98
  return preview;
82
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
+ }
83
105
  /**
84
106
  * Map one core `AgentEvent` to view actions. `cost` (the active model's cost
85
107
  * table) lets `turn_end` accumulate USD cost; omit it in tests that only assert
86
- * 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.
87
110
  */
88
- export function reduceEvent(event, cost) {
111
+ export function reduceEvent(event, cost, patches) {
89
112
  switch (event.type) {
90
113
  case 'agent_start':
91
114
  return [{ type: 'runStart' }];
@@ -103,16 +126,27 @@ export function reduceEvent(event, cost) {
103
126
  return [{ type: 'runEnd' }];
104
127
  case 'tool_execution_start':
105
128
  return [{ type: 'toolExecStart', toolCallId: event.toolCallId }];
106
- case 'tool_execution_end':
129
+ case 'tool_execution_end': {
130
+ const patch = patches?.take(event.toolCallId);
131
+ const preview = buildToolPreview(event.result);
107
132
  return [
108
133
  {
109
134
  type: 'toolExecEnd',
110
135
  toolCallId: event.toolCallId,
111
136
  isError: event.isError,
112
137
  duration: event.duration,
113
- 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 } : {}),
114
147
  },
115
148
  ];
149
+ }
116
150
  case 'message_update':
117
151
  return reduceStreamEvent(event.streamEvent);
118
152
  case 'code_execution_start':
@@ -168,6 +202,23 @@ function reduceStreamEvent(se) {
168
202
  case 'done':
169
203
  // Authoritative usage is redundant with `turn_end`; nothing to add.
170
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 }];
171
222
  default:
172
223
  return [];
173
224
  }
@@ -204,6 +255,18 @@ function nextId(state) {
204
255
  function mapEntry(entries, id, fn) {
205
256
  return entries.map((e) => (e.id === id ? fn(e) : e));
206
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
+ }
207
270
  /**
208
271
  * Append one entry and ring-trim the result (tui-render-performance L1).
209
272
  *
@@ -221,7 +284,18 @@ function mapEntry(entries, id, fn) {
221
284
  function appendEntry(state, entries, entry) {
222
285
  const trimmed = trimEntries([...entries, entry], entryRetain());
223
286
  if (trimmed.dropped > 0 && process.env.NODE_ENV !== 'production') {
224
- const live = [state.streamingId, state.teamEntryId, state.todoEntryId].filter((id) => typeof id === 'string');
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');
225
299
  for (const id of live) {
226
300
  if (!trimmed.entries.some((e) => e.id === id)) {
227
301
  throw new Error(`trimEntries dropped a live entry (${id}) - see I-L1-1`);
@@ -233,6 +307,79 @@ function appendEntry(state, entries, entry) {
233
307
  droppedEntries: state.droppedEntries + trimmed.dropped,
234
308
  };
235
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
+ }
236
383
  function findToolEntryId(state, toolCallId) {
237
384
  for (let i = state.entries.length - 1; i >= 0; i -= 1) {
238
385
  const e = state.entries[i];
@@ -255,6 +402,10 @@ export function viewReducer(state, action) {
255
402
  // transcript. The PREVIOUS card stays exactly where it is — it is
256
403
  // history, and history is not retracted.
257
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,
258
409
  turnProduced: false,
259
410
  errorNoticed: false,
260
411
  aborted: false,
@@ -285,7 +436,16 @@ export function viewReducer(state, action) {
285
436
  return state;
286
437
  return {
287
438
  ...state,
288
- 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),
289
449
  };
290
450
  }
291
451
  case 'thinkingDelta': {
@@ -304,7 +464,14 @@ export function viewReducer(state, action) {
304
464
  return {
305
465
  ...state,
306
466
  entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
307
- ? { ...e, text: appendBounded(e.text, action.delta, ENTRY_LIMITS.text) }
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
+ }
308
475
  : e),
309
476
  };
310
477
  }
@@ -352,6 +519,28 @@ export function viewReducer(state, action) {
352
519
  entries: mapEntry(state.entries, id, (e) => e.kind === 'tool' ? { ...e, status: 'running' } : e),
353
520
  };
354
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
+ }
355
544
  case 'toolExecEnd': {
356
545
  const id = findToolEntryId(state, action.toolCallId);
357
546
  if (!id)
@@ -365,6 +554,13 @@ export function viewReducer(state, action) {
365
554
  isError: action.isError,
366
555
  durationMs: action.duration,
367
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,
368
564
  }
369
565
  : e),
370
566
  };
@@ -372,12 +568,20 @@ export function viewReducer(state, action) {
372
568
  case 'turnEnd': {
373
569
  const entries = state.streamingId
374
570
  ? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
375
- ? { ...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
+ }
376
581
  : e)
377
582
  : state.entries;
378
583
  return {
379
584
  ...state,
380
- entries,
381
585
  streamingId: undefined,
382
586
  turnProduced: true,
383
587
  contextTokens: action.usage.inputTokens + action.usage.outputTokens,
@@ -386,12 +590,14 @@ export function viewReducer(state, action) {
386
590
  outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
387
591
  costUsd: state.usageTotal.costUsd + action.costDelta,
388
592
  },
593
+ // The turn produced an answer, so whatever it had to retry, it recovered.
594
+ ...settleRetryCard(state, entries, 'recovered'),
389
595
  };
390
596
  }
391
597
  case 'runEnd': {
392
598
  // Finalize any still-streaming entry.
393
599
  let entries = state.streamingId
394
- ? 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)
395
601
  : state.entries;
396
602
  let seq = state.seq;
397
603
  let droppedEntries = state.droppedEntries;
@@ -414,27 +620,61 @@ export function viewReducer(state, action) {
414
620
  if (state.todoEntryId) {
415
621
  entries = mapEntry(entries, state.todoEntryId, (e) => e.kind === 'todo' ? { ...e, live: false } : e);
416
622
  }
417
- return { ...state, entries, seq, droppedEntries, 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
+ };
418
645
  }
419
646
  case 'notice': {
420
647
  const { id, seq } = nextId(state);
421
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 };
422
657
  return {
423
658
  ...state,
424
659
  seq,
425
- ...appendEntry(state, state.entries, entry),
660
+ droppedEntries: appended.droppedEntries,
426
661
  errorNoticed: action.level === 'error' ? true : state.errorNoticed,
662
+ ...settled,
427
663
  };
428
664
  }
429
665
  case 'abortMark': {
430
666
  // Mark the run aborted even when no assistant entry is streaming (e.g. an
431
667
  // abort during a tool call) so the runEnd failure guard stays suppressed.
432
- if (!state.streamingId)
433
- 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;
434
671
  return {
435
672
  ...state,
436
673
  aborted: true,
437
- 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'),
438
678
  };
439
679
  }
440
680
  // `teamEntryId` is dropped with the entries it pointed at. Keeping it would
@@ -457,6 +697,12 @@ export function viewReducer(state, action) {
457
697
  // the conversation, so the model still believes in the plan, and a panel
458
698
  // that disagrees with the model is worse than no panel.
459
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,
460
706
  };
461
707
  case 'resetConversation':
462
708
  return {
@@ -473,6 +719,8 @@ export function viewReducer(state, action) {
473
719
  // the STORE; this clears the mirror.
474
720
  todos: null,
475
721
  todoEntryId: undefined,
722
+ retryEntryId: undefined,
723
+ retry: null,
476
724
  };
477
725
  case 'setOverlay':
478
726
  return { ...state, overlay: action.overlay };
@@ -509,6 +757,10 @@ export function viewReducer(state, action) {
509
757
  const runs = action.specs.map((spec) => ({
510
758
  label: spec.label,
511
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,
512
764
  phase: 'queued',
513
765
  turns: 0,
514
766
  toolCalls: 0,
@@ -582,6 +834,12 @@ export function viewReducer(state, action) {
582
834
  }
583
835
  // --- Todo planning (todo-plan-execution §5.2) -------------------------
584
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.
585
843
  const entry = (id) => ({
586
844
  id,
587
845
  kind: 'todo',
@@ -613,6 +871,212 @@ export function viewReducer(state, action) {
613
871
  // live projection goes away, which is what unmounts the rail.
614
872
  case 'todoCleared':
615
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
+ }
616
1080
  case 'restoreEntries': {
617
1081
  // Adopt a seq beyond any restored id to keep future ids unique.
618
1082
  const maxSeq = action.entries.reduce((acc, e) => {
@@ -633,6 +1097,15 @@ export function viewReducer(state, action) {
633
1097
  // The LIST itself arrives separately, through `controller.restoreTodos`
634
1098
  // and the store's own event (§3.13).
635
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,
636
1109
  };
637
1110
  }
638
1111
  default: