@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,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `aragon exec` - wiring only (cli-integration-surface section 3.3 / 6.1).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* NO PROCESS-GLOBAL STATE, AND THAT IS A RULE RATHER THAN A HABIT (section 2.2).
|
|
7
|
+
* A programmatic Node API is the deferral most likely to be asked for first, and
|
|
8
|
+
* the only thing that makes it cheap later is `runExec()` holding nothing the
|
|
9
|
+
* process owns: the controller arrives through a factory, both streams are
|
|
10
|
+
* parameters, and the exit code is RETURNED rather than assigned.
|
|
11
|
+
*
|
|
12
|
+
* THE CONTROLLER COMES FROM A FACTORY BECAUSE `makeController` LIVES IN
|
|
13
|
+
* `cli.tsx` (see the note on `ExecControllerFactory`). Reaching for it directly
|
|
14
|
+
* would make `cli.tsx -> exec/cli-commands.ts -> exec/index.ts -> cli.tsx` a
|
|
15
|
+
* cycle, and duplicating it here would give the headless approval gates a second
|
|
16
|
+
* definition - the one thing `makeController`'s own comment says must not
|
|
17
|
+
* happen.
|
|
18
|
+
*/
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
import process from 'node:process';
|
|
22
|
+
import { runHeadless } from '../agent/headless.js';
|
|
23
|
+
import { computeCost } from '../agent/usage.js';
|
|
24
|
+
import { loadConfig } from '../config/load.js';
|
|
25
|
+
import { latestSessionForCwd, readSessionFile, resolveSessionRef, resumedSessionId, sessionPathFor, tryAcquireSessionLock, writeSession, } from '../session/store.js';
|
|
26
|
+
import { createEmitter } from './emitter.js';
|
|
27
|
+
import { resolveExecOptions } from './options.js';
|
|
28
|
+
import { resolvePermission } from './permission.js';
|
|
29
|
+
import { ExecRunner, singlePrompt } from './runner.js';
|
|
30
|
+
import { createStdinPromptSource, prefixSource } from './stdin-stream.js';
|
|
31
|
+
/**
|
|
32
|
+
* Run one `aragon exec` invocation and resolve with the process exit code.
|
|
33
|
+
*
|
|
34
|
+
* EXIT 2 MEANS THE RUN NEVER STARTED, AND STDOUT CARRIES NOTHING ON THAT PATH.
|
|
35
|
+
* Option errors, prompt errors and session errors are all reported on stderr
|
|
36
|
+
* alone: emitting a lone `result` line for them would break the guarantee
|
|
37
|
+
* `system/init` is the FIRST stream-json line (AC-5), and a wrapper that got a
|
|
38
|
+
* `result` without an `init` could not tell a run that failed to start from one
|
|
39
|
+
* that failed. A preflight failure is different - the format is settled and the
|
|
40
|
+
* schema has begun - so that one DOES emit a `result` with `exitCode: 2`.
|
|
41
|
+
*/
|
|
42
|
+
export async function runExec(flags, raw, promptArg, ctx) {
|
|
43
|
+
const out = ctx.stdout ?? process.stdout;
|
|
44
|
+
const err = ctx.stderr ?? process.stderr;
|
|
45
|
+
const now = ctx.now ?? Date.now;
|
|
46
|
+
const resolved = resolveExecOptions(raw);
|
|
47
|
+
if (!resolved.ok) {
|
|
48
|
+
err.write(`${resolved.message}\n`);
|
|
49
|
+
return 2;
|
|
50
|
+
}
|
|
51
|
+
const options = resolved.options;
|
|
52
|
+
for (const warning of options.warnings)
|
|
53
|
+
err.write(`${warning}\n`);
|
|
54
|
+
const appended = readOptionalText(options.appendSystemPrompt, options.appendSystemPromptFile, err, '--append-system-prompt-file');
|
|
55
|
+
if (appended === null)
|
|
56
|
+
return 2;
|
|
57
|
+
const cwd = loadConfig(flags).cwd;
|
|
58
|
+
const plan = planSession(options, cwd, err);
|
|
59
|
+
if (!plan)
|
|
60
|
+
return 2;
|
|
61
|
+
const prompts = resolvePromptSource(options, promptArg, ctx, err);
|
|
62
|
+
if (!prompts)
|
|
63
|
+
return 2;
|
|
64
|
+
const lock = plan.saving ? tryAcquireSessionLock(plan.id) : null;
|
|
65
|
+
if (plan.saving && !lock) {
|
|
66
|
+
err.write(`session_busy: another aragon exec run holds session "${plan.id}". ` +
|
|
67
|
+
'Retry, or use a different --session-id.\n');
|
|
68
|
+
return 2;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return await execute({ flags, options, plan, prompts, ctx, out, err, now, appended });
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
lock?.release();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
async function execute(input) {
|
|
78
|
+
const { options, plan, ctx, out, err, now } = input;
|
|
79
|
+
const startedAt = now();
|
|
80
|
+
const permission = resolvePermission({
|
|
81
|
+
mode: options.permissionMode,
|
|
82
|
+
allow: options.allowTools,
|
|
83
|
+
deny: options.denyTools,
|
|
84
|
+
});
|
|
85
|
+
const controller = ctx.makeController(withPermissionMode(input.flags, options.permissionMode), {
|
|
86
|
+
...(permission ? { permission } : {}),
|
|
87
|
+
...(input.appended ? { appendSystemPrompt: input.appended } : {}),
|
|
88
|
+
});
|
|
89
|
+
const emitter = createEmitter(options.outputFormat, out);
|
|
90
|
+
const runner = new ExecRunner({
|
|
91
|
+
emitter,
|
|
92
|
+
sessionId: plan.id,
|
|
93
|
+
...(options.maxTurns !== undefined ? { maxTurns: options.maxTurns } : {}),
|
|
94
|
+
...(options.maxDurationMs !== undefined ? { maxDurationMs: options.maxDurationMs } : {}),
|
|
95
|
+
partialMessages: options.partialMessages,
|
|
96
|
+
includeThinking: options.includeThinking,
|
|
97
|
+
followThrough: controller.getTodoConfig().followThrough,
|
|
98
|
+
quiet: options.quiet,
|
|
99
|
+
stderr: err,
|
|
100
|
+
now,
|
|
101
|
+
});
|
|
102
|
+
if (plan.saved) {
|
|
103
|
+
controller.replaceMessages(plan.saved.messages);
|
|
104
|
+
controller.restoreTodos(plan.saved.todos);
|
|
105
|
+
}
|
|
106
|
+
const config = controller.getConfig();
|
|
107
|
+
emitter.init({
|
|
108
|
+
sessionId: plan.id,
|
|
109
|
+
cli: ctx.version,
|
|
110
|
+
cwd: controller.getCwd(),
|
|
111
|
+
startedAt,
|
|
112
|
+
model: {
|
|
113
|
+
provider: config.provider,
|
|
114
|
+
id: config.model,
|
|
115
|
+
baseUrl: config.baseUrl ?? null,
|
|
116
|
+
},
|
|
117
|
+
permissionMode: options.permissionMode,
|
|
118
|
+
tools: controller.listTools().map((t) => t.name),
|
|
119
|
+
resumed: plan.saved !== null,
|
|
120
|
+
});
|
|
121
|
+
runner.attach(controller);
|
|
122
|
+
const pre = controller.preflight();
|
|
123
|
+
if (!pre.ok) {
|
|
124
|
+
runner.detach();
|
|
125
|
+
controller.dispose();
|
|
126
|
+
err.write(`${pre.message ?? 'Configuration error.'}\n`);
|
|
127
|
+
emitter.result(failedResult(plan.id, config, pre.message ?? 'Configuration error.', 'config', now() - startedAt));
|
|
128
|
+
return 2;
|
|
129
|
+
}
|
|
130
|
+
let exitCode = 0;
|
|
131
|
+
try {
|
|
132
|
+
exitCode = await drive(input, controller, runner);
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
runner.detach();
|
|
136
|
+
controller.dispose();
|
|
137
|
+
}
|
|
138
|
+
// PERSIST FIRST, ANNOUNCE SECOND (D-2). A caller that reads `result` and
|
|
139
|
+
// immediately respawns with the same `--session-id` must not race the writer
|
|
140
|
+
// of the file it is about to read.
|
|
141
|
+
const stats = runner.stats();
|
|
142
|
+
if (plan.saving)
|
|
143
|
+
persist(plan, controller, stats.turns, stats.usage, ctx.version, now());
|
|
144
|
+
const todo = controller.getTodoSnapshot?.() ?? null;
|
|
145
|
+
emitter.result({
|
|
146
|
+
sessionId: plan.id,
|
|
147
|
+
isError: exitCode === 1,
|
|
148
|
+
stopReason: stats.stopReason,
|
|
149
|
+
exitCode,
|
|
150
|
+
result: stats.lastAssistantText,
|
|
151
|
+
turns: stats.turns,
|
|
152
|
+
durationMs: now() - startedAt,
|
|
153
|
+
usage: {
|
|
154
|
+
inputTokens: stats.usage.inputTokens,
|
|
155
|
+
outputTokens: stats.usage.outputTokens,
|
|
156
|
+
totalTokens: stats.usage.inputTokens + stats.usage.outputTokens,
|
|
157
|
+
},
|
|
158
|
+
cost: buildCost(controller, stats.usage),
|
|
159
|
+
model: { provider: config.provider, id: config.model },
|
|
160
|
+
todos: todo ? { total: todo.total, done: todo.doneCount } : null,
|
|
161
|
+
error: stats.errorCode && stats.errorMessage
|
|
162
|
+
? { code: stats.errorCode, message: stats.errorMessage }
|
|
163
|
+
: null,
|
|
164
|
+
});
|
|
165
|
+
return exitCode;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* `text` DELEGATES TO `runHeadless` VERBATIM (D-1 / AC-1).
|
|
169
|
+
*
|
|
170
|
+
* Same controller, same `quiet`, same `followThrough`, same streams. That is the
|
|
171
|
+
* entire text path: reimplementing it inside the emitter would make "the human
|
|
172
|
+
* CLI is unchanged" an argument someone has to trust instead of a fact anyone
|
|
173
|
+
* can check, and the two would drift the first time a retry line changed.
|
|
174
|
+
*/
|
|
175
|
+
async function drive(input, controller, runner) {
|
|
176
|
+
const { options, prompts, out, err } = input;
|
|
177
|
+
if (options.outputFormat !== 'text') {
|
|
178
|
+
await runner.run(prompts);
|
|
179
|
+
return runner.exitCode();
|
|
180
|
+
}
|
|
181
|
+
const first = await prompts.next();
|
|
182
|
+
if (first === null) {
|
|
183
|
+
err.write('No prompt provided for `aragon exec`.\n');
|
|
184
|
+
return 2;
|
|
185
|
+
}
|
|
186
|
+
const code = await runHeadless(controller, first, {
|
|
187
|
+
quiet: options.quiet,
|
|
188
|
+
followThrough: controller.getTodoConfig().followThrough,
|
|
189
|
+
stdout: out,
|
|
190
|
+
stderr: err,
|
|
191
|
+
});
|
|
192
|
+
// An interrupt still wins: `runHeadless` cannot know a signal arrived, and
|
|
193
|
+
// reporting 0 for a run the user stopped would be a lie a shell script acts on.
|
|
194
|
+
return runner.stats().stopReason === 'interrupted' ? runner.exitCode() : code;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* `--permission-mode plan` IS `--plan`, and something has to carry it there.
|
|
198
|
+
*
|
|
199
|
+
* `resolvePermission` returns `undefined` for `plan` ON PURPOSE (P0-2): the mode
|
|
200
|
+
* contributes NOTHING to the registration filter, because `--plan` does not
|
|
201
|
+
* unregister the five in `PLAN_MODE_BLOCKED_TOOLS` - it registers them and
|
|
202
|
+
* refuses at call time, which is what keeps the refusal text that steers the
|
|
203
|
+
* model to `submit_plan`. But that leaves the mode with no carrier at all unless
|
|
204
|
+
* something turns it on, and `AgentController` reads it from exactly ONE place:
|
|
205
|
+
* `config.startInPlanMode`, which `loadConfig` resolves from `flags.plan`.
|
|
206
|
+
*
|
|
207
|
+
* WITHOUT THIS LINE THE FLAG IS A SILENT NO-OP OF THE WORST KIND (IF-6). The run
|
|
208
|
+
* would report `permissionMode: "plan"` in `system/init` while the plan gate was
|
|
209
|
+
* never armed and the system prompt carried no plan block - so a caller who
|
|
210
|
+
* asked for "read-only research and a written plan", the README's own example,
|
|
211
|
+
* would get a run that edits files, with nothing anywhere reporting it. Note
|
|
212
|
+
* that comparing `listTools()` against `-p --plan` does NOT catch it: both
|
|
213
|
+
* spellings register the same names either way, because the plan gate wraps
|
|
214
|
+
* rather than removes.
|
|
215
|
+
*
|
|
216
|
+
* ONLY `plan` TOUCHES THE FLAGS. `auto` and `strict` return the SAME object, so
|
|
217
|
+
* every other path builds today's config exactly - which is what keeps text mode
|
|
218
|
+
* byte-identical to `-p` (AC-1), and leaves a user whose `config.json` sets
|
|
219
|
+
* `planModeDefault: true` with the plan mode `-p` would have given them.
|
|
220
|
+
*/
|
|
221
|
+
function withPermissionMode(flags, mode) {
|
|
222
|
+
return mode === 'plan' ? { ...flags, plan: true } : flags;
|
|
223
|
+
}
|
|
224
|
+
function buildCost(controller, usage) {
|
|
225
|
+
const config = controller.getConfig();
|
|
226
|
+
const known = controller.isPricedModel({
|
|
227
|
+
providerId: config.provider,
|
|
228
|
+
modelId: config.model,
|
|
229
|
+
});
|
|
230
|
+
// `amount` is `0` when the price is unknown, mirroring the fast tier's rule
|
|
231
|
+
// that a feature which looks free while it is spending money is worse than one
|
|
232
|
+
// that admits it does not know.
|
|
233
|
+
return {
|
|
234
|
+
amount: known ? computeCost(usage, controller.getModelInfo().cost) : 0,
|
|
235
|
+
currency: 'USD',
|
|
236
|
+
known,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function failedResult(sessionId, config, message, code, durationMs) {
|
|
240
|
+
return {
|
|
241
|
+
sessionId,
|
|
242
|
+
isError: true,
|
|
243
|
+
stopReason: 'error',
|
|
244
|
+
exitCode: 2,
|
|
245
|
+
result: '',
|
|
246
|
+
turns: 0,
|
|
247
|
+
durationMs,
|
|
248
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
249
|
+
cost: { amount: 0, currency: 'USD', known: false },
|
|
250
|
+
model: { provider: config.provider, id: config.model },
|
|
251
|
+
todos: null,
|
|
252
|
+
error: { code, message },
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
// Prompt resolution (section 4.1)
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
/**
|
|
259
|
+
* Positional argument, then `--prompt-file`, then stdin - first match wins.
|
|
260
|
+
*
|
|
261
|
+
* Returns `null` after writing the reason to stderr, so the caller can exit 2
|
|
262
|
+
* without a second error vocabulary.
|
|
263
|
+
*/
|
|
264
|
+
function resolvePromptSource(options, promptArg, ctx, err) {
|
|
265
|
+
const stdin = ctx.stdin ?? process.stdin;
|
|
266
|
+
const opening = [];
|
|
267
|
+
const positional = promptArg?.trim();
|
|
268
|
+
if (positional)
|
|
269
|
+
opening.push(positional);
|
|
270
|
+
else if (options.promptFile) {
|
|
271
|
+
const text = readTextFile(options.promptFile, err, '--prompt-file');
|
|
272
|
+
if (text === null)
|
|
273
|
+
return null;
|
|
274
|
+
if (text.trim().length > 0)
|
|
275
|
+
opening.push(text);
|
|
276
|
+
}
|
|
277
|
+
if (options.inputFormat === 'stream-json') {
|
|
278
|
+
return prefixSource(opening, createStdinPromptSource({
|
|
279
|
+
input: stdin,
|
|
280
|
+
// Reported on stderr rather than as a schema event, because the runner
|
|
281
|
+
// owns the emitter and this source is constructed before it exists. The
|
|
282
|
+
// line is skipped either way, which is the property AC-20 pins.
|
|
283
|
+
onBadLine: (message) => err.write(`${message}\n`),
|
|
284
|
+
// The runner installs the signal terminator; a caller-requested
|
|
285
|
+
// interrupt is the same intent arriving through a different channel.
|
|
286
|
+
onInterrupt: () => process.kill(process.pid, 'SIGINT'),
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
if (opening.length === 0 && !stdinIsTty(stdin)) {
|
|
290
|
+
// Piped stdin, `--input-format text`: the whole of stdin is the prompt.
|
|
291
|
+
return { next: makeOnce(() => readAllSync(stdin)) };
|
|
292
|
+
}
|
|
293
|
+
if (opening.length === 0) {
|
|
294
|
+
err.write('no_prompt: pass a prompt argument, --prompt-file <path>, or pipe one on stdin.\n');
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
return singlePrompt(opening[0]);
|
|
298
|
+
}
|
|
299
|
+
function makeOnce(read) {
|
|
300
|
+
let served = false;
|
|
301
|
+
return async () => {
|
|
302
|
+
if (served)
|
|
303
|
+
return null;
|
|
304
|
+
served = true;
|
|
305
|
+
return read();
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function stdinIsTty(stdin) {
|
|
309
|
+
return stdin.isTTY === true;
|
|
310
|
+
}
|
|
311
|
+
async function readAllSync(stdin) {
|
|
312
|
+
const chunks = [];
|
|
313
|
+
for await (const chunk of stdin) {
|
|
314
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
|
315
|
+
}
|
|
316
|
+
const text = Buffer.concat(chunks).toString('utf-8').trim();
|
|
317
|
+
return text.length > 0 ? text : null;
|
|
318
|
+
}
|
|
319
|
+
/** `''` when neither source is set, `null` when a file could not be read. */
|
|
320
|
+
function readOptionalText(inline, file, err, flag) {
|
|
321
|
+
const parts = [];
|
|
322
|
+
if (inline)
|
|
323
|
+
parts.push(inline);
|
|
324
|
+
if (file) {
|
|
325
|
+
const text = readTextFile(file, err, flag);
|
|
326
|
+
if (text === null)
|
|
327
|
+
return null;
|
|
328
|
+
parts.push(text);
|
|
329
|
+
}
|
|
330
|
+
return parts.join('\n\n');
|
|
331
|
+
}
|
|
332
|
+
function readTextFile(path, err, flag) {
|
|
333
|
+
try {
|
|
334
|
+
return readFileSync(path, 'utf-8');
|
|
335
|
+
}
|
|
336
|
+
catch (e) {
|
|
337
|
+
err.write(`${flag}: could not read ${path} (${e.message}).\n`);
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* The three resume forms, and how they differ:
|
|
343
|
+
*
|
|
344
|
+
* `--session-id <id>` resume it, or CREATE it (the wrapper-friendly upsert)
|
|
345
|
+
* `--resume <id|path>` resume it, or exit 2 `session_not_found`
|
|
346
|
+
* `--continue` the newest session for this cwd, or exit 2
|
|
347
|
+
*
|
|
348
|
+
* Returns `null` after writing the reason to stderr.
|
|
349
|
+
*/
|
|
350
|
+
function planSession(options, cwd, err) {
|
|
351
|
+
const saving = options.saveSession;
|
|
352
|
+
if (options.resume !== undefined) {
|
|
353
|
+
const path = resolveSessionRef(options.resume, cwd);
|
|
354
|
+
const saved = path ? readSessionFile(path) : null;
|
|
355
|
+
if (!path || !saved) {
|
|
356
|
+
err.write(`session_not_found: no readable session for --resume "${options.resume}".\n`);
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
// NOT `options.resume` AND NOT `meta.id` UNCHECKED (R-7). The flag accepts
|
|
360
|
+
// `<id|path>`, so the value the caller typed is frequently a path, and
|
|
361
|
+
// `meta.id` comes out of a file - neither is usable as a filename without
|
|
362
|
+
// being checked. `resumedSessionId` is the one place that decision is made.
|
|
363
|
+
return { id: resumedSessionId(path, saved), saved, saving, cwd };
|
|
364
|
+
}
|
|
365
|
+
if (options.continueSession) {
|
|
366
|
+
const entry = latestSessionForCwd(cwd);
|
|
367
|
+
const saved = entry ? readSessionFile(entry.path) : null;
|
|
368
|
+
if (!entry || !saved) {
|
|
369
|
+
err.write(`no_session_for_cwd: no previous aragon exec session for ${cwd}.\n`);
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
return { id: resumedSessionId(entry.path, saved), saved, saving, cwd };
|
|
373
|
+
}
|
|
374
|
+
if (options.sessionId !== undefined) {
|
|
375
|
+
// THE UPSERT. A wrapper that mints its own id must not have to ask whether
|
|
376
|
+
// this is the first turn; that is the whole reason this form exists.
|
|
377
|
+
return { id: options.sessionId, saved: readSessionFile(sessionPathFor(options.sessionId)), saving, cwd };
|
|
378
|
+
}
|
|
379
|
+
return { id: randomUUID(), saved: null, saving, cwd };
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Write the session, or leave the disk untouched.
|
|
383
|
+
*
|
|
384
|
+
* `entries: []` IS THE DECISION, NOT AN OMISSION (D-14 / P1-6). `Entry[]` is
|
|
385
|
+
* produced by the renderer's reducer driven by `App.tsx`; `AgentController`
|
|
386
|
+
* exposes `getMessages()` and no `getEntries()`, and the team / todo / fast /
|
|
387
|
+
* retry entries arrive through separate dispatch paths inside the App.
|
|
388
|
+
* Reconstructing a faithful transcript here means porting a meaningful slice of
|
|
389
|
+
* the renderer into the headless path, for a payoff nobody asked for.
|
|
390
|
+
*
|
|
391
|
+
* The alternative was never "leave it undecided": `[]` PASSES `loadSession`'s
|
|
392
|
+
* validation, so an implementer who skipped the work would ship a session that
|
|
393
|
+
* resumes in the TUI with full model memory and a BLANK TRANSCRIPT, with no
|
|
394
|
+
* error anywhere. Choosing that deliberately - and documenting it in the
|
|
395
|
+
* README's Limits - is better than arriving at it by accident.
|
|
396
|
+
*/
|
|
397
|
+
function persist(plan, controller, turns, usage, cli, at) {
|
|
398
|
+
const previous = plan.saved?.meta;
|
|
399
|
+
const config = controller.getConfig();
|
|
400
|
+
const meta = {
|
|
401
|
+
id: plan.id,
|
|
402
|
+
cwd: controller.getCwd(),
|
|
403
|
+
createdAt: previous?.createdAt ?? at,
|
|
404
|
+
updatedAt: at,
|
|
405
|
+
// CUMULATIVE ACROSS INVOCATIONS. A wrapper budgeting a conversation cares
|
|
406
|
+
// about the conversation, not about the process that happened to run one
|
|
407
|
+
// turn of it.
|
|
408
|
+
turns: (previous?.turns ?? 0) + turns,
|
|
409
|
+
cli,
|
|
410
|
+
provider: config.provider,
|
|
411
|
+
model: config.model,
|
|
412
|
+
usage: {
|
|
413
|
+
inputTokens: (previous?.usage.inputTokens ?? 0) + usage.inputTokens,
|
|
414
|
+
outputTokens: (previous?.usage.outputTokens ?? 0) + usage.outputTokens,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
try {
|
|
418
|
+
writeSession({
|
|
419
|
+
id: plan.id,
|
|
420
|
+
session: {
|
|
421
|
+
model: {
|
|
422
|
+
providerId: config.provider,
|
|
423
|
+
modelId: config.model,
|
|
424
|
+
...(config.baseUrl ? { baseUrl: config.baseUrl } : {}),
|
|
425
|
+
},
|
|
426
|
+
messages: controller.getMessages(),
|
|
427
|
+
entries: [],
|
|
428
|
+
todos: controller.getTodoSnapshot?.()?.items ?? [],
|
|
429
|
+
},
|
|
430
|
+
meta,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
catch (e) {
|
|
434
|
+
// A run that produced an answer must not be reported as a failure because
|
|
435
|
+
// the home directory turned read-only. The caller still has the answer on
|
|
436
|
+
// stdout; the next invocation will simply start fresh.
|
|
437
|
+
process.stderr.write(`[session] could not save "${plan.id}": ${e.message}\n`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
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 type { ExecInputFormat, ExecOutputFormat, ExecPermissionMode } from './events.js';
|
|
18
|
+
/** Raw commander output. Every field is whatever the user typed, or absent. */
|
|
19
|
+
export interface RawExecOptions {
|
|
20
|
+
outputFormat?: string;
|
|
21
|
+
inputFormat?: string;
|
|
22
|
+
partialMessages?: boolean;
|
|
23
|
+
includeThinking?: boolean;
|
|
24
|
+
promptFile?: string;
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
resume?: string;
|
|
27
|
+
continue?: boolean;
|
|
28
|
+
/** `--no-save-session` => `false`; absent => `undefined`. */
|
|
29
|
+
saveSession?: boolean;
|
|
30
|
+
maxTurns?: string;
|
|
31
|
+
maxDuration?: string;
|
|
32
|
+
/** The hidden `--timeout` alias of `--max-duration` (P2-1 / D-16). */
|
|
33
|
+
timeout?: string;
|
|
34
|
+
permissionMode?: string;
|
|
35
|
+
allowTool?: string[];
|
|
36
|
+
denyTool?: string[];
|
|
37
|
+
appendSystemPrompt?: string;
|
|
38
|
+
appendSystemPromptFile?: string;
|
|
39
|
+
/** The ROOT `-p/--print`, hoisted by commander. Refused (P2-5). */
|
|
40
|
+
print?: boolean;
|
|
41
|
+
quiet?: boolean;
|
|
42
|
+
}
|
|
43
|
+
export interface ExecOptions {
|
|
44
|
+
outputFormat: ExecOutputFormat;
|
|
45
|
+
inputFormat: ExecInputFormat;
|
|
46
|
+
partialMessages: boolean;
|
|
47
|
+
includeThinking: boolean;
|
|
48
|
+
promptFile?: string;
|
|
49
|
+
sessionId?: string;
|
|
50
|
+
resume?: string;
|
|
51
|
+
continueSession: boolean;
|
|
52
|
+
saveSession: boolean;
|
|
53
|
+
maxTurns?: number;
|
|
54
|
+
maxDurationMs?: number;
|
|
55
|
+
permissionMode: ExecPermissionMode;
|
|
56
|
+
allowTools: string[];
|
|
57
|
+
denyTools: string[];
|
|
58
|
+
appendSystemPrompt?: string;
|
|
59
|
+
appendSystemPromptFile?: string;
|
|
60
|
+
quiet: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Non-fatal notes for stderr. Empty on every well-formed invocation, so a
|
|
63
|
+
* caller can treat a non-empty list as "you asked for something inert".
|
|
64
|
+
*/
|
|
65
|
+
warnings: string[];
|
|
66
|
+
}
|
|
67
|
+
export type ExecUsageCode = 'invalid_output_format' | 'invalid_input_format' | 'invalid_permission_mode' | 'invalid_max_turns' | 'invalid_max_duration' | 'invalid_session_id' | 'unknown_tool' | 'unsupported_in_text_mode' | 'input_format_requires_stream_json' | 'stream_only_flag' | 'session_flag_conflict' | 'print_not_supported';
|
|
68
|
+
export type ExecOptionsResult = {
|
|
69
|
+
ok: true;
|
|
70
|
+
options: ExecOptions;
|
|
71
|
+
} | {
|
|
72
|
+
ok: false;
|
|
73
|
+
code: ExecUsageCode;
|
|
74
|
+
message: string;
|
|
75
|
+
};
|
|
76
|
+
export declare function resolveExecOptions(raw: RawExecOptions): ExecOptionsResult;
|