@aragon-agent/cli 0.6.3 → 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 (94) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +95 -2
  3. package/dist/agent/controller.d.ts +119 -0
  4. package/dist/agent/controller.js +275 -1
  5. package/dist/agent/reducer.d.ts +69 -0
  6. package/dist/agent/reducer.js +100 -0
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +2 -0
  9. package/dist/cli.js +84 -22
  10. package/dist/commands/builtins.js +92 -1
  11. package/dist/compaction/child.js +5 -0
  12. package/dist/compaction/command.js +14 -1
  13. package/dist/compaction/compactor.d.ts +34 -0
  14. package/dist/compaction/compactor.js +158 -37
  15. package/dist/compaction/types.d.ts +36 -0
  16. package/dist/compaction/wiring.d.ts +9 -0
  17. package/dist/compaction/wiring.js +22 -1
  18. package/dist/config/env.d.ts +1 -1
  19. package/dist/config/env.js +48 -1
  20. package/dist/config/load.d.ts +11 -0
  21. package/dist/config/load.js +39 -1
  22. package/dist/config/schema.d.ts +85 -0
  23. package/dist/config/schema.js +33 -0
  24. package/dist/config/store.js +21 -1
  25. package/dist/input/limits.d.ts +72 -0
  26. package/dist/input/limits.js +63 -0
  27. package/dist/input/paste-parse.d.ts +71 -0
  28. package/dist/input/paste-parse.js +95 -0
  29. package/dist/input/stdin-filter.d.ts +81 -0
  30. package/dist/input/stdin-filter.js +379 -0
  31. package/dist/logging/install.d.ts +2 -0
  32. package/dist/logging/install.js +53 -1
  33. package/dist/proc/classify.d.ts +52 -0
  34. package/dist/proc/classify.js +143 -0
  35. package/dist/proc/kill-tree.d.ts +42 -0
  36. package/dist/proc/kill-tree.js +119 -0
  37. package/dist/proc/limits.d.ts +84 -0
  38. package/dist/proc/limits.js +84 -0
  39. package/dist/proc/log-ring.d.ts +68 -0
  40. package/dist/proc/log-ring.js +102 -0
  41. package/dist/proc/prompt.d.ts +24 -0
  42. package/dist/proc/prompt.js +49 -0
  43. package/dist/proc/readiness.d.ts +102 -0
  44. package/dist/proc/readiness.js +219 -0
  45. package/dist/proc/supervisor.d.ts +103 -0
  46. package/dist/proc/supervisor.js +465 -0
  47. package/dist/proc/types.d.ts +175 -0
  48. package/dist/proc/types.js +15 -0
  49. package/dist/session/persist.js +36 -1
  50. package/dist/tools/bash-tool.d.ts +51 -2
  51. package/dist/tools/bash-tool.js +321 -51
  52. package/dist/tools/fs-tools.d.ts +17 -0
  53. package/dist/tools/index.d.ts +55 -2
  54. package/dist/tools/index.js +39 -1
  55. package/dist/tools/proc-tools.d.ts +25 -0
  56. package/dist/tools/proc-tools.js +102 -0
  57. package/dist/ui/App.d.ts +15 -2
  58. package/dist/ui/App.js +256 -7
  59. package/dist/ui/Composer.d.ts +54 -1
  60. package/dist/ui/Composer.js +50 -6
  61. package/dist/ui/PromptInput.d.ts +49 -3
  62. package/dist/ui/PromptInput.js +184 -52
  63. package/dist/ui/StatusBar.d.ts +19 -0
  64. package/dist/ui/StatusBar.js +5 -2
  65. package/dist/ui/Transcript.js +29 -1
  66. package/dist/ui/composer-limits.d.ts +31 -0
  67. package/dist/ui/composer-limits.js +50 -0
  68. package/dist/ui/composer-rows.d.ts +73 -0
  69. package/dist/ui/composer-rows.js +201 -0
  70. package/dist/ui/editor-reducer.d.ts +37 -0
  71. package/dist/ui/editor-reducer.js +88 -12
  72. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  73. package/dist/ui/entries/ServiceCard.js +116 -0
  74. package/dist/ui/entries/UserEntry.d.ts +17 -3
  75. package/dist/ui/entries/UserEntry.js +34 -5
  76. package/dist/ui/layout/budget.d.ts +34 -6
  77. package/dist/ui/layout/budget.js +32 -7
  78. package/dist/ui/layout/virtual-window.js +51 -1
  79. package/dist/ui/overlays/HelpOverlay.js +3 -0
  80. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  81. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  82. package/dist/ui/overlays/SettingsScreen.js +14 -1
  83. package/dist/ui/paste-frames.d.ts +87 -0
  84. package/dist/ui/paste-frames.js +112 -0
  85. package/dist/ui/paste-tokens.d.ts +90 -0
  86. package/dist/ui/paste-tokens.js +147 -0
  87. package/dist/ui/screen.d.ts +16 -0
  88. package/dist/ui/screen.js +45 -2
  89. package/dist/ui/selection/selection-controller.d.ts +1 -1
  90. package/dist/ui/transcript-text.js +49 -0
  91. package/dist/ui/use-wheel-routing.d.ts +1 -1
  92. package/package.json +2 -2
  93. package/dist/input/stdin-mouse-filter.d.ts +0 -47
  94. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -59,6 +59,24 @@ function stripLiveToolOutput(entries) {
59
59
  ? { ...e, live: undefined, liveSeq: undefined, lastOutputAt: undefined }
60
60
  : e);
61
61
  }
62
+ /**
63
+ * Drop the service tail on the way to disk, beside `stripLiveToolOutput` and for
64
+ * the same three reasons (background-service-supervision §6).
65
+ *
66
+ * A resumed card's tail describes a PROCESS THAT NO LONGER EXISTS - it died with
67
+ * the session, because nothing in this package outlives the CLI by design. The
68
+ * tail is also the only unbounded field a service entry carries, and dropping it
69
+ * keeps a saved session's size a function of how many services ran rather than
70
+ * of how chatty they were.
71
+ *
72
+ * `rowsSeen` IS KEPT. It is the revision cursor, not display data, and a
73
+ * restored card that reported 0 rows seen while `normalizeLoadedEntries` was
74
+ * flipping its status would produce a revision collision with a live card that
75
+ * genuinely has none.
76
+ */
77
+ function stripServiceTail(entries) {
78
+ return entries.map((e) => (e.kind === 'service' && e.rows.length > 0 ? { ...e, rows: [] } : e));
79
+ }
62
80
  /**
63
81
  * `data` GAINS `todos`, AND WIDENING `SavedSession` ALONE IS NOT ENOUGH (P0-2):
64
82
  * the writer below builds its payload from THIS parameter, not from the type, so
@@ -81,7 +99,7 @@ export function saveSession(filePath, data) {
81
99
  savedAt: Date.now(),
82
100
  model: data.model,
83
101
  messages: data.messages,
84
- entries: stripLiveToolOutput(data.entries),
102
+ entries: stripServiceTail(stripLiveToolOutput(data.entries)),
85
103
  todos: data.todos,
86
104
  ...(data.meta ? { meta: data.meta } : {}),
87
105
  };
@@ -158,6 +176,23 @@ export function normalizeLoadedEntries(entries) {
158
176
  reason: 'interrupted (session resumed)',
159
177
  };
160
178
  }
179
+ // THE SIXTH CLAUSE (background-service-supervision §6 / R-7). A card saved
180
+ // while its service was up resumes claiming to be running while nothing is -
181
+ // the process died with the session, because nothing here outlives the CLI.
182
+ //
183
+ // AND `starting` IS THE ONE THAT ACTUALLY COSTS: it is the single service
184
+ // status that blocks `Transcript`'s MONOTONIC settled boundary (D-11), so a
185
+ // restored `starting` card would never settle and every frame for the rest of
186
+ // the session would re-render the whole tail. `ready` / `running` would
187
+ // "only" be a lie; this one is a lie that gets slower.
188
+ //
189
+ // `stopped` rather than `exited`: we did not observe an exit code, and
190
+ // claiming one would be inventing a number. The terminal record the session
191
+ // may also carry is already terminal and is left alone.
192
+ if (entry.kind === 'service' &&
193
+ (entry.status === 'starting' || entry.status === 'ready' || entry.status === 'running')) {
194
+ return { ...entry, status: 'stopped', rows: [] };
195
+ }
161
196
  return entry;
162
197
  });
163
198
  }
@@ -1,12 +1,61 @@
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 { type AgentTool } from '@aragon-agent/core';
33
+ import type { ProcSupervisorPort } from '../proc/types.js';
11
34
  import type { ToolDeps } from './fs-tools.js';
12
- export declare function makeBash(deps: ToolDeps): AgentTool;
35
+ export interface BashToolOptions {
36
+ /**
37
+ * Present only when background launches are on for this session.
38
+ *
39
+ * ITS ABSENCE IS WHAT MAKES I-2 CHECKABLE: no `background` property in the
40
+ * schema, no supervisor call, no readiness timer, and a description string
41
+ * identical to the pre-feature one.
42
+ */
43
+ supervisor?: ProcSupervisorPort;
44
+ /** `cfg.bash.autoBackground`, read LIVE. */
45
+ autoBackground?: () => boolean;
46
+ /** `cfg.bash.startupSettleMs`, read LIVE. */
47
+ startupSettleMs?: () => number;
48
+ /**
49
+ * Whether a card can be rendered and a user can keep a service alive (D-15 /
50
+ * P1-8).
51
+ *
52
+ * FALSE IN `aragon exec` AND `-p`. It suppresses only the CLASSIFIER: an
53
+ * explicit `background: true` is still honoured — the model asked for it —
54
+ * and the result then carries one extra line saying the service dies with the
55
+ * run. Without this rule `aragon -p "npm run dev"` would background a server,
56
+ * return in `startupSettleMs`, and reap it at process exit with no card, no
57
+ * `/bg`, no event and nothing in the result saying so.
58
+ */
59
+ interactive?: boolean;
60
+ }
61
+ export declare function makeBash(deps: ToolDeps, options?: BashToolOptions): AgentTool;
@@ -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;