@aragon-agent/cli 0.5.12 → 0.6.1

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 (56) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +301 -6
  3. package/dist/agent/controller.d.ts +32 -0
  4. package/dist/agent/controller.js +25 -0
  5. package/dist/agent/system-prompt.d.ts +16 -0
  6. package/dist/agent/system-prompt.js +5 -0
  7. package/dist/cli.js +71 -2
  8. package/dist/config/ui-state.d.ts +18 -0
  9. package/dist/config/ui-state.js +10 -0
  10. package/dist/diagnostics/cli-commands.d.ts +10 -0
  11. package/dist/diagnostics/cli-commands.js +61 -0
  12. package/dist/diagnostics/doctor.d.ts +29 -0
  13. package/dist/diagnostics/doctor.js +167 -0
  14. package/dist/diagnostics/info.d.ts +30 -0
  15. package/dist/diagnostics/info.js +99 -0
  16. package/dist/exec/cli-commands.d.ts +31 -0
  17. package/dist/exec/cli-commands.js +63 -0
  18. package/dist/exec/emitter.d.ts +80 -0
  19. package/dist/exec/emitter.js +127 -0
  20. package/dist/exec/events.d.ts +226 -0
  21. package/dist/exec/events.js +43 -0
  22. package/dist/exec/index.d.ts +56 -0
  23. package/dist/exec/index.js +435 -0
  24. package/dist/exec/options.d.ts +76 -0
  25. package/dist/exec/options.js +221 -0
  26. package/dist/exec/permission.d.ts +66 -0
  27. package/dist/exec/permission.js +96 -0
  28. package/dist/exec/runner.d.ts +204 -0
  29. package/dist/exec/runner.js +559 -0
  30. package/dist/exec/stdin-stream.d.ts +46 -0
  31. package/dist/exec/stdin-stream.js +144 -0
  32. package/dist/logging/install.d.ts +22 -0
  33. package/dist/logging/install.js +22 -0
  34. package/dist/session/cli-commands.d.ts +30 -0
  35. package/dist/session/cli-commands.js +162 -0
  36. package/dist/session/persist.d.ts +46 -0
  37. package/dist/session/persist.js +8 -0
  38. package/dist/session/store.d.ts +129 -0
  39. package/dist/session/store.js +385 -0
  40. package/dist/team/runtime.d.ts +12 -0
  41. package/dist/team/runtime.js +6 -0
  42. package/dist/team/subagent.d.ts +12 -0
  43. package/dist/team/subagent.js +4 -0
  44. package/dist/tools/index.d.ts +20 -0
  45. package/dist/tools/index.js +26 -0
  46. package/dist/ui/ActivityLine.d.ts +16 -0
  47. package/dist/ui/ActivityLine.js +23 -1
  48. package/dist/ui/App.d.ts +14 -1
  49. package/dist/ui/App.js +38 -3
  50. package/dist/ui/BottomStatusRow.d.ts +33 -1
  51. package/dist/ui/BottomStatusRow.js +20 -6
  52. package/dist/ui/use-startup-notices.d.ts +67 -4
  53. package/dist/ui/use-startup-notices.js +76 -2
  54. package/dist/ui/win-vt-input.d.ts +57 -0
  55. package/dist/ui/win-vt-input.js +62 -0
  56. 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
+ }