@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
@@ -137,6 +137,9 @@ export function createSubagent(spec, deps, hooks) {
137
137
  const run = {
138
138
  label: spec.label,
139
139
  description: spec.description,
140
+ // FROM THE SPEC, which the normalizer already downgraded when the tier was
141
+ // unavailable — so this is what RAN, not what was asked for (§3.4).
142
+ tier: spec.tier,
140
143
  phase: 'queued',
141
144
  turns: 0,
142
145
  toolCalls: 0,
@@ -151,11 +154,31 @@ export function createSubagent(spec, deps, hooks) {
151
154
  let agentRef = null;
152
155
  const getAgent = () => agentRef;
153
156
  const tools = buildSubagentTools(spec, deps, getAgent);
154
- const model = {
155
- providerId: deps.config.provider,
156
- modelId: deps.config.model,
157
- ...(deps.config.baseUrl ? { baseUrl: deps.config.baseUrl } : {}),
158
- };
157
+ // THE TWO HARDCODED CONFIG READS THAT USED TO LIVE HERE NOW ROUTE THROUGH ONE
158
+ // RESOLVER (fast-model-tier §3.4). The fallback reproduces them exactly, so a
159
+ // caller that passes no `resolveTier` builds a byte-identical child.
160
+ //
161
+ // `thinkingLevel` is PER TIER and defaults to `'off'` for the fast tier
162
+ // (D-13): a "fast" model asked to think for 32 768 tokens is not fast, and
163
+ // inheriting the session's `xhigh` into a mechanical file-scan child would be
164
+ // the most expensive possible reading of the word.
165
+ //
166
+ // `maxTokens` is deliberately NOT per tier (RV-15). A child inherits
167
+ // `deps.config.maxTokens` below, which is an ambition sized for the main
168
+ // model; the adapter clamps it down to whatever the fast model actually
169
+ // accepts (`llm/provider.ts:44-52`), so a large inherited value degrades to
170
+ // the right number rather than to an HTTP 400.
171
+ const resolved = deps.resolveTier
172
+ ? deps.resolveTier(spec.tier)
173
+ : {
174
+ ref: {
175
+ providerId: deps.config.provider,
176
+ modelId: deps.config.model,
177
+ ...(deps.config.baseUrl ? { baseUrl: deps.config.baseUrl } : {}),
178
+ },
179
+ thinkingLevel: deps.config.thinkingLevel,
180
+ };
181
+ const model = resolved.ref;
159
182
  const agent = deps.agentFactory({
160
183
  systemPrompt: buildSystemPrompt({
161
184
  cwd: deps.getCwd(),
@@ -174,7 +197,7 @@ export function createSubagent(spec, deps, hooks) {
174
197
  }),
175
198
  model,
176
199
  tools,
177
- thinkingLevel: deps.config.thinkingLevel,
200
+ thinkingLevel: resolved.thinkingLevel,
178
201
  // The LEAD's registry instance, REUSED (P2-2). It holds adapter state, and
179
202
  // constructing five of them per dispatch is pure waste.
180
203
  providerRegistry: deps.providerRegistry,
@@ -194,18 +217,34 @@ export function createSubagent(spec, deps, hooks) {
194
217
  agentRef = agent;
195
218
  let lastEmit = 0;
196
219
  let lastPhase = run.phase;
220
+ /**
221
+ * The retry projection as it was last PUBLISHED (§6.10).
222
+ *
223
+ * A retry transition has to pass the throttle for the same reason a phase
224
+ * transition does, and it is not covered by the phase check: a child entering
225
+ * backoff stays `thinking`, so `retry_scheduled` arriving within a throttle
226
+ * window of the previous token would be COALESCED AWAY — and the next event is
227
+ * up to thirty seconds later, which is exactly the interval the row exists to
228
+ * explain. The row would then say `thinking` for the whole wait.
229
+ */
230
+ let lastRetryKey = '';
197
231
  // The tail of the text the child is writing right now (F-1). Bounded on every
198
232
  // append: a long turn would otherwise grow this without limit, and it is the
199
233
  // only per-token allocation this feature adds.
200
234
  let textTail = '';
201
235
  const emit = () => {
202
236
  const now = Date.now();
203
- // Phase transitions always pass; everything else is coalesced. Five children
204
- // streaming tokens must not push five React renders per token, and the
205
- // streaming coalescer in `App.tsx` protects the transcript, not this path.
206
- if (run.phase === lastPhase && now - lastEmit < TEAM_LIMITS.agentUpdateThrottleMs)
237
+ const retryKey = run.retry ? `${run.retry.attempt}/${run.retry.maxRetries}` : '';
238
+ // Phase and retry transitions always pass; everything else is coalesced. Five
239
+ // children streaming tokens must not push five React renders per token, and
240
+ // the streaming coalescer in `App.tsx` protects the transcript, not this path.
241
+ if (run.phase === lastPhase &&
242
+ retryKey === lastRetryKey &&
243
+ now - lastEmit < TEAM_LIMITS.agentUpdateThrottleMs) {
207
244
  return;
245
+ }
208
246
  lastPhase = run.phase;
247
+ lastRetryKey = retryKey;
209
248
  lastEmit = now;
210
249
  // Exactly one sanitize per PUBLISHED frame rather than one per token (P2-2):
211
250
  // `emit()` throws away all but one result per throttle window, so two
@@ -238,6 +277,9 @@ export function createSubagent(spec, deps, hooks) {
238
277
  break;
239
278
  case 'turn_end': {
240
279
  run.turns += 1;
280
+ // A completed turn means whatever it retried, it recovered (§6.10). The
281
+ // row goes back to reporting its phase.
282
+ run.retry = undefined;
241
283
  run.usage = {
242
284
  inputTokens: run.usage.inputTokens + event.usage.inputTokens,
243
285
  outputTokens: run.usage.outputTokens + event.usage.outputTokens,
@@ -288,6 +330,26 @@ export function createSubagent(spec, deps, hooks) {
288
330
  if (event.streamEvent.type === 'error') {
289
331
  run.error = formatStreamError(event.streamEvent.error);
290
332
  run.retryable = isRetryableStreamError(event.streamEvent.error);
333
+ // The ladder is over: an `error` reaching a subscriber means `withRetry`
334
+ // stopped retrying. Leaving the projection set would freeze the row at
335
+ // `retrying 10/10` on top of a failure (§6.10).
336
+ run.retry = undefined;
337
+ }
338
+ else if (event.streamEvent.type === 'retry_scheduled') {
339
+ // SET, and the phase is deliberately LEFT ALONE: the child is still
340
+ // `thinking` as far as the scheduler is concerned, and rewriting the
341
+ // phase would put a retry into `SubagentPhase` — a union the report, the
342
+ // panel and `canSend` all read for a different question.
343
+ run.retry = {
344
+ attempt: event.streamEvent.attempt,
345
+ maxRetries: event.streamEvent.maxRetries,
346
+ };
347
+ }
348
+ else if (event.streamEvent.type === 'retry_attempt') {
349
+ run.retry = {
350
+ attempt: event.streamEvent.attempt,
351
+ maxRetries: event.streamEvent.maxRetries,
352
+ };
291
353
  }
292
354
  else if (event.streamEvent.type === 'text_delta') {
293
355
  // O(1) per delta: one bounded concat and one bounded slice, and
@@ -27,12 +27,38 @@ export interface TaskToolDeps {
27
27
  runtime: TeamRuntime;
28
28
  /** Read LIVE, never cached: `/team off` flips this, it does not unregister. */
29
29
  isTeamEnabled: () => boolean;
30
+ /**
31
+ * Whether the fast tier was resolvable AT CONSTRUCTION (fast-model-tier §3.3 /
32
+ * C-2).
33
+ *
34
+ * DECIDED ONCE, because the tool array is built once and must never be
35
+ * rebuilt: `Agent.setTools()` mutates the live `ToolRegistry`, and
36
+ * `submit_plan` flips the session mode from INSIDE a tool execution. So the
37
+ * `model` property either exists in this schema for the whole session or never
38
+ * does — and with it absent, `TASK_DESCRIPTION` and the schema are
39
+ * BYTE-IDENTICAL to the pre-feature build (AC-3).
40
+ *
41
+ * Optional so every existing construction site and test compiles unchanged.
42
+ */
43
+ fastRegistered?: boolean;
44
+ /**
45
+ * Whether `model:"fast"` is honoured RIGHT NOW. Read LIVE, at dispatch time
46
+ * (RV-3) — the tier can stop resolving mid-session, and a downgrade is the
47
+ * honest observable either way.
48
+ */
49
+ fastAvailable?: () => boolean;
30
50
  maxSubagents: () => number;
31
51
  /** Whether a usable key exists for the active provider right now (P2-3). */
32
52
  hasApiKey: () => boolean;
33
53
  activeProvider: () => string;
34
54
  /** The lead's cost table, so the report header states real spend (R-5). */
35
55
  modelCost: () => ModelCost | undefined;
56
+ /** The FAST model's cost table (fast-model-tier §3.4). Absent when the tier is
57
+ * off, which is what leaves the report byte-identical. */
58
+ fastModelCost?: () => ModelCost | undefined;
59
+ /** Whether the static price table has never heard of the fast model (C-11 /
60
+ * RV-4). `unknown` is rendered as unknown, NEVER as `$0.00`. */
61
+ fastPricingUnknown?: () => boolean;
36
62
  /**
37
63
  * Run the dispatch with the LEAD's idle watchdog paused, `try/finally` inside.
38
64
  *
@@ -41,11 +41,37 @@ const TASK_DESCRIPTION = 'Run several independent subagents in parallel and get
41
41
  'cannot ask the user anything. Give each a description of at most ' +
42
42
  `${TEAM_LIMITS.descriptionChars} characters and a complete prompt. Extra entries beyond the ` +
43
43
  'configured maximum are dropped, so keep within it.';
44
+ /**
45
+ * The one sentence appended when the fast tier exists (fast-model-tier §4.1).
46
+ *
47
+ * WHEN to use a tier is POLICY and lives here and in `<fast_tier>`, where the
48
+ * model actually reads it; the `enum` below is SHAPE and lives in the schema.
49
+ * That split is what keeps behaviour the same whether or not `ajv` — an OPTIONAL
50
+ * dependency of core — happened to install (C-8).
51
+ */
52
+ const TASK_FAST_SENTENCE = ' Set model:"fast" on a subagent whose job is bulk reading, searching or summarizing; ' +
53
+ 'leave it out for work that needs judgement.';
54
+ /**
55
+ * NOT `as const`: `AgentTool.parameters` is a `JSONSchema7`, whose `enum` is a
56
+ * MUTABLE `unknown[]`, and a readonly tuple is not assignable to it.
57
+ */
58
+ const SUBAGENT_MODEL_PROPERTY = {
59
+ type: 'string',
60
+ enum: ['main', 'fast'],
61
+ default: 'main',
62
+ description: 'Which tier runs this subagent. "fast" is a cheaper, quicker model - use it for ' +
63
+ 'mechanical, high-volume work (reading or searching many files, summarizing long ' +
64
+ 'output, mechanical edits). Keep "main" for design, tricky debugging, and anything ' +
65
+ 'that must be right the first time.',
66
+ };
44
67
  export function createTaskTool(deps) {
68
+ const fastRegistered = deps.fastRegistered === true;
45
69
  return {
46
70
  name: 'task',
47
71
  label: 'Dispatch subagents',
48
- description: TASK_DESCRIPTION,
72
+ // BYTE-IDENTICAL WITHOUT THE TIER (AC-3): concatenation is conditional, so a
73
+ // default session's description is the exact string it always was.
74
+ description: fastRegistered ? `${TASK_DESCRIPTION}${TASK_FAST_SENTENCE}` : TASK_DESCRIPTION,
49
75
  parameters: {
50
76
  type: 'object',
51
77
  properties: {
@@ -73,6 +99,11 @@ export function createTaskTool(deps) {
73
99
  default: false,
74
100
  description: 'Refuse all writes and shell for this subagent.',
75
101
  },
102
+ // Spread rather than a `?:` property, so with the tier off the
103
+ // object has exactly the four keys it always had — `undefined`
104
+ // survives `JSON.stringify` as an absent key but not as an
105
+ // identical object, and AC-3 is asserted on the schema itself.
106
+ ...(fastRegistered ? { model: SUBAGENT_MODEL_PROPERTY } : {}),
76
107
  },
77
108
  required: ['description', 'prompt'],
78
109
  },
@@ -100,19 +131,32 @@ export function createTaskTool(deps) {
100
131
  'yourself, or ask the user to set a key.');
101
132
  }
102
133
  // 4. Repair, never reject. Zero survivors is the single hard failure.
103
- const { specs, requested } = normalizeSubagentSpecs(params.subagents, deps.maxSubagents());
134
+ // `fastAvailable` is read HERE, at dispatch time, not captured at
135
+ // construction (RV-3): the tier can stop resolving mid-session, and the
136
+ // normalizer and the subagent factory must agree about that at the same
137
+ // instant or a child gets built against a provider with no key.
138
+ const { specs, requested, downgraded } = normalizeSubagentSpecs(params.subagents, deps.maxSubagents(), { fastAvailable: deps.fastAvailable?.() === true });
104
139
  if (specs.length === 0) {
105
140
  return errorResult('No usable subagent specs: each needs a description and a prompt.');
106
141
  }
107
142
  // 5. Pause the lead's watchdog for the whole dispatch (I-3).
108
- const outcome = await deps.withPausedWatchdog(() => deps.runtime.dispatch(specs, requested, ctx.signal));
143
+ const outcome = await deps.withPausedWatchdog(() => deps.runtime.dispatch(specs, requested, ctx.signal, { downgraded }));
109
144
  // 6. ALWAYS A NON-ERROR RESULT, abort and all-children-failed included.
110
145
  // A cancellation or a partial result rendered as a tool FAILURE invites
111
146
  // the model to retry the whole fan-out, which is the most expensive
112
147
  // possible reaction (the same reasoning as `ask_user`'s cancelled
113
148
  // shape, and D-14 one level up).
149
+ // TWO COST TABLES (§3.4 / R-7). Summing fast-tier tokens at the lead's
150
+ // price is not a rounding error: for a Haiku child under a Sonnet lead
151
+ // it over-reports by roughly an order of magnitude, and the whole
152
+ // justification for the feature is a number in this report.
114
153
  const cost = deps.modelCost();
115
- return textResult(buildDispatchReport(outcome, cost ? { cost } : {}));
154
+ const fastCost = deps.fastModelCost?.();
155
+ return textResult(buildDispatchReport(outcome, {
156
+ ...(cost ? { cost } : {}),
157
+ ...(fastCost ? { fastCost } : {}),
158
+ ...(deps.fastPricingUnknown?.() ? { fastPricingUnknown: true } : {}),
159
+ }));
116
160
  },
117
161
  };
118
162
  }
@@ -11,6 +11,7 @@
11
11
  * changes ZERO files under `packages/core/` (AC-12).
12
12
  */
13
13
  import type { TokenUsage } from '@aragon-agent/core';
14
+ import type { FastTierName } from '../fast/types.js';
14
15
  import type { ActivityArgs } from './activity.js';
15
16
  /** One normalized delegation request. Produced only by `normalizeSubagentSpecs`. */
16
17
  export interface SubagentSpec {
@@ -26,12 +27,34 @@ export interface SubagentSpec {
26
27
  * permission than the session it was spawned from.
27
28
  */
28
29
  readOnly: boolean;
30
+ /**
31
+ * Which model runs this child (fast-model-tier §3.4).
32
+ *
33
+ * THE WIRE NAME IS `model` AND THE INTERNAL NAME IS `tier`, on purpose: a
34
+ * model expects to see `model:"fast"`, while `SubagentSpec.model` would read
35
+ * as a model ID at every call site in this package.
36
+ *
37
+ * DELEGATION IS A MODEL CHOICE, NEVER A PERMISSION BOUNDARY (D-15 / I-4). A
38
+ * fast child gets the same tools, the same `--confirm` gate, the same
39
+ * plan-mode gate and the same skills ceiling as any other; only the model
40
+ * differs. Restricting a fast child's tools would look like security, would
41
+ * not be, and would make the report's `[fast]` rows mean two different things.
42
+ */
43
+ tier: FastTierName;
29
44
  }
30
45
  export type SubagentPhase = 'queued' | 'starting' | 'thinking' | 'tool' | 'waiting' | 'done' | 'failed' | 'aborted';
31
46
  /** Live, mutable record of one child. Projected into the UI and the report. */
32
47
  export interface SubagentRun {
33
48
  label: string;
34
49
  description: string;
50
+ /**
51
+ * The tier this child actually ran on (fast-model-tier §3.4).
52
+ *
53
+ * WHAT RAN, NOT WHAT WAS ASKED FOR. A spec the normalizer downgraded arrives
54
+ * here as `'main'`, which is what makes the report's `[fast]` annotation and
55
+ * its second cost table agree with the bill.
56
+ */
57
+ tier: FastTierName;
35
58
  phase: SubagentPhase;
36
59
  startedAt?: number;
37
60
  endedAt?: number;
@@ -104,6 +127,42 @@ export interface SubagentRun {
104
127
  * `formatStreamError` and must never become a machine-readable contract.
105
128
  */
106
129
  retryable?: boolean;
130
+ /**
131
+ * The child's LIVE API-retry state (llm-api-retry-backoff §6.10), or absent when
132
+ * it is not in a backoff.
133
+ *
134
+ * THIS EXISTS BECAUSE A CHILD'S RETRY IS OTHERWISE INVISIBLE (R-14). Subagents
135
+ * inherit the lead's retry policy for free — `TeamRuntime` reuses the lead's
136
+ * registry instance — but a child's `retry_scheduled` goes to that CHILD's own
137
+ * `Agent` listeners and never reaches the lead's `ViewState`. Without this field
138
+ * a subagent spending three minutes in backoff shows no card, no chip and no
139
+ * countdown: the dispatch simply appears hung, which is the single worst reading
140
+ * of a mechanism whose whole claim is that waiting is now legible.
141
+ *
142
+ * The fix uses the channel that already exists rather than inventing a second
143
+ * card: `TeamCard` and `transcript-text` already render one row per child, and
144
+ * this rides on it. `ViewState.retry` stays LEAD-ONLY, and the lead's status-bar
145
+ * chip is deliberately NOT driven by children — `agents 3/5` already reports the
146
+ * dispatch, and a second aggregate would put two numbers on the bar that
147
+ * disagree about what "3" means.
148
+ *
149
+ * EPHEMERAL, exactly like `activity` and `activityArgs`: cleared on the child's
150
+ * next `turn_end` or terminal error, never persisted, and a settled team entry
151
+ * keeps only what the run finally did.
152
+ *
153
+ * NO `resumeAt`, DELIBERATELY, and this is the one place §6.10's sketch is not
154
+ * followed. A countdown needs something to re-render it, and nothing does: the
155
+ * team row is drawn from `agent_update`, a child in backoff emits exactly ONE
156
+ * event for the whole wait, and the lead's 1 Hz `retryTick` is gated on the
157
+ * LEAD's own `state.retry`. A `7s` that never changed for thirty seconds is a
158
+ * worse readout than no number at all — it reads as a hung countdown, which is
159
+ * precisely the impression this row exists to remove. The counter alone is
160
+ * honest and is what AC-34 asserts.
161
+ */
162
+ retry?: {
163
+ attempt: number;
164
+ maxRetries: number;
165
+ };
107
166
  }
108
167
  /** One message on the team bus. */
109
168
  export interface TeamMessage {
@@ -124,8 +183,27 @@ export interface DispatchOutcome {
124
183
  aborted: boolean;
125
184
  /** Messages addressed to `lead`; surfaced in their own report section. */
126
185
  leadMail: TeamMessage[];
127
- /** Summed across every child. Real spend; it must reach the status bar (§3.9). */
186
+ /**
187
+ * Summed across MAIN-TIER children. Real spend; it must reach the status bar
188
+ * (§3.9).
189
+ *
190
+ * THE MEANING NARROWED WHEN THE FAST TIER LANDED, and the split is not a
191
+ * rounding concern: a Haiku child under a Sonnet lead priced at the lead's
192
+ * table over-reports by roughly an order of magnitude, and the entire
193
+ * justification for delegating is a number in this report (R-7).
194
+ */
128
195
  usage: TokenUsage;
196
+ /** Summed across FAST-TIER children. Absent when none ran, which is what
197
+ * keeps a pre-feature dispatch's report byte-identical. */
198
+ fastUsage?: TokenUsage;
199
+ /**
200
+ * Specs that asked for `model:"fast"` and ran on `main` anyway (§3.4 / R-6).
201
+ *
202
+ * REPORTED, NEVER SILENT. A silent downgrade means the model asked for a cheap
203
+ * child, got an expensive one, and has no way to learn that its cost model is
204
+ * wrong.
205
+ */
206
+ downgraded?: number;
129
207
  }
130
208
  /**
131
209
  * What the UI renders: a cheap immutable projection, rebuilt per event.
@@ -160,6 +238,7 @@ export type TeamEvent = {
160
238
  dispatchId: string;
161
239
  label: string;
162
240
  usage: TokenUsage;
241
+ tier: FastTierName;
163
242
  } | {
164
243
  type: 'message';
165
244
  dispatchId: string;
@@ -57,7 +57,9 @@ export function makeBash(deps) {
57
57
  },
58
58
  required: ['command'],
59
59
  },
60
- async execute(_id, rawParams, ctx) {
60
+ // `id` rather than `_id`: the tool-call id is what keys the live-output side
61
+ // channel (agent-activity-presentation-live §3.1.2).
62
+ async execute(id, rawParams, ctx) {
61
63
  const params = rawParams;
62
64
  const command = params.command;
63
65
  const cwd = params.cwd ? params.cwd : deps.getCwd();
@@ -106,11 +108,25 @@ export function makeBash(deps) {
106
108
  clearTimeout(timer);
107
109
  ctx.signal?.removeEventListener('abort', onAbort);
108
110
  }
111
+ // `output` IS UNTOUCHED BY THE RECORDER, and that is what makes AC-30
112
+ // checkable by comparison rather than by inspection: the string the
113
+ // model receives, its truncation and the `[exit code N]` footer are all
114
+ // byte-identical to the pre-round build. The recorder is a SECOND
115
+ // consumer of a chunk this tool already had (R-7).
116
+ //
117
+ // No throttle here, deliberately: `append` is O(chunk), and a per-chunk
118
+ // throttle in the tool would drop the LAST chunk of a burst -- the one
119
+ // that matters. What must be throttled is the view update, and `App`
120
+ // does that where the machinery for it already lives (§3.1.2 / D-34).
109
121
  child.stdout?.on('data', (d) => {
110
- output += d.toString();
122
+ const s = d.toString();
123
+ output += s;
124
+ deps.recordOutput?.(id, s);
111
125
  });
112
126
  child.stderr?.on('data', (d) => {
113
- output += d.toString();
127
+ const s = d.toString();
128
+ output += s;
129
+ deps.recordOutput?.(id, s);
114
130
  });
115
131
  child.on('error', (err) => {
116
132
  finish(() => errorResult(`Failed to run command: ${err.message}`));
@@ -1,11 +1,24 @@
1
1
  /**
2
- * Minimal unified-diff renderer for `edit_file` previews (spec §4).
2
+ * The MODEL-FACING unified diff for `edit_file` (spec §4), re-expressed on top
3
+ * of `buildPatch` (agent-activity-presentation §3.3.4).
3
4
  *
4
- * Not a full Myers diff: it trims the common leading/trailing lines and renders
5
- * the differing middle as removed (`-`) / added (`+`) blocks with a small
6
- * amount of surrounding context. That is enough for a legible edit preview
7
- * without pulling in a diff dependency.
5
+ * The signature, the call site (`fs-tools.ts`), the `---` / `+++` headers and the
6
+ * ` ` / `- ` / `+ ` row prefixes are all unchanged; what changed is that the
7
+ * differing middle is now a real hunk list instead of one removed block followed
8
+ * by one added block. For a `replace_all` touching line 40 and line 900 that used
9
+ * to be 860 removed rows and 860 near-identical added rows.
10
+ *
11
+ * TWO WIDTHS, ONE ALGORITHM (D-20). This renderer keeps `CONTEXT_LINES = 2`; the
12
+ * UI passes `PATCH_LIMITS.context` (3). Collapsing them to one constant is the
13
+ * tempting simplification and it is wrong: `fs-tools.ts` passes no override, so a
14
+ * shared 3 would add two context rows to EVERY model-facing diff — the direction
15
+ * AC-17 forbids, in the most common case, with nothing watching.
16
+ *
17
+ * The shape of the change to the model's input is therefore exact: a
18
+ * single-region edit is today's rows plus ONE `@@` header, a multi-region edit is
19
+ * strictly smaller, and a trailing `\r` no longer reaches the prompt.
8
20
  */
21
+ import { type FilePatch } from './patch.js';
9
22
  export interface DiffOptions {
10
23
  /** File path shown in the `---`/`+++` header. */
11
24
  path?: string;
@@ -13,3 +26,10 @@ export interface DiffOptions {
13
26
  context?: number;
14
27
  }
15
28
  export declare function renderUnifiedDiff(oldStr: string, newStr: string, options?: DiffOptions): string;
29
+ /**
30
+ * A `FilePatch` as unified-diff text.
31
+ *
32
+ * Exported so the patch the user reads and the diff the model reads are provably
33
+ * the same change (D-10), and so `patch.test.ts` can assert the round trip.
34
+ */
35
+ export declare function renderPatchText(patch: FilePatch, path?: string): string;
@@ -1,50 +1,81 @@
1
1
  /**
2
- * Minimal unified-diff renderer for `edit_file` previews (spec §4).
2
+ * The MODEL-FACING unified diff for `edit_file` (spec §4), re-expressed on top
3
+ * of `buildPatch` (agent-activity-presentation §3.3.4).
3
4
  *
4
- * Not a full Myers diff: it trims the common leading/trailing lines and renders
5
- * the differing middle as removed (`-`) / added (`+`) blocks with a small
6
- * amount of surrounding context. That is enough for a legible edit preview
7
- * without pulling in a diff dependency.
5
+ * The signature, the call site (`fs-tools.ts`), the `---` / `+++` headers and the
6
+ * ` ` / `- ` / `+ ` row prefixes are all unchanged; what changed is that the
7
+ * differing middle is now a real hunk list instead of one removed block followed
8
+ * by one added block. For a `replace_all` touching line 40 and line 900 that used
9
+ * to be 860 removed rows and 860 near-identical added rows.
10
+ *
11
+ * TWO WIDTHS, ONE ALGORITHM (D-20). This renderer keeps `CONTEXT_LINES = 2`; the
12
+ * UI passes `PATCH_LIMITS.context` (3). Collapsing them to one constant is the
13
+ * tempting simplification and it is wrong: `fs-tools.ts` passes no override, so a
14
+ * shared 3 would add two context rows to EVERY model-facing diff — the direction
15
+ * AC-17 forbids, in the most common case, with nothing watching.
16
+ *
17
+ * The shape of the change to the model's input is therefore exact: a
18
+ * single-region edit is today's rows plus ONE `@@` header, a multi-region edit is
19
+ * strictly smaller, and a trailing `\r` no longer reaches the prompt.
8
20
  */
21
+ import { PATCH_LIMITS, buildPatch } from './patch.js';
9
22
  const CONTEXT_LINES = 2;
23
+ /** Appended when the rendered text hits `PATCH_LIMITS.modelDiffMaxChars`. */
24
+ const DIFF_TRUNCATED_NOTE = '... (diff truncated)';
10
25
  export function renderUnifiedDiff(oldStr, newStr, options = {}) {
11
- const context = options.context ?? CONTEXT_LINES;
12
- const oldLines = oldStr.split('\n');
13
- const newLines = newStr.split('\n');
14
- // Common leading lines.
15
- let start = 0;
16
- const maxStart = Math.min(oldLines.length, newLines.length);
17
- while (start < maxStart && oldLines[start] === newLines[start])
18
- start += 1;
19
- // Common trailing lines (not overlapping the common prefix).
20
- let endOld = oldLines.length - 1;
21
- let endNew = newLines.length - 1;
22
- while (endOld >= start &&
23
- endNew >= start &&
24
- oldLines[endOld] === newLines[endNew]) {
25
- endOld -= 1;
26
- endNew -= 1;
27
- }
28
- const header = options.path
29
- ? [`--- ${options.path}`, `+++ ${options.path}`]
30
- : [];
31
- const lines = [];
32
- const ctxStart = Math.max(0, start - context);
33
- for (let i = ctxStart; i < start; i += 1) {
34
- lines.push(` ${oldLines[i]}`);
35
- }
36
- for (let i = start; i <= endOld; i += 1) {
37
- lines.push(`- ${oldLines[i]}`);
38
- }
39
- for (let i = start; i <= endNew; i += 1) {
40
- lines.push(`+ ${newLines[i]}`);
26
+ const patch = buildPatch(oldStr, newStr, {
27
+ path: options.path ?? '',
28
+ context: options.context ?? CONTEXT_LINES,
29
+ });
30
+ return renderPatchText(patch, options.path);
31
+ }
32
+ /**
33
+ * A `FilePatch` as unified-diff text.
34
+ *
35
+ * Exported so the patch the user reads and the diff the model reads are provably
36
+ * the same change (D-10), and so `patch.test.ts` can assert the round trip.
37
+ */
38
+ export function renderPatchText(patch, path) {
39
+ const header = path ? [`--- ${path}`, `+++ ${path}`] : [];
40
+ if (patch.hunks.length === 0) {
41
+ return `${header.join('\n')}${header.length ? '\n' : ''}(no textual change)`;
41
42
  }
42
- const ctxEnd = Math.min(oldLines.length - 1, endOld + context);
43
- for (let i = endOld + 1; i <= ctxEnd; i += 1) {
44
- lines.push(` ${oldLines[i]}`);
43
+ const lines = [...header];
44
+ for (const hunk of patch.hunks) {
45
+ lines.push(`@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`);
46
+ for (const line of hunk.lines)
47
+ lines.push(`${signOf(line)}${line.text}`);
45
48
  }
46
- if (lines.length === 0) {
47
- return `${header.join('\n')}${header.length ? '\n' : ''}(no textual change)`;
49
+ if (patch.truncated)
50
+ lines.push(DIFF_TRUNCATED_NOTE);
51
+ return clampToBudget(lines);
52
+ }
53
+ function signOf(line) {
54
+ if (line.kind === 'add')
55
+ return '+ ';
56
+ if (line.kind === 'del')
57
+ return '- ';
58
+ return ' ';
59
+ }
60
+ /**
61
+ * Cap the rendered text at `modelDiffMaxChars`.
62
+ *
63
+ * Whole rows, not a mid-row slice: half a diff line reaching the model reads as
64
+ * a real line that happens to be wrong, which is worse than an honestly short
65
+ * diff. `edit_file`'s result had no bound at all before this round beyond the
66
+ * executor's 100 KB backstop.
67
+ */
68
+ function clampToBudget(lines) {
69
+ const kept = [];
70
+ let size = 0;
71
+ for (const line of lines) {
72
+ const next = size + line.length + 1;
73
+ if (kept.length > 0 && next > PATCH_LIMITS.modelDiffMaxChars) {
74
+ kept.push(DIFF_TRUNCATED_NOTE);
75
+ break;
76
+ }
77
+ kept.push(line);
78
+ size = next;
48
79
  }
49
- return [...header, ...lines].join('\n');
80
+ return kept.join('\n');
50
81
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The CLI-local side channel a `FilePatch` travels on (agent-activity-presentation
3
+ * §3.3.5 / D-8).
4
+ *
5
+ * The patch does NOT ride in the tool result: `ToolResult` has no metadata
6
+ * channel and `packages/core`'s surface is frozen by `public-api.test.ts`, and
7
+ * more importantly a richer display must not cost the model more tokens. So the
8
+ * tool records the patch here, keyed by its own tool-call id, and `reduceEvent`
9
+ * takes it out again when `tool_execution_end` arrives.
10
+ *
11
+ * Three properties, each load-bearing:
12
+ *
13
+ * - **Owner-scoped.** The key is `${owner}:${toolCallId}`. Only `'lead'` is ever
14
+ * written this round (`team/**` is out of scope, so `buildSubagentTools`
15
+ * passes no recorder), but subagents build their tools from the same factory
16
+ * — so the day one gets a recorder, a child's tool-call id must not be able
17
+ * to land on a lead's card. One string concatenation, spent now (D-13).
18
+ * - **Take-once.** `tool_execution_end` fires exactly once per call, and a patch
19
+ * not consumed there never will be. Removal on read is what keeps a long
20
+ * session's store at constant size however many files are edited.
21
+ * - **Bounded FIFO.** At `cap` entries the oldest key is evicted. The cap is a
22
+ * RING, not a leak detector: nothing is retained longer than `cap` writes, so
23
+ * a patch nobody consumes cannot accumulate.
24
+ */
25
+ import type { FilePatch } from './patch.js';
26
+ export interface FileChangeStore {
27
+ record(owner: string, toolCallId: string, patch: FilePatch): void;
28
+ /** Returns and REMOVES the patch. A patch is consumed exactly once. */
29
+ take(owner: string, toolCallId: string): FilePatch | undefined;
30
+ size(): number;
31
+ }
32
+ /** Entries retained before the oldest is evicted. */
33
+ export declare const FILE_CHANGE_STORE_CAP = 64;
34
+ export declare function createFileChangeStore(cap?: number): FileChangeStore;