@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
@@ -9,19 +9,25 @@
9
9
  * from the event stream by the reducer (TUI) and the headless writer.
10
10
  */
11
11
  import { Agent, initProviders, ModelRegistry, } from '@aragon-agent/core';
12
- import { clampTeamConfig, clampTodoConfig, isAdapterProvider } from '../config/schema.js';
12
+ import { clampFastConfig, clampRetryConfig, clampTeamConfig, clampTodoConfig, isAdapterProvider, toRetryPolicy, } from '../config/schema.js';
13
13
  import { makeGetApiKey } from '../config/load.js';
14
14
  import { updatePersistedConfig } from '../config/store.js';
15
15
  import { createBuiltinTools } from '../tools/index.js';
16
+ import { createFileChangeStore } from '../tools/file-change-store.js';
17
+ import { createToolOutputStore, } from '../tools/tool-output-store.js';
16
18
  import { TeamRuntime } from '../team/runtime.js';
17
19
  import { TeamHumanQueue } from '../team/human-queue.js';
18
20
  import { createTaskTool } from '../team/task-tool.js';
19
21
  import { buildTeamBlock } from '../team/prompt.js';
22
+ import { buildFastBlock } from '../fast/prompt.js';
23
+ import { describeFastTierProblem, fastProviderOf, resolveFastTier } from '../fast/resolve.js';
24
+ import { FastWiring, offFastStatus } from '../fast/wiring.js';
20
25
  import { TodoStore } from '../todo/store.js';
21
26
  import { createTodoTool } from '../todo/todo-tool.js';
22
27
  import { buildTodoBlock } from '../todo/prompt.js';
23
28
  import { createPlanTools } from '../tools/plan-tools.js';
24
29
  import { DENY_ALL_HUMAN_INPUT } from '../tools/human-input.js';
30
+ import { getLogger } from '../logging/logger.js';
25
31
  import { createNodeSkillHost } from '../skills/node-host.js';
26
32
  import { SkillService } from '../skills/service.js';
27
33
  import { createSkillTools } from '../skills/tools.js';
@@ -94,13 +100,67 @@ export class AgentController {
94
100
  todos;
95
101
  /** Static: whether this session could ever render the rail. See the dep. */
96
102
  todoPanelCapable;
103
+ /**
104
+ * Structured file diffs on their way to the UI (agent-activity-presentation
105
+ * §3.3.5). A bounded, take-once ring; see `file-change-store.ts` for why each
106
+ * of those three words is load-bearing.
107
+ *
108
+ * DECLARED WITH AN INITIALIZER, so it exists before `createBuiltinTools` runs
109
+ * in the constructor body and the recorder closure it hands over is valid from
110
+ * the first tool call.
111
+ */
112
+ fileChanges = createFileChangeStore();
113
+ /**
114
+ * A running tool's output on its way to the UI
115
+ * (agent-activity-presentation-live §3.1.3).
116
+ *
117
+ * NULL WHEN THE FEATURE IS OFF (P1-6). `fileChanges` next door is declared
118
+ * with an initializer because it is unconditional; this one cannot be, since a
119
+ * field initializer cannot read a constructor parameter -- and an
120
+ * unconditional initializer would allocate on every construction and make
121
+ * AC-31's "no store is allocated" false by construction. `private readonly
122
+ * fast: FastWiring | null` below is the same declaration shape in the same
123
+ * class.
124
+ *
125
+ * ASSIGNED IN THE CONSTRUCTOR BODY, ABOVE THE `createBuiltinTools` CALL, not
126
+ * near `this.fast` which runs after it. The binding there is spread
127
+ * CONDITIONALLY on this field, so an assignment placed after the call would
128
+ * read `undefined`, add no property, and produce a build in which the feature
129
+ * is silently off with the key on.
130
+ */
131
+ toolOutputs;
132
+ outputListeners = new Set();
133
+ // -----------------------------------------------------------------------
134
+ // Fast model tier (fast-model-tier §3.1 / §3.3)
135
+ //
136
+ // ONE FIELD AND FOUR THIN FORWARDERS. The flag pair, the tier cache, the
137
+ // reviewer's lifetime and the `resolveTier` closure all live in
138
+ // `fast/wiring.ts`, which is a BUDGET decision rather than an aesthetic one
139
+ // (C-12 / RV-5): `CLAUDE.md` caps a source file at 1000 lines and this one had
140
+ // 40 to spare when the design was written.
141
+ //
142
+ // `null` when the tier could not be resolved at construction, which is the
143
+ // byte-identity branch: no reviewer is subscribed, `task` carries no `model`
144
+ // property, and `<fast_tier>` is never spliced (I-2).
145
+ // -----------------------------------------------------------------------
146
+ fast;
147
+ /**
148
+ * Set SYNCHRONOUSLY by `abort()` before `agent.abort()` (§3.5.4a guard 1).
149
+ * Cleared at the next `agent_start`.
150
+ */
151
+ abortRequested = false;
152
+ /** User messages queued since the last `turn_start` (§3.5.4a guard 2). */
153
+ userSteerCount = 0;
97
154
  constructor(config, deps = {}) {
98
155
  this.config = config;
99
156
  this.cwd = config.cwd;
100
157
  const humanInput = deps.humanInput ?? DENY_ALL_HUMAN_INPUT;
101
158
  this.planInteractive = humanInput.neverPrompts !== true;
102
159
  this.effectiveMode = config.startInPlanMode ? 'plan' : 'build';
103
- this.providerRegistry = initProviders();
160
+ // The retry policy is installed HERE and replaced live by `setRetryConfig`
161
+ // (llm-api-retry-backoff §6.3). Subagents inherit it for free: `TeamRuntime`
162
+ // reuses this very registry instance.
163
+ this.providerRegistry = initProviders({ retryPolicy: toRetryPolicy(config.retry) });
104
164
  this.modelRegistry = new ModelRegistry(this.providerRegistry);
105
165
  this.skillsEnabled = config.skills.enabled;
106
166
  this.onSkillsChanged = deps.onSkillsChanged;
@@ -132,12 +192,33 @@ export class AgentController {
132
192
  this.todoEnabled = config.todo.enabled;
133
193
  this.todoPanelCapable = deps.todoPanelCapable === true;
134
194
  this.todos = this.todoRegistered ? new TodoStore() : null;
195
+ // --- Fast model tier ---------------------------------------------------
196
+ //
197
+ // RESOLVED BEFORE THE TOOL ARRAY, because `fastRegistered` decides whether
198
+ // `task`'s schema carries a `model` property at all and the array is built
199
+ // ONCE (C-2). `FastWiring` re-runs the same pure function in its own
200
+ // constructor; the two agree by construction because they read the same
201
+ // config through the same resolver.
202
+ const fastTier = resolveFastTier(config, (id) => this.hasApiKey(id));
203
+ const fastRegistered = fastTier.ok;
135
204
  // --- Team mode ---------------------------------------------------------
136
205
  this.teamRegistered = config.team.enabled;
137
206
  this.teamEnabled = config.team.enabled;
138
207
  this.teamRuntime = this.teamRegistered
139
208
  ? new TeamRuntime({
140
209
  getConfig: () => this.config,
210
+ // Read LIVE through the wiring, which does not exist yet — the closure
211
+ // is only ever called from inside a dispatch, long after construction.
212
+ resolveTier: (tier) => this.fast
213
+ ? this.fast.resolveTier(tier)
214
+ : {
215
+ ref: {
216
+ providerId: this.config.provider,
217
+ modelId: this.config.model,
218
+ ...(this.config.baseUrl ? { baseUrl: this.config.baseUrl } : {}),
219
+ },
220
+ thinkingLevel: this.config.thinkingLevel,
221
+ },
141
222
  providerRegistry: this.providerRegistry,
142
223
  getCwd: () => this.cwd,
143
224
  getMode: () => this.effectiveMode,
@@ -166,8 +247,26 @@ export class AgentController {
166
247
  ...(confirm ? { humanQueue: new TeamHumanQueue(confirm) } : {}),
167
248
  })
168
249
  : null;
250
+ // ABOVE `createBuiltinTools`, and the ordering is load-bearing for the
251
+ // reason the field's own comment gives: the binding below is spread
252
+ // conditionally on it (§3.1.3 / P1-6).
253
+ this.toolOutputs = config.liveToolOutput ? createToolOutputStore() : null;
169
254
  this.tools = createBuiltinTools({
170
255
  getCwd: () => this.cwd,
256
+ // OWNED HERE, NOT IN `cli.tsx` (D-18): `createBuiltinTools` is called from
257
+ // this constructor and from `team/subagent.ts`, never from `cli.tsx`, so
258
+ // this is the one place a recorder can be bound to the toolset that will
259
+ // use it. Pre-bound to `'lead'`, so `fs-tools.ts` never learns what an
260
+ // owner is; subagents get no recorder this round (D-13).
261
+ recordChange: (id, patch) => this.fileChanges.record('lead', id, patch),
262
+ // The live-output recorder, at the identical site and for the identical
263
+ // reason, and spread ONLY WHEN THE STORE EXISTS: with `liveToolOutput` off
264
+ // the options bag has no `recordOutput` property at all, so
265
+ // `createBuiltinTools`'s conditional spread yields today's `deps` byte for
266
+ // byte and `bash` is the pre-round tool (AC-31).
267
+ ...(this.toolOutputs
268
+ ? { recordOutput: (id, chunk) => this.emitToolOutput('lead', id, chunk) }
269
+ : {}),
171
270
  confirmTools: config.confirmTools,
172
271
  // Routed through the same watchdog pause as the plan tools. This is a
173
272
  // pre-existing bug being fixed in passing: a `--confirm` user who took
@@ -220,10 +319,23 @@ export class AgentController {
220
319
  runtime: this.teamRuntime,
221
320
  // Read LIVE: `/team off` flips the flag, it cannot unregister.
222
321
  isTeamEnabled: () => this.teamEnabled,
322
+ // FROZEN AT CONSTRUCTION (C-2). `/fast on` in a session launched
323
+ // without the tier cannot add a schema property, and it says so
324
+ // rather than advertising one that is not there.
325
+ fastRegistered,
326
+ // Read LIVE at dispatch time (RV-3): the tier can stop resolving
327
+ // mid-session, and the normalizer and the subagent factory must
328
+ // agree about that at the same instant.
329
+ fastAvailable: () => this.fast?.delegationAvailable() === true,
223
330
  maxSubagents: () => this.config.team.maxSubagents,
224
331
  hasApiKey: () => this.hasApiKey(),
225
332
  activeProvider: () => this.config.provider,
226
333
  modelCost: () => this.getModelInfo().cost,
334
+ fastModelCost: () => {
335
+ const ref = this.fast?.fastRef();
336
+ return ref ? this.getModelInfoFor(ref).cost : undefined;
337
+ },
338
+ fastPricingUnknown: () => this.fast?.snapshot().pricingUnknown === true,
227
339
  withPausedWatchdog: (fn) => this.withPausedWatchdog(fn),
228
340
  }),
229
341
  ]
@@ -275,6 +387,71 @@ export class AgentController {
275
387
  },
276
388
  },
277
389
  });
390
+ // --- Fast tier, part 2: after the Agent exists --------------------------
391
+ //
392
+ // `FastWiring` subscribes to the lead's event stream, so it cannot be built
393
+ // before the `Agent`. The prompt is therefore composed twice at startup:
394
+ // once above without `<fast_tier>` (the wiring is still null) and once here.
395
+ // That is one extra string build at construction and no live path.
396
+ this.fast = fastRegistered
397
+ ? new FastWiring({
398
+ getConfig: () => this.config,
399
+ hasKey: (id) => this.hasApiKey(id),
400
+ getApiKey: (id) => this.resolveKey(id),
401
+ isPricedModel: (ref) => this.isPricedModel(ref),
402
+ subscribe: (listener) => this.agent.subscribe(listener),
403
+ complete: (providerId, request) => this.providerRegistry.complete(providerId, request),
404
+ // THE REVIEWER GOES THROUGH THIS METHOD, NOT `agent.steer()` DIRECTLY,
405
+ // so its own block is counted alongside the user's — which is what
406
+ // `userSteerCount` would otherwise mis-report and what would make
407
+ // guard 2 clear a queue it does not own (D-21).
408
+ //
409
+ // It is decremented back out below: the counter must answer "did the
410
+ // USER queue anything", and the reviewer's own message is not that.
411
+ steer: (text) => {
412
+ this.steer(text);
413
+ this.userSteerCount -= 1;
414
+ },
415
+ isRunning: () => this.isRunning(),
416
+ isAbortRequested: () => this.abortRequested,
417
+ userSteerCount: () => this.userSteerCount,
418
+ clearAllQueues: () => this.clearAllQueues(),
419
+ notify: (level, text) => deps.notify?.(level, text),
420
+ onPromptChanged: () => this.rebuildSystemPrompt(),
421
+ })
422
+ : null;
423
+ // The two guards' bookkeeping, and the only reason this class subscribes to
424
+ // its own agent. `turn_start` is where a user steer stops being pending;
425
+ // `agent_start` is where a requested abort stops being in force.
426
+ this.agent.subscribe((event) => {
427
+ if (event.type === 'agent_start')
428
+ this.abortRequested = false;
429
+ if (event.type === 'turn_start')
430
+ this.userSteerCount = 0;
431
+ // The authoritative result arrives with this event, so from this instant
432
+ // the tail is superseded and its slot must be released (§3.1.1). Clearing
433
+ // HERE rather than on read is what keeps the store's size a function of
434
+ // CONCURRENT calls instead of total calls.
435
+ if (event.type === 'tool_execution_end') {
436
+ this.toolOutputs?.clear('lead', event.toolCallId);
437
+ }
438
+ });
439
+ if (this.fast) {
440
+ this.rebuildSystemPrompt();
441
+ // Fast-tier children are counted from the outcome rather than from the
442
+ // specs, so a downgraded child is not miscounted as a saving (§3.6).
443
+ this.teamRuntime?.subscribe((event) => {
444
+ if (event.type !== 'dispatch_end')
445
+ return;
446
+ this.fast?.noteDelegated(event.outcome.runs.filter((r) => r.tier === 'fast').length);
447
+ });
448
+ }
449
+ // ONE LINE FOR THE TWO CONFIGURATION MISTAKES THAT ARE OTHERWISE INVISIBLE
450
+ // (§3.3): the tier is on but unnamed, or named against a provider with no
451
+ // key. Nothing else in the session would ever mention either.
452
+ const problem = describeFastTierProblem(fastTier, fastProviderOf(config));
453
+ if (problem)
454
+ deps.notify?.('warn', problem);
278
455
  }
279
456
  resolveKey(providerId) {
280
457
  return makeGetApiKey(this.config)(providerId);
@@ -324,6 +501,11 @@ export class AgentController {
324
501
  ? buildTeamBlock({
325
502
  maxSubagents: this.config.team.maxSubagents,
326
503
  maxConcurrent: this.config.team.maxConcurrent,
504
+ // One cross-reference sentence, present only when the capability
505
+ // is (§3.8). `this.fast` is null during the FIRST compose (the
506
+ // wiring needs the Agent), which is why the constructor composes
507
+ // again once it exists.
508
+ fastDelegation: this.fast?.delegationAvailable() === true,
327
509
  })
328
510
  : '',
329
511
  // BOTH FLAGS, exactly as team mode uses both, and for the identical
@@ -336,6 +518,17 @@ export class AgentController {
336
518
  panelVisible: this.todoPanelCapable && this.config.todo.panel,
337
519
  })
338
520
  : '',
521
+ // The SAME two flags one more time, plus the live resolution — all three
522
+ // live inside `FastWiring.available()` (§3.3). `buildFastBlock` itself
523
+ // returns `''` when neither capability is on, so a user who turned both
524
+ // off pays nothing for the tier being technically live.
525
+ fastBlock: this.fast && this.fast.available()
526
+ ? buildFastBlock({
527
+ model: this.fast.fastRef()?.modelId ?? '',
528
+ delegate: this.config.fast.delegate,
529
+ review: this.config.fast.review,
530
+ })
531
+ : '',
339
532
  });
340
533
  }
341
534
  rebuildSystemPrompt() {
@@ -437,9 +630,21 @@ export class AgentController {
437
630
  // an unfinished one survives "continue" / "now do the rest" but not four
438
631
  // unrelated turns.
439
632
  this.todos?.beginUserTurn();
633
+ // The fast reviewer is asked whether the run is on track FOR THIS (§3.5.2).
634
+ // Without it the reviewer judges progress without being told the goal, which
635
+ // is the one input it cannot reconstruct from the event stream.
636
+ this.fast?.setGoal(text);
440
637
  return this.agent.prompt(text);
441
638
  }
442
639
  abort() {
640
+ // SET BEFORE `agent.abort()`, SYNCHRONOUSLY — guard 1 of §3.5.4a. The loop
641
+ // still emits the batch's final `tool_execution_end` (`agent-loop.ts:249`)
642
+ // after an Esc lands inside the last tool's `await`, and the reviewer's
643
+ // listener runs synchronously inside that emit; without this flag it would
644
+ // see `isRunning() === true`, steer, and strand a critique in a queue
645
+ // nothing ever clears (RV-1).
646
+ this.abortRequested = true;
647
+ this.fast?.abort();
443
648
  this.agent.abort();
444
649
  // DELIBERATE REDUNDANCY (§3.8). Aborting the lead already aborts the
445
650
  // in-flight `task` call's `ctx.signal`, which the runtime listens to — that
@@ -456,6 +661,13 @@ export class AgentController {
456
661
  // routes through `steer` whenever the agent happens to be running.
457
662
  if (this.skillsEnabled)
458
663
  this.skills.absorbPendingFrames();
664
+ // COUNTED so the reviewer can prove it owns the queue before clearing it
665
+ // (§3.5.4a guard 2 / D-21). This method is the only other writer of
666
+ // `steeringQueue` in the package, and `Agent` exposes no way to remove ONE
667
+ // message — so `userSteerCount === 0` is the only sound licence for
668
+ // `clearAllQueues()`, and with a user message in there the reviewer must
669
+ // leave its own residue rather than destroy something the user typed.
670
+ this.userSteerCount += 1;
459
671
  this.agent.steer(text);
460
672
  }
461
673
  followUp(text) {
@@ -632,6 +844,7 @@ export class AgentController {
632
844
  /** Abort every child and clear timers. Idempotent; safe to call twice. */
633
845
  dispose() {
634
846
  this.teamRuntime?.dispose();
847
+ this.fast?.dispose();
635
848
  }
636
849
  // -----------------------------------------------------------------------
637
850
  // Todo planning (todo-plan-execution §3.6a)
@@ -708,11 +921,47 @@ export class AgentController {
708
921
  this.todos?.clear('user');
709
922
  }
710
923
  // -----------------------------------------------------------------------
924
+ // API retry (llm-api-retry-backoff §6.3)
925
+ //
926
+ // NO `retryRegistered` TWIN of the team / todo flag pairs, and that asymmetry
927
+ // is the point: retry is not a tool, so nothing is decided at construction.
928
+ // The policy lives on the `ProviderRegistry`, which means it can be replaced
929
+ // for a running session — including for children, because `TeamRuntime` holds
930
+ // the same registry instance.
931
+ // -----------------------------------------------------------------------
932
+ getRetryConfig() {
933
+ return this.config.retry;
934
+ }
935
+ /**
936
+ * Adopt a new retry config for this session AND PUSH IT INTO THE REGISTRY.
937
+ *
938
+ * THE SECOND CALL IS REQUIRED, NOT A NICETY (§6.3). Without it `/retry off`
939
+ * would persist correctly and change nothing until the next launch — the
940
+ * precise failure `config/schema.ts` warns about twice already, and the one a
941
+ * user reports as "the setting does nothing".
942
+ *
943
+ * Clamped here as well as on the persist path, so a bad value can reach neither
944
+ * the runtime nor the file.
945
+ */
946
+ setRetryConfig(patch) {
947
+ const retry = clampRetryConfig({ ...this.config.retry, ...patch });
948
+ this.config = { ...this.config, retry };
949
+ this.providerRegistry.setRetryPolicy(toRetryPolicy(retry));
950
+ return retry;
951
+ }
952
+ // -----------------------------------------------------------------------
711
953
  // Mutators
712
954
  // -----------------------------------------------------------------------
713
955
  setModel(provider, model, baseUrl) {
714
956
  this.config = { ...this.config, provider, model, baseUrl };
715
957
  this.agent.setModel({ providerId: provider, modelId: model, ...(baseUrl ? { baseUrl } : {}) });
958
+ // NOT NEUTRAL TO THE FAST TIER even when the user never touched a `fast.*`
959
+ // key (§3.9 / RV-3): `fast.provider: ''` and `fast.baseUrl: ''` INHERIT from
960
+ // exactly these three fields. Switching the main provider can therefore turn
961
+ // a working tier into `no_key`, or silently re-point an inheriting tier at
962
+ // another vendor's gateway — and `<fast_tier>` would go on naming the model
963
+ // it used to resolve to.
964
+ this.fast?.onConfigChanged();
716
965
  }
717
966
  setThinkingLevel(level) {
718
967
  this.config = { ...this.config, thinkingLevel: level };
@@ -729,6 +978,9 @@ export class AgentController {
729
978
  ...this.config,
730
979
  apiKeys: { ...this.config.apiKeys, [provider]: key },
731
980
  };
981
+ // Rule 5 of §3.2 reads `apiKeys`, so a key edit is a tier event: this is the
982
+ // path that turns `no_key` back into a working tier without a relaunch.
983
+ this.fast?.onConfigChanged();
732
984
  }
733
985
  setCwd(cwd) {
734
986
  this.cwd = cwd;
@@ -744,6 +996,61 @@ export class AgentController {
744
996
  getCwd() {
745
997
  return this.cwd;
746
998
  }
999
+ /**
1000
+ * The structured diff a file tool left behind, CONSUMED ONCE.
1001
+ *
1002
+ * Called by `reduceEvent` on `tool_execution_end` and nowhere else, which is
1003
+ * what keeps the store at constant size however many files a session edits.
1004
+ * `undefined` is an ordinary answer — a tool that records nothing, a toolset
1005
+ * built without a recorder, or an entry the 64-write ring has already evicted
1006
+ * — and every one of those falls back to `ToolPreview`.
1007
+ */
1008
+ takeFilePatch(toolCallId) {
1009
+ return this.fileChanges.take('lead', toolCallId);
1010
+ }
1011
+ /**
1012
+ * Sanitise a chunk into the store and hand the resulting tail to the view.
1013
+ *
1014
+ * SYNCHRONOUS, INSIDE THE CHILD-PROCESS DATA HANDLER. The alternative (queue
1015
+ * plus timer) adds a second clock to a component that already has one, and
1016
+ * `App` coalesces at the other end anyway (D-34).
1017
+ *
1018
+ * EVERY LISTENER IS WRAPPED, and that is not defensive noise (R-5 / AC-35): a
1019
+ * throw here propagates into `child.stdout.on('data')`, which is the reason a
1020
+ * render-side bug could otherwise kill a ten-minute build. `debug` rather than
1021
+ * `warn` because a listener that throws once throws on every chunk.
1022
+ */
1023
+ emitToolOutput(owner, toolCallId, chunk) {
1024
+ if (!this.toolOutputs)
1025
+ return;
1026
+ const rows = this.toolOutputs.append(owner, toolCallId, chunk);
1027
+ for (const listener of this.outputListeners) {
1028
+ try {
1029
+ listener({ toolCallId, rows });
1030
+ }
1031
+ catch (err) {
1032
+ getLogger().debug('tool', 'tool_output_listener_threw', {
1033
+ toolCallId,
1034
+ error: err instanceof Error ? err.message : String(err),
1035
+ });
1036
+ }
1037
+ }
1038
+ }
1039
+ /**
1040
+ * Subscribe to the CLI-local live tool-output stream (§3.1.3).
1041
+ *
1042
+ * Returns a no-op unsubscribe when `liveToolOutput` was off at construction,
1043
+ * so every caller can subscribe unconditionally -- the contract
1044
+ * `subscribeTeam` / `subscribeTodos` / `subscribeFast` already set.
1045
+ */
1046
+ subscribeToolOutput(listener) {
1047
+ if (!this.toolOutputs)
1048
+ return () => { };
1049
+ this.outputListeners.add(listener);
1050
+ return () => {
1051
+ this.outputListeners.delete(listener);
1052
+ };
1053
+ }
747
1054
  clearMessages() {
748
1055
  this.agent.clearMessages();
749
1056
  // A new conversation has no loaded skills, so the "already loaded earlier
@@ -782,10 +1089,95 @@ export class AgentController {
782
1089
  }
783
1090
  /** Resolve `ModelInfo` for the active model, falling back to a runtime model. */
784
1091
  getModelInfo() {
785
- const found = this.modelRegistry.getModel(this.config.provider, this.config.model);
1092
+ return this.getModelInfoFor({
1093
+ providerId: this.config.provider,
1094
+ modelId: this.config.model,
1095
+ });
1096
+ }
1097
+ /**
1098
+ * Resolve `ModelInfo` for ANY model (fast-model-tier §3.6 / RV-4).
1099
+ *
1100
+ * ONE RESOLUTION PATH, which is the same reason §3.2 has exactly one
1101
+ * `resolveFastTier`: `getModelInfo()` above is now a call into this, so "the
1102
+ * cost table for the model that actually billed" cannot drift from "the cost
1103
+ * table for the session's model" as two separate bodies.
1104
+ */
1105
+ getModelInfoFor(ref) {
1106
+ const found = this.modelRegistry.getModel(ref.providerId, ref.modelId);
786
1107
  if (found)
787
1108
  return found;
788
- return this.modelRegistry.buildRuntimeModel(this.config.provider, this.config.model);
1109
+ return this.modelRegistry.buildRuntimeModel(ref.providerId, ref.modelId);
1110
+ }
1111
+ /**
1112
+ * Whether the STATIC table knows this model's price (C-11 / RV-4).
1113
+ *
1114
+ * `buildRuntimeModel` returns `cost: { input: 0, output: 0 }` for a model it
1115
+ * has never seen — a table of zeros, not a missing one — so every consumer
1116
+ * that renders spend needs to be able to tell "free" from "unknown". The fast
1117
+ * tier is precisely where an unrecognised id is likely, and `$0.00` on a
1118
+ * feature that is spending money is the same class of lie as pricing a Haiku
1119
+ * child at Sonnet rates.
1120
+ */
1121
+ isPricedModel(ref) {
1122
+ return this.modelRegistry.getModel(ref.providerId, ref.modelId) !== undefined;
1123
+ }
1124
+ // -----------------------------------------------------------------------
1125
+ // Fast model tier (fast-model-tier §3.1) — FOUR THIN FORWARDERS
1126
+ // -----------------------------------------------------------------------
1127
+ /** Whether `task`'s schema carries `model`. Fixed at construction (C-2). */
1128
+ isFastRegistered() {
1129
+ return this.fast !== null;
1130
+ }
1131
+ /** Whether the tier will actually be used. Flipped live by `/fast on|off`. */
1132
+ isFastEnabled() {
1133
+ return this.fast?.isEnabled() === true;
1134
+ }
1135
+ /**
1136
+ * Flip the live switch and re-render the prompt.
1137
+ *
1138
+ * Turning it ON in a session that started without the tier deliberately does
1139
+ * NOT register it: the tool array is immutable (C-2), and advertising a schema
1140
+ * field that is not there is the dead end `/team on` already documents.
1141
+ * `/fast` reports that honestly by checking `isFastRegistered()` first.
1142
+ */
1143
+ setFastEnabled(enabled) {
1144
+ this.fast?.setEnabled(enabled);
1145
+ }
1146
+ getFastConfig() {
1147
+ return this.config.fast;
1148
+ }
1149
+ /**
1150
+ * Adopt a new fast config for this session.
1151
+ *
1152
+ * THE RE-RESOLUTION IS NOT OPTIONAL (§3.2 rule 8). `App` reads
1153
+ * `controller.getConfig()` on every render and the block interpolates the
1154
+ * model id, so a persist without this call reports success and changes
1155
+ * nothing until relaunch — the failure `/todo panel` records (P1-2).
1156
+ */
1157
+ setFastConfig(patch) {
1158
+ const fast = clampFastConfig({ ...this.config.fast, ...patch });
1159
+ this.config = { ...this.config, fast };
1160
+ // `onConfigChanged` rebuilds the prompt; when there is no wiring (the tier
1161
+ // was never registered) nothing has to be rebuilt, because no block is
1162
+ // spliced either way.
1163
+ this.fast?.onConfigChanged();
1164
+ return fast;
1165
+ }
1166
+ /** Everything `/fast status`, the settings line and the chip read. */
1167
+ getFastStatus() {
1168
+ return this.fast ? this.fast.status() : offFastStatus();
1169
+ }
1170
+ /**
1171
+ * Subscribe to the CLI-local fast event stream.
1172
+ *
1173
+ * Returns a no-op unsubscribe when the tier was off at construction, so every
1174
+ * caller can subscribe unconditionally — the contract `subscribeTeam` and
1175
+ * `subscribeTodos` already set.
1176
+ */
1177
+ subscribeFast(listener) {
1178
+ if (!this.fast)
1179
+ return () => { };
1180
+ return this.fast.subscribe(listener);
789
1181
  }
790
1182
  /** List usable tools (name + description) for the /tools command. */
791
1183
  listTools() {
@@ -8,6 +8,7 @@ import type { AgentEvent, ModelInfo } from '@aragon-agent/core';
8
8
  import type { PreflightResult } from './controller.js';
9
9
  import type { TeamEvent } from '../team/types.js';
10
10
  import type { TodoEvent, TodoSnapshot } from '../todo/types.js';
11
+ import type { FastEvent } from '../fast/types.js';
11
12
  import { type FollowThroughMode } from '../todo/follow-through.js';
12
13
  /** The minimal controller surface headless mode needs (real or stubbed). */
13
14
  export interface HeadlessController {
@@ -35,6 +36,13 @@ export interface HeadlessController {
35
36
  * keeps a `--no-todo` headless run byte-identical to today's.
36
37
  */
37
38
  subscribeTodos?(listener: (event: TodoEvent) => void): () => void;
39
+ /**
40
+ * The CLI-local fast-tier stream — OPTIONAL, for the identical reason the two
41
+ * above are (fast-model-tier §3.9). A controller with no tier has no fast
42
+ * events, and `runHeadless` calls it with `?.`, which keeps a default `-p` run
43
+ * byte-identical to today's.
44
+ */
45
+ subscribeFast?(listener: (event: FastEvent) => void): () => void;
38
46
  /**
39
47
  * The current plan — OPTIONAL, for the identical reason the two subscriptions
40
48
  * above are (todo-plan-followthrough §3.6 rule 2). ABSENT MEANS THE
@@ -21,6 +21,8 @@ export async function runHeadless(controller, prompt, options = {}) {
21
21
  let sawTurnEnd = false;
22
22
  let wroteText = false;
23
23
  const total = { inputTokens: 0, outputTokens: 0 };
24
+ /** Retries seen this run, so the recovery line can name a count. */
25
+ let retryCount = 0;
24
26
  const unsubscribe = controller.subscribe((event) => {
25
27
  switch (event.type) {
26
28
  case 'message_update': {
@@ -33,6 +35,25 @@ export async function runHeadless(controller, prompt, options = {}) {
33
35
  errored = true;
34
36
  err.write(`\n${formatStreamError(se.error)}\n`);
35
37
  }
38
+ else if (se.type === 'retry_scheduled') {
39
+ // STDERR, NEVER STDOUT (llm-api-retry-backoff §6.7). stdout is the
40
+ // machine-readable answer and has to stay parseable by
41
+ // `aragon -p | jq`; a retry line on it would corrupt every consumer.
42
+ // Not `--quiet`-gated, unlike the tool lines: a three-minute pause with
43
+ // no explanation anywhere is not a "compact" run, it is a hang.
44
+ retryCount = se.attempt;
45
+ // The em dash is a literal because `agent/headless.ts` is the CLI's one
46
+ // documented glyph-scanner exemption: `aragon -p` does no capability
47
+ // probe, so there is no `Glyphs` set to read here.
48
+ err.write(`\n[retry ${se.attempt}/${se.maxRetries}] ${se.errorType} — ` +
49
+ `waiting ${Math.round(se.delayMs / 1000)}s\n`);
50
+ }
51
+ else if (se.type === 'stream_restart') {
52
+ // The partial answer already on stdout is about to be replayed. Saying so
53
+ // is the only way a piped consumer can tell a duplicated paragraph from
54
+ // a model that repeated itself.
55
+ err.write(`[retry] discarding a partial response and restarting\n`);
56
+ }
36
57
  break;
37
58
  }
38
59
  case 'tool_execution_start':
@@ -46,6 +67,10 @@ export async function runHeadless(controller, prompt, options = {}) {
46
67
  break;
47
68
  case 'turn_end':
48
69
  sawTurnEnd = true;
70
+ if (retryCount > 0) {
71
+ err.write(`[retry] recovered after ${retryCount} ${retryCount === 1 ? 'retry' : 'retries'}\n`);
72
+ retryCount = 0;
73
+ }
49
74
  total.inputTokens += event.usage.inputTokens;
50
75
  total.outputTokens += event.usage.outputTokens;
51
76
  break;
@@ -128,6 +153,20 @@ export async function runHeadless(controller, prompt, options = {}) {
128
153
  lastActive = activeIndex;
129
154
  err.write(`[todo] ${activeIndex + 1}/${total} ${items[activeIndex]?.content ?? ''}\n`);
130
155
  });
156
+ // Fast-tier review spend must reach the `[usage]` footer, or `-p` under-reports
157
+ // by however much the reviewer consumed (§3.6).
158
+ //
159
+ // NOTHING IS WRITTEN TO STDOUT, AND NOTHING TO STDERR EITHER (D-12). stdout is
160
+ // the answer channel and is piped into other tools; stderr in `-p` carries the
161
+ // tool trace, and an unrequested second opinion is not a tool the user asked
162
+ // for. The reviews are in the log records either way, which is where a `-p`
163
+ // user goes to ask what happened.
164
+ const unsubscribeFast = controller.subscribeFast?.((event) => {
165
+ if (event.type !== 'usage')
166
+ return;
167
+ total.inputTokens += event.usage.inputTokens;
168
+ total.outputTokens += event.usage.outputTokens;
169
+ });
131
170
  // --- The bounded continuation loop (todo-plan-followthrough §3.6). --------
132
171
  //
133
172
  // THE THREE `unsubscribe` CALLS STAY OUTSIDE IT (rule 4). The loop wraps the
@@ -187,6 +226,7 @@ export async function runHeadless(controller, prompt, options = {}) {
187
226
  unsubscribe();
188
227
  unsubscribeTeam?.();
189
228
  unsubscribeTodos?.();
229
+ unsubscribeFast?.();
190
230
  }
191
231
  // Ensure a trailing newline so piped output is well-formed.
192
232
  if (wroteText)