@aragon-agent/cli 0.6.38 → 0.6.40

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 (209) hide show
  1. package/CHANGELOG.md +263 -0
  2. package/README.md +544 -11
  3. package/dist/agent/controller.d.ts +245 -10
  4. package/dist/agent/controller.js +367 -22
  5. package/dist/agent/model-profile-settings.js +12 -1
  6. package/dist/agent/plan-prompt.d.ts +1 -3
  7. package/dist/agent/plan-prompt.js +13 -7
  8. package/dist/agent/reducer.d.ts +70 -2
  9. package/dist/agent/reducer.js +59 -6
  10. package/dist/agent/skill-triage-prompt.d.ts +42 -0
  11. package/dist/agent/skill-triage-prompt.js +74 -0
  12. package/dist/agent/system-prompt.d.ts +28 -2
  13. package/dist/agent/system-prompt.js +12 -4
  14. package/dist/cli.js +106 -32
  15. package/dist/commands/builtins.js +24 -3
  16. package/dist/commands/keys-command.d.ts +23 -0
  17. package/dist/commands/keys-command.js +38 -0
  18. package/dist/commands/registry.d.ts +39 -0
  19. package/dist/commands/skills.js +86 -1
  20. package/dist/commands/subagent-command.d.ts +25 -0
  21. package/dist/commands/subagent-command.js +242 -0
  22. package/dist/commands/task-command.d.ts +39 -0
  23. package/dist/commands/task-command.js +102 -0
  24. package/dist/compaction/command.js +16 -3
  25. package/dist/compaction/compactor.d.ts +25 -0
  26. package/dist/compaction/compactor.js +111 -10
  27. package/dist/compaction/digest.d.ts +24 -1
  28. package/dist/compaction/digest.js +139 -17
  29. package/dist/compaction/limits.d.ts +21 -0
  30. package/dist/compaction/limits.js +21 -0
  31. package/dist/compaction/reason-text.d.ts +36 -0
  32. package/dist/compaction/reason-text.js +78 -0
  33. package/dist/compaction/types.d.ts +82 -14
  34. package/dist/compaction/wiring.d.ts +13 -8
  35. package/dist/compaction/wiring.js +1 -0
  36. package/dist/config/cli-commands.d.ts +23 -0
  37. package/dist/config/cli-commands.js +36 -0
  38. package/dist/config/env.js +17 -4
  39. package/dist/config/load.js +55 -3
  40. package/dist/config/schema.d.ts +125 -10
  41. package/dist/config/schema.js +138 -14
  42. package/dist/config/store.js +23 -3
  43. package/dist/diagnostics/doctor.d.ts +9 -0
  44. package/dist/diagnostics/doctor.js +34 -0
  45. package/dist/diagnostics/info.d.ts +71 -0
  46. package/dist/diagnostics/info.js +91 -5
  47. package/dist/exec/bridge/caller-tools.d.ts +96 -0
  48. package/dist/exec/bridge/caller-tools.js +213 -0
  49. package/dist/exec/bridge/decisions.d.ts +78 -0
  50. package/dist/exec/bridge/decisions.js +116 -0
  51. package/dist/exec/bridge/human-answers.d.ts +38 -0
  52. package/dist/exec/bridge/human-answers.js +164 -0
  53. package/dist/exec/bridge/limits.d.ts +68 -0
  54. package/dist/exec/bridge/limits.js +68 -0
  55. package/dist/exec/bridge/ndjson.d.ts +17 -0
  56. package/dist/exec/bridge/ndjson.js +83 -0
  57. package/dist/exec/bridge/pending.d.ts +70 -0
  58. package/dist/exec/bridge/pending.js +132 -0
  59. package/dist/exec/bridge/protocol.d.ts +346 -0
  60. package/dist/exec/bridge/protocol.js +256 -0
  61. package/dist/exec/bridge/server.d.ts +142 -0
  62. package/dist/exec/bridge/server.js +502 -0
  63. package/dist/exec/bridge/source.d.ts +58 -0
  64. package/dist/exec/bridge/source.js +145 -0
  65. package/dist/exec/bridge/writer.d.ts +26 -0
  66. package/dist/exec/bridge/writer.js +156 -0
  67. package/dist/exec/cli-commands.js +30 -2
  68. package/dist/exec/emitter.d.ts +16 -2
  69. package/dist/exec/emitter.js +26 -6
  70. package/dist/exec/events.d.ts +465 -10
  71. package/dist/exec/events.js +47 -1
  72. package/dist/exec/hooks/dispatch.d.ts +76 -0
  73. package/dist/exec/hooks/dispatch.js +169 -0
  74. package/dist/exec/hooks/index.d.ts +83 -0
  75. package/dist/exec/hooks/index.js +205 -0
  76. package/dist/exec/hooks/limits.d.ts +56 -0
  77. package/dist/exec/hooks/limits.js +49 -0
  78. package/dist/exec/hooks/registry.d.ts +49 -0
  79. package/dist/exec/hooks/registry.js +91 -0
  80. package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
  81. package/dist/exec/hooks/tool-wrapper.js +154 -0
  82. package/dist/exec/hooks/turn.d.ts +61 -0
  83. package/dist/exec/hooks/turn.js +59 -0
  84. package/dist/exec/hooks/validate.d.ts +85 -0
  85. package/dist/exec/hooks/validate.js +310 -0
  86. package/dist/exec/index.d.ts +130 -4
  87. package/dist/exec/index.js +501 -10
  88. package/dist/exec/options.d.ts +124 -2
  89. package/dist/exec/options.js +413 -6
  90. package/dist/exec/permission.d.ts +9 -1
  91. package/dist/exec/permission.js +7 -1
  92. package/dist/exec/request/limits.d.ts +51 -0
  93. package/dist/exec/request/limits.js +49 -0
  94. package/dist/exec/request/scope.d.ts +106 -0
  95. package/dist/exec/request/scope.js +149 -0
  96. package/dist/exec/result/limits.d.ts +74 -0
  97. package/dist/exec/result/limits.js +72 -0
  98. package/dist/exec/result/prompt.d.ts +28 -0
  99. package/dist/exec/result/prompt.js +74 -0
  100. package/dist/exec/result/schema.d.ts +99 -0
  101. package/dist/exec/result/schema.js +464 -0
  102. package/dist/exec/result/sink.d.ts +56 -0
  103. package/dist/exec/result/sink.js +50 -0
  104. package/dist/exec/result/tool.d.ts +37 -0
  105. package/dist/exec/result/tool.js +118 -0
  106. package/dist/exec/runner.d.ts +395 -1
  107. package/dist/exec/runner.js +860 -8
  108. package/dist/exec/threads/aggregate.d.ts +34 -0
  109. package/dist/exec/threads/aggregate.js +30 -0
  110. package/dist/exec/threads/capacity.d.ts +23 -0
  111. package/dist/exec/threads/capacity.js +38 -0
  112. package/dist/exec/threads/dispatch-source.d.ts +60 -0
  113. package/dist/exec/threads/dispatch-source.js +106 -0
  114. package/dist/exec/threads/execute-threads.d.ts +53 -0
  115. package/dist/exec/threads/execute-threads.js +234 -0
  116. package/dist/exec/threads/gate.d.ts +42 -0
  117. package/dist/exec/threads/gate.js +81 -0
  118. package/dist/exec/threads/identity.d.ts +36 -0
  119. package/dist/exec/threads/identity.js +67 -0
  120. package/dist/exec/threads/interrupt.d.ts +29 -0
  121. package/dist/exec/threads/interrupt.js +79 -0
  122. package/dist/exec/threads/limits.d.ts +58 -0
  123. package/dist/exec/threads/limits.js +35 -0
  124. package/dist/exec/threads/outcome.d.ts +47 -0
  125. package/dist/exec/threads/outcome.js +26 -0
  126. package/dist/exec/threads/registry.d.ts +81 -0
  127. package/dist/exec/threads/registry.js +427 -0
  128. package/dist/exec/threads/runtime.d.ts +107 -0
  129. package/dist/exec/threads/runtime.js +523 -0
  130. package/dist/exec/threads/thread-source.d.ts +20 -0
  131. package/dist/exec/threads/thread-source.js +134 -0
  132. package/dist/fast/limits.d.ts +54 -0
  133. package/dist/fast/limits.js +54 -0
  134. package/dist/fast/summary-call.d.ts +72 -0
  135. package/dist/fast/summary-call.js +133 -0
  136. package/dist/fast/summary-prompt.d.ts +106 -0
  137. package/dist/fast/summary-prompt.js +320 -0
  138. package/dist/fast/task-summary-types.d.ts +66 -0
  139. package/dist/fast/task-summary-types.js +17 -0
  140. package/dist/logging/logger.d.ts +1 -1
  141. package/dist/proc/limits.d.ts +7 -0
  142. package/dist/proc/limits.js +7 -0
  143. package/dist/proc/log-mirror.d.ts +21 -4
  144. package/dist/proc/log-mirror.js +29 -7
  145. package/dist/proc/output-streams.d.ts +50 -0
  146. package/dist/proc/output-streams.js +54 -0
  147. package/dist/skills/service.d.ts +41 -0
  148. package/dist/skills/service.js +62 -0
  149. package/dist/skills/triage.d.ts +194 -0
  150. package/dist/skills/triage.js +393 -0
  151. package/dist/team/forced-dispatch.d.ts +95 -0
  152. package/dist/team/forced-dispatch.js +208 -0
  153. package/dist/team/limits.d.ts +21 -1
  154. package/dist/team/limits.js +21 -1
  155. package/dist/team/normalize.d.ts +15 -0
  156. package/dist/team/normalize.js +21 -4
  157. package/dist/team/prompt.d.ts +6 -3
  158. package/dist/team/prompt.js +9 -5
  159. package/dist/team/runtime.d.ts +9 -0
  160. package/dist/team/runtime.js +8 -0
  161. package/dist/team/subagent.d.ts +15 -0
  162. package/dist/team/subagent.js +45 -1
  163. package/dist/team/task-tool.js +11 -7
  164. package/dist/tools/bash-tool.d.ts +10 -0
  165. package/dist/tools/bash-tool.js +71 -6
  166. package/dist/tools/human-input.d.ts +35 -2
  167. package/dist/tools/human-input.js +38 -0
  168. package/dist/tools/index.d.ts +93 -1
  169. package/dist/tools/index.js +112 -3
  170. package/dist/tools/plan-tools.d.ts +20 -12
  171. package/dist/tools/plan-tools.js +24 -17
  172. package/dist/tools/tool-gate.d.ts +100 -0
  173. package/dist/tools/tool-gate.js +87 -0
  174. package/dist/ui/App.js +238 -12
  175. package/dist/ui/Header.d.ts +28 -16
  176. package/dist/ui/Header.js +21 -19
  177. package/dist/ui/StatusBar.d.ts +1 -0
  178. package/dist/ui/StatusBar.js +1 -0
  179. package/dist/ui/Transcript.js +1 -1
  180. package/dist/ui/entries/CompactionCard.d.ts +11 -1
  181. package/dist/ui/entries/CompactionCard.js +65 -4
  182. package/dist/ui/interaction-hints.js +6 -0
  183. package/dist/ui/layout/OverlayFrame.d.ts +18 -0
  184. package/dist/ui/layout/OverlayFrame.js +19 -4
  185. package/dist/ui/layout/header-hints.d.ts +55 -62
  186. package/dist/ui/layout/header-hints.js +59 -166
  187. package/dist/ui/layout/header-left.d.ts +115 -0
  188. package/dist/ui/layout/header-left.js +206 -0
  189. package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
  190. package/dist/ui/layout/shortcut-panel-layout.js +321 -0
  191. package/dist/ui/layout/status-detail-layout.js +5 -1
  192. package/dist/ui/layout/status-layout.d.ts +10 -0
  193. package/dist/ui/layout/status-layout.js +18 -1
  194. package/dist/ui/layout/task-summary-cell.d.ts +61 -0
  195. package/dist/ui/layout/task-summary-cell.js +65 -0
  196. package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
  197. package/dist/ui/overlays/HelpOverlay.js +29 -2
  198. package/dist/ui/overlays/SettingsScreen.js +29 -5
  199. package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
  200. package/dist/ui/overlays/ShortcutPanel.js +106 -0
  201. package/dist/ui/overlays/TeamFanoutPage.d.ts +35 -0
  202. package/dist/ui/overlays/TeamFanoutPage.js +78 -0
  203. package/dist/ui/overlays/model-profile-state.d.ts +19 -1
  204. package/dist/ui/overlays/model-profile-state.js +12 -0
  205. package/dist/ui/overlays/team-fanout-state.d.ts +73 -0
  206. package/dist/ui/overlays/team-fanout-state.js +97 -0
  207. package/dist/ui/shortcuts.d.ts +81 -0
  208. package/dist/ui/shortcuts.js +221 -0
  209. package/package.json +2 -2
@@ -19,12 +19,22 @@
19
19
  * inheriting the pipe — `Start-Process`, `&`, `nohup`, and every
20
20
  * `npm run dev` that re-execs — keeps that pipe open after the shell is
21
21
  * long gone. The shell exited; the promise did not.
22
+ *
22
23
  * 2. `stdio` defaulted to `'pipe'` for stdin, so a command that PROMPTS —
23
24
  * `npm init`, a package manager asking to install something, `sudo`, a git
24
25
  * credential prompt — blocked on a read that could never be satisfied.
25
26
  * 3. An abort killed the tree and then went on waiting for a `'close'` that a
26
27
  * detached grandchild was still holding.
27
28
  *
29
+ * WHY THE DRAIN WINDOW IS A BOUND AND NOT A VERDICT. The first build called
30
+ * anything still unsettled when that window closed "a held pipe" and printed
31
+ * the `'exit'` drain note on the strength of it. Under load the two disagree:
32
+ * the release gate caught a command that had printed two lines and exited
33
+ * immediately being reported as holding its output stream, because the timers
34
+ * phase came round before libuv delivered the close notification. The note is a
35
+ * claim about STATE, so it is decided by asking the streams
36
+ * (`proc/output-streams.ts`) instead of guessing from a clock.
37
+ *
28
38
  * The happy path is BYTE-IDENTICAL to the pre-change build: `$ cmd\n<output>\n
29
39
  * [exit code N]`, same truncation, same footer. That is what keeps the existing
30
40
  * `bash` assertions meaningful rather than rewritten.
@@ -35,6 +45,7 @@ import { defineTool, errorResult, textResult, } from '@aragon-agent/core';
35
45
  import { looksLongRunning } from '../proc/classify.js';
36
46
  import { killTree } from '../proc/kill-tree.js';
37
47
  import { PROC_LIMITS } from '../proc/limits.js';
48
+ import { hasOpenOutputStreams } from '../proc/output-streams.js';
38
49
  import { isTerminalStatus } from '../proc/types.js';
39
50
  /**
40
51
  * The two sentences appended to the tool description when background launches
@@ -285,6 +296,14 @@ export function makeBash(deps, options = {}) {
285
296
  * the same choice for its `killedByTimeout` flag, for the same reason.
286
297
  */
287
298
  let killReason;
299
+ /**
300
+ * The exit code, remembered the moment `'exit'` reports it.
301
+ *
302
+ * THREE PATHS SETTLE THE ORDINARY CASE NOW - the two streams ending, the
303
+ * `'close'` event, and the drain bound - and all three must build the same
304
+ * body, so none of them may depend on having been handed the code itself.
305
+ */
306
+ let exitInfo = null;
288
307
  /**
289
308
  * The result body, from whatever is known at settle time.
290
309
  *
@@ -342,6 +361,25 @@ export function makeBash(deps, options = {}) {
342
361
  releaseForeground();
343
362
  resolvePromise(build());
344
363
  };
364
+ /** See `proc/output-streams.ts` for why this is asked and not assumed. */
365
+ const streamsStillOpen = () => hasOpenOutputStreams(child);
366
+ /**
367
+ * THE ORDINARY BODY, built from whatever the streams say at this instant.
368
+ *
369
+ * Without a held pipe it is byte-identical to the `'close'` path - no note -
370
+ * which is what AC-30 measures. With one it is the honest diagnosis, and the
371
+ * sentence is only ever printed while it is TRUE.
372
+ */
373
+ const settleOrdinary = () => {
374
+ const info = exitInfo;
375
+ if (!info)
376
+ return;
377
+ settle(() => buildBody({
378
+ code: info.code,
379
+ signalName: info.signalName,
380
+ note: streamsStillOpen(),
381
+ }));
382
+ };
345
383
  const captured = () => (output.length > 0 ? output : '(no output)');
346
384
  /**
347
385
  * Arm the post-kill force-settle. AT MOST ONCE.
@@ -421,16 +459,43 @@ export function makeBash(deps, options = {}) {
421
459
  settle(() => errorResult(`Failed to run command: ${err.message}`));
422
460
  });
423
461
  /**
424
- * DEFENCE 1. `'exit'` fires when the PROCESS ends; `'close'` waits for
425
- * every stdio stream, which a detached grandchild can hold open forever.
426
- * So `'exit'` starts a short drain and `'close'` — if it arrives — wins
427
- * the race and produces the byte-identical happy-path body.
462
+ * DEFENCE 1. `'exit'` fires when the PROCESS ends; `'close'` waits for every
463
+ * stdio stream, which a detached grandchild can hold open forever. So `'exit'`
464
+ * starts a short drain and `'close'` - if it arrives - wins the race and
465
+ * produces the byte-identical happy-path body.
466
+ *
467
+ * AND THE COMMON CASE NO LONGER WAITS FOR THAT RACE AT ALL. Two ended streams
468
+ * after `'exit'` mean not one more byte can arrive, so the body `'close'` would
469
+ * build is already known: settle it there. That removes the only window in which
470
+ * a busy event loop could hand the timer a verdict the pipes had already
471
+ * contradicted - the release-gate failure recorded in `proc/output-streams.ts`.
428
472
  */
473
+ const onStreamSettled = () => {
474
+ if (!exitInfo || settled)
475
+ return;
476
+ if (!streamsStillOpen())
477
+ settleOrdinary();
478
+ };
479
+ child.stdout?.on('end', onStreamSettled);
480
+ child.stderr?.on('end', onStreamSettled);
429
481
  child.on('exit', (code, signalName) => {
430
- if (settled || drainTimer)
482
+ exitInfo = { code, signalName };
483
+ if (settled)
484
+ return;
485
+ if (!streamsStillOpen()) {
486
+ settleOrdinary();
487
+ return;
488
+ }
489
+ if (drainTimer)
431
490
  return;
432
491
  drainTimer = setTimeout(() => {
433
- settle(() => buildBody({ code, signalName, note: true }));
492
+ // DEFERRED PAST THE POLL PHASE, DELIBERATELY. Asking the streams from
493
+ // inside the timer callback would look at them before the loop has
494
+ // dispatched an `'end'`/`'close'` already queued behind it - which is
495
+ // precisely the starvation that produced the false note. The check phase
496
+ // runs after the poll phase of the same iteration, so by then the answer is
497
+ // observable rather than guessable.
498
+ setImmediate(settleOrdinary);
434
499
  }, PROC_LIMITS.drainMs);
435
500
  });
436
501
  child.on('close', (code, signalName) => {
@@ -137,6 +137,23 @@ export declare const PLAN_LIMITS: {
137
137
  readonly maxRisks: 10;
138
138
  readonly maxOpenQuestions: 10;
139
139
  };
140
+ /**
141
+ * The STORAGE bound of the `ask_user` de-duplication guard — NOT a refusal
142
+ * condition, and the distinction is the whole point.
143
+ *
144
+ * Storage is memory hygiene: a turn that asks a thousand DISTINCT question sets
145
+ * is pathological, but it is still the user's turn, and Esc interrupts it. The
146
+ * refusal rule is semantic: "never ask the same answered questions twice". Folding
147
+ * the bound into the refusal rule would restore the round cap this feature
148
+ * deletes, with a different number written on it.
149
+ *
150
+ * Owned here rather than in `plan-tools.ts` (the caller) or `controller.ts` (the
151
+ * state holder) because this file is already the single authority on the
152
+ * structural limits of human input, beside `QUESTION_LIMITS` / `PLAN_LIMITS`.
153
+ */
154
+ export declare const ASK_GUARD_LIMITS: {
155
+ readonly maxRemembered: 128;
156
+ };
140
157
  /**
141
158
  * Trim to `max` characters, preferring a word boundary within the last
142
159
  * `WORD_BOUNDARY_WINDOW` characters. Cutting a sentence mid-word reads as
@@ -165,13 +182,29 @@ export declare function normalizePlan(raw: unknown): NormalizedPlan | null;
165
182
  export interface AnswersResultShape {
166
183
  answers: Answer[];
167
184
  cancelled: boolean;
168
- roundsUsed: number;
169
- roundsRemaining: number;
170
185
  }
186
+ /**
187
+ * A question SET's identity: the question texts, trimmed, lowercased, whitespace
188
+ * collapsed, sorted, joined.
189
+ *
190
+ * `id` and `header` are excluded on purpose — a model that re-asks the same thing
191
+ * under a new id is asking the same thing, and the ids are the model's own
192
+ * bookkeeping rather than part of what the user reads.
193
+ *
194
+ * The fingerprint is stable because `normalizeQuestions` already ran on the TOOL
195
+ * side: header truncation and option trimming cannot make the same question look
196
+ * different here. Worst case is 5 questions x 300 chars = 1,500 chars.
197
+ */
198
+ export declare function questionFingerprint(questions: NormalizedQuestion[]): string;
171
199
  /**
172
200
  * Render the `ask_user` result: one human-readable line so the transcript
173
201
  * preview is legible without parsing, then the JSON the model reads.
174
202
  *
203
+ * CARRIES NO ROUND COUNTER, deliberately (D-4). The model used to be told
204
+ * `roundsUsed` / `roundsRemaining` on every call, which is a per-turn reminder
205
+ * that it is running out — and an agent that reads "you have 1 round left"
206
+ * behaves like one. There is no limit any more, so there is nothing to report.
207
+ *
175
208
  * `asked` is passed separately rather than derived from `answers.length`
176
209
  * because a cancelled wizard returns fewer answers than it asked for, and
177
210
  * "answered 1 of 3" is the whole information content of that line.
@@ -51,6 +51,21 @@ export const PLAN_LIMITS = {
51
51
  maxRisks: 10,
52
52
  maxOpenQuestions: 10,
53
53
  };
54
+ /**
55
+ * The STORAGE bound of the `ask_user` de-duplication guard — NOT a refusal
56
+ * condition, and the distinction is the whole point.
57
+ *
58
+ * Storage is memory hygiene: a turn that asks a thousand DISTINCT question sets
59
+ * is pathological, but it is still the user's turn, and Esc interrupts it. The
60
+ * refusal rule is semantic: "never ask the same answered questions twice". Folding
61
+ * the bound into the refusal rule would restore the round cap this feature
62
+ * deletes, with a different number written on it.
63
+ *
64
+ * Owned here rather than in `plan-tools.ts` (the caller) or `controller.ts` (the
65
+ * state holder) because this file is already the single authority on the
66
+ * structural limits of human input, beside `QUESTION_LIMITS` / `PLAN_LIMITS`.
67
+ */
68
+ export const ASK_GUARD_LIMITS = { maxRemembered: 128 };
54
69
  /** How far back a truncation will look for a space before cutting hard. */
55
70
  const WORD_BOUNDARY_WINDOW = 12;
56
71
  // ---------------------------------------------------------------------------
@@ -208,10 +223,33 @@ export function normalizePlan(raw) {
208
223
  openQuestions: nonEmptyStrings(src.openQuestions, PLAN_LIMITS.maxOpenQuestions),
209
224
  };
210
225
  }
226
+ /**
227
+ * A question SET's identity: the question texts, trimmed, lowercased, whitespace
228
+ * collapsed, sorted, joined.
229
+ *
230
+ * `id` and `header` are excluded on purpose — a model that re-asks the same thing
231
+ * under a new id is asking the same thing, and the ids are the model's own
232
+ * bookkeeping rather than part of what the user reads.
233
+ *
234
+ * The fingerprint is stable because `normalizeQuestions` already ran on the TOOL
235
+ * side: header truncation and option trimming cannot make the same question look
236
+ * different here. Worst case is 5 questions x 300 chars = 1,500 chars.
237
+ */
238
+ export function questionFingerprint(questions) {
239
+ return questions
240
+ .map((q) => q.question.trim().toLowerCase().replace(/\s+/g, ' '))
241
+ .sort()
242
+ .join(' | ');
243
+ }
211
244
  /**
212
245
  * Render the `ask_user` result: one human-readable line so the transcript
213
246
  * preview is legible without parsing, then the JSON the model reads.
214
247
  *
248
+ * CARRIES NO ROUND COUNTER, deliberately (D-4). The model used to be told
249
+ * `roundsUsed` / `roundsRemaining` on every call, which is a per-turn reminder
250
+ * that it is running out — and an agent that reads "you have 1 round left"
251
+ * behaves like one. There is no limit any more, so there is nothing to report.
252
+ *
215
253
  * `asked` is passed separately rather than derived from `answers.length`
216
254
  * because a cancelled wizard returns fewer answers than it asked for, and
217
255
  * "answered 1 of 3" is the whole information content of that line.
@@ -14,6 +14,8 @@ import type { ToolPermission } from '../exec/permission.js';
14
14
  import type { FilePatch } from './patch.js';
15
15
  import type { ProcSupervisorPort } from '../proc/types.js';
16
16
  import type { JobNotice } from '../proc/notices.js';
17
+ import { type ToolGate } from './tool-gate.js';
18
+ import { type ToolHookRunner } from '../exec/hooks/tool-wrapper.js';
17
19
  export interface ConfirmRequest {
18
20
  tool: string;
19
21
  summary: string;
@@ -191,6 +193,22 @@ export interface BuiltinToolsOptions {
191
193
  * to the pre-feature build.
192
194
  */
193
195
  jobTools?: AgentTool[];
196
+ /**
197
+ * `submit_result`, appended verbatim when the caller supplied `--result-schema`
198
+ * (headless-request-scope-and-typed-result section 2.3.9).
199
+ *
200
+ * THROUGH THE FACTORY FOR THE REASON `teamTools`, `todoTools`, `procTools` AND
201
+ * `jobTools` EACH RECORD IN CAPITALS: `tools.test.ts::C7` asserts `HOST_TOOL_NAMES`
202
+ * equals what this factory PRODUCES, so appending `submit_result` in
203
+ * `AgentController` after the factory returned would turn it red with a message
204
+ * about two lists of names that says nothing about structured results.
205
+ *
206
+ * ABSENT ON EVERY OTHER PATH - the TUI, `-p`, every subagent - which leaves their
207
+ * tool array byte-identical to the pre-feature build (I-S2). A subagent must never
208
+ * receive it: its product is a report that goes back through `task`, and a second
209
+ * submitter in one request would compete with the lead's schema.
210
+ */
211
+ resultTool?: AgentTool;
194
212
  /**
195
213
  * The completion-notice queue's read side, for the PULL channel (§4.6).
196
214
  *
@@ -260,7 +278,81 @@ export interface BuiltinToolsOptions {
260
278
  * `ToolPermission.isAllowed` applies for itself.
261
279
  */
262
280
  permission?: ToolPermission;
281
+ /**
282
+ * The per-call approval gate (headless-host-bridge §2.3.3). Bridge mode only.
283
+ *
284
+ * ABSENT EVERYWHERE ELSE, which is what keeps every other tool array identical
285
+ * by object identity: the mapping below runs only when this key exists, the same
286
+ * house rule `permission` and `jobNotices` follow.
287
+ */
288
+ toolGate?: ToolGate;
289
+ /**
290
+ * The caller's lifecycle hooks, wrapping `tool_pre` / `tool_post`
291
+ * (headless-threads-and-lifecycle-hooks section 2.4.9).
292
+ *
293
+ * IT IS PRESENT WHENEVER THE HOOK CHANNEL IS OPEN, not only once a hook is registered:
294
+ * `hook_register` arrives AFTER the controller (and therefore after this factory) was
295
+ * built, so a wrapper installed lazily would never be installed at all. The wrappers
296
+ * read the table live and cost one `chainFor` when it is empty, which is why the
297
+ * observable behaviour with zero hooks is exactly today's.
298
+ *
299
+ * ABSENT FOR THE TUI, `-p`, every subagent and every non-bridge exec run - so their tool
300
+ * arrays stay byte-identical to the pre-feature build (I-H1's spirit, and `tools.test.ts`
301
+ * C7's letter).
302
+ */
303
+ hookRunner?: ToolHookRunner;
304
+ /**
305
+ * The host's watchdog pause, handed to the gate wrapper.
306
+ *
307
+ * THE SAME FUNCTION the plan tools receive (`withPausedWatchdog`), not a second
308
+ * wrapper around it: that method is documented as NOT reentrant, and nesting two
309
+ * pauses would resume the watchdog one level too early.
310
+ */
311
+ withToolWait?: <T>(fn: () => Promise<T>) => Promise<T>;
263
312
  }
313
+ /**
314
+ * The filesystem-mutating set the `--confirm` wrapper shares.
315
+ *
316
+ * EXPORTED SO `CALLER_GATED_TOOLS` CAN EXTEND IT rather than restating it
317
+ * (headless-host-bridge §2.3.2). `exec/permission.ts` already records the rule:
318
+ * a second copy of a name list is a list that drifts, and the drift is discovered
319
+ * by the tool that is refused in one mode and allowed in the other.
320
+ */
321
+ export declare const MUTATING_TOOLS: ReadonlySet<string>;
322
+ /**
323
+ * The names `--permission-mode ask` asks the caller about.
324
+ *
325
+ * `MUTATING_TOOLS` is the filesystem set the confirm wrapper already shares.
326
+ * `skill_install` is deliberately ABSENT: it already has its own gate
327
+ * (`ApprovalGate`, `exec/bridge/decisions.ts`) and asking twice reads as "no
328
+ * approval at all" - the same argument that makes `--confirm` and `ask` mutually
329
+ * exclusive in `exec/options.ts`. `skill_create` is PRESENT because it writes
330
+ * SKILL.md plus arbitrary bundled files and has no gate of any kind: without it,
331
+ * `ask` mode would let a headless run create files on disk that the caller never
332
+ * saw.
333
+ *
334
+ * ONE DEFINITION, THREE CONSUMERS: `ToolGate.applies`, the executor's two-phase
335
+ * timeout override in `agent/controller.ts`, and the acks and docs that report
336
+ * which tools are gated. That is `BRIDGE_LIMITS`-style discipline (I-6) for a name
337
+ * set instead of a number.
338
+ */
339
+ export declare const CALLER_GATED_TOOLS: ReadonlySet<string>;
340
+ /**
341
+ * The executor timeout each gated tool needs while a human may be answering.
342
+ *
343
+ * THE TWO PHASES, ADDED (headless-host-bridge section 2.3.4). `ToolExecutor` arms
344
+ * its timer BEFORE the approval wrapper runs, so a ceiling of `toolTimeoutMs`
345
+ * alone would leave the tool less than its configured budget by exactly the time
346
+ * the caller took to answer. ADMITTED OVER-GRANT: a caller that answers instantly
347
+ * still gets the sum. The opposite trade - charging the wait to the tool - would
348
+ * make "the human thought for two minutes" read as "this command is too slow",
349
+ * and a tool timeout is supposed to mean the latter.
350
+ *
351
+ * IT IS A FUNCTION RATHER THAN A LINE IN THE CONTROLLER so the formula has a name
352
+ * and a test: the numbers here reach a place (`toolTimeoutOverrides`) that no
353
+ * assertion can read back, and an unreadable ceiling is one that quietly drifts.
354
+ */
355
+ export declare function gatedToolTimeouts(toolTimeoutMs: number, decisionBudgetMs: number): Record<string, number>;
264
356
  /**
265
357
  * Tools the ceiling always permits (D-G6). The ONLY place that knows which of
266
358
  * this host's tools are read-only — core must not (D-G7).
@@ -309,5 +401,5 @@ export declare const PLAN_MODE_BLOCKED_TOOLS: ReadonlySet<string>;
309
401
  * declaration MEANS, and that reading must not change with how the CLI was
310
402
  * started.
311
403
  */
312
- export declare const HOST_TOOL_NAMES: readonly ["read_file", "write_file", "edit_file", "list_dir", "glob", "grep", "bash", "skill", "skill_find", "skill_install", "skill_create", "ask_user", "submit_plan", "todo_write", "bash_output", "bash_kill", "job_wait", "job_list", "task"];
404
+ export declare const HOST_TOOL_NAMES: readonly ["read_file", "write_file", "edit_file", "list_dir", "glob", "grep", "bash", "skill", "skill_find", "skill_install", "skill_create", "ask_user", "submit_plan", "todo_write", "bash_output", "bash_kill", "job_wait", "job_list", "submit_result", "task"];
313
405
  export declare function createBuiltinTools(options: BuiltinToolsOptions): AgentTool[];
@@ -14,7 +14,57 @@ import { makeReadFile, makeWriteFile, makeEditFile, makeListDir } from './fs-too
14
14
  import { makeGlob, makeGrep } from './search-tools.js';
15
15
  import { makeBash } from './bash-tool.js';
16
16
  import { withJobNoticeTail } from './job-tools.js';
17
- const MUTATING_TOOLS = new Set(['write_file', 'edit_file', 'bash']);
17
+ import { withToolApproval } from './tool-gate.js';
18
+ // VALUE IMPORTS, AND THE DIRECTION IS SAFE: `exec/hooks/tool-wrapper.ts` imports only Core
19
+ // and its own siblings, so `tools/index.ts -> exec/hooks -> (nothing back here)` is not a
20
+ // cycle. The wrappers are plain `{...tool, execute}` objects, exactly like the four this
21
+ // file already composes.
22
+ import { withToolPostHooks, withToolPreHooks, } from '../exec/hooks/tool-wrapper.js';
23
+ /**
24
+ * The filesystem-mutating set the `--confirm` wrapper shares.
25
+ *
26
+ * EXPORTED SO `CALLER_GATED_TOOLS` CAN EXTEND IT rather than restating it
27
+ * (headless-host-bridge §2.3.2). `exec/permission.ts` already records the rule:
28
+ * a second copy of a name list is a list that drifts, and the drift is discovered
29
+ * by the tool that is refused in one mode and allowed in the other.
30
+ */
31
+ export const MUTATING_TOOLS = new Set(['write_file', 'edit_file', 'bash']);
32
+ /**
33
+ * The names `--permission-mode ask` asks the caller about.
34
+ *
35
+ * `MUTATING_TOOLS` is the filesystem set the confirm wrapper already shares.
36
+ * `skill_install` is deliberately ABSENT: it already has its own gate
37
+ * (`ApprovalGate`, `exec/bridge/decisions.ts`) and asking twice reads as "no
38
+ * approval at all" - the same argument that makes `--confirm` and `ask` mutually
39
+ * exclusive in `exec/options.ts`. `skill_create` is PRESENT because it writes
40
+ * SKILL.md plus arbitrary bundled files and has no gate of any kind: without it,
41
+ * `ask` mode would let a headless run create files on disk that the caller never
42
+ * saw.
43
+ *
44
+ * ONE DEFINITION, THREE CONSUMERS: `ToolGate.applies`, the executor's two-phase
45
+ * timeout override in `agent/controller.ts`, and the acks and docs that report
46
+ * which tools are gated. That is `BRIDGE_LIMITS`-style discipline (I-6) for a name
47
+ * set instead of a number.
48
+ */
49
+ export const CALLER_GATED_TOOLS = new Set([...MUTATING_TOOLS, 'skill_create']);
50
+ /**
51
+ * The executor timeout each gated tool needs while a human may be answering.
52
+ *
53
+ * THE TWO PHASES, ADDED (headless-host-bridge section 2.3.4). `ToolExecutor` arms
54
+ * its timer BEFORE the approval wrapper runs, so a ceiling of `toolTimeoutMs`
55
+ * alone would leave the tool less than its configured budget by exactly the time
56
+ * the caller took to answer. ADMITTED OVER-GRANT: a caller that answers instantly
57
+ * still gets the sum. The opposite trade - charging the wait to the tool - would
58
+ * make "the human thought for two minutes" read as "this command is too slow",
59
+ * and a tool timeout is supposed to mean the latter.
60
+ *
61
+ * IT IS A FUNCTION RATHER THAN A LINE IN THE CONTROLLER so the formula has a name
62
+ * and a test: the numbers here reach a place (`toolTimeoutOverrides`) that no
63
+ * assertion can read back, and an unreadable ceiling is one that quietly drifts.
64
+ */
65
+ export function gatedToolTimeouts(toolTimeoutMs, decisionBudgetMs) {
66
+ return Object.fromEntries([...CALLER_GATED_TOOLS].map((name) => [name, toolTimeoutMs + decisionBudgetMs]));
67
+ }
18
68
  /**
19
69
  * Tools the ceiling always permits (D-G6). The ONLY place that knows which of
20
70
  * this host's tools are read-only — core must not (D-G7).
@@ -146,6 +196,13 @@ export const HOST_TOOL_NAMES = [
146
196
  // (long-task-jobs §4.3).
147
197
  'job_wait',
148
198
  'job_list',
199
+ // `submit_result` (headless-request-scope-and-typed-result section 2.3.9) belongs to
200
+ // the superset for the reason the two job names above do: a skill's
201
+ // `allowed-tools: [submit_result]` must be answerable, and `--allow-tool` /
202
+ // `--deny-tool` validate against THIS list. REGISTRATION is a separate decision -
203
+ // only `--result-schema` registers it, which is why `--deny-tool submit_result`
204
+ // without a schema is warned about rather than obeyed (`exec/options.ts` W-3).
205
+ 'submit_result',
149
206
  // `team_send` / `team_wait` are DELIBERATELY ABSENT (P2-5). This constant
150
207
  // answers "can a skill's `allowed-tools` declaration ever take effect?", and
151
208
  // those two are never registered on a lead — so a skill naming them genuinely
@@ -309,12 +366,53 @@ export function createBuiltinTools(options) {
309
366
  if (options.jobTools && options.jobTools.length > 0) {
310
367
  tools = [...tools, ...options.jobTools];
311
368
  }
369
+ // AFTER `jobTools`, BEFORE `teamTools`: `submit_result` is the answer-shaped sibling
370
+ // of the plan tools rather than a team tool, and keeping a lead's `task` last means
371
+ // `HOST_TOOL_NAMES` grows by one insertion instead of a reshuffle
372
+ // (headless-request-scope-and-typed-result section 2.6 row 16).
373
+ //
374
+ // INSIDE THE FACTORY, NEVER APPENDED IN `AgentController` - `tools.test.ts::C7`
375
+ // compares `HOST_TOOL_NAMES` against what this function produces.
376
+ if (options.resultTool) {
377
+ tools = [...tools, options.resultTool];
378
+ }
312
379
  // BEFORE both wrappers, so the lead's `task` is covered by the plan gate and
313
380
  // the ceiling like everything else, and a child's comm tools are covered by
314
381
  // everything except the ceiling (see `policyExempt`).
315
382
  if (options.teamTools && options.teamTools.length > 0) {
316
383
  tools = [...tools, ...options.teamTools];
317
384
  }
385
+ // --- The caller's per-call approval gate (headless-host-bridge §2.3.3). ---
386
+ //
387
+ // AFTER THE LAST APPEND AND BEFORE THE PERMISSION FILTER, for the two structural
388
+ // reasons the `permission` block below records, plus one of its own: the appends
389
+ // above are what put `skill_create` in this array, and that is the single
390
+ // write-capable tool with no other gate. Placing this immediately after
391
+ // `withConfirmation` - where the array still holds only the seven built-ins -
392
+ // would leave it permanently unaskable, so a headless run could create files in
393
+ // the skills directory that the caller never saw.
394
+ //
395
+ // At CALL time this ends up inside the plan gate and inside the skill ceiling
396
+ // (both applied below), which is the order their comments demand: neither needs
397
+ // I/O, so neither may sit behind a human wait.
398
+ if (options.toolGate) {
399
+ const gate = options.toolGate;
400
+ const withToolWait = options.withToolWait;
401
+ tools = tools.map((tool) => gate.applies(tool.name)
402
+ ? withToolApproval(tool, gate, withToolWait ? { withHumanWait: withToolWait } : {})
403
+ : tool);
404
+ }
405
+ // --- The caller's lifecycle hooks: `tool_pre` (section 2.4.9). ---
406
+ //
407
+ // IMMEDIATELY AFTER THE APPROVAL GATE, WHICH MAKES IT OUTSIDE IT AT CALL TIME: a human
408
+ // must approve the arguments the hook produced, never the ones it replaced. The two
409
+ // policy wrappers below are applied later and are therefore outside this one, so a tool
410
+ // plan mode already refuses never costs a hook round trip (I-H4b - a recorded trade-off,
411
+ // not an oversight).
412
+ if (options.hookRunner) {
413
+ const hookRunner = options.hookRunner;
414
+ tools = tools.map((tool) => withToolPreHooks(tool, hookRunner));
415
+ }
318
416
  // --- The `aragon exec` permission filter (cli-integration-surface §4.2). ---
319
417
  //
320
418
  // ITS OWN `if`, AFTER THE LAST APPEND AND BEFORE THE `toolPolicy` WRAPPER, and
@@ -386,8 +484,19 @@ export function createBuiltinTools(options) {
386
484
  return list;
387
485
  return list.map((tool) => withJobNoticeTail(tool, () => queue.drain()));
388
486
  };
487
+ // THE OUTERMOST LAYER OF ALL, AND THAT IS THE WHOLE OF I-H13 (section 2.4.9): the
488
+ // `<job_notices>` tail is appended INSIDE this wrapper and carries a background job's
489
+ // last raw output lines, so a redactor placed any deeper would miss the most likely
490
+ // leak. The cost - a `tool_post` `replace` swallows the notice block - is accepted and
491
+ // documented: every notice is also a `job` event the caller already received.
492
+ const withHooksPost = (list) => {
493
+ const hookRunner = options.hookRunner;
494
+ if (!hookRunner)
495
+ return list;
496
+ return list.map((tool) => withToolPostHooks(tool, hookRunner));
497
+ };
389
498
  if (!options.agentMode)
390
- return withJobNotices(tools);
499
+ return withHooksPost(withJobNotices(tools));
391
500
  const getMode = options.agentMode;
392
- return withJobNotices(tools.map((t) => withPlanModeGate(t, getMode)));
501
+ return withHooksPost(withJobNotices(tools.map((t) => withPlanModeGate(t, getMode))));
393
502
  }
@@ -17,22 +17,30 @@
17
17
  * selected by whether an optional install step succeeded.
18
18
  */
19
19
  import { type AgentTool } from '@aragon-agent/core';
20
- import { type HumanInputGate } from './human-input.js';
21
- /** The outcome of trying to spend one `ask_user` round. */
22
- export interface AskRoundTicket {
23
- ok: boolean;
24
- used: number;
25
- remaining: number;
26
- max: number;
27
- }
20
+ import { type HumanInputGate, type NormalizedQuestion } from './human-input.js';
28
21
  export interface PlanToolsOptions {
29
22
  gate: HumanInputGate;
30
23
  /**
31
- * Spend one `ask_user` round. The counter is owned by the controller because
32
- * it resets per user turn, and the controller is the only object that sees a
33
- * turn begin.
24
+ * Read-only: has this exact question SET already been ANSWERED in this turn?
25
+ *
26
+ * Called BEFORE `gate.request`, and it must have no side effects — that is why
27
+ * the guard is two callbacks rather than one. A single read-and-write function
28
+ * can only be placed before the question (which records an answer nobody gave)
29
+ * or after it (which cannot prevent a repeat). Both are wrong, and the second
30
+ * one silently turns "cancelled rounds stay askable" into a lie.
31
+ */
32
+ hasAnsweredQuestions: (questions: NormalizedQuestion[]) => boolean;
33
+ /**
34
+ * Write-only: remember that this question SET was answered. Called AFTER
35
+ * `gate.request`, and NEVER when the round was cancelled.
36
+ *
37
+ * Cancellation is not consent. `Esc` may be a mis-touch, and turning a
38
+ * mis-touch into "this question may never be asked again" forces the model to
39
+ * guess exactly when it most needs to ask — the behaviour this feature exists
40
+ * to remove. The cheap failure (asking once more) is preferable to the
41
+ * expensive one (information lost for the rest of the turn).
34
42
  */
35
- takeAskRound: () => AskRoundTicket;
43
+ noteAnsweredQuestions: (questions: NormalizedQuestion[]) => void;
36
44
  /**
37
45
  * Run `fn` with the idle watchdog paused. `try/finally` inside, so a throw in
38
46
  * the overlay path cannot leave the watchdog disarmed for the rest of the run.
@@ -25,18 +25,25 @@ const NO_INTERACTIVE_USER = 'No interactive user is attached (-p/--print mode).
25
25
  'state your assumptions explicitly and continue.';
26
26
  const NO_INTERACTIVE_REVIEWER = 'No interactive reviewer is attached. Emit the full plan as your final answer ' +
27
27
  'in markdown instead.';
28
- function roundCapRefusal(used) {
29
- return (`You have already asked ${used} rounds of questions (the configured maximum). ` +
30
- 'Proceed with your best assumptions and state them in the plan.');
31
- }
32
28
  // ---------------------------------------------------------------------------
33
29
  // ask_user
34
30
  // ---------------------------------------------------------------------------
35
31
  const ASK_USER_DESCRIPTION = 'Ask the user 1-5 multiple-choice questions when a decision would change the work. ' +
36
- 'Give each question a stable id, a header of at most 12 characters, and 2-4 options; ' +
37
- 'exactly one option must set recommended:true, with the reason in its description. ' +
38
- 'Extra questions or options beyond these limits are dropped, so keep within them. ' +
39
- 'Do not ask anything you could answer by reading the workspace.';
32
+ 'There is no limit on how many rounds you ask: call this as many times as the work ' +
33
+ 'needs, one batch at a time. Give each question a stable id, a header of at most 12 ' +
34
+ 'characters, and 2-4 options; exactly one option must set recommended:true, with the ' +
35
+ 'reason in its description. Extra questions or options beyond the per-call limits are ' +
36
+ 'dropped, so keep each round inside them. Do not ask anything you could answer by ' +
37
+ 'reading the workspace, and never re-ask a question this turn already answered.';
38
+ /**
39
+ * The de-duplication notice, as a NON-error result.
40
+ *
41
+ * `textResult`, not `errorResult`: a tool failure invites the model to retry the
42
+ * same call, and its only possible outcome is the same refusal. This is the same
43
+ * reasoning as the cancellation path below.
44
+ */
45
+ const DUPLICATE_ROUND_RESULT = 'You already asked this exact set of questions in this turn and the user answered. ' +
46
+ 'Use those answers; if you need something else, ask a different question.';
40
47
  function makeAskUser(opts) {
41
48
  return {
42
49
  name: 'ask_user',
@@ -91,9 +98,10 @@ function makeAskUser(opts) {
91
98
  if (questions.length === 0) {
92
99
  return errorResult('No usable questions: each needs 2-4 distinct options.');
93
100
  }
94
- const ticket = opts.takeAskRound();
95
- if (!ticket.ok)
96
- return errorResult(roundCapRefusal(ticket.used));
101
+ // BEFORE the wait: a set the user has already answered must not open an
102
+ // overlay a second time. Read-only — nothing is recorded here.
103
+ if (opts.hasAnsweredQuestions(questions))
104
+ return textResult(DUPLICATE_ROUND_RESULT);
97
105
  const response = await opts.withHumanWait(() => opts.gate.request({ kind: 'questions', questions }, ctx.signal));
98
106
  // `null` covers cancel, unmount, abort AND timeout. From the model's point
99
107
  // of view an unanswered question and an abandoned one are the same
@@ -102,12 +110,11 @@ function makeAskUser(opts) {
102
110
  // invites a retry loop.
103
111
  const cancelled = response === null || response.kind !== 'answers' || response.cancelled;
104
112
  const answers = response !== null && response.kind === 'answers' ? response.answers : [];
105
- return textResult(formatAnswersResult({
106
- answers,
107
- cancelled,
108
- roundsUsed: ticket.used,
109
- roundsRemaining: ticket.remaining,
110
- }, questions.length));
113
+ // AFTER the answer, and only for a real answer. A cancelled round leaves
114
+ // the set askable: mis-touch must not cost the user information.
115
+ if (!cancelled)
116
+ opts.noteAnsweredQuestions(questions);
117
+ return textResult(formatAnswersResult({ answers, cancelled }, questions.length));
111
118
  },
112
119
  };
113
120
  }