@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
@@ -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;
@@ -0,0 +1,57 @@
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
+ /** Entries retained before the oldest is evicted. */
26
+ export const FILE_CHANGE_STORE_CAP = 64;
27
+ export function createFileChangeStore(cap = FILE_CHANGE_STORE_CAP) {
28
+ // A `Map` iterates in insertion order, so the first key IS the oldest.
29
+ const entries = new Map();
30
+ const limit = Math.max(1, Math.floor(cap));
31
+ const keyOf = (owner, toolCallId) => `${owner}:${toolCallId}`;
32
+ return {
33
+ record(owner, toolCallId, patch) {
34
+ const key = keyOf(owner, toolCallId);
35
+ // Delete first so a re-record moves the key to the END of the ring rather
36
+ // than leaving it at its original age.
37
+ entries.delete(key);
38
+ entries.set(key, patch);
39
+ while (entries.size > limit) {
40
+ const oldest = entries.keys().next();
41
+ if (oldest.done)
42
+ break;
43
+ entries.delete(oldest.value);
44
+ }
45
+ },
46
+ take(owner, toolCallId) {
47
+ const key = keyOf(owner, toolCallId);
48
+ const patch = entries.get(key);
49
+ if (patch !== undefined)
50
+ entries.delete(key);
51
+ return patch;
52
+ },
53
+ size() {
54
+ return entries.size;
55
+ },
56
+ };
57
+ }
@@ -7,8 +7,34 @@
7
7
  * tools do not re-implement them.
8
8
  */
9
9
  import { type AgentTool } from '@aragon-agent/core';
10
+ import { type FilePatch } from './patch.js';
10
11
  export interface ToolDeps {
11
12
  getCwd: () => string;
13
+ /**
14
+ * Hand a structured patch to the UI on the CLI-local side channel
15
+ * (agent-activity-presentation §3.3.5).
16
+ *
17
+ * PRE-BOUND TO ITS OWNER by whoever supplies it, so this module never learns
18
+ * what an owner is. OPTIONAL, and omitting it leaves both tools byte-identical
19
+ * to the pre-feature build — including the extra `write_file` stat/read, which
20
+ * is skipped entirely when there is nobody to record for.
21
+ */
22
+ recordChange?: (toolCallId: string, patch: FilePatch) => void;
23
+ /**
24
+ * Hand a raw output chunk to the UI on the CLI-local side channel
25
+ * (agent-activity-presentation-live §3.1).
26
+ *
27
+ * PRE-BOUND TO ITS OWNER, exactly like `recordChange` above, so this module
28
+ * never learns what an owner is. OPTIONAL: omitting it leaves `bash`
29
+ * byte-identical to the pre-feature build -- not one extra allocation, not one
30
+ * extra call (AC-31).
31
+ *
32
+ * ONLY `bash` CALLS IT (D-32). Every other builtin is bounded local I/O that
33
+ * settles in milliseconds, so a progress channel for them would be machinery
34
+ * with no observer. The channel is keyed by tool-call id rather than by tool
35
+ * name, so a future long-running tool costs one call site.
36
+ */
37
+ recordOutput?: (toolCallId: string, chunk: string) => void;
12
38
  }
13
39
  export declare function makeReadFile(deps: ToolDeps): AgentTool;
14
40
  export declare function makeWriteFile(deps: ToolDeps): AgentTool;