@aragon-agent/cli 0.6.2 → 0.6.4

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 (167) hide show
  1. package/CHANGELOG.md +321 -0
  2. package/README.md +297 -2
  3. package/dist/agent/controller.d.ts +193 -1
  4. package/dist/agent/controller.js +475 -2
  5. package/dist/agent/headless.d.ts +8 -0
  6. package/dist/agent/headless.js +47 -0
  7. package/dist/agent/reducer.d.ts +189 -0
  8. package/dist/agent/reducer.js +301 -2
  9. package/dist/agent/system-prompt.d.ts +14 -0
  10. package/dist/agent/system-prompt.js +2 -0
  11. package/dist/cli.js +297 -16
  12. package/dist/commands/builtins.js +165 -24
  13. package/dist/commands/registry.d.ts +27 -0
  14. package/dist/compaction/archive.d.ts +175 -0
  15. package/dist/compaction/archive.js +267 -0
  16. package/dist/compaction/child.d.ts +97 -0
  17. package/dist/compaction/child.js +118 -0
  18. package/dist/compaction/command.d.ts +41 -0
  19. package/dist/compaction/command.js +355 -0
  20. package/dist/compaction/compactor.d.ts +302 -0
  21. package/dist/compaction/compactor.js +810 -0
  22. package/dist/compaction/digest.d.ts +62 -0
  23. package/dist/compaction/digest.js +164 -0
  24. package/dist/compaction/limits.d.ts +165 -0
  25. package/dist/compaction/limits.js +172 -0
  26. package/dist/compaction/pressure.d.ts +135 -0
  27. package/dist/compaction/pressure.js +167 -0
  28. package/dist/compaction/summarize-call.d.ts +70 -0
  29. package/dist/compaction/summarize-call.js +86 -0
  30. package/dist/compaction/summary-prompt.d.ts +85 -0
  31. package/dist/compaction/summary-prompt.js +197 -0
  32. package/dist/compaction/tail-budget.d.ts +52 -0
  33. package/dist/compaction/tail-budget.js +67 -0
  34. package/dist/compaction/types.d.ts +215 -0
  35. package/dist/compaction/types.js +22 -0
  36. package/dist/compaction/wiring.d.ts +264 -0
  37. package/dist/compaction/wiring.js +577 -0
  38. package/dist/config/app-paths.d.ts +14 -0
  39. package/dist/config/app-paths.js +16 -0
  40. package/dist/config/cli-commands.d.ts +41 -0
  41. package/dist/config/cli-commands.js +77 -1
  42. package/dist/config/env.d.ts +5 -2
  43. package/dist/config/env.js +140 -3
  44. package/dist/config/load.d.ts +42 -0
  45. package/dist/config/load.js +101 -1
  46. package/dist/config/schema.d.ts +244 -0
  47. package/dist/config/schema.js +170 -0
  48. package/dist/config/store.js +37 -1
  49. package/dist/config/ui-state.d.ts +28 -1
  50. package/dist/config/ui-state.js +9 -0
  51. package/dist/exec/events.d.ts +50 -1
  52. package/dist/exec/runner.js +42 -0
  53. package/dist/input/limits.d.ts +72 -0
  54. package/dist/input/limits.js +63 -0
  55. package/dist/input/mouse-events.d.ts +31 -5
  56. package/dist/input/mouse-events.js +86 -27
  57. package/dist/input/paste-parse.d.ts +71 -0
  58. package/dist/input/paste-parse.js +95 -0
  59. package/dist/input/stdin-filter.d.ts +81 -0
  60. package/dist/input/stdin-filter.js +379 -0
  61. package/dist/logging/install.d.ts +53 -1
  62. package/dist/logging/install.js +81 -1
  63. package/dist/logging/logger.d.ts +1 -1
  64. package/dist/proc/classify.d.ts +52 -0
  65. package/dist/proc/classify.js +143 -0
  66. package/dist/proc/kill-tree.d.ts +42 -0
  67. package/dist/proc/kill-tree.js +119 -0
  68. package/dist/proc/limits.d.ts +84 -0
  69. package/dist/proc/limits.js +84 -0
  70. package/dist/proc/log-ring.d.ts +68 -0
  71. package/dist/proc/log-ring.js +102 -0
  72. package/dist/proc/prompt.d.ts +24 -0
  73. package/dist/proc/prompt.js +49 -0
  74. package/dist/proc/readiness.d.ts +102 -0
  75. package/dist/proc/readiness.js +219 -0
  76. package/dist/proc/supervisor.d.ts +103 -0
  77. package/dist/proc/supervisor.js +465 -0
  78. package/dist/proc/types.d.ts +175 -0
  79. package/dist/proc/types.js +15 -0
  80. package/dist/session/persist.js +55 -1
  81. package/dist/team/report.js +7 -1
  82. package/dist/team/runtime.d.ts +10 -0
  83. package/dist/team/runtime.js +7 -0
  84. package/dist/team/subagent.d.ts +31 -1
  85. package/dist/team/subagent.js +42 -15
  86. package/dist/team/types.d.ts +8 -0
  87. package/dist/tools/bash-tool.d.ts +51 -2
  88. package/dist/tools/bash-tool.js +321 -51
  89. package/dist/tools/fs-tools.d.ts +17 -0
  90. package/dist/tools/index.d.ts +55 -2
  91. package/dist/tools/index.js +39 -1
  92. package/dist/tools/proc-tools.d.ts +25 -0
  93. package/dist/tools/proc-tools.js +102 -0
  94. package/dist/ui/ActivityLine.d.ts +19 -1
  95. package/dist/ui/ActivityLine.js +10 -2
  96. package/dist/ui/App.d.ts +62 -2
  97. package/dist/ui/App.js +546 -16
  98. package/dist/ui/Composer.d.ts +66 -1
  99. package/dist/ui/Composer.js +50 -6
  100. package/dist/ui/PromptInput.d.ts +82 -3
  101. package/dist/ui/PromptInput.js +232 -49
  102. package/dist/ui/StatusBar.d.ts +59 -0
  103. package/dist/ui/StatusBar.js +16 -4
  104. package/dist/ui/TeamPanel.js +7 -1
  105. package/dist/ui/Transcript.d.ts +21 -1
  106. package/dist/ui/Transcript.js +61 -3
  107. package/dist/ui/clipboard.d.ts +52 -0
  108. package/dist/ui/clipboard.js +91 -0
  109. package/dist/ui/composer-limits.d.ts +31 -0
  110. package/dist/ui/composer-limits.js +50 -0
  111. package/dist/ui/composer-rows.d.ts +73 -0
  112. package/dist/ui/composer-rows.js +201 -0
  113. package/dist/ui/editor-reducer.d.ts +37 -0
  114. package/dist/ui/editor-reducer.js +88 -12
  115. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  116. package/dist/ui/entries/CompactionCard.js +158 -0
  117. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  118. package/dist/ui/entries/ServiceCard.js +116 -0
  119. package/dist/ui/entries/UserEntry.d.ts +17 -3
  120. package/dist/ui/entries/UserEntry.js +34 -5
  121. package/dist/ui/frame-differ.d.ts +46 -0
  122. package/dist/ui/frame-differ.js +94 -10
  123. package/dist/ui/gauge.d.ts +16 -1
  124. package/dist/ui/gauge.js +25 -6
  125. package/dist/ui/glyphs.d.ts +11 -0
  126. package/dist/ui/glyphs.js +2 -0
  127. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  128. package/dist/ui/layout/ScrollViewport.js +128 -21
  129. package/dist/ui/layout/budget.d.ts +44 -9
  130. package/dist/ui/layout/budget.js +42 -10
  131. package/dist/ui/layout/follow-state.d.ts +137 -0
  132. package/dist/ui/layout/follow-state.js +150 -0
  133. package/dist/ui/layout/virtual-window.d.ts +11 -0
  134. package/dist/ui/layout/virtual-window.js +85 -1
  135. package/dist/ui/overlays/HelpOverlay.js +9 -1
  136. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  137. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  138. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  139. package/dist/ui/overlays/SettingsScreen.js +74 -2
  140. package/dist/ui/palettes.d.ts +16 -0
  141. package/dist/ui/palettes.js +6 -0
  142. package/dist/ui/paste-frames.d.ts +87 -0
  143. package/dist/ui/paste-frames.js +112 -0
  144. package/dist/ui/paste-tokens.d.ts +90 -0
  145. package/dist/ui/paste-tokens.js +147 -0
  146. package/dist/ui/screen.d.ts +38 -0
  147. package/dist/ui/screen.js +113 -8
  148. package/dist/ui/selection/highlight.d.ts +39 -0
  149. package/dist/ui/selection/highlight.js +64 -0
  150. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  151. package/dist/ui/selection/screen-mirror.js +35 -0
  152. package/dist/ui/selection/selection-controller.d.ts +88 -0
  153. package/dist/ui/selection/selection-controller.js +282 -0
  154. package/dist/ui/selection/selection.d.ts +90 -0
  155. package/dist/ui/selection/selection.js +223 -0
  156. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  157. package/dist/ui/stdout-frame-writer.js +15 -0
  158. package/dist/ui/theme.d.ts +11 -0
  159. package/dist/ui/theme.js +2 -0
  160. package/dist/ui/transcript-text.js +72 -0
  161. package/dist/ui/use-startup-notices.d.ts +27 -1
  162. package/dist/ui/use-startup-notices.js +43 -11
  163. package/dist/ui/use-wheel-routing.d.ts +1 -1
  164. package/dist/ui/use-wheel-routing.js +8 -0
  165. package/package.json +4 -2
  166. package/dist/input/stdin-mouse-filter.d.ts +0 -42
  167. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -1,59 +1,150 @@
1
1
  /**
2
- * bash tool — run a shell command at full permission (spec §3.6).
2
+ * bash tool — run a shell command at full permission (spec §3.6), and, since
3
+ * background-service-supervision, supervise the ones that do not exit.
3
4
  *
4
5
  * Shell selection: Windows uses `process.env.ComSpec` (cmd.exe by default);
5
6
  * POSIX uses `/bin/sh -c`. The command's stdout+stderr and exit code are
6
7
  * captured. The `timeout?` param can only SHRINK within the executor ceiling
7
8
  * (the core ToolExecutor owns the real timeout + 100 KB truncation — R4), so
8
9
  * this tool does not re-implement an output cap.
10
+ *
11
+ * I-1 — THIS TOOL NEVER LEAVES ITS PROMISE PENDING. Every exit path goes through
12
+ * `settle`, and `settle` is reachable from FIVE places: `'error'`, `'close'`,
13
+ * `'exit'` plus a drain timer, an abort plus a kill grace, and the `timeout`
14
+ * param plus the same grace. That is the whole of W1, and each of the five
15
+ * closes a way the old implementation could hang:
16
+ *
17
+ * 1. It settled on `'close'`, which fires only when the process has exited AND
18
+ * every stdio stream is closed. A child that spawns a detached grandchild
19
+ * inheriting the pipe — `Start-Process`, `&`, `nohup`, and every
20
+ * `npm run dev` that re-execs — keeps that pipe open after the shell is
21
+ * long gone. The shell exited; the promise did not.
22
+ * 2. `stdio` defaulted to `'pipe'` for stdin, so a command that PROMPTS —
23
+ * `npm init`, a package manager asking to install something, `sudo`, a git
24
+ * credential prompt — blocked on a read that could never be satisfied.
25
+ * 3. An abort killed the tree and then went on waiting for a `'close'` that a
26
+ * detached grandchild was still holding.
27
+ *
28
+ * The happy path is BYTE-IDENTICAL to the pre-change build: `$ cmd\n<output>\n
29
+ * [exit code N]`, same truncation, same footer. That is what keeps the existing
30
+ * `bash` assertions meaningful rather than rewritten.
9
31
  */
10
32
  import { spawn } from 'node:child_process';
11
33
  import process from 'node:process';
12
34
  import { defineTool, errorResult, textResult, } from '@aragon-agent/core';
35
+ import { looksLongRunning } from '../proc/classify.js';
36
+ import { killTree } from '../proc/kill-tree.js';
37
+ import { PROC_LIMITS } from '../proc/limits.js';
13
38
  /**
14
- * Kill the whole process tree. With `shell:true` the direct child is the shell
15
- * wrapper, so on Windows we must `taskkill /t` to reach the grandchild; a plain
16
- * `child.kill()` would orphan it (leaving pipes open and cwd locked).
39
+ * The two sentences appended to the tool description when background launches
40
+ * are available.
41
+ *
42
+ * MODEL-FACING POLICY, so their wording is versioned with the prompt block in
43
+ * `proc/prompt.ts` rather than treated as a comment. They are appended
44
+ * CONDITIONALLY, which is half of what keeps I-2 true: with the feature off the
45
+ * description — and therefore the system prompt's tool list — is the pre-feature
46
+ * string, byte for byte.
17
47
  */
18
- function killTree(child) {
19
- if (child.pid === undefined)
20
- return;
21
- if (process.platform === 'win32') {
22
- try {
23
- spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true });
24
- }
25
- catch {
26
- try {
27
- child.kill('SIGKILL');
28
- }
29
- catch {
30
- /* ignore */
31
- }
32
- }
48
+ const BACKGROUND_DESCRIPTION = ' Set `background: true` for anything that does not exit on its own - dev ' +
49
+ 'servers, watchers, `docker compose up`. Never use `Start-Process`, `&`, ' +
50
+ '`nohup` or `screen` to detach a command yourself; the runtime supervises ' +
51
+ 'background commands, and a self-detached process cannot be reported on or ' +
52
+ 'stopped.';
53
+ /**
54
+ * The settle window used when a supervisor is supplied WITHOUT one.
55
+ *
56
+ * Only reachable from a test that builds the tool by hand: `AgentController`
57
+ * always passes `cfg.bash.startupSettleMs`. It mirrors `DEFAULT_BASH_CONFIG`
58
+ * rather than importing it, because nothing in `tools/` imports `config/` — a
59
+ * tool takes its policy through the closures below, which is what
60
+ * `startupSettleMs` is. It is NOT expressed in terms of `PROC_LIMITS`: those are
61
+ * structural bounds, this is policy, and a multiple of `killGraceMs` that
62
+ * happens to equal the default today would silently move if that bound ever did.
63
+ */
64
+ const FALLBACK_STARTUP_SETTLE_MS = 4000;
65
+ /**
66
+ * The foreground registry a `bash` child registers with, so `forceStop()` can
67
+ * reach it. `undefined` on the paths that build tools without a supervisor
68
+ * (every existing test), which is what keeps those byte-identical.
69
+ */
70
+ function trackForeground(supervisor, pid) {
71
+ return supervisor ? supervisor.trackForeground(pid) : () => { };
72
+ }
73
+ /** Render a background launch's result block for the model (§3.5). */
74
+ function renderServiceResult(service, opts) {
75
+ const lines = [`$ ${service.command}`];
76
+ lines.push(`[background] service ${service.id} started (pid ${service.pid ?? '?'})`);
77
+ const elapsed = ((service.readyAt ?? service.endedAt ?? Date.now()) - service.startedAt) / 1000;
78
+ if (service.status === 'ready') {
79
+ const how = service.detectedBy === 'probe' ? 'port probe' : 'output';
80
+ const where = service.url ?? (service.port ? `port ${service.port}` : '');
81
+ lines.push(`[ready] ${where} (detected from ${how} after ${elapsed.toFixed(1)}s)`);
82
+ }
83
+ else if (service.status === 'running') {
84
+ lines.push(`[running] still alive, nothing to connect to (a watcher, most likely)`);
85
+ }
86
+ else if (service.status === 'exited' || service.status === 'failed') {
87
+ const code = service.exitCode === null ? `signal ${service.signal}` : `code ${service.exitCode}`;
88
+ lines.push(`[exited] ${code} after ${elapsed.toFixed(1)}s`);
33
89
  }
34
90
  else {
35
- try {
36
- child.kill('SIGKILL');
37
- }
38
- catch {
39
- /* ignore */
40
- }
91
+ lines.push(`[starting] not ready yet after ${elapsed.toFixed(1)}s - still coming up`);
92
+ }
93
+ if (service.portPreoccupied && service.port !== undefined) {
94
+ lines.push(`[note] port ${service.port} was already in use before start`);
95
+ }
96
+ if (service.rows.length > 0) {
97
+ lines.push('[log tail]');
98
+ for (const row of service.rows)
99
+ lines.push(` ${row}`);
100
+ }
101
+ if (opts.ephemeral) {
102
+ // D-15 / P1-8: headless has no user who could keep it alive, and saying so
103
+ // is the difference between a surprise and a documented behaviour.
104
+ lines.push('[note] this service will be stopped when the run ends.');
41
105
  }
106
+ lines.push(`Read more with bash_output({ service: "${service.id}" }); ` +
107
+ `stop it with bash_kill({ service: "${service.id}" }).`);
108
+ const body = lines.join('\n');
109
+ // A service that DIED is an error result, so the model treats it as one: this
110
+ // is the failure the user actually cares about, reported in 400 ms instead of
111
+ // three minutes of stall (G1).
112
+ return service.status === 'exited' && service.exitCode !== 0
113
+ ? errorResult(body)
114
+ : service.status === 'failed'
115
+ ? errorResult(body)
116
+ : textResult(body);
42
117
  }
43
- export function makeBash(deps) {
118
+ export function makeBash(deps, options = {}) {
119
+ const supervisor = options.supervisor;
120
+ const backgroundAvailable = supervisor !== undefined;
44
121
  return defineTool({
45
122
  name: 'bash',
46
123
  label: 'Shell',
47
124
  description: 'Run a shell command at full permission and return its combined ' +
48
125
  'stdout+stderr and exit code. `timeout` (ms) may only shrink within the ' +
49
126
  'agent tool-timeout ceiling; `cwd` defaults to the working directory. ' +
50
- 'Emit shell syntax compatible with the OS/shell stated in the system prompt.',
127
+ 'Emit shell syntax compatible with the OS/shell stated in the system prompt.' +
128
+ (backgroundAvailable ? BACKGROUND_DESCRIPTION : ''),
51
129
  parameters: {
52
130
  type: 'object',
53
131
  properties: {
54
132
  command: { type: 'string', description: 'The shell command to run.' },
55
133
  timeout: { type: 'number', description: 'Optional timeout in milliseconds (shrinks within the ceiling).' },
56
134
  cwd: { type: 'string', description: 'Working directory for this command.' },
135
+ // SPREAD CONDITIONALLY. With the feature off the schema is the object it
136
+ // is today, which is what `buildSystemPrompt`'s byte-identity claim (I-2)
137
+ // is measured against.
138
+ ...(backgroundAvailable
139
+ ? {
140
+ background: {
141
+ type: 'boolean',
142
+ description: 'true = supervise this command and return immediately with a ' +
143
+ 'service id. false = never background it. Omit to let the ' +
144
+ 'runtime decide from the command.',
145
+ },
146
+ }
147
+ : {}),
57
148
  },
58
149
  required: ['command'],
59
150
  },
@@ -63,6 +154,25 @@ export function makeBash(deps) {
63
154
  const params = rawParams;
64
155
  const command = params.command;
65
156
  const cwd = params.cwd ? params.cwd : deps.getCwd();
157
+ // --- The three-valued background decision (D-2 / §3.5) ----------------
158
+ //
159
+ // `true` -> background, always.
160
+ // `false` -> foreground, always. The escape hatch; nothing overrides it.
161
+ // absent -> the classifier, and only where a card can be rendered.
162
+ if (supervisor && params.background !== false) {
163
+ const auto = options.interactive !== false &&
164
+ options.autoBackground?.() !== false &&
165
+ looksLongRunning(command);
166
+ if (params.background === true || auto) {
167
+ return runBackground(supervisor, {
168
+ command,
169
+ cwd,
170
+ toolCallId: id,
171
+ settleMs: options.startupSettleMs?.() ?? FALLBACK_STARTUP_SETTLE_MS,
172
+ ephemeral: options.interactive === false,
173
+ });
174
+ }
175
+ }
66
176
  return new Promise((resolvePromise) => {
67
177
  let child;
68
178
  try {
@@ -74,40 +184,144 @@ export function makeBash(deps) {
74
184
  cwd,
75
185
  env: process.env,
76
186
  windowsHide: true,
187
+ // W1.2 — NO UNANSWERABLE READ. `spawn` defaults every descriptor to
188
+ // a pipe, so a command that prompts blocks on a read nobody will
189
+ // ever satisfy and the promise never settles. `'ignore'` turns that
190
+ // into an EOF the command can react to.
191
+ stdio: ['ignore', 'pipe', 'pipe'],
77
192
  });
78
193
  }
79
194
  catch (err) {
80
195
  resolvePromise(errorResult(err instanceof Error ? err.message : String(err)));
81
196
  return;
82
197
  }
198
+ const releaseForeground = trackForeground(supervisor, child.pid);
83
199
  let output = '';
84
- let killedByTimeout = false;
85
200
  let settled = false;
86
- const finish = (fn) => {
201
+ let drainTimer;
202
+ let graceTimer;
203
+ let selfTimer;
204
+ /**
205
+ * WHY the child is being killed, when it is.
206
+ *
207
+ * THE FLAG OUTRANKS `'close'`, AND THAT ORDERING IS THE WHOLE POINT.
208
+ * Killing a tree usually DOES produce a prompt `'close'`, so a `'close'`
209
+ * handler that rendered the ordinary `[signal SIGTERM]` body would win
210
+ * the race in the common case and the honest footer would only ever be
211
+ * seen in the rare one - the model would be told "the command was
212
+ * signalled" instead of "the command was still running when the ceiling
213
+ * fired, re-run it in the background", which is precisely the sentence
214
+ * that stops it reaching for `Start-Process`. The pre-change build made
215
+ * the same choice for its `killedByTimeout` flag, for the same reason.
216
+ */
217
+ let killReason;
218
+ /**
219
+ * The result body, from whatever is known at settle time.
220
+ *
221
+ * ONE BUILDER FOR ALL FIVE SETTLE PATHS, so a footer can never depend on
222
+ * WHICH of them got there first - the property that makes the abort and
223
+ * timeout wordings deterministic rather than a race.
224
+ */
225
+ const buildBody = (opts) => {
226
+ const body = captured();
227
+ if (killReason === 'timeout') {
228
+ // BYTE-IDENTICAL TO THE PRE-CHANGE BUILD for the `timeout` param.
229
+ return errorResult(`Command timed out after ${params.timeout}ms.\n${body}`);
230
+ }
231
+ if (killReason === 'abort') {
232
+ // P1-5. The captured output is the evidence the model needs, and
233
+ // `ctx.abortCause` is what separates "the ceiling fired" (which earns
234
+ // the advice) from "the user pressed Esc" (which does not - they did
235
+ // not ask for a workaround).
236
+ const footer = ctx.abortCause === 'timeout'
237
+ ? '[aborted - the command was still running]\n' +
238
+ 'It does not look like this command terminates on its own. ' +
239
+ 'Re-run it with `background: true` if it is a server or a watcher.'
240
+ : '[aborted]';
241
+ return errorResult(`$ ${command}\n${body}\n${footer}`);
242
+ }
243
+ const status = opts.code === null ? `signal ${opts.signalName}` : `exit code ${opts.code}`;
244
+ const note = opts.note
245
+ ? '[note] the command exited but a background child is still holding its ' +
246
+ 'output stream.\n'
247
+ : '';
248
+ const text = `$ ${command}\n${body}\n${note}[${status}]`;
249
+ return opts.code === 0 ? textResult(text) : errorResult(text);
250
+ };
251
+ /**
252
+ * THE ONE FUNNEL. Idempotent, clears every timer, removes the abort
253
+ * listener and releases the foreground registration. Five call sites;
254
+ * see the module header for which ways of hanging each one closes.
255
+ */
256
+ const settle = (build) => {
87
257
  if (settled)
88
258
  return;
89
259
  settled = true;
90
- cleanup();
91
- resolvePromise(fn());
260
+ if (drainTimer)
261
+ clearTimeout(drainTimer);
262
+ if (graceTimer)
263
+ clearTimeout(graceTimer);
264
+ if (selfTimer)
265
+ clearTimeout(selfTimer);
266
+ ctx.signal?.removeEventListener('abort', onAbort);
267
+ releaseForeground();
268
+ resolvePromise(build());
269
+ };
270
+ const captured = () => (output.length > 0 ? output : '(no output)');
271
+ /**
272
+ * Arm the post-kill force-settle. AT MOST ONCE.
273
+ *
274
+ * Both kill paths reach it and the executor's ceiling aborts through
275
+ * `ctx.signal` a moment after the tool's own timer fires, so a plain
276
+ * assignment would overwrite a pending timer with a second one — and
277
+ * `settle` clears only the reference it can see, leaving the orphan to
278
+ * hold the loop for another `killGraceMs` after the promise resolved.
279
+ */
280
+ const armGrace = () => {
281
+ if (graceTimer)
282
+ return;
283
+ graceTimer = setTimeout(() => {
284
+ settle(() => buildBody({ code: null, signalName: null }));
285
+ }, PROC_LIMITS.killGraceMs);
92
286
  };
93
287
  // Self-timer for the `timeout` param (shrinks within the executor ceiling).
94
- let timer;
95
288
  if (params.timeout && params.timeout > 0) {
96
- timer = setTimeout(() => {
97
- killedByTimeout = true;
98
- killTree(child);
289
+ selfTimer = setTimeout(() => {
290
+ killReason = 'timeout';
291
+ killTree(child.pid, 'SIGKILL');
292
+ // THE SAME GRACE THE ABORT PATH USES. Killing is not settling: a
293
+ // detached grandchild can hold the pipe open past the kill, and the
294
+ // old build then waited for a `'close'` that never came.
295
+ armGrace();
99
296
  }, params.timeout);
100
297
  }
101
- // Cooperative cancellation via the executor/agent AbortSignal.
298
+ /**
299
+ * Cooperative cancellation via the executor/agent AbortSignal.
300
+ *
301
+ * THE FORCE-SETTLED BODY CARRIES THE CAPTURED OUTPUT AND NAMES THE CAUSE
302
+ * (P1-5), and that is not a nicety. Today an abort kills the tree,
303
+ * `'close'` fires, and the model receives `$ cmd`, everything printed,
304
+ * and `[signal SIGTERM]`. The executor's own ceiling
305
+ * (`DEFAULT_TOOL_TIMEOUT_MS`) aborts through this SAME signal — so a bare
306
+ * `Command aborted.` would hand the model three words and no evidence at
307
+ * exactly the moment it needs to learn that the command does not
308
+ * terminate, which is the evidence that makes it reach for
309
+ * `background: true` instead of `Start-Process`.
310
+ *
311
+ * `ctx.abortCause` is what separates the two: `'timeout'` earns the
312
+ * advice, `'external'` (an Esc) does not — the user did not ask the model
313
+ * for a workaround.
314
+ */
102
315
  const onAbort = () => {
103
- killTree(child);
316
+ // NOT OVERWRITTEN when a `timeout` kill is already in force: the
317
+ // executor's ceiling aborts through this same signal a moment after
318
+ // the tool's own timer fired, and the `timeout` wording is the more
319
+ // specific of the two.
320
+ killReason ??= 'abort';
321
+ killTree(child.pid, 'SIGKILL');
322
+ armGrace();
104
323
  };
105
324
  ctx.signal?.addEventListener('abort', onAbort, { once: true });
106
- function cleanup() {
107
- if (timer)
108
- clearTimeout(timer);
109
- ctx.signal?.removeEventListener('abort', onAbort);
110
- }
111
325
  // `output` IS UNTOUCHED BY THE RECORDER, and that is what makes AC-30
112
326
  // checkable by comparison rather than by inspection: the string the
113
327
  // model receives, its truncation and the `[exit code N]` footer are all
@@ -129,19 +343,75 @@ export function makeBash(deps) {
129
343
  deps.recordOutput?.(id, s);
130
344
  });
131
345
  child.on('error', (err) => {
132
- finish(() => errorResult(`Failed to run command: ${err.message}`));
346
+ settle(() => errorResult(`Failed to run command: ${err.message}`));
133
347
  });
134
- child.on('close', (code, signalName) => {
135
- const trimmed = output.length > 0 ? output : '(no output)';
136
- if (killedByTimeout) {
137
- finish(() => errorResult(`Command timed out after ${params.timeout}ms.\n${trimmed}`));
348
+ /**
349
+ * DEFENCE 1. `'exit'` fires when the PROCESS ends; `'close'` waits for
350
+ * every stdio stream, which a detached grandchild can hold open forever.
351
+ * So `'exit'` starts a short drain and `'close'` — if it arrives — wins
352
+ * the race and produces the byte-identical happy-path body.
353
+ */
354
+ child.on('exit', (code, signalName) => {
355
+ if (settled || drainTimer)
138
356
  return;
139
- }
140
- const status = code === null ? `signal ${signalName}` : `exit code ${code}`;
141
- const body = `$ ${command}\n${trimmed}\n[${status}]`;
142
- finish(() => (code === 0 ? textResult(body) : errorResult(body)));
357
+ drainTimer = setTimeout(() => {
358
+ settle(() => buildBody({ code, signalName, note: true }));
359
+ }, PROC_LIMITS.drainMs);
360
+ });
361
+ child.on('close', (code, signalName) => {
362
+ settle(() => buildBody({ code, signalName }));
143
363
  });
144
364
  });
145
365
  },
146
366
  });
147
367
  }
368
+ /**
369
+ * Start a supervised service and return as soon as there is something true to
370
+ * say (D-3).
371
+ *
372
+ * THE FIRST OF THREE: ready, exit, or `startupSettleMs`. Never later. A 400 ms
373
+ * crash is reported in 400 ms with the stack in the tool result, and a slow
374
+ * server never costs more than the settle window of turn time.
375
+ *
376
+ * IT DOES NOT CALL `deps.recordOutput` (P2-9). The tool call settles within the
377
+ * window and `tool_execution_end` then clears the live-output slot, so a tail
378
+ * written there would flash and vanish; the `ServiceCard` owns this service's
379
+ * output for its whole life and is the only surface that can keep it.
380
+ */
381
+ async function runBackground(supervisor, args) {
382
+ const started = await supervisor.start({
383
+ command: args.command,
384
+ cwd: args.cwd,
385
+ toolCallId: args.toolCallId,
386
+ });
387
+ if (!started.ok || !started.service) {
388
+ return errorResult(started.error ?? 'Could not start the background service.');
389
+ }
390
+ const id = started.service.id;
391
+ const settled = await new Promise((resolve) => {
392
+ let done = false;
393
+ const finish = (snapshot) => {
394
+ if (done)
395
+ return;
396
+ done = true;
397
+ unsubscribe();
398
+ clearTimeout(timer);
399
+ resolve(snapshot);
400
+ };
401
+ const unsubscribe = supervisor.subscribe((event) => {
402
+ if (event.service.id !== id)
403
+ return;
404
+ if (event.type === 'ready' || event.type === 'exited' || event.type === 'stopped') {
405
+ finish(event.service);
406
+ }
407
+ });
408
+ const timer = setTimeout(() => finish(supervisor.get(id) ?? started.service), args.settleMs);
409
+ timer.unref?.();
410
+ // A service that finished between `start()` and this subscription (a crash
411
+ // inside 1 ms is not hypothetical) would otherwise wait out the whole window.
412
+ const now = supervisor.get(id);
413
+ if (now && now.status !== 'starting')
414
+ finish(now);
415
+ });
416
+ return renderServiceResult(settled, { ephemeral: args.ephemeral });
417
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { type AgentTool } from '@aragon-agent/core';
10
10
  import { type FilePatch } from './patch.js';
11
+ import type { ProcSupervisorPort } from '../proc/types.js';
11
12
  export interface ToolDeps {
12
13
  getCwd: () => string;
13
14
  /**
@@ -35,6 +36,22 @@ export interface ToolDeps {
35
36
  * name, so a future long-running tool costs one call site.
36
37
  */
37
38
  recordOutput?: (toolCallId: string, chunk: string) => void;
39
+ /**
40
+ * The process supervisor, when background launches are on for this session
41
+ * (background-service-supervision §3.5).
42
+ *
43
+ * THE TWO-INTERFACE RULE ABOVE APPLIES VERBATIM, and this package has already
44
+ * made that mistake twice (`recordChange` in round 1, `recordOutput` in round
45
+ * 2): adding the field HERE alone type-checks and forwards nothing, so `bash`
46
+ * never sees a supervisor, every long-running command runs in the foreground
47
+ * exactly as it does today, and NOTHING ANYWHERE ERRORS. It must be added here
48
+ * AND forwarded in `tools/index.ts`.
49
+ *
50
+ * OPTIONAL: omitting it leaves `bash` byte-identical to the pre-feature build —
51
+ * no `background` property in its schema, no supervisor call, and the
52
+ * pre-feature description string (I-2).
53
+ */
54
+ procs?: ProcSupervisorPort;
38
55
  }
39
56
  export declare function makeReadFile(deps: ToolDeps): AgentTool;
40
57
  export declare function makeWriteFile(deps: ToolDeps): AgentTool;
@@ -12,6 +12,7 @@ import { type AgentTool, type ToolPolicyDecision, type ToolPolicyVerdict } from
12
12
  import type { AgentMode } from '../agent/agent-mode.js';
13
13
  import type { ToolPermission } from '../exec/permission.js';
14
14
  import type { FilePatch } from './patch.js';
15
+ import type { ProcSupervisorPort } from '../proc/types.js';
15
16
  export interface ConfirmRequest {
16
17
  tool: string;
17
18
  summary: string;
@@ -151,6 +152,58 @@ export interface BuiltinToolsOptions {
151
152
  * `--no-skills` is.
152
153
  */
153
154
  teamTools?: AgentTool[];
155
+ /**
156
+ * The process supervisor (background-service-supervision §3.5).
157
+ *
158
+ * THE TWO-INTERFACE RULE: it is declared on `ToolDeps` as well, and adding it
159
+ * to only one of the two type-checks and forwards nothing — `bash` never sees
160
+ * a supervisor and every long-running command keeps hanging, with nothing
161
+ * anywhere erroring. Both, or neither.
162
+ *
163
+ * The conditional spread below is what makes I-2 true at this layer: with
164
+ * background services off the controller passes no key, so no property is
165
+ * added and `deps` is the object it is today.
166
+ */
167
+ procs?: ProcSupervisorPort;
168
+ /**
169
+ * `bash_output` / `bash_kill`, appended verbatim.
170
+ *
171
+ * THROUGH THE FACTORY FOR THE REASON `teamTools` AND `todoTools` EACH RECORD
172
+ * IN CAPITALS (P0-2): `tools.test.ts::C7` asserts `HOST_TOOL_NAMES` equals
173
+ * what this factory PRODUCES, so appending these two in `AgentController`
174
+ * after the factory returned would turn C7 red with a message about two lists
175
+ * of names that says nothing about background services.
176
+ *
177
+ * An empty array (`bash.background: false`) leaves the tool list
178
+ * byte-identical to the pre-feature build — I-2, provable by object identity
179
+ * exactly as `--no-skills` and `--no-team` are.
180
+ */
181
+ procTools?: AgentTool[];
182
+ /**
183
+ * `cfg.bash.autoBackground`, READ AT CALL TIME.
184
+ *
185
+ * Never cached: `aragon config set bash.autoBackground false` takes effect in
186
+ * the running session, and a cached `true` would go on backgrounding commands
187
+ * the user just told it not to.
188
+ */
189
+ autoBackground?: () => boolean;
190
+ /** `cfg.bash.startupSettleMs`, read at call time for the same reason. */
191
+ startupSettleMs?: () => number;
192
+ /**
193
+ * Whether this session can render a service card and keep a service alive
194
+ * (D-15 / P1-8).
195
+ *
196
+ * FALSE UNDER `aragon exec` AND `-p`. It suppresses only the CLASSIFIER: an
197
+ * explicit `background: true` is still honoured, and its result then says the
198
+ * service dies with the run. Without it `aragon -p "npm run dev"` would
199
+ * background a server, return in `startupSettleMs`, and reap it at process
200
+ * exit with nothing anywhere saying so.
201
+ *
202
+ * It is the SAME SIGNAL `planTools` already uses to decide that no overlay can
203
+ * be rendered, so this costs one boolean rather than a new capability probe.
204
+ * Absent means interactive, which is what every existing caller is.
205
+ */
206
+ interactive?: boolean;
154
207
  /**
155
208
  * Tool names that skip the CEILING wrapper only (never the plan gate, never
156
209
  * confirmation).
@@ -199,7 +252,7 @@ export interface BuiltinToolsOptions {
199
252
  * is the whole of what this mechanism is worth, and saying so plainly beats
200
253
  * implying it covers more.
201
254
  */
202
- export declare const SKILL_TOOL_FLOOR: readonly ["read_file", "list_dir", "glob", "grep", "skill", "skill_find", "ask_user", "submit_plan", "task", "todo_write"];
255
+ export declare const SKILL_TOOL_FLOOR: readonly ["read_file", "list_dir", "glob", "grep", "skill", "skill_find", "ask_user", "submit_plan", "task", "todo_write", "bash_output"];
203
256
  /**
204
257
  * The five tools plan mode refuses: exactly the complement of `SKILL_TOOL_FLOOR`
205
258
  * within `HOST_TOOL_NAMES` — "the five that change something", the set this file
@@ -232,5 +285,5 @@ export declare const PLAN_MODE_BLOCKED_TOOLS: ReadonlySet<string>;
232
285
  * declaration MEANS, and that reading must not change with how the CLI was
233
286
  * started.
234
287
  */
235
- 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", "task"];
288
+ 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", "task"];
236
289
  export declare function createBuiltinTools(options: BuiltinToolsOptions): AgentTool[];
@@ -59,6 +59,12 @@ export const SKILL_TOOL_FLOOR = [
59
59
  // inside that skill's frame — the identical dead end that put `ask_user` /
60
60
  // `submit_plan` / `task` here.
61
61
  'todo_write',
62
+ // `bash_output` joins the FLOOR rather than the blocked set (D-12), and the
63
+ // partition test below is what forces that choice to be conscious. It READS A
64
+ // BUFFER: it cannot write to disk or run a shell, which is the floor's stated
65
+ // membership test. Blocking it in plan mode would also let the agent start a
66
+ // service it then could not read — the dead end the floor exists to prevent.
67
+ 'bash_output',
62
68
  ];
63
69
  /**
64
70
  * The five tools plan mode refuses: exactly the complement of `SKILL_TOOL_FLOOR`
@@ -77,6 +83,11 @@ export const PLAN_MODE_BLOCKED_TOOLS = new Set([
77
83
  'bash',
78
84
  'skill_install',
79
85
  'skill_create',
86
+ // `bash_kill` joins the BLOCKED set (D-12). It TERMINATES A PROCESS, which is
87
+ // a mutation of the world and the complement's stated membership test. Nothing
88
+ // is dead-ended by it: `Ctrl+C` and `/bg stop` both remain available to the
89
+ // USER in plan mode, so the person can always stop what the agent started.
90
+ 'bash_kill',
80
91
  ]);
81
92
  /**
82
93
  * Every tool name this host CAN register (7 built-ins + 4 skill tools).
@@ -113,6 +124,13 @@ export const HOST_TOOL_NAMES = [
113
124
  'ask_user',
114
125
  'submit_plan',
115
126
  'todo_write',
127
+ // Both names are here even though `bash.background: false` registers NEITHER,
128
+ // and that is the constant's documented meaning rather than an inconsistency:
129
+ // it is the SUPERSET of what this host CAN register, answering "can a skill's
130
+ // `allowed-tools` declaration ever take effect?" — the same relationship
131
+ // `skills doctor` already has with `--no-skills`.
132
+ 'bash_output',
133
+ 'bash_kill',
116
134
  // `team_send` / `team_wait` are DELIBERATELY ABSENT (P2-5). This constant
117
135
  // answers "can a skill's `allowed-tools` declaration ever take effect?", and
118
136
  // those two are never registered on a lead — so a skill naming them genuinely
@@ -221,6 +239,10 @@ export function createBuiltinTools(options) {
221
239
  ...(options.recordChange ? { recordChange: options.recordChange } : {}),
222
240
  // Added in the same shape and for the same reason (P1-1).
223
241
  ...(options.recordOutput ? { recordOutput: options.recordOutput } : {}),
242
+ // And the third, in the same shape and for the same reason. The FORWARD half
243
+ // of the two-interface note on `procs` above: without this line the field is
244
+ // on both interfaces, nothing errors, and `bash` still hangs.
245
+ ...(options.procs ? { procs: options.procs } : {}),
224
246
  };
225
247
  let tools = [
226
248
  makeReadFile(deps),
@@ -229,7 +251,18 @@ export function createBuiltinTools(options) {
229
251
  makeListDir(deps),
230
252
  makeGlob(deps),
231
253
  makeGrep(deps),
232
- makeBash(deps),
254
+ // The second argument is spread CONDITIONALLY on the supervisor being
255
+ // present, so with background services off `makeBash` receives its default
256
+ // `{}` and produces the pre-feature tool: no `background` property in the
257
+ // schema, the pre-feature description string, no supervisor call (I-2).
258
+ makeBash(deps, options.procs
259
+ ? {
260
+ supervisor: options.procs,
261
+ ...(options.autoBackground ? { autoBackground: options.autoBackground } : {}),
262
+ ...(options.startupSettleMs ? { startupSettleMs: options.startupSettleMs } : {}),
263
+ ...(options.interactive !== undefined ? { interactive: options.interactive } : {}),
264
+ }
265
+ : {}),
233
266
  ];
234
267
  if (options.confirmTools && options.confirm) {
235
268
  const confirm = options.confirm;
@@ -246,6 +279,11 @@ export function createBuiltinTools(options) {
246
279
  if (options.todoTools && options.todoTools.length > 0) {
247
280
  tools = [...tools, ...options.todoTools];
248
281
  }
282
+ // AFTER `todoTools`, BEFORE `teamTools`, so a lead's `task` stays last and
283
+ // `HOST_TOOL_NAMES` changes by two insertions rather than a reshuffle.
284
+ if (options.procTools && options.procTools.length > 0) {
285
+ tools = [...tools, ...options.procTools];
286
+ }
249
287
  // BEFORE both wrappers, so the lead's `task` is covered by the plan gate and
250
288
  // the ceiling like everything else, and a child's comm tools are covered by
251
289
  // everything except the ceiling (see `policyExempt`).