@aragon-agent/cli 0.5.12 → 0.6.2
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 +100 -0
- package/README.md +322 -6
- package/dist/agent/agent-mode.d.ts +39 -0
- package/dist/agent/agent-mode.js +36 -0
- package/dist/agent/controller.d.ts +32 -0
- package/dist/agent/controller.js +25 -0
- package/dist/agent/system-prompt.d.ts +16 -0
- package/dist/agent/system-prompt.js +5 -0
- package/dist/cli.js +143 -2
- package/dist/config/ui-state.d.ts +29 -0
- package/dist/config/ui-state.js +14 -4
- package/dist/diagnostics/cli-commands.d.ts +10 -0
- package/dist/diagnostics/cli-commands.js +61 -0
- package/dist/diagnostics/doctor.d.ts +29 -0
- package/dist/diagnostics/doctor.js +207 -0
- package/dist/diagnostics/info.d.ts +30 -0
- package/dist/diagnostics/info.js +99 -0
- package/dist/exec/cli-commands.d.ts +31 -0
- package/dist/exec/cli-commands.js +63 -0
- package/dist/exec/emitter.d.ts +80 -0
- package/dist/exec/emitter.js +127 -0
- package/dist/exec/events.d.ts +226 -0
- package/dist/exec/events.js +43 -0
- package/dist/exec/index.d.ts +56 -0
- package/dist/exec/index.js +439 -0
- package/dist/exec/options.d.ts +76 -0
- package/dist/exec/options.js +221 -0
- package/dist/exec/permission.d.ts +66 -0
- package/dist/exec/permission.js +96 -0
- package/dist/exec/runner.d.ts +204 -0
- package/dist/exec/runner.js +559 -0
- package/dist/exec/stdin-stream.d.ts +46 -0
- package/dist/exec/stdin-stream.js +144 -0
- package/dist/logging/install.d.ts +22 -0
- package/dist/logging/install.js +22 -0
- package/dist/session/cli-commands.d.ts +30 -0
- package/dist/session/cli-commands.js +184 -0
- package/dist/session/persist.d.ts +46 -0
- package/dist/session/persist.js +8 -0
- package/dist/session/store.d.ts +147 -0
- package/dist/session/store.js +442 -0
- package/dist/team/runtime.d.ts +12 -0
- package/dist/team/runtime.js +6 -0
- package/dist/team/subagent.d.ts +12 -0
- package/dist/team/subagent.js +4 -0
- package/dist/tools/index.d.ts +20 -0
- package/dist/tools/index.js +26 -0
- package/dist/ui/ActivityLine.d.ts +16 -0
- package/dist/ui/ActivityLine.js +23 -1
- package/dist/ui/App.d.ts +14 -1
- package/dist/ui/App.js +61 -7
- package/dist/ui/BottomStatusRow.d.ts +33 -1
- package/dist/ui/BottomStatusRow.js +20 -6
- package/dist/ui/Composer.d.ts +14 -1
- package/dist/ui/Composer.js +5 -5
- package/dist/ui/overlays/HelpOverlay.js +12 -1
- package/dist/ui/use-startup-notices.d.ts +98 -4
- package/dist/ui/use-startup-notices.js +108 -2
- package/dist/ui/win-vt-force.d.ts +136 -0
- package/dist/ui/win-vt-force.js +237 -0
- package/dist/ui/win-vt-input.d.ts +57 -0
- package/dist/ui/win-vt-input.js +62 -0
- package/package.json +7 -3
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aragon exec` option resolution (cli-integration-surface section 4.1).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* PURE. Every branch here is a decision about the FLAGS, never about the world:
|
|
7
|
+
* no file is read, no session is resolved, no stdin is consumed. Prompt
|
|
8
|
+
* resolution and file reads live in `exec/index.ts`, which is the module allowed
|
|
9
|
+
* to touch the process.
|
|
10
|
+
*
|
|
11
|
+
* A USAGE ERROR IS EXIT 2 AND NOTHING ON STDOUT. That is the whole reason this
|
|
12
|
+
* module returns a discriminated union instead of throwing: `--output-format
|
|
13
|
+
* json` promises exactly one JSON object on stdout, and a stack trace or a
|
|
14
|
+
* half-written event would corrupt every consumer. The caller writes the message
|
|
15
|
+
* to stderr and exits.
|
|
16
|
+
*/
|
|
17
|
+
import { HOST_TOOL_NAMES } from '../tools/index.js';
|
|
18
|
+
import { isValidSessionId } from '../session/store.js';
|
|
19
|
+
const OUTPUT_FORMATS = ['text', 'json', 'stream-json'];
|
|
20
|
+
const INPUT_FORMATS = ['text', 'stream-json'];
|
|
21
|
+
const PERMISSION_MODES = ['auto', 'plan', 'strict'];
|
|
22
|
+
/**
|
|
23
|
+
* Registered nowhere in a headless session, yet real names (P2-2).
|
|
24
|
+
*
|
|
25
|
+
* `planTools` is empty under `exec`, so `--allow-tool ask_user` validates and
|
|
26
|
+
* then does nothing. REJECTING IT WOULD BE WRONG - `HOST_TOOL_NAMES` answers
|
|
27
|
+
* "can this declaration ever take effect?", and the answer for these two is yes,
|
|
28
|
+
* just not here - so exec accepts it and says one line on stderr.
|
|
29
|
+
*/
|
|
30
|
+
const NEVER_REGISTERED_HEADLESS = ['ask_user', 'submit_plan'];
|
|
31
|
+
/**
|
|
32
|
+
* Flags that need to observe or interrupt the stream `runHeadless` owns, and are
|
|
33
|
+
* therefore REFUSED under `--output-format text` (D-1).
|
|
34
|
+
*
|
|
35
|
+
* The refusal is explicit and names both the flag and the format, never a silent
|
|
36
|
+
* no-op: text mode delegates to `runHeadless` VERBATIM, which is what makes
|
|
37
|
+
* "the human CLI is unchanged" a fact anyone can check rather than an argument
|
|
38
|
+
* someone has to trust. Session flags are absent from this list on purpose -
|
|
39
|
+
* save and resume happen OUTSIDE the run, so they work in every format.
|
|
40
|
+
*/
|
|
41
|
+
const TEXT_MODE_REFUSED = [
|
|
42
|
+
{ flag: '--max-turns', test: (o) => o.maxTurns !== undefined },
|
|
43
|
+
{
|
|
44
|
+
flag: '--max-duration',
|
|
45
|
+
test: (o) => o.maxDuration !== undefined || o.timeout !== undefined,
|
|
46
|
+
},
|
|
47
|
+
{ flag: '--input-format stream-json', test: (o) => o.inputFormat === 'stream-json' },
|
|
48
|
+
{ flag: '--partial-messages', test: (o) => o.partialMessages === true },
|
|
49
|
+
{ flag: '--include-thinking', test: (o) => o.includeThinking === true },
|
|
50
|
+
];
|
|
51
|
+
function usage(code, message) {
|
|
52
|
+
return { ok: false, code, message };
|
|
53
|
+
}
|
|
54
|
+
/** Accept both `--allow-tool a --allow-tool b` and `--allow-tool a,b`. */
|
|
55
|
+
function splitNames(values) {
|
|
56
|
+
if (!values)
|
|
57
|
+
return [];
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const value of values) {
|
|
60
|
+
for (const part of value.split(',')) {
|
|
61
|
+
const name = part.trim();
|
|
62
|
+
if (name.length > 0 && !out.includes(name))
|
|
63
|
+
out.push(name);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
function parsePositiveInt(raw) {
|
|
69
|
+
if (!/^\d+$/.test(raw.trim()))
|
|
70
|
+
return null;
|
|
71
|
+
const n = Number.parseInt(raw.trim(), 10);
|
|
72
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
73
|
+
}
|
|
74
|
+
export function resolveExecOptions(raw) {
|
|
75
|
+
if (raw.print) {
|
|
76
|
+
return usage('print_not_supported', '`aragon exec` is already the headless face; `-p/--print` is not accepted here. ' +
|
|
77
|
+
'Use `aragon exec --output-format text` for the same stdout as `aragon -p`.');
|
|
78
|
+
}
|
|
79
|
+
const outputFormat = (raw.outputFormat ?? 'text').trim();
|
|
80
|
+
if (!OUTPUT_FORMATS.includes(outputFormat)) {
|
|
81
|
+
return usage('invalid_output_format', `Unknown --output-format "${outputFormat}". Choose: ${OUTPUT_FORMATS.join(', ')}.`);
|
|
82
|
+
}
|
|
83
|
+
const inputFormat = (raw.inputFormat ?? 'text').trim();
|
|
84
|
+
if (!INPUT_FORMATS.includes(inputFormat)) {
|
|
85
|
+
return usage('invalid_input_format', `Unknown --input-format "${inputFormat}". Choose: ${INPUT_FORMATS.join(', ')}.`);
|
|
86
|
+
}
|
|
87
|
+
if (inputFormat === 'stream-json' && outputFormat !== 'stream-json') {
|
|
88
|
+
return usage('input_format_requires_stream_json', '--input-format stream-json requires --output-format stream-json.');
|
|
89
|
+
}
|
|
90
|
+
const formatError = checkFormatCompatibility(raw, outputFormat);
|
|
91
|
+
if (formatError)
|
|
92
|
+
return formatError;
|
|
93
|
+
const permissionMode = (raw.permissionMode ?? 'auto').trim();
|
|
94
|
+
if (!PERMISSION_MODES.includes(permissionMode)) {
|
|
95
|
+
return usage('invalid_permission_mode', `Unknown --permission-mode "${permissionMode}". Choose: ${PERMISSION_MODES.join(', ')}.`);
|
|
96
|
+
}
|
|
97
|
+
const budgets = resolveBudgets(raw);
|
|
98
|
+
if (!budgets.ok)
|
|
99
|
+
return budgets.error;
|
|
100
|
+
const sessionError = checkSessionFlags(raw);
|
|
101
|
+
if (sessionError)
|
|
102
|
+
return sessionError;
|
|
103
|
+
const allowTools = splitNames(raw.allowTool);
|
|
104
|
+
const denyTools = splitNames(raw.denyTool);
|
|
105
|
+
const toolError = checkToolNames([...allowTools, ...denyTools]);
|
|
106
|
+
if (toolError)
|
|
107
|
+
return toolError;
|
|
108
|
+
const warnings = [];
|
|
109
|
+
for (const name of allowTools) {
|
|
110
|
+
if (NEVER_REGISTERED_HEADLESS.includes(name)) {
|
|
111
|
+
warnings.push(`--allow-tool ${name}: that tool is never registered in a headless run, so allowing it has no effect.`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
options: {
|
|
117
|
+
outputFormat: outputFormat,
|
|
118
|
+
inputFormat: inputFormat,
|
|
119
|
+
partialMessages: raw.partialMessages === true,
|
|
120
|
+
includeThinking: raw.includeThinking === true,
|
|
121
|
+
...(raw.promptFile ? { promptFile: raw.promptFile } : {}),
|
|
122
|
+
...(raw.sessionId ? { sessionId: raw.sessionId.trim() } : {}),
|
|
123
|
+
...(raw.resume ? { resume: raw.resume.trim() } : {}),
|
|
124
|
+
continueSession: raw.continue === true,
|
|
125
|
+
// `--no-save-session` is the only way this becomes `false`; commander
|
|
126
|
+
// leaves it `undefined` when the flag is absent.
|
|
127
|
+
saveSession: raw.saveSession !== false,
|
|
128
|
+
...(budgets.maxTurns !== undefined ? { maxTurns: budgets.maxTurns } : {}),
|
|
129
|
+
...(budgets.maxDurationMs !== undefined ? { maxDurationMs: budgets.maxDurationMs } : {}),
|
|
130
|
+
permissionMode: permissionMode,
|
|
131
|
+
allowTools,
|
|
132
|
+
denyTools,
|
|
133
|
+
...(raw.appendSystemPrompt ? { appendSystemPrompt: raw.appendSystemPrompt } : {}),
|
|
134
|
+
...(raw.appendSystemPromptFile
|
|
135
|
+
? { appendSystemPromptFile: raw.appendSystemPromptFile }
|
|
136
|
+
: {}),
|
|
137
|
+
quiet: raw.quiet === true,
|
|
138
|
+
warnings,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** Split out so `resolveExecOptions` stays under this package's size ceiling. */
|
|
143
|
+
function checkFormatCompatibility(raw, outputFormat) {
|
|
144
|
+
if (outputFormat === 'text') {
|
|
145
|
+
for (const { flag, test } of TEXT_MODE_REFUSED) {
|
|
146
|
+
if (!test(raw))
|
|
147
|
+
continue;
|
|
148
|
+
return usage('unsupported_in_text_mode', `${flag} is not supported with --output-format text (text mode delegates to the ` +
|
|
149
|
+
'same renderer as `aragon -p`). Use --output-format json or stream-json.');
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
if (outputFormat === 'json') {
|
|
154
|
+
if (raw.partialMessages === true) {
|
|
155
|
+
return usage('stream_only_flag', '--partial-messages requires --output-format stream-json.');
|
|
156
|
+
}
|
|
157
|
+
if (raw.includeThinking === true) {
|
|
158
|
+
return usage('stream_only_flag', '--include-thinking requires --output-format stream-json.');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
function resolveBudgets(raw) {
|
|
164
|
+
let maxTurns;
|
|
165
|
+
if (raw.maxTurns !== undefined) {
|
|
166
|
+
const parsed = parsePositiveInt(raw.maxTurns);
|
|
167
|
+
if (parsed === null || parsed < 1) {
|
|
168
|
+
return { ok: false, error: usage('invalid_max_turns', '--max-turns must be an integer >= 1.') };
|
|
169
|
+
}
|
|
170
|
+
maxTurns = parsed;
|
|
171
|
+
}
|
|
172
|
+
let maxDurationMs;
|
|
173
|
+
// `--max-duration` wins over the hidden `--timeout` alias when a caller passes
|
|
174
|
+
// both; the alias exists so a guess is not punished, not so it can disagree.
|
|
175
|
+
const durationRaw = raw.maxDuration ?? raw.timeout;
|
|
176
|
+
if (durationRaw !== undefined) {
|
|
177
|
+
const parsed = parsePositiveInt(durationRaw);
|
|
178
|
+
if (parsed === null || parsed < 1000) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
error: usage('invalid_max_duration', '--max-duration must be an integer >= 1000 (milliseconds).'),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
maxDurationMs = parsed;
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
ok: true,
|
|
188
|
+
...(maxTurns !== undefined ? { maxTurns } : {}),
|
|
189
|
+
...(maxDurationMs !== undefined ? { maxDurationMs } : {}),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
function checkSessionFlags(raw) {
|
|
193
|
+
if (raw.sessionId !== undefined) {
|
|
194
|
+
const id = raw.sessionId.trim();
|
|
195
|
+
if (!isValidSessionId(id)) {
|
|
196
|
+
return usage('invalid_session_id', `Invalid --session-id "${raw.sessionId}". Use 1-64 characters matching ` +
|
|
197
|
+
'[A-Za-z0-9][A-Za-z0-9._-]* (no "/", "\\", ".." or a leading dot).');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const chosen = [
|
|
201
|
+
raw.sessionId !== undefined ? '--session-id' : null,
|
|
202
|
+
raw.resume !== undefined ? '--resume' : null,
|
|
203
|
+
raw.continue === true ? '--continue' : null,
|
|
204
|
+
].filter((flag) => flag !== null);
|
|
205
|
+
if (chosen.length > 1) {
|
|
206
|
+
return usage('session_flag_conflict', `Pass only one of ${chosen.join(', ')}: they are three different answers to ` +
|
|
207
|
+
'"which session is this".');
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
function checkToolNames(names) {
|
|
212
|
+
const known = new Set(HOST_TOOL_NAMES);
|
|
213
|
+
for (const name of names) {
|
|
214
|
+
if (known.has(name))
|
|
215
|
+
continue;
|
|
216
|
+
// A typo that silently allows nothing is exactly the failure this feature
|
|
217
|
+
// exists to prevent, so the valid set is printed rather than hinted at.
|
|
218
|
+
return usage('unknown_tool', `Unknown tool "${name}". Valid names: ${[...HOST_TOOL_NAMES].join(', ')}.`);
|
|
219
|
+
}
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The non-interactive tool-permission policy (cli-integration-surface section
|
|
3
|
+
* 4.2 / 5.3).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* PURE. No I/O, no process access. The only import is the comm-tool name set,
|
|
8
|
+
* for the reason invariant 4 records below.
|
|
9
|
+
*
|
|
10
|
+
* TWO ORTHOGONAL CONTROLS, EACH WITH EXACTLY ONE MEANING:
|
|
11
|
+
*
|
|
12
|
+
* `--permission-mode` selects the BASELINE (`auto` everything, `plan`
|
|
13
|
+
* everything, `strict` nothing)
|
|
14
|
+
* `--allow-tool` ADDS to the baseline
|
|
15
|
+
* `--deny-tool` REMOVES, and wins over allow
|
|
16
|
+
*
|
|
17
|
+
* `plan` CONTRIBUTES NOTHING TO THE FILTER, and getting that wrong was P0-2.
|
|
18
|
+
* `--plan` does not unregister the five in `PLAN_MODE_BLOCKED_TOOLS`:
|
|
19
|
+
* `tools/index.ts::withPlanModeGate` wraps every tool and returns
|
|
20
|
+
* `planRefusal(name)` for those five AT CALL TIME, so they stay in
|
|
21
|
+
* `listTools()`. Unregistering them here instead would (a) make AC-15
|
|
22
|
+
* unsatisfiable - it compares the registered set against `-p --plan` name by
|
|
23
|
+
* name, (b) make `system/init.tools` disagree between two spellings of one mode,
|
|
24
|
+
* and (c) cost the model the refusal text that steers it to `submit_plan`. In
|
|
25
|
+
* plan mode the point is that the model KNOWS the write exists and is being
|
|
26
|
+
* deferred; for a deny list the point is the opposite (D-4).
|
|
27
|
+
*/
|
|
28
|
+
export type PermissionMode = 'auto' | 'plan' | 'strict';
|
|
29
|
+
export interface ToolPermission {
|
|
30
|
+
mode: PermissionMode;
|
|
31
|
+
allow: ReadonlySet<string>;
|
|
32
|
+
deny: ReadonlySet<string>;
|
|
33
|
+
/** Resolved per tool array; the only method the tool factory calls. */
|
|
34
|
+
isAllowed(tool: string): boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface ResolvePermissionInput {
|
|
37
|
+
mode: PermissionMode;
|
|
38
|
+
allow: readonly string[];
|
|
39
|
+
deny: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The whole policy as a pure set operation over a REGISTERED list.
|
|
43
|
+
*
|
|
44
|
+
* Exported for the unit test and for anything that wants to describe the policy
|
|
45
|
+
* without building tools. The runtime path goes through `isAllowed` instead,
|
|
46
|
+
* because `createBuiltinTools` filters the array it is in the middle of
|
|
47
|
+
* assembling and has no "registered list" to hand over.
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolveToolAllowList(registered: readonly string[], mode: PermissionMode, allow: readonly string[], deny: readonly string[]): ReadonlySet<string>;
|
|
50
|
+
/**
|
|
51
|
+
* Build the object the tool factory receives, or `undefined` when the policy is
|
|
52
|
+
* a no-op.
|
|
53
|
+
*
|
|
54
|
+
* RETURNING `undefined` IS WHAT MAKES SECTION 4.2 INVARIANT 2 HOLD BY
|
|
55
|
+
* CONSTRUCTION RATHER THAN BY CARE (P1-2). With no `permission` key in the
|
|
56
|
+
* options bag, `createBuiltinTools` never enters its filter branch and returns
|
|
57
|
+
* the same tool OBJECTS it does today - which is what AC-28 asserts by identity,
|
|
58
|
+
* the discipline `AC-G17` already applies to `--no-skills`.
|
|
59
|
+
*
|
|
60
|
+
* `strict` always produces an object, even with empty lists: an empty allow list
|
|
61
|
+
* under `strict` means "no tools at all", which is a real posture and not an
|
|
62
|
+
* absence of one. `auto` and `plan` with no lists produce nothing, and that
|
|
63
|
+
* covers every existing path plus the documented `--permission-mode plan`
|
|
64
|
+
* alias of `-p --plan`.
|
|
65
|
+
*/
|
|
66
|
+
export declare function resolvePermission(input: ResolvePermissionInput): ToolPermission | undefined;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The non-interactive tool-permission policy (cli-integration-surface section
|
|
3
|
+
* 4.2 / 5.3).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* PURE. No I/O, no process access. The only import is the comm-tool name set,
|
|
8
|
+
* for the reason invariant 4 records below.
|
|
9
|
+
*
|
|
10
|
+
* TWO ORTHOGONAL CONTROLS, EACH WITH EXACTLY ONE MEANING:
|
|
11
|
+
*
|
|
12
|
+
* `--permission-mode` selects the BASELINE (`auto` everything, `plan`
|
|
13
|
+
* everything, `strict` nothing)
|
|
14
|
+
* `--allow-tool` ADDS to the baseline
|
|
15
|
+
* `--deny-tool` REMOVES, and wins over allow
|
|
16
|
+
*
|
|
17
|
+
* `plan` CONTRIBUTES NOTHING TO THE FILTER, and getting that wrong was P0-2.
|
|
18
|
+
* `--plan` does not unregister the five in `PLAN_MODE_BLOCKED_TOOLS`:
|
|
19
|
+
* `tools/index.ts::withPlanModeGate` wraps every tool and returns
|
|
20
|
+
* `planRefusal(name)` for those five AT CALL TIME, so they stay in
|
|
21
|
+
* `listTools()`. Unregistering them here instead would (a) make AC-15
|
|
22
|
+
* unsatisfiable - it compares the registered set against `-p --plan` name by
|
|
23
|
+
* name, (b) make `system/init.tools` disagree between two spellings of one mode,
|
|
24
|
+
* and (c) cost the model the refusal text that steers it to `submit_plan`. In
|
|
25
|
+
* plan mode the point is that the model KNOWS the write exists and is being
|
|
26
|
+
* deferred; for a deny list the point is the opposite (D-4).
|
|
27
|
+
*/
|
|
28
|
+
import { TEAM_SUBAGENT_TOOL_NAMES } from '../team/limits.js';
|
|
29
|
+
/**
|
|
30
|
+
* Whether a single name survives the policy.
|
|
31
|
+
*
|
|
32
|
+
* `TEAM_SUBAGENT_TOOL_NAMES` IS ALWAYS ALLOWED (invariant 4 / P1-3).
|
|
33
|
+
* `team_send` / `team_wait` are registered only on CHILDREN and are the
|
|
34
|
+
* mechanism children use to coordinate; filtering them out of a `strict` run
|
|
35
|
+
* would break subagent messaging in a way that reads as a hang - a child waiting
|
|
36
|
+
* for mail that can never arrive.
|
|
37
|
+
*
|
|
38
|
+
* The set is IMPORTED, not restated. v1 proposed a new `EXEC_POLICY_EXEMPT`
|
|
39
|
+
* beside `SKILL_TOOL_FLOOR`; that would be a second list of the same two names
|
|
40
|
+
* (the drift `tools/index.ts` keeps its two sets adjacent to avoid) AND a third
|
|
41
|
+
* meaning for one word, since `policyExempt` already means "skips the CEILING
|
|
42
|
+
* wrapper only, never the plan gate, never confirmation".
|
|
43
|
+
*/
|
|
44
|
+
function decide(input, tool) {
|
|
45
|
+
if (TEAM_SUBAGENT_TOOL_NAMES.has(tool))
|
|
46
|
+
return true;
|
|
47
|
+
// Deny wins, unconditionally and first. A caller who names one tool in both
|
|
48
|
+
// lists gets the safe reading, and a wrapper that concatenates two config
|
|
49
|
+
// fragments cannot accidentally re-enable something the outer one removed.
|
|
50
|
+
if (input.deny.includes(tool))
|
|
51
|
+
return false;
|
|
52
|
+
if (input.mode === 'strict')
|
|
53
|
+
return input.allow.includes(tool);
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The whole policy as a pure set operation over a REGISTERED list.
|
|
58
|
+
*
|
|
59
|
+
* Exported for the unit test and for anything that wants to describe the policy
|
|
60
|
+
* without building tools. The runtime path goes through `isAllowed` instead,
|
|
61
|
+
* because `createBuiltinTools` filters the array it is in the middle of
|
|
62
|
+
* assembling and has no "registered list" to hand over.
|
|
63
|
+
*/
|
|
64
|
+
export function resolveToolAllowList(registered, mode, allow, deny) {
|
|
65
|
+
const input = { mode, allow, deny };
|
|
66
|
+
return new Set(registered.filter((name) => decide(input, name)));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Build the object the tool factory receives, or `undefined` when the policy is
|
|
70
|
+
* a no-op.
|
|
71
|
+
*
|
|
72
|
+
* RETURNING `undefined` IS WHAT MAKES SECTION 4.2 INVARIANT 2 HOLD BY
|
|
73
|
+
* CONSTRUCTION RATHER THAN BY CARE (P1-2). With no `permission` key in the
|
|
74
|
+
* options bag, `createBuiltinTools` never enters its filter branch and returns
|
|
75
|
+
* the same tool OBJECTS it does today - which is what AC-28 asserts by identity,
|
|
76
|
+
* the discipline `AC-G17` already applies to `--no-skills`.
|
|
77
|
+
*
|
|
78
|
+
* `strict` always produces an object, even with empty lists: an empty allow list
|
|
79
|
+
* under `strict` means "no tools at all", which is a real posture and not an
|
|
80
|
+
* absence of one. `auto` and `plan` with no lists produce nothing, and that
|
|
81
|
+
* covers every existing path plus the documented `--permission-mode plan`
|
|
82
|
+
* alias of `-p --plan`.
|
|
83
|
+
*/
|
|
84
|
+
export function resolvePermission(input) {
|
|
85
|
+
if (input.mode !== 'strict' && input.allow.length === 0 && input.deny.length === 0) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const allow = new Set(input.allow);
|
|
89
|
+
const deny = new Set(input.deny);
|
|
90
|
+
return {
|
|
91
|
+
mode: input.mode,
|
|
92
|
+
allow,
|
|
93
|
+
deny,
|
|
94
|
+
isAllowed: (tool) => decide(input, tool),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stream translation, budgets and interrupts for `aragon exec`
|
|
3
|
+
* (cli-integration-surface section 3.3 / 3.5 / 3.6).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* IT NEVER TOUCHES STDOUT. Everything it wants to say goes through the
|
|
8
|
+
* `ExecEmitter` it was handed, which is the only object in this feature that
|
|
9
|
+
* holds a stdout stream (R-2). The one exception is `stderr`, used for the
|
|
10
|
+
* caller-facing notices `-p` already writes there.
|
|
11
|
+
*
|
|
12
|
+
* IT DOES NOT EMIT `result` EITHER, and that is deliberate (D-2). `run()`
|
|
13
|
+
* RETURNS the parameters; `exec/index.ts` persists the session first and emits
|
|
14
|
+
* second, because a caller that reads `result` and immediately respawns with the
|
|
15
|
+
* same `--session-id` must not race the writer of the file it is about to read.
|
|
16
|
+
*/
|
|
17
|
+
import type { TokenUsage } from '@aragon-agent/core';
|
|
18
|
+
import type { HeadlessController } from '../agent/headless.js';
|
|
19
|
+
import { type FollowThroughMode } from '../todo/follow-through.js';
|
|
20
|
+
import type { ExecEmitter } from './emitter.js';
|
|
21
|
+
import type { ExecStopReason } from './events.js';
|
|
22
|
+
/**
|
|
23
|
+
* The controller surface the runner needs.
|
|
24
|
+
*
|
|
25
|
+
* IT EXTENDS `HeadlessController` RATHER THAN NAMING `AgentController`, for the
|
|
26
|
+
* reason that interface records about itself: it is deliberately minimal so a
|
|
27
|
+
* test can satisfy it with an OBJECT LITERAL. `abort` is required here and not
|
|
28
|
+
* there because a budget with no way to stop the run is not a budget.
|
|
29
|
+
*/
|
|
30
|
+
export interface ExecRunnerController extends HeadlessController {
|
|
31
|
+
abort(): void;
|
|
32
|
+
/** `cost.known` (P2-3). Optional so a stub need not model pricing. */
|
|
33
|
+
isPricedModel?(ref: {
|
|
34
|
+
providerId: string;
|
|
35
|
+
modelId: string;
|
|
36
|
+
}): boolean;
|
|
37
|
+
}
|
|
38
|
+
/** Where the caller's messages come from. One turn, or an NDJSON stdin stream. */
|
|
39
|
+
export interface ExecPromptSource {
|
|
40
|
+
/** The next caller message, or `null` when the caller is done. */
|
|
41
|
+
next(): Promise<string | null>;
|
|
42
|
+
}
|
|
43
|
+
export declare function singlePrompt(text: string): ExecPromptSource;
|
|
44
|
+
/** Replaceable so a unit test can assert the mechanism, not just the outcome. */
|
|
45
|
+
export interface ExecSignalPort {
|
|
46
|
+
setTerminator(fn: (signo: number) => void): void;
|
|
47
|
+
exit(code: number): void;
|
|
48
|
+
}
|
|
49
|
+
export interface ExecRunnerOptions {
|
|
50
|
+
emitter: ExecEmitter;
|
|
51
|
+
sessionId: string;
|
|
52
|
+
maxTurns?: number;
|
|
53
|
+
maxDurationMs?: number;
|
|
54
|
+
partialMessages?: boolean;
|
|
55
|
+
includeThinking?: boolean;
|
|
56
|
+
followThrough?: FollowThroughMode;
|
|
57
|
+
quiet?: boolean;
|
|
58
|
+
stderr?: NodeJS.WritableStream;
|
|
59
|
+
now?: () => number;
|
|
60
|
+
signals?: ExecSignalPort;
|
|
61
|
+
/** Turns already spent by earlier invocations of a resumed session. */
|
|
62
|
+
priorTurns?: number;
|
|
63
|
+
}
|
|
64
|
+
export interface ExecRunStats {
|
|
65
|
+
turns: number;
|
|
66
|
+
usage: TokenUsage;
|
|
67
|
+
lastAssistantText: string;
|
|
68
|
+
errored: boolean;
|
|
69
|
+
stopReason: ExecStopReason;
|
|
70
|
+
errorCode: string | null;
|
|
71
|
+
errorMessage: string | null;
|
|
72
|
+
signalNumber: number | null;
|
|
73
|
+
}
|
|
74
|
+
export declare class ExecRunner {
|
|
75
|
+
private readonly emitter;
|
|
76
|
+
private readonly sessionId;
|
|
77
|
+
private readonly opts;
|
|
78
|
+
private readonly signals;
|
|
79
|
+
private readonly now;
|
|
80
|
+
private controller;
|
|
81
|
+
private unsubscribers;
|
|
82
|
+
private turns;
|
|
83
|
+
private readonly usage;
|
|
84
|
+
private lastAssistantText;
|
|
85
|
+
private errored;
|
|
86
|
+
private sawTurnEnd;
|
|
87
|
+
private stopReason;
|
|
88
|
+
private errorCode;
|
|
89
|
+
private errorMessage;
|
|
90
|
+
private signalNumber;
|
|
91
|
+
private thinkingBuffer;
|
|
92
|
+
private budgetTimer;
|
|
93
|
+
private timerStarted;
|
|
94
|
+
private detached;
|
|
95
|
+
private lastSignalAt;
|
|
96
|
+
constructor(options: ExecRunnerOptions);
|
|
97
|
+
/**
|
|
98
|
+
* Subscribe to all four streams.
|
|
99
|
+
*
|
|
100
|
+
* TEXT MODE CALLS THIS TOO, WITH A `TextEmitter` THAT WRITES NOTHING (section
|
|
101
|
+
* 3.2). The listener is what counts `turn_end` and accumulates usage for
|
|
102
|
+
* `SessionMeta`; byte-equality with `runHeadless` is a property of what is
|
|
103
|
+
* WRITTEN, not of how many listeners exist, and `runHeadless` already tolerates
|
|
104
|
+
* multiple subscribers.
|
|
105
|
+
*/
|
|
106
|
+
attach(controller: ExecRunnerController): void;
|
|
107
|
+
/**
|
|
108
|
+
* Drop the subscriptions and the timer.
|
|
109
|
+
*
|
|
110
|
+
* IDEMPOTENT, and it clears the wall-clock timer even though that timer is
|
|
111
|
+
* `unref`d: `unref` only stops it holding the event loop open, it does not stop
|
|
112
|
+
* it firing and calling `abort()` on a controller the caller has since
|
|
113
|
+
* disposed (R-10).
|
|
114
|
+
*/
|
|
115
|
+
detach(): void;
|
|
116
|
+
stats(): ExecRunStats;
|
|
117
|
+
/**
|
|
118
|
+
* `0` success | `1` agent error | `3` budget | `128 + signo` for a signal.
|
|
119
|
+
*
|
|
120
|
+
* `3` IS ITS OWN CODE RATHER THAN `0` OR `1` (D-6). Both alternatives lose
|
|
121
|
+
* information a shell script needs and cannot recover without parsing JSON:
|
|
122
|
+
* `0` claims a complete answer that does not exist, `1` claims a malfunction
|
|
123
|
+
* that did not happen.
|
|
124
|
+
*/
|
|
125
|
+
exitCode(): number;
|
|
126
|
+
/**
|
|
127
|
+
* Drive the conversation until the source is exhausted or a budget fires.
|
|
128
|
+
*
|
|
129
|
+
* The inner loop is the todo follow-through continuation of `runHeadless`,
|
|
130
|
+
* with the IDENTICAL policy - both call `decideFollowThrough`, which is why
|
|
131
|
+
* the two faces cannot drift about what "unfinished" means.
|
|
132
|
+
*/
|
|
133
|
+
run(source: ExecPromptSource): Promise<void>;
|
|
134
|
+
/** One caller message plus every auto-continuation it produces. */
|
|
135
|
+
private runFollowThroughChain;
|
|
136
|
+
/**
|
|
137
|
+
* The wall clock starts at the FIRST `prompt()`, not at process start: the
|
|
138
|
+
* caller is bounding the run, and config resolution plus a skills scan are not
|
|
139
|
+
* part of what they asked to bound.
|
|
140
|
+
*/
|
|
141
|
+
private startBudgetTimer;
|
|
142
|
+
private turnBudgetExhausted;
|
|
143
|
+
/**
|
|
144
|
+
* Enforced at `turn_start`, NOT at `turn_end` (implementation finding IF-3).
|
|
145
|
+
*
|
|
146
|
+
* The design said "count `turn_end`; on reaching `n`, abort", and the counting
|
|
147
|
+
* half is right - `turns` is exactly that count. Acting on it at `turn_end` is
|
|
148
|
+
* not: a run that COMPLETES in exactly `n` turns would trip the budget it
|
|
149
|
+
* never exceeded and report `stopReason: "max_turns"` with exit 3, so a CI
|
|
150
|
+
* script running `aragon exec --max-turns 20 ... || fail` would fail on a
|
|
151
|
+
* successful twenty-turn run. That is the single most likely way to make a
|
|
152
|
+
* safety ceiling unusable as a safety ceiling.
|
|
153
|
+
*
|
|
154
|
+
* A turn that is STARTING when the count already sits at `n` is, by contrast,
|
|
155
|
+
* unambiguous: the model asked for turn `n + 1` and the caller said no. AC-16
|
|
156
|
+
* is satisfied either way - "a model that would take five turns" with
|
|
157
|
+
* `--max-turns 2` still exits 3 with the partial answer in `result.result`.
|
|
158
|
+
*/
|
|
159
|
+
private checkTurnBudget;
|
|
160
|
+
/**
|
|
161
|
+
* EXEC REPLACES THE TERMINATOR; IT DOES NOT ADD A LISTENER.
|
|
162
|
+
*
|
|
163
|
+
* `logging/install.ts` registers the `SIGINT` / `SIGTERM` / `SIGHUP` listeners
|
|
164
|
+
* once, for EVERY invocation, and its default terminator exits. A listener
|
|
165
|
+
* added here would be later in EventEmitter order and would never run - the
|
|
166
|
+
* process is already gone - so the run would vanish with no `result` line,
|
|
167
|
+
* which is precisely the "the wrapper has to time out to learn the run ended"
|
|
168
|
+
* outcome this whole section exists to prevent (P0-1 / R-15).
|
|
169
|
+
*
|
|
170
|
+
* FIRST SIGNAL: abort, record the reason, and RETURN WITHOUT EXITING. The run
|
|
171
|
+
* settles through the normal path - session persisted, `result` emitted,
|
|
172
|
+
* `process.exitCode = 128 + signo`.
|
|
173
|
+
*
|
|
174
|
+
* SECOND SIGNAL WITHIN 2s: exit immediately. That escape is what makes
|
|
175
|
+
* returning safe: a wedged settle is always one more Ctrl-C away from ending.
|
|
176
|
+
*
|
|
177
|
+
* AFTER `detach()`: exit immediately as well, because from that moment there
|
|
178
|
+
* is nothing left to settle and the caller's Ctrl-C must not become "nothing
|
|
179
|
+
* happens".
|
|
180
|
+
*
|
|
181
|
+
* `detach()` DOES NOT RESTORE THE PREVIOUS TERMINATOR, AND THAT IS THE POINT
|
|
182
|
+
* OF THE BRANCH ABOVE (implementation finding IF-7). The design asked for a
|
|
183
|
+
* restore in `finally`; `setSignalTerminator` returns `void` and there is no
|
|
184
|
+
* getter, so a literal restore would mean changing `logging/install.ts`, which
|
|
185
|
+
* the change plan holds to a doc-comment-only edit. The `detached` branch is
|
|
186
|
+
* what makes that safe rather than merely convenient: once detached this
|
|
187
|
+
* terminator does `exit(128 + signo)`, which is byte for byte what the default
|
|
188
|
+
* terminator does, so a left-behind one is INDISTINGUISHABLE from a restored
|
|
189
|
+
* one. Anything that ever gives this process a terminator worth returning to -
|
|
190
|
+
* the full-screen one that restores the terminal, or a second `runExec` in one
|
|
191
|
+
* process under the programmatic API section 2.2 hedges for - must revisit
|
|
192
|
+
* this and add the getter.
|
|
193
|
+
*/
|
|
194
|
+
private installTerminator;
|
|
195
|
+
private emit;
|
|
196
|
+
/** Caller-facing prose. STDERR ALWAYS - stdout carries nothing but the schema. */
|
|
197
|
+
private note;
|
|
198
|
+
private onAgentEvent;
|
|
199
|
+
private onStreamEvent;
|
|
200
|
+
private onTurnEnd;
|
|
201
|
+
private flushThinking;
|
|
202
|
+
private recordError;
|
|
203
|
+
private emitTeam;
|
|
204
|
+
}
|