@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.
- package/CHANGELOG.md +105 -0
- package/README.md +95 -2
- package/dist/agent/controller.d.ts +119 -0
- package/dist/agent/controller.js +275 -1
- package/dist/agent/reducer.d.ts +69 -0
- package/dist/agent/reducer.js +100 -0
- package/dist/agent/system-prompt.d.ts +14 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/cli.js +84 -22
- package/dist/commands/builtins.js +92 -1
- package/dist/compaction/child.js +5 -0
- package/dist/compaction/command.js +14 -1
- package/dist/compaction/compactor.d.ts +34 -0
- package/dist/compaction/compactor.js +158 -37
- package/dist/compaction/types.d.ts +36 -0
- package/dist/compaction/wiring.d.ts +9 -0
- package/dist/compaction/wiring.js +22 -1
- package/dist/config/env.d.ts +1 -1
- package/dist/config/env.js +48 -1
- package/dist/config/load.d.ts +11 -0
- package/dist/config/load.js +39 -1
- package/dist/config/schema.d.ts +85 -0
- package/dist/config/schema.js +33 -0
- package/dist/config/store.js +21 -1
- package/dist/input/limits.d.ts +72 -0
- package/dist/input/limits.js +63 -0
- package/dist/input/paste-parse.d.ts +71 -0
- package/dist/input/paste-parse.js +95 -0
- package/dist/input/stdin-filter.d.ts +81 -0
- package/dist/input/stdin-filter.js +379 -0
- package/dist/logging/install.d.ts +2 -0
- package/dist/logging/install.js +53 -1
- package/dist/proc/classify.d.ts +52 -0
- package/dist/proc/classify.js +143 -0
- package/dist/proc/kill-tree.d.ts +42 -0
- package/dist/proc/kill-tree.js +119 -0
- package/dist/proc/limits.d.ts +84 -0
- package/dist/proc/limits.js +84 -0
- package/dist/proc/log-ring.d.ts +68 -0
- package/dist/proc/log-ring.js +102 -0
- package/dist/proc/prompt.d.ts +24 -0
- package/dist/proc/prompt.js +49 -0
- package/dist/proc/readiness.d.ts +102 -0
- package/dist/proc/readiness.js +219 -0
- package/dist/proc/supervisor.d.ts +103 -0
- package/dist/proc/supervisor.js +465 -0
- package/dist/proc/types.d.ts +175 -0
- package/dist/proc/types.js +15 -0
- package/dist/session/persist.js +36 -1
- package/dist/tools/bash-tool.d.ts +51 -2
- package/dist/tools/bash-tool.js +321 -51
- package/dist/tools/fs-tools.d.ts +17 -0
- package/dist/tools/index.d.ts +55 -2
- package/dist/tools/index.js +39 -1
- package/dist/tools/proc-tools.d.ts +25 -0
- package/dist/tools/proc-tools.js +102 -0
- package/dist/ui/App.d.ts +15 -2
- package/dist/ui/App.js +256 -7
- package/dist/ui/Composer.d.ts +54 -1
- package/dist/ui/Composer.js +50 -6
- package/dist/ui/PromptInput.d.ts +49 -3
- package/dist/ui/PromptInput.js +184 -52
- package/dist/ui/StatusBar.d.ts +19 -0
- package/dist/ui/StatusBar.js +5 -2
- package/dist/ui/Transcript.js +29 -1
- package/dist/ui/composer-limits.d.ts +31 -0
- package/dist/ui/composer-limits.js +50 -0
- package/dist/ui/composer-rows.d.ts +73 -0
- package/dist/ui/composer-rows.js +201 -0
- package/dist/ui/editor-reducer.d.ts +37 -0
- package/dist/ui/editor-reducer.js +88 -12
- package/dist/ui/entries/ServiceCard.d.ts +66 -0
- package/dist/ui/entries/ServiceCard.js +116 -0
- package/dist/ui/entries/UserEntry.d.ts +17 -3
- package/dist/ui/entries/UserEntry.js +34 -5
- package/dist/ui/layout/budget.d.ts +34 -6
- package/dist/ui/layout/budget.js +32 -7
- package/dist/ui/layout/virtual-window.js +51 -1
- package/dist/ui/overlays/HelpOverlay.js +3 -0
- package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
- package/dist/ui/overlays/QuestionOverlay.js +5 -1
- package/dist/ui/overlays/SettingsScreen.js +14 -1
- package/dist/ui/paste-frames.d.ts +87 -0
- package/dist/ui/paste-frames.js +112 -0
- package/dist/ui/paste-tokens.d.ts +90 -0
- package/dist/ui/paste-tokens.js +147 -0
- package/dist/ui/screen.d.ts +16 -0
- package/dist/ui/screen.js +45 -2
- package/dist/ui/selection/selection-controller.d.ts +1 -1
- package/dist/ui/transcript-text.js +49 -0
- package/dist/ui/use-wheel-routing.d.ts +1 -1
- package/package.json +2 -2
- package/dist/input/stdin-mouse-filter.d.ts +0 -47
- package/dist/input/stdin-mouse-filter.js +0 -153
package/dist/session/persist.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/tools/bash-tool.js
CHANGED
|
@@ -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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
+
settle(() => errorResult(`Failed to run command: ${err.message}`));
|
|
133
347
|
});
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
}
|
package/dist/tools/fs-tools.d.ts
CHANGED
|
@@ -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;
|