@aragon-agent/cli 0.5.8 → 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 (123) hide show
  1. package/CHANGELOG.md +279 -0
  2. package/README.md +1429 -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/cli.js +86 -2
  16. package/dist/commands/builtins.d.ts +1 -1
  17. package/dist/commands/builtins.js +308 -2
  18. package/dist/config/cli-commands.d.ts +53 -2
  19. package/dist/config/cli-commands.js +136 -3
  20. package/dist/config/env.d.ts +6 -3
  21. package/dist/config/env.js +103 -3
  22. package/dist/config/load.d.ts +49 -0
  23. package/dist/config/load.js +83 -1
  24. package/dist/config/schema.d.ts +212 -2
  25. package/dist/config/schema.js +207 -1
  26. package/dist/config/store.js +20 -1
  27. package/dist/fast/critique.d.ts +43 -0
  28. package/dist/fast/critique.js +62 -0
  29. package/dist/fast/digest.d.ts +42 -0
  30. package/dist/fast/digest.js +85 -0
  31. package/dist/fast/frames.d.ts +99 -0
  32. package/dist/fast/frames.js +180 -0
  33. package/dist/fast/limits.d.ts +98 -0
  34. package/dist/fast/limits.js +98 -0
  35. package/dist/fast/prompt.d.ts +72 -0
  36. package/dist/fast/prompt.js +94 -0
  37. package/dist/fast/resolve.d.ts +43 -0
  38. package/dist/fast/resolve.js +105 -0
  39. package/dist/fast/review-call.d.ts +99 -0
  40. package/dist/fast/review-call.js +130 -0
  41. package/dist/fast/reviewer.d.ts +248 -0
  42. package/dist/fast/reviewer.js +664 -0
  43. package/dist/fast/types.d.ts +128 -0
  44. package/dist/fast/types.js +14 -0
  45. package/dist/fast/wiring.d.ts +170 -0
  46. package/dist/fast/wiring.js +316 -0
  47. package/dist/logging/install.d.ts +15 -1
  48. package/dist/logging/install.js +37 -4
  49. package/dist/logging/logger.d.ts +1 -1
  50. package/dist/session/persist.js +57 -1
  51. package/dist/team/normalize.d.ts +26 -1
  52. package/dist/team/normalize.js +23 -3
  53. package/dist/team/prompt.d.ts +11 -0
  54. package/dist/team/prompt.js +10 -0
  55. package/dist/team/report.d.ts +18 -0
  56. package/dist/team/report.js +27 -1
  57. package/dist/team/runtime.d.ts +13 -2
  58. package/dist/team/runtime.js +33 -6
  59. package/dist/team/subagent.d.ts +19 -1
  60. package/dist/team/subagent.js +72 -10
  61. package/dist/team/task-tool.d.ts +26 -0
  62. package/dist/team/task-tool.js +48 -4
  63. package/dist/team/types.d.ts +80 -1
  64. package/dist/tools/bash-tool.js +19 -3
  65. package/dist/tools/diff.d.ts +25 -5
  66. package/dist/tools/diff.js +72 -41
  67. package/dist/tools/file-change-store.d.ts +34 -0
  68. package/dist/tools/file-change-store.js +57 -0
  69. package/dist/tools/fs-tools.d.ts +26 -0
  70. package/dist/tools/fs-tools.js +62 -5
  71. package/dist/tools/index.d.ts +31 -0
  72. package/dist/tools/index.js +7 -1
  73. package/dist/tools/patch.d.ts +115 -0
  74. package/dist/tools/patch.js +453 -0
  75. package/dist/tools/terminal-output.d.ts +107 -0
  76. package/dist/tools/terminal-output.js +198 -0
  77. package/dist/tools/tool-output-store.d.ts +68 -0
  78. package/dist/tools/tool-output-store.js +102 -0
  79. package/dist/ui/ActivityLine.d.ts +48 -0
  80. package/dist/ui/ActivityLine.js +22 -0
  81. package/dist/ui/App.d.ts +1 -4
  82. package/dist/ui/App.js +273 -37
  83. package/dist/ui/BottomStatusRow.d.ts +43 -0
  84. package/dist/ui/BottomStatusRow.js +14 -0
  85. package/dist/ui/Composer.d.ts +1 -6
  86. package/dist/ui/Composer.js +2 -2
  87. package/dist/ui/PromptInput.d.ts +1 -15
  88. package/dist/ui/PromptInput.js +1 -38
  89. package/dist/ui/StatusBar.d.ts +37 -0
  90. package/dist/ui/StatusBar.js +5 -3
  91. package/dist/ui/TeamPanel.d.ts +5 -4
  92. package/dist/ui/TeamPanel.js +1 -1
  93. package/dist/ui/TodoPanel.d.ts +5 -4
  94. package/dist/ui/Transcript.d.ts +26 -3
  95. package/dist/ui/Transcript.js +76 -10
  96. package/dist/ui/activity-phrases.d.ts +33 -0
  97. package/dist/ui/activity-phrases.js +50 -0
  98. package/dist/ui/entries/AssistantEntry.d.ts +20 -0
  99. package/dist/ui/entries/AssistantEntry.js +3 -2
  100. package/dist/ui/entries/DiffView.d.ts +67 -0
  101. package/dist/ui/entries/DiffView.js +103 -0
  102. package/dist/ui/entries/FastCard.d.ts +53 -0
  103. package/dist/ui/entries/FastCard.js +87 -0
  104. package/dist/ui/entries/RetryCard.d.ts +56 -0
  105. package/dist/ui/entries/RetryCard.js +60 -0
  106. package/dist/ui/entries/TeamCard.js +6 -1
  107. package/dist/ui/entries/ToolCard.d.ts +38 -1
  108. package/dist/ui/entries/ToolCard.js +68 -5
  109. package/dist/ui/glyphs.d.ts +9 -0
  110. package/dist/ui/glyphs.js +2 -0
  111. package/dist/ui/layout/AppShell.d.ts +13 -22
  112. package/dist/ui/layout/AppShell.js +3 -38
  113. package/dist/ui/layout/virtual-window.d.ts +1 -1
  114. package/dist/ui/layout/virtual-window.js +109 -5
  115. package/dist/ui/overlays/HelpOverlay.js +16 -2
  116. package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
  117. package/dist/ui/overlays/SettingsScreen.js +147 -1
  118. package/dist/ui/transcript-text.js +27 -0
  119. package/dist/ui/use-wheel-routing.d.ts +16 -32
  120. package/dist/ui/use-wheel-routing.js +21 -38
  121. package/package.json +2 -2
  122. package/dist/ui/layout/regions.d.ts +0 -55
  123. package/dist/ui/layout/regions.js +0 -52
@@ -0,0 +1,664 @@
1
+ /**
2
+ * FastReviewer — the periodic asynchronous review (fast-model-tier §3.5).
3
+ *
4
+ * ASCII ONLY: `src/fast/**` is inside the glyph scanner's scope.
5
+ *
6
+ * THE ENTIRE CLASS IS SHAPED AROUND *WHEN* IT IS ALLOWED TO SPEAK.
7
+ * `Agent.steer()` pushes onto a queue that `runAgentLoop` drains at three
8
+ * places, and only one of them is safe:
9
+ *
10
+ * 1 top of the loop, before the LLM call (`agent-loop.ts:126`) - pushed as a
11
+ * `user` message, conversation shape stays valid. SAFE.
12
+ * 2 after the LLM call, before tool execution (`:199`) - `continue`, leaving
13
+ * the assistant's `tool_use` blocks with no `tool_result`. Anthropic then
14
+ * rejects the NEXT request with HTTP 400. HARD FAILURE.
15
+ * 3 between two tool executions (`:214`) - every remaining call in the batch
16
+ * is replaced with "Tool execution skipped due to steering interrupt."
17
+ * SILENTLY DESTRUCTIVE.
18
+ *
19
+ * For a human pressing Enter mid-run, 2 and 3 are the DESIRED semantics. For an
20
+ * unrequested background critique they are a defect generator whose symptoms
21
+ * (an HTTP 400 about tool_result ids; work silently deleted) point nowhere near
22
+ * the reviewer. So this class calls `steer()` from EXACTLY ONE PLACE: inside the
23
+ * synchronous listener for the `tool_execution_end` that brings the batch to
24
+ * zero. `Agent.emit()` runs listeners synchronously inside the loop
25
+ * (`agent.ts:340`), the batch is finished, and the next `hasSteering()` the loop
26
+ * evaluates is therefore checkpoint 1 (AC-20).
27
+ *
28
+ * AND STEERING IS NOT DELIVERY (§3.5.4a / RV-1 / D-19). `steer()` only enqueues;
29
+ * `runLoopWithLifecycle`'s `finally` never clears the queues (`agent.ts:410`);
30
+ * and the loop emits the batch's last `tool_execution_end` (`:249`) BEFORE it
31
+ * re-tests the abort signal (`:271`). So an Esc landing inside the final tool's
32
+ * `await` yields exactly the interleaving where the reviewer steers, the loop
33
+ * breaks, and the block resurfaces in the user's NEXT conversation wearing the
34
+ * user's role. Two guards close it:
35
+ *
36
+ * guard 1 do not steer into a REQUESTED abort (`isAbortRequested`).
37
+ * guard 2 prove delivery. `awaitingDrain` is set on steer and cleared at the
38
+ * next `turn_start` - the loop cannot reach `turn_start` (`:136`)
39
+ * without having passed checkpoint 1 (`:126`), so `turn_start` IS the
40
+ * receipt. Still true at `agent_end` means the block was stranded.
41
+ *
42
+ * Guard 2 exists because the idle watchdog calls `Agent.abort()` DIRECTLY
43
+ * (`watchdog.ts:15-17`), where no controller-level flag can see it (D-20) - and
44
+ * the watchdog fires precisely on the long quiet runs where a review is most
45
+ * likely to be pending.
46
+ *
47
+ * A future refactor that moves `steer()` into the `complete()` callback will
48
+ * look tidier and will reintroduce all of the above.
49
+ *
50
+ * WHAT WAS SPLIT OUT, AND WHAT DELIBERATELY WAS NOT (hardening W3 / §3.3).
51
+ * The turn-frame ring moved to `frames.ts` and the bounded call to
52
+ * `review-call.ts`, both leaves. THE PROTOCOL STAYED HERE, WHOLE: the two drain
53
+ * guards above (AC-H11) and the call-cancellation stamping - `callAbort`,
54
+ * `callAbortReason`, `cancelCall()` and the one `new AbortController()`
55
+ * (AC-H16). A split that puts half of either behind a module boundary is a
56
+ * "simplification" that reopens a P0, and in the cancellation case it is round
57
+ * 1's IF-7 verbatim; `review-call.ts`'s header states why.
58
+ */
59
+ import { getLogger } from '../logging/logger.js';
60
+ import { normalizeCritique } from './critique.js';
61
+ import { buildReviewDigest } from './digest.js';
62
+ import { TurnFrameRing } from './frames.js';
63
+ import { FAST_LIMITS } from './limits.js';
64
+ import { renderReviewInjection } from './prompt.js';
65
+ import { accumulateUsage, assistantText, classifyReviewFailure, errorText, runReviewCall, usageOf, } from './review-call.js';
66
+ export class FastReviewer {
67
+ deps;
68
+ /** Scope `fast` since round 2 (W4): an unrequested background call is not one
69
+ * of the LEAD's records, and a support reader filtering `agent` wants those. */
70
+ log = getLogger().child('fast');
71
+ now;
72
+ unsubscribe = null;
73
+ disposed = false;
74
+ // --- Per-run state -----------------------------------------------------
75
+ runId = 0;
76
+ /** The turn-frame ring (`frames.ts`). Owns `turns`, the open frame and the
77
+ * outstanding-call counter; decides nothing. */
78
+ ring = new TurnFrameRing({ canRecord: () => this.canReviewAgain() });
79
+ inFlight = false;
80
+ pending = null;
81
+ reviewsThisRun = 0;
82
+ /** Set on steer, cleared at the next `turn_start`. Guard 2's receipt. */
83
+ awaitingDrain = false;
84
+ awaitingReviewIndex = 0;
85
+ /** The run's own abort channel for an in-flight `complete()`. */
86
+ callAbort = null;
87
+ /**
88
+ * WHY the in-flight call was aborted - RECORDED here because it cannot be
89
+ * recovered from the error: a timeout and a cancellation go through the same
90
+ * `controller.abort()` and arrive as byte-identical bare `Error`s. The full
91
+ * argument, and the prohibition it implies for `review-call.ts`, is in that
92
+ * file's header; this field and `cancelCall()` are the halves it protects.
93
+ */
94
+ callAbortReason = null;
95
+ // --- Per-session state -------------------------------------------------
96
+ goal = '';
97
+ /**
98
+ * Reviews STARTED this session. NEVER RESET (§3.1.1).
99
+ *
100
+ * This is BOTH the status line's numerator and the session budget's left-hand
101
+ * side, on purpose: a parallel `reviewsThisSession` would be a second answer
102
+ * to one question, and the first time the two drifted the product would print
103
+ * `12/40` while suppressing at a number nobody can see.
104
+ */
105
+ reviewIndex = 0;
106
+ /** The limit at which exhaustion was last announced; `-1` = not announced.
107
+ * Keyed on the LIMIT, not on a boolean, so raising the budget re-arms the
108
+ * notice without a second latch to keep in sync (§3.1.3). */
109
+ budgetAnnouncedAt = -1;
110
+ consecutiveFailures = 0;
111
+ selfDisabled = false;
112
+ usage = { inputTokens: 0, outputTokens: 0 };
113
+ constructor(deps) {
114
+ this.deps = deps;
115
+ this.now = deps.now ?? Date.now;
116
+ this.unsubscribe = deps.subscribe((event) => this.onAgentEvent(event));
117
+ }
118
+ // -----------------------------------------------------------------------
119
+ // Public surface
120
+ // -----------------------------------------------------------------------
121
+ /** The user's request for this run, captured in `AgentController.prompt()`. */
122
+ setGoal(text) {
123
+ this.goal = String(text ?? '').slice(0, FAST_LIMITS.goalChars);
124
+ }
125
+ /** Reviews STARTED this session, for `/fast status` and `FastSnapshot`. */
126
+ reviewCount() {
127
+ return this.reviewIndex;
128
+ }
129
+ /** The live session budget, read from config so `/fast budget <n>` takes
130
+ * effect on the next sealed frame with no restart. */
131
+ budgetLimit() {
132
+ return this.deps.getConfig().reviewMaxPerSession;
133
+ }
134
+ /** Carried on the snapshot rather than re-derived by callers (RV-H10). */
135
+ isBudgetReached() {
136
+ return this.reviewIndex >= this.budgetLimit();
137
+ }
138
+ sessionUsage() {
139
+ return { ...this.usage };
140
+ }
141
+ isReviewInFlight() {
142
+ return this.inFlight;
143
+ }
144
+ /**
145
+ * `AgentController.abort()`.
146
+ *
147
+ * Aborts the in-flight call and drops `pending`. It MUST NOT clear
148
+ * `awaitingDrain`: that flag is guard 2's receipt and is resolved only at
149
+ * `turn_start` (delivered) or `agent_end` (stranded).
150
+ */
151
+ abort() {
152
+ this.cancelCall();
153
+ if (this.pending)
154
+ this.dropPending('aborted');
155
+ }
156
+ /**
157
+ * Cancel an in-flight review because nobody wants the answer any more.
158
+ *
159
+ * A CANCELLATION IS NOT A FAILURE (§3.5.5). The three callers - `endRun()`
160
+ * (the run finished first), `abort()` (Esc) and `dispose()` - all mean "never
161
+ * mind", and none of them is evidence of a MISCONFIGURED tier, which is the
162
+ * only thing self-disable exists for (D-18 / D-24). Scoring them as strikes
163
+ * silently killed the reviewer for the rest of a session over three ordinary
164
+ * runs - the common shape, since a turn that answers without tools ends the
165
+ * run - behind a warn quoting an internal stream message (IF-7).
166
+ *
167
+ * THE STAMP MUST HAPPEN HERE, BEFORE THE ABORT, AND THE CONTROLLER MUST STAY
168
+ * REACHABLE FROM THIS METHOD (AC-H16).
169
+ */
170
+ cancelCall() {
171
+ if (!this.callAbort)
172
+ return;
173
+ this.callAbortReason = 'cancelled';
174
+ this.callAbort.abort();
175
+ }
176
+ /** Idempotent. Unsubscribes as well as aborting. */
177
+ dispose() {
178
+ if (this.disposed)
179
+ return;
180
+ this.disposed = true;
181
+ this.abort();
182
+ this.unsubscribe?.();
183
+ this.unsubscribe = null;
184
+ }
185
+ // -----------------------------------------------------------------------
186
+ // The event listener - SYNCHRONOUS, inside the core loop (C-7)
187
+ // -----------------------------------------------------------------------
188
+ onAgentEvent(event) {
189
+ switch (event.type) {
190
+ case 'agent_start':
191
+ this.beginRun();
192
+ break;
193
+ case 'turn_start':
194
+ this.onTurnStart();
195
+ break;
196
+ case 'message_update':
197
+ this.ring.recordDelta(event.streamEvent);
198
+ break;
199
+ case 'tool_execution_start':
200
+ this.ring.noteToolArgs(event.toolCallId, event.args);
201
+ break;
202
+ case 'turn_end':
203
+ this.ring.endTurn(event.message);
204
+ break;
205
+ case 'tool_execution_end':
206
+ this.onToolEnd(event.toolCallId, event.toolName, event.isError, event.duration);
207
+ break;
208
+ case 'agent_end':
209
+ this.endRun();
210
+ break;
211
+ default:
212
+ break;
213
+ }
214
+ }
215
+ beginRun() {
216
+ // MINTED HERE, NOT READ FROM THE EVENT (RV-8): `agent_start` carries no
217
+ // payload (`agent.ts:385`), so `runId` is a counter this class owns. Every
218
+ // stale-result test compares against it.
219
+ this.runId += 1;
220
+ this.ring.reset();
221
+ this.pending = null;
222
+ // PER-RUN ONLY. `reviewIndex` and `budgetAnnouncedAt` are deliberately NOT
223
+ // reset beside this: they are the session budget (§3.1.1), and tidying the
224
+ // two counters into one reset silently removes it.
225
+ this.reviewsThisRun = 0;
226
+ this.awaitingDrain = false;
227
+ this.awaitingReviewIndex = 0;
228
+ }
229
+ onTurnStart() {
230
+ // GUARD 2's RECEIPT. The loop cannot reach `turn_start` (`agent-loop.ts:136`)
231
+ // without having passed checkpoint 1 (`:126`), so arriving here proves the
232
+ // steered block was drained into the conversation.
233
+ if (this.awaitingDrain) {
234
+ this.log.debug('fast_review_delivered', { reviewIndex: this.awaitingReviewIndex });
235
+ this.awaitingDrain = false;
236
+ this.awaitingReviewIndex = 0;
237
+ }
238
+ this.ring.beginTurn();
239
+ }
240
+ onToolEnd(toolCallId, toolName, isError, duration) {
241
+ const batchEmpty = this.ring.endTool(toolCallId, toolName, isError, duration);
242
+ if (!batchEmpty)
243
+ return;
244
+ // THE BATCH IS EMPTY. This is both the sealing point (§3.5.1) and the one
245
+ // injection window (§3.5.4), in that order: injecting first clears `pending`
246
+ // so the freshly sealed turn can trigger the next review.
247
+ this.ring.seal();
248
+ this.maybeInject();
249
+ this.maybeStartReview();
250
+ }
251
+ endRun() {
252
+ // GUARD 2 (§3.5.4a). `awaitingDrain` still set means the loop never reached
253
+ // `turn_start`, so the block sits in a queue nothing will ever clear (C-10)
254
+ // and would surface at checkpoint 1 of the user's NEXT question.
255
+ if (this.awaitingDrain)
256
+ this.recoverStranded();
257
+ if (this.pending)
258
+ this.dropPending('run_ended');
259
+ this.cancelCall();
260
+ this.ring.endRun();
261
+ }
262
+ /**
263
+ * Remove a block that was steered and never delivered.
264
+ *
265
+ * THE ASYMMETRY IS DELIBERATE (D-21): the reviewer may always destroy its own
266
+ * message and may NEVER destroy the user's. `Agent` exposes no way to remove
267
+ * one entry - only `clearAllQueues()` - so removal is permitted only while the
268
+ * reviewer can PROVE it owns the whole queue, which `userSteerCount() === 0`
269
+ * establishes: `AgentController.steer()` is the only other writer in the
270
+ * package and the counter resets at every `turn_start`.
271
+ *
272
+ * With a user steer queued it clears NOTHING and warns. The residue is
273
+ * bounded, rare, labelled by its own `<fast_review turn model>` wrapper, and -
274
+ * unlike v1 - reported.
275
+ */
276
+ recoverStranded() {
277
+ const reviewIndex = this.awaitingReviewIndex;
278
+ this.awaitingDrain = false;
279
+ this.awaitingReviewIndex = 0;
280
+ const userQueued = this.deps.userSteerCount();
281
+ const cleared = userQueued === 0;
282
+ if (cleared) {
283
+ this.deps.clearAllQueues();
284
+ }
285
+ this.log.warn('fast_review_stranded', { reviewIndex, cleared, userQueued });
286
+ this.log.info('fast_review_dropped', { reviewIndex, reason: 'stranded' });
287
+ this.emitReview({
288
+ index: reviewIndex,
289
+ runId: this.runId,
290
+ turn: this.ring.turns,
291
+ model: this.currentModelId(),
292
+ kind: 'dropped',
293
+ detail: cleared
294
+ ? 'missed its window (run ended before delivery)'
295
+ : 'missed its window; a queued message could not be cleared',
296
+ durationMs: 0,
297
+ injected: false,
298
+ });
299
+ }
300
+ // -----------------------------------------------------------------------
301
+ // Trigger
302
+ // -----------------------------------------------------------------------
303
+ /** Whether another review could still be STARTED this run. */
304
+ canReviewAgain() {
305
+ if (this.selfDisabled)
306
+ return false;
307
+ if (!this.deps.available())
308
+ return false;
309
+ if (this.deps.getConfig().review !== true)
310
+ return false;
311
+ return this.reviewsThisRun < FAST_LIMITS.maxReviewsPerRun;
312
+ }
313
+ maybeStartReview() {
314
+ if (!this.canReviewAgain())
315
+ return;
316
+ // SINGLE-FLIGHT IS NOT AN OPTIMIZATION. With `reviewEveryTurns: 1` and a slow
317
+ // fast provider an unguarded reviewer starts a second call before the first
318
+ // returns, then a third, and the run ends with four critiques racing for one
319
+ // window - of which three are stale by construction.
320
+ if (this.inFlight || this.pending !== null)
321
+ return;
322
+ const turns = this.ring.turns;
323
+ const cadence = Math.max(1, this.deps.getConfig().reviewEveryTurns);
324
+ if (turns === 0 || turns % cadence !== 0)
325
+ return;
326
+ // THE SESSION BUDGET, AFTER THE CADENCE GATE AND NOT BEFORE (§3.1.2 / D-H5).
327
+ // An exhausted session still seals a frame and still reaches this method on
328
+ // every turn; checking earlier would evaluate the budget on all of them and
329
+ // the notice would be tied to the clock rather than to a review the user
330
+ // actually lost. It is NOT folded into `canReviewAgain()` either: that is a
331
+ // predicate with three other callers, and a query that emits UI is how a
332
+ // status read starts writing to the transcript.
333
+ if (this.reviewIndex >= this.budgetLimit()) {
334
+ this.announceBudgetReached();
335
+ return;
336
+ }
337
+ void this.runReview(turns);
338
+ }
339
+ /**
340
+ * Say it once per limit value, and say what is NOT affected.
341
+ *
342
+ * `info`, NOT `warn` (D-H6): D-24 reserves the alarming register for a
343
+ * MISCONFIGURED tier, because that is the case that never fixes itself. A
344
+ * budget reached is the system working as configured, and warning here would
345
+ * train the user to ignore the register that matters.
346
+ *
347
+ * NEITHER `consecutiveFailures` NOR `selfDisabled` IS TOUCHED. A budget stop
348
+ * is neither a success that clears the strike counter nor a failure that
349
+ * raises it - the exact distinction IF-7 was filed for, applied to a second
350
+ * cause. And the reviewer is not broken: it must revive the moment the budget
351
+ * rises, which `budgetLimit()` reading live config is what delivers.
352
+ *
353
+ * NO TRANSCRIPT CARD, and that is a correctness requirement rather than a
354
+ * styling choice (§3.1.3). R-8 ("it did nothing" is never the observable
355
+ * outcome) governs a review that STARTED; every such path emits a `FastReview`
356
+ * carrying an `index`, and indices come from `this.reviewIndex += 1`. Minting
357
+ * one here would advance the very counter `/fast status` renders as the
358
+ * numerator, so the first suppression would display `41/40`.
359
+ */
360
+ announceBudgetReached() {
361
+ const limit = this.budgetLimit();
362
+ if (this.budgetAnnouncedAt === limit)
363
+ return;
364
+ this.budgetAnnouncedAt = limit;
365
+ this.deps.notify('info', `Fast reviews: session budget reached (${limit}). ` +
366
+ `Delegation is unaffected. Raise it with /fast budget <n>.`);
367
+ this.log.info('fast_review_budget_reached', { limit, reviews: this.reviewIndex });
368
+ }
369
+ // -----------------------------------------------------------------------
370
+ // The call
371
+ // -----------------------------------------------------------------------
372
+ currentModelId() {
373
+ const tier = this.deps.getTier();
374
+ return tier.ok ? tier.ref.modelId : '';
375
+ }
376
+ async runReview(turn) {
377
+ const tier = this.deps.getTier();
378
+ if (!tier.ok)
379
+ return;
380
+ const apiKey = this.deps.getApiKey(tier.ref.providerId);
381
+ if (!apiKey)
382
+ return;
383
+ const cfg = this.deps.getConfig();
384
+ const runId = this.runId;
385
+ const index = (this.reviewIndex += 1);
386
+ this.reviewsThisRun += 1;
387
+ this.inFlight = true;
388
+ const frames = this.ring.frames();
389
+ const digest = buildReviewDigest({
390
+ frames,
391
+ goal: this.goal,
392
+ contextTurns: cfg.reviewContextTurns,
393
+ });
394
+ this.log.debug('fast_review_start', {
395
+ runId,
396
+ turn,
397
+ reviewIndex: index,
398
+ budget: this.budgetLimit(),
399
+ digestChars: digest.length,
400
+ frames: frames.length,
401
+ });
402
+ // The digest text itself is `trace` only: it carries workspace content, and
403
+ // `logging/redact.ts` already draws that line for prompt text.
404
+ this.log.trace('fast_review_digest', { reviewIndex: index, digest });
405
+ this.deps.emit({ type: 'review_start', index, turn });
406
+ // THE ONE `new AbortController()` OF THIS FEATURE, AND IT STAYS IN THIS FILE
407
+ // (AC-H16 / RV-H1). `review-call.ts` receives the signal and a timeout
408
+ // callback; it never constructs, owns or classifies the abort, because
409
+ // `cancelCall()` above has to be able to stamp `'cancelled'` before aborting.
410
+ const controller = new AbortController();
411
+ this.callAbort = controller;
412
+ this.callAbortReason = null;
413
+ const startedAt = this.now();
414
+ try {
415
+ const outcome = await runReviewCall({
416
+ complete: (providerId, request) => this.deps.complete(providerId, request),
417
+ ref: tier.ref,
418
+ apiKey,
419
+ digest,
420
+ reviewMaxChars: cfg.reviewMaxChars,
421
+ signal: controller.signal,
422
+ now: startedAt,
423
+ // A TIMEOUT *IS* A STRIKE (§3.5.5: "a transport error, a timeout or an
424
+ // unparseable answer increments consecutiveFailures"), and stamping it
425
+ // here is what keeps it distinguishable from a cancellation once the
426
+ // retry layer has flattened both into the same bare `Error`.
427
+ onTimeout: () => {
428
+ this.callAbortReason = 'timeout';
429
+ controller.abort();
430
+ },
431
+ });
432
+ if (outcome.ok) {
433
+ this.onReviewAnswer(index, runId, turn, tier.ref.modelId, outcome.message, startedAt);
434
+ }
435
+ else if (this.callAbortReason === 'cancelled') {
436
+ this.onReviewCancelled(index, runId, turn, startedAt);
437
+ }
438
+ else {
439
+ this.onReviewFailure(index, runId, turn, outcome.error, startedAt);
440
+ }
441
+ }
442
+ finally {
443
+ if (this.callAbort === controller) {
444
+ this.callAbort = null;
445
+ this.callAbortReason = null;
446
+ }
447
+ this.inFlight = false;
448
+ }
449
+ }
450
+ /**
451
+ * A review nobody is waiting for any more.
452
+ *
453
+ * It still has to produce a TERMINAL card. `review_start` already put a
454
+ * `running` entry in the transcript, and `Transcript`'s settled boundary is
455
+ * monotonic (C-5) - an entry that never settles is re-rendered on every frame
456
+ * for the rest of the session. "It did nothing" is also never allowed to be
457
+ * the observable outcome (R-8).
458
+ *
459
+ * `consecutiveFailures` is left EXACTLY as it was: a cancellation is neither a
460
+ * success that clears the count nor a failure that raises it.
461
+ */
462
+ onReviewCancelled(index, runId, turn, startedAt) {
463
+ this.log.info('fast_review_dropped', { reviewIndex: index, reason: 'aborted' });
464
+ this.emitReview({
465
+ index,
466
+ runId,
467
+ turn,
468
+ model: this.currentModelId(),
469
+ kind: 'dropped',
470
+ detail: DROP_DETAIL.aborted,
471
+ durationMs: Math.max(0, this.now() - startedAt),
472
+ injected: false,
473
+ });
474
+ }
475
+ onReviewAnswer(index, runId, turn, model, message, startedAt) {
476
+ const durationMs = Math.max(0, this.now() - startedAt);
477
+ const { usage, missing: usageMissing } = usageOf(message);
478
+ this.usage = accumulateUsage(this.usage, usage);
479
+ this.deps.emit({ type: 'usage', usage });
480
+ this.consecutiveFailures = 0;
481
+ const critique = normalizeCritique(assistantText(message), this.deps.getConfig().reviewMaxChars);
482
+ this.log.info('fast_review_done', {
483
+ reviewIndex: index,
484
+ ms: durationMs,
485
+ kind: critique.kind,
486
+ chars: critique.kind === 'advice' ? critique.text.length : 0,
487
+ usage,
488
+ ...(usageMissing ? { usageMissing: true } : {}),
489
+ });
490
+ // STALE RUN: the answer outlived the run it was about. Injecting it into a
491
+ // later conversation is R-4 exactly.
492
+ if (runId !== this.runId || !this.deps.isRunning()) {
493
+ this.emitReview({
494
+ index,
495
+ runId,
496
+ turn,
497
+ model,
498
+ kind: 'dropped',
499
+ detail: 'the run ended before the review came back',
500
+ durationMs,
501
+ usage,
502
+ injected: false,
503
+ });
504
+ this.log.info('fast_review_dropped', { reviewIndex: index, reason: 'stale_run' });
505
+ return;
506
+ }
507
+ if (critique.kind !== 'advice') {
508
+ // Shown, never injected (D-16): the user can see the harness is alive
509
+ // without the lead paying context for "nothing to say".
510
+ this.emitReview({
511
+ index,
512
+ runId,
513
+ turn,
514
+ model,
515
+ kind: critique.kind,
516
+ durationMs,
517
+ usage,
518
+ injected: false,
519
+ });
520
+ return;
521
+ }
522
+ // THE ASYNC CALLBACK NEVER INJECTS. It only parks the critique; the window
523
+ // is opened by a synchronous `tool_execution_end` listener (D-7).
524
+ this.pending = {
525
+ runId,
526
+ reviewIndex: index,
527
+ turn,
528
+ text: critique.text,
529
+ model,
530
+ at: this.now(),
531
+ durationMs,
532
+ usage,
533
+ };
534
+ }
535
+ onReviewFailure(index, runId, turn, err, startedAt) {
536
+ const durationMs = Math.max(0, this.now() - startedAt);
537
+ const { errorType, transient } = classifyReviewFailure(err);
538
+ if (!transient)
539
+ this.consecutiveFailures += 1;
540
+ this.log.warn('fast_review_failed', {
541
+ reviewIndex: index,
542
+ error: errorText(err),
543
+ errorType,
544
+ transient,
545
+ consecutiveFailures: this.consecutiveFailures,
546
+ });
547
+ this.emitReview({
548
+ index,
549
+ runId,
550
+ turn,
551
+ model: this.currentModelId(),
552
+ kind: 'failed',
553
+ detail: errorText(err),
554
+ durationMs,
555
+ injected: false,
556
+ });
557
+ // ONE NOTICE, AT THE THRESHOLD - never one per failure (D-18). Three
558
+ // identical stream errors in the transcript teach the user nothing and bury
559
+ // the answer they were reading.
560
+ if (!this.selfDisabled && this.consecutiveFailures >= FAST_LIMITS.maxConsecutiveFailures) {
561
+ this.selfDisabled = true;
562
+ this.deps.notify('warn', `Fast review disabled for this session after ${FAST_LIMITS.maxConsecutiveFailures} ` +
563
+ `failures. Last error: ${errorText(err)}`);
564
+ }
565
+ }
566
+ // -----------------------------------------------------------------------
567
+ // The injection window (§3.5.4) - THE ONLY `steer()` CALL SITE
568
+ // -----------------------------------------------------------------------
569
+ maybeInject() {
570
+ const pending = this.pending;
571
+ if (!pending)
572
+ return;
573
+ // The run moved on without it.
574
+ if (pending.runId !== this.runId) {
575
+ this.dropPending('stale_run');
576
+ return;
577
+ }
578
+ // STALENESS (RV-12). One `task` dispatch is a single tool call that can run
579
+ // for minutes, so a critique triggered just before one arrives at the far
580
+ // side describing a state five children have since rewritten. Advice that is
581
+ // merely late is worse than no advice, because the lead cannot tell.
582
+ const age = this.now() - pending.at;
583
+ const behind = this.ring.turns - pending.turn;
584
+ if (age > FAST_LIMITS.pendingMaxAgeMs || behind > FAST_LIMITS.pendingMaxTurnsBehind) {
585
+ this.dropPending('stale_pending');
586
+ return;
587
+ }
588
+ // GUARD 1 (§3.5.4a). `AgentController.abort()` sets this synchronously
589
+ // BEFORE `agent.abort()`, so the common Esc path is visible here even though
590
+ // the loop has not broken yet.
591
+ if (this.deps.isAbortRequested() || !this.deps.isRunning()) {
592
+ this.dropPending('aborted');
593
+ return;
594
+ }
595
+ const block = renderReviewInjection({
596
+ turn: pending.turn,
597
+ model: pending.model,
598
+ text: pending.text,
599
+ });
600
+ // NOTE THE ORDERING THIS SITS ON (RV-9): `tool_execution_end` is emitted at
601
+ // `agent-loop.ts:249`, BEFORE its own `tool_result` is pushed at `:263`. The
602
+ // design is still correct because `steer()` only ENQUEUES and the drain does
603
+ // not happen until checkpoint 1 of the next iteration, by which time `:263`
604
+ // has run. An "improvement" that pushed this block onto `messageManager`
605
+ // directly from here would place a `user` message BETWEEN an assistant's
606
+ // `tool_use` and its `tool_result` - checkpoint 2's HTTP 400, reintroduced
607
+ // by a change that looks like a simplification.
608
+ this.deps.steer(block);
609
+ this.awaitingDrain = true;
610
+ this.awaitingReviewIndex = pending.reviewIndex;
611
+ this.pending = null;
612
+ this.log.info('fast_review_injected', {
613
+ reviewIndex: pending.reviewIndex,
614
+ turn: pending.turn,
615
+ // Always 0 - the assertion, recorded.
616
+ outstanding: this.ring.outstandingCalls,
617
+ });
618
+ this.emitReview({
619
+ index: pending.reviewIndex,
620
+ runId: pending.runId,
621
+ turn: pending.turn,
622
+ model: pending.model,
623
+ kind: 'advice',
624
+ text: pending.text,
625
+ durationMs: pending.durationMs,
626
+ ...(pending.usage ? { usage: pending.usage } : {}),
627
+ injected: true,
628
+ });
629
+ }
630
+ dropPending(reason) {
631
+ const pending = this.pending;
632
+ if (!pending)
633
+ return;
634
+ this.pending = null;
635
+ this.log.info('fast_review_dropped', { reviewIndex: pending.reviewIndex, reason });
636
+ this.emitReview({
637
+ index: pending.reviewIndex,
638
+ runId: pending.runId,
639
+ turn: pending.turn,
640
+ model: pending.model,
641
+ kind: 'dropped',
642
+ detail: DROP_DETAIL[reason],
643
+ durationMs: pending.durationMs,
644
+ ...(pending.usage ? { usage: pending.usage } : {}),
645
+ injected: false,
646
+ });
647
+ }
648
+ emitReview(review) {
649
+ this.deps.emit({ type: 'review_end', review });
650
+ }
651
+ }
652
+ /**
653
+ * The one-line reason a review is shown as `dropped`.
654
+ *
655
+ * "It did nothing" is never the observable outcome (R-8): every path that ends
656
+ * without an injection still produces a card and a log record.
657
+ */
658
+ const DROP_DETAIL = {
659
+ run_ended: 'missed its window (the run ended first)',
660
+ aborted: 'cancelled',
661
+ stale_run: 'the run ended before the review came back',
662
+ stale_pending: 'too late to be useful',
663
+ stranded: 'missed its window (run ended before delivery)',
664
+ };