@aragon-agent/cli 0.6.38 → 0.6.39

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 +2523 -2326
  2. package/README.md +3614 -3173
  3. package/dist/agent/controller.d.ts +142 -1
  4. package/dist/agent/controller.js +171 -3
  5. package/dist/agent/reducer.d.ts +41 -1
  6. package/dist/agent/reducer.js +25 -0
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +5 -0
  9. package/dist/cli.js +85 -27
  10. package/dist/commands/builtins.js +13 -1
  11. package/dist/commands/keys-command.d.ts +23 -0
  12. package/dist/commands/keys-command.js +38 -0
  13. package/dist/commands/registry.d.ts +39 -0
  14. package/dist/commands/task-command.d.ts +39 -0
  15. package/dist/commands/task-command.js +102 -0
  16. package/dist/config/cli-commands.d.ts +23 -0
  17. package/dist/config/cli-commands.js +36 -0
  18. package/dist/config/env.js +17 -4
  19. package/dist/config/load.js +6 -1
  20. package/dist/config/schema.d.ts +43 -4
  21. package/dist/config/schema.js +31 -5
  22. package/dist/config/store.js +17 -2
  23. package/dist/diagnostics/doctor.d.ts +9 -0
  24. package/dist/diagnostics/doctor.js +34 -0
  25. package/dist/diagnostics/info.d.ts +71 -0
  26. package/dist/diagnostics/info.js +91 -5
  27. package/dist/exec/bridge/caller-tools.d.ts +96 -0
  28. package/dist/exec/bridge/caller-tools.js +213 -0
  29. package/dist/exec/bridge/decisions.d.ts +78 -0
  30. package/dist/exec/bridge/decisions.js +116 -0
  31. package/dist/exec/bridge/human-answers.d.ts +38 -0
  32. package/dist/exec/bridge/human-answers.js +164 -0
  33. package/dist/exec/bridge/limits.d.ts +68 -0
  34. package/dist/exec/bridge/limits.js +68 -0
  35. package/dist/exec/bridge/ndjson.d.ts +17 -0
  36. package/dist/exec/bridge/ndjson.js +83 -0
  37. package/dist/exec/bridge/pending.d.ts +70 -0
  38. package/dist/exec/bridge/pending.js +132 -0
  39. package/dist/exec/bridge/protocol.d.ts +346 -0
  40. package/dist/exec/bridge/protocol.js +256 -0
  41. package/dist/exec/bridge/server.d.ts +142 -0
  42. package/dist/exec/bridge/server.js +502 -0
  43. package/dist/exec/bridge/source.d.ts +58 -0
  44. package/dist/exec/bridge/source.js +145 -0
  45. package/dist/exec/bridge/writer.d.ts +26 -0
  46. package/dist/exec/bridge/writer.js +156 -0
  47. package/dist/exec/cli-commands.js +30 -2
  48. package/dist/exec/emitter.d.ts +16 -2
  49. package/dist/exec/emitter.js +26 -6
  50. package/dist/exec/events.d.ts +465 -10
  51. package/dist/exec/events.js +47 -1
  52. package/dist/exec/hooks/dispatch.d.ts +76 -0
  53. package/dist/exec/hooks/dispatch.js +169 -0
  54. package/dist/exec/hooks/index.d.ts +83 -0
  55. package/dist/exec/hooks/index.js +205 -0
  56. package/dist/exec/hooks/limits.d.ts +56 -0
  57. package/dist/exec/hooks/limits.js +49 -0
  58. package/dist/exec/hooks/registry.d.ts +49 -0
  59. package/dist/exec/hooks/registry.js +91 -0
  60. package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
  61. package/dist/exec/hooks/tool-wrapper.js +154 -0
  62. package/dist/exec/hooks/turn.d.ts +61 -0
  63. package/dist/exec/hooks/turn.js +59 -0
  64. package/dist/exec/hooks/validate.d.ts +85 -0
  65. package/dist/exec/hooks/validate.js +310 -0
  66. package/dist/exec/index.d.ts +130 -4
  67. package/dist/exec/index.js +501 -10
  68. package/dist/exec/options.d.ts +124 -2
  69. package/dist/exec/options.js +413 -6
  70. package/dist/exec/permission.d.ts +9 -1
  71. package/dist/exec/permission.js +7 -1
  72. package/dist/exec/request/limits.d.ts +51 -0
  73. package/dist/exec/request/limits.js +49 -0
  74. package/dist/exec/request/scope.d.ts +106 -0
  75. package/dist/exec/request/scope.js +149 -0
  76. package/dist/exec/result/limits.d.ts +74 -0
  77. package/dist/exec/result/limits.js +72 -0
  78. package/dist/exec/result/prompt.d.ts +28 -0
  79. package/dist/exec/result/prompt.js +74 -0
  80. package/dist/exec/result/schema.d.ts +99 -0
  81. package/dist/exec/result/schema.js +464 -0
  82. package/dist/exec/result/sink.d.ts +56 -0
  83. package/dist/exec/result/sink.js +50 -0
  84. package/dist/exec/result/tool.d.ts +37 -0
  85. package/dist/exec/result/tool.js +118 -0
  86. package/dist/exec/runner.d.ts +395 -1
  87. package/dist/exec/runner.js +860 -8
  88. package/dist/exec/threads/aggregate.d.ts +34 -0
  89. package/dist/exec/threads/aggregate.js +30 -0
  90. package/dist/exec/threads/capacity.d.ts +23 -0
  91. package/dist/exec/threads/capacity.js +38 -0
  92. package/dist/exec/threads/dispatch-source.d.ts +60 -0
  93. package/dist/exec/threads/dispatch-source.js +106 -0
  94. package/dist/exec/threads/execute-threads.d.ts +53 -0
  95. package/dist/exec/threads/execute-threads.js +234 -0
  96. package/dist/exec/threads/gate.d.ts +42 -0
  97. package/dist/exec/threads/gate.js +81 -0
  98. package/dist/exec/threads/identity.d.ts +36 -0
  99. package/dist/exec/threads/identity.js +67 -0
  100. package/dist/exec/threads/interrupt.d.ts +29 -0
  101. package/dist/exec/threads/interrupt.js +79 -0
  102. package/dist/exec/threads/limits.d.ts +58 -0
  103. package/dist/exec/threads/limits.js +35 -0
  104. package/dist/exec/threads/outcome.d.ts +47 -0
  105. package/dist/exec/threads/outcome.js +26 -0
  106. package/dist/exec/threads/registry.d.ts +81 -0
  107. package/dist/exec/threads/registry.js +427 -0
  108. package/dist/exec/threads/runtime.d.ts +107 -0
  109. package/dist/exec/threads/runtime.js +523 -0
  110. package/dist/exec/threads/thread-source.d.ts +20 -0
  111. package/dist/exec/threads/thread-source.js +134 -0
  112. package/dist/fast/limits.d.ts +54 -0
  113. package/dist/fast/limits.js +54 -0
  114. package/dist/fast/summary-call.d.ts +72 -0
  115. package/dist/fast/summary-call.js +133 -0
  116. package/dist/fast/summary-prompt.d.ts +106 -0
  117. package/dist/fast/summary-prompt.js +320 -0
  118. package/dist/fast/task-summary-types.d.ts +66 -0
  119. package/dist/fast/task-summary-types.js +17 -0
  120. package/dist/logging/logger.d.ts +1 -1
  121. package/dist/proc/limits.d.ts +7 -0
  122. package/dist/proc/limits.js +7 -0
  123. package/dist/proc/output-streams.d.ts +50 -0
  124. package/dist/proc/output-streams.js +54 -0
  125. package/dist/team/runtime.d.ts +9 -0
  126. package/dist/team/runtime.js +8 -0
  127. package/dist/team/subagent.d.ts +15 -0
  128. package/dist/team/subagent.js +45 -1
  129. package/dist/tools/bash-tool.d.ts +10 -0
  130. package/dist/tools/bash-tool.js +71 -6
  131. package/dist/tools/index.d.ts +93 -1
  132. package/dist/tools/index.js +112 -3
  133. package/dist/tools/tool-gate.d.ts +100 -0
  134. package/dist/tools/tool-gate.js +87 -0
  135. package/dist/ui/App.js +224 -11
  136. package/dist/ui/Header.d.ts +28 -16
  137. package/dist/ui/Header.js +21 -19
  138. package/dist/ui/interaction-hints.js +6 -0
  139. package/dist/ui/layout/OverlayFrame.d.ts +18 -0
  140. package/dist/ui/layout/OverlayFrame.js +19 -4
  141. package/dist/ui/layout/header-hints.d.ts +55 -62
  142. package/dist/ui/layout/header-hints.js +59 -166
  143. package/dist/ui/layout/header-left.d.ts +115 -0
  144. package/dist/ui/layout/header-left.js +206 -0
  145. package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
  146. package/dist/ui/layout/shortcut-panel-layout.js +321 -0
  147. package/dist/ui/layout/status-detail-layout.js +5 -1
  148. package/dist/ui/layout/task-summary-cell.d.ts +61 -0
  149. package/dist/ui/layout/task-summary-cell.js +65 -0
  150. package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
  151. package/dist/ui/overlays/HelpOverlay.js +29 -2
  152. package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
  153. package/dist/ui/overlays/ShortcutPanel.js +106 -0
  154. package/dist/ui/shortcuts.d.ts +81 -0
  155. package/dist/ui/shortcuts.js +221 -0
  156. package/package.json +2 -2
@@ -14,7 +14,7 @@ import { Agent, createSkillFindTool, } from '@aragon-agent/core';
14
14
  import { formatStreamError } from '../agent/reducer.js';
15
15
  import { buildSystemPrompt } from '../agent/system-prompt.js';
16
16
  import { buildFastBlock } from '../fast/prompt.js';
17
- import { createBuiltinTools } from '../tools/index.js';
17
+ import { createBuiltinTools, gatedToolTimeouts, } from '../tools/index.js';
18
18
  import { describeToolActivity, pickActivityArgs, sanitizeActivity } from './activity.js';
19
19
  import { TEAM_LIMITS, TEAM_SUBAGENT_TOOL_NAMES } from './limits.js';
20
20
  import { appendTimeline } from './timeline.js';
@@ -24,6 +24,26 @@ import { makeTeamSend, makeTeamWait, withMailboxTail } from './comm-tools.js';
24
24
  import { buildSubagentBlock } from './prompt.js';
25
25
  /** The default factory: a real `Agent` from `@aragon-agent/core`. */
26
26
  export const defaultSubagentFactory = (config) => new Agent(config);
27
+ /**
28
+ * Hold a child's idle watchdog across a caller wait.
29
+ *
30
+ * The child's watchdog is its own (that is the point of a separate ceiling), so a
31
+ * four-minute approval would otherwise abort the child and take the dispatch with
32
+ * it - R-1, one level down. `finally` is what makes "paused" never outlive the
33
+ * wait, including on an abort.
34
+ */
35
+ function pauseChildWatchdog(getAgent) {
36
+ return async (fn) => {
37
+ const agent = getAgent();
38
+ agent?.pauseIdleWatchdog();
39
+ try {
40
+ return await fn();
41
+ }
42
+ finally {
43
+ agent?.resumeIdleWatchdog();
44
+ }
45
+ };
46
+ }
27
47
  /** The final assistant text of a turn, or `''` when the turn was tool calls only. */
28
48
  function assistantText(message) {
29
49
  if (!message || !Array.isArray(message.content))
@@ -59,6 +79,21 @@ export function buildSubagentTools(spec, deps, getAgent) {
59
79
  const tools = createBuiltinTools({
60
80
  getCwd: deps.getCwd,
61
81
  confirmTools: deps.confirmTools,
82
+ // THE CHILD ASKS THE CALLER TOO (section 2.3.7). Without this the approval
83
+ // gate would hold for the lead and be bypassed by one `task` call - the hole
84
+ // the `--deny-tool` pass-through above records, one feature later and with a
85
+ // human on the other end of it who believes they are approving everything.
86
+ //
87
+ // The watchdog pause is the CHILD'S OWN, never `TeamHumanQueue`'s: that queue
88
+ // is `--confirm`'s, and nesting a second pause is exactly what
89
+ // `withPausedWatchdog` forbids. `getAgent()` is the same live handle the queue
90
+ // and `team_wait` already use.
91
+ ...(deps.toolGateFor
92
+ ? {
93
+ toolGate: deps.toolGateFor(spec.label),
94
+ withToolWait: pauseChildWatchdog(getAgent),
95
+ }
96
+ : {}),
62
97
  // Queued through the ONE human slot, with this child's watchdog paused for
63
98
  // the whole queue wait (§3.11 / I-4 / P0-4).
64
99
  ...(deps.confirmTools && deps.humanQueue
@@ -253,6 +288,15 @@ export function createSubagent(spec, deps, hooks) {
253
288
  ...(deps.config.maxTokens !== undefined ? { maxTokens: deps.config.maxTokens } : {}),
254
289
  timeouts: {
255
290
  toolTimeout: deps.config.toolTimeoutMs,
291
+ // THE SAME TWO PHASES THE LEAD GETS (section 2.3.4). A child that asks the
292
+ // caller and waits is billed for the wait out of its tool's budget unless
293
+ // this is here, and the symptom would be "the approved command was killed
294
+ // mid-run" only inside a fan-out.
295
+ ...(deps.toolGateFor && deps.decisionBudgetMs !== undefined
296
+ ? {
297
+ toolTimeoutOverrides: gatedToolTimeouts(deps.config.toolTimeoutMs, deps.decisionBudgetMs),
298
+ }
299
+ : {}),
256
300
  // A child keeps its OWN idle watchdog, and that is a feature: a wedged
257
301
  // child is aborted by its own ceiling without taking the dispatch with it
258
302
  // (§3.8). The two places a child legitimately blocks on something other
@@ -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.
@@ -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) => {
@@ -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
  }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * The per-call approval gate: one wrapper, one shared denial text, no I/O.
3
+ *
4
+ * ASCII ONLY - `src/tools/**` is inside the glyph scanner's scope.
5
+ *
6
+ * WHY A WRAPPER AND NOT A CORE HOOK (headless-host-bridge section 2.3.3). A
7
+ * `beforeExecute` callback on Core's `ToolExecutor` would put a human wait in
8
+ * front of the plan gate's refusal, freeze a new field into the public API that
9
+ * `public-api.test.ts` guards, and teach the engine about a host shape it has no
10
+ * business knowing. The `{...tool, execute}` shape below is the one already used
11
+ * three times in `tools/index.ts` (confirmation, skill ceiling, plan gate), so
12
+ * nothing in the engine has to learn that an approval exists.
13
+ *
14
+ * WHERE IT SITS IN THE STACK IS PART OF THE DESIGN, not an accident of import
15
+ * order: `tools/index.ts` applies it AFTER every append (so `skill_create` is
16
+ * reachable) and BEFORE the `toolPolicy` / plan-gate wrappers are added, so at
17
+ * call time those two refusals - which need no I/O - happen first and a tool that
18
+ * policy would refuse never interrupts a human.
19
+ */
20
+ import { type AgentTool } from '@aragon-agent/core';
21
+ export interface ToolGateRequest {
22
+ tool: string;
23
+ /** Core's tool-call id, so the caller can pair this with the `tool_call` event. */
24
+ callId: string;
25
+ input: Record<string, unknown>;
26
+ /** One readable line: `Run: npm test`. Comes from the same `summarize()`. */
27
+ summary: string;
28
+ /** The turn this belongs to, when the host can say. */
29
+ turn?: number;
30
+ /**
31
+ * The tool's `ctx.signal`. WITHOUT IT THE WAIT HAS NO CEILING: the idle
32
+ * watchdog is paused for the duration and the executor's own timeout only
33
+ * aborts the context, so an unanswered request would wait for the process to
34
+ * die. Passed through, never invented.
35
+ */
36
+ signal?: AbortSignal;
37
+ }
38
+ /**
39
+ * `message` is the model-visible text used VERBATIM when present.
40
+ *
41
+ * The three non-approval outcomes are not variations of "denied": a timeout says
42
+ * how long it waited, an interruption says the turn ended. Collapsing them into
43
+ * one sentence would make the model re-try a command after a one-second user
44
+ * click and would hide a real timeout in a log that reads like a refusal.
45
+ */
46
+ export type ToolGateVerdict = {
47
+ allow: true;
48
+ } | {
49
+ allow: false;
50
+ reason?: string;
51
+ message?: string;
52
+ };
53
+ export interface ToolGate {
54
+ /**
55
+ * PURE AND NAME-ONLY: `CALLER_GATED_TOOLS.has(name)`.
56
+ *
57
+ * The session-level allow set is deliberately NOT consulted here - it lives in
58
+ * `decide()`. Mixing them would make `applies()` stateful, and the object
59
+ * identity assertions that prove "no gate means no change" would stop being
60
+ * decidable.
61
+ */
62
+ applies(toolName: string): boolean;
63
+ /** NEVER rejects. Resolves `deny` on abort / timeout / capacity / closure. */
64
+ decide(request: ToolGateRequest): Promise<ToolGateVerdict>;
65
+ }
66
+ /**
67
+ * THE ONE PLACE A DENIAL IS WORDED. Shared by the wrapper below and by the tests,
68
+ * so "the model is told what happened and what to do instead" is a single string
69
+ * rather than a paragraph repeated in two modules.
70
+ *
71
+ * The last sentence is not politeness. A bare "denied" makes a model retry the
72
+ * same call; the observed failure mode is a paid loop that burns a budget on one
73
+ * forbidden command. Naming the tool and the reason is what lets it route around
74
+ * the refusal instead.
75
+ */
76
+ export declare function denialMessage(tool: string, reason?: string): string;
77
+ /** The text a timed-out decision produces. Exported for the same reason. */
78
+ export declare function timeoutMessage(tool: string, timeoutMs: number): string;
79
+ /** The text an aborted turn produces, in the middle of a decision wait. */
80
+ export declare function interruptedMessage(tool: string): string;
81
+ export interface WithToolApprovalOptions {
82
+ /**
83
+ * The host's watchdog pause, forwarded straight from the controller.
84
+ *
85
+ * THE WAIT IS THE WHOLE REASON IT EXISTS: the idle watchdog would otherwise fire
86
+ * mid-approval and abort a run the user was in the middle of authorising, which
87
+ * on screen reads as "it randomly stopped". `withPausedWatchdog` is not
88
+ * reentrant, so this is the SAME function the plan tools receive rather than a
89
+ * second wrapper around it.
90
+ */
91
+ withHumanWait?: <T>(fn: () => Promise<T>) => Promise<T>;
92
+ }
93
+ /**
94
+ * Wrap a tool behind the gate. Same `{...tool, execute}` shape as every other
95
+ * wrapper in this package, and the same `errorResult` discipline: a refusal is a
96
+ * TOOL RESULT, never a throw, because the engine pushes whatever this returns
97
+ * into history - and a `tool_use` with no `tool_result` is an HTTP 400 on the next
98
+ * request.
99
+ */
100
+ export declare function withToolApproval(tool: AgentTool, gate: ToolGate, options?: WithToolApprovalOptions): AgentTool;