@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,559 @@
|
|
|
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 { formatStreamError } from '../agent/reducer.js';
|
|
18
|
+
import { setSignalTerminator } from '../logging/install.js';
|
|
19
|
+
import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
|
|
20
|
+
export function singlePrompt(text) {
|
|
21
|
+
let served = false;
|
|
22
|
+
return {
|
|
23
|
+
next: async () => {
|
|
24
|
+
if (served)
|
|
25
|
+
return null;
|
|
26
|
+
served = true;
|
|
27
|
+
return text;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const DEFAULT_SIGNAL_PORT = {
|
|
32
|
+
setTerminator: setSignalTerminator,
|
|
33
|
+
exit: (code) => process.exit(code),
|
|
34
|
+
};
|
|
35
|
+
/** A second signal inside this window stops waiting for a graceful settle. */
|
|
36
|
+
const SECOND_SIGNAL_WINDOW_MS = 2000;
|
|
37
|
+
export class ExecRunner {
|
|
38
|
+
emitter;
|
|
39
|
+
sessionId;
|
|
40
|
+
opts;
|
|
41
|
+
signals;
|
|
42
|
+
now;
|
|
43
|
+
controller = null;
|
|
44
|
+
unsubscribers = [];
|
|
45
|
+
turns = 0;
|
|
46
|
+
usage = { inputTokens: 0, outputTokens: 0 };
|
|
47
|
+
lastAssistantText = '';
|
|
48
|
+
errored = false;
|
|
49
|
+
sawTurnEnd = false;
|
|
50
|
+
stopReason = 'end_turn';
|
|
51
|
+
errorCode = null;
|
|
52
|
+
errorMessage = null;
|
|
53
|
+
signalNumber = null;
|
|
54
|
+
thinkingBuffer = '';
|
|
55
|
+
budgetTimer = null;
|
|
56
|
+
timerStarted = false;
|
|
57
|
+
detached = false;
|
|
58
|
+
lastSignalAt = 0;
|
|
59
|
+
constructor(options) {
|
|
60
|
+
this.emitter = options.emitter;
|
|
61
|
+
this.sessionId = options.sessionId;
|
|
62
|
+
this.opts = options;
|
|
63
|
+
this.signals = options.signals ?? DEFAULT_SIGNAL_PORT;
|
|
64
|
+
this.now = options.now ?? Date.now;
|
|
65
|
+
}
|
|
66
|
+
// -----------------------------------------------------------------------
|
|
67
|
+
// Attach / detach
|
|
68
|
+
// -----------------------------------------------------------------------
|
|
69
|
+
/**
|
|
70
|
+
* Subscribe to all four streams.
|
|
71
|
+
*
|
|
72
|
+
* TEXT MODE CALLS THIS TOO, WITH A `TextEmitter` THAT WRITES NOTHING (section
|
|
73
|
+
* 3.2). The listener is what counts `turn_end` and accumulates usage for
|
|
74
|
+
* `SessionMeta`; byte-equality with `runHeadless` is a property of what is
|
|
75
|
+
* WRITTEN, not of how many listeners exist, and `runHeadless` already tolerates
|
|
76
|
+
* multiple subscribers.
|
|
77
|
+
*/
|
|
78
|
+
attach(controller) {
|
|
79
|
+
this.controller = controller;
|
|
80
|
+
this.unsubscribers.push(controller.subscribe((event) => this.onAgentEvent(event)));
|
|
81
|
+
const team = controller.subscribeTeam?.((event) => {
|
|
82
|
+
if (event.type === 'usage') {
|
|
83
|
+
this.usage.inputTokens += event.usage.inputTokens;
|
|
84
|
+
this.usage.outputTokens += event.usage.outputTokens;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
this.emitTeam(event);
|
|
88
|
+
});
|
|
89
|
+
if (team)
|
|
90
|
+
this.unsubscribers.push(team);
|
|
91
|
+
const todos = controller.subscribeTodos?.((event) => {
|
|
92
|
+
if (event.type !== 'updated')
|
|
93
|
+
return;
|
|
94
|
+
const { total, doneCount, activeIndex, items } = event.snapshot;
|
|
95
|
+
this.emit({
|
|
96
|
+
type: 'todo',
|
|
97
|
+
sessionId: this.sessionId,
|
|
98
|
+
total,
|
|
99
|
+
done: doneCount,
|
|
100
|
+
activeIndex,
|
|
101
|
+
items: items.map((item) => ({ content: item.content, status: item.status })),
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
if (todos)
|
|
105
|
+
this.unsubscribers.push(todos);
|
|
106
|
+
const fast = controller.subscribeFast?.((event) => {
|
|
107
|
+
if (event.type === 'usage') {
|
|
108
|
+
this.usage.inputTokens += event.usage.inputTokens;
|
|
109
|
+
this.usage.outputTokens += event.usage.outputTokens;
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (event.type !== 'review_end' || !event.review.text)
|
|
113
|
+
return;
|
|
114
|
+
this.emit({
|
|
115
|
+
type: 'fast_review',
|
|
116
|
+
sessionId: this.sessionId,
|
|
117
|
+
turn: event.review.turn,
|
|
118
|
+
model: event.review.model,
|
|
119
|
+
text: event.review.text,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
if (fast)
|
|
123
|
+
this.unsubscribers.push(fast);
|
|
124
|
+
this.installTerminator();
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Drop the subscriptions and the timer.
|
|
128
|
+
*
|
|
129
|
+
* IDEMPOTENT, and it clears the wall-clock timer even though that timer is
|
|
130
|
+
* `unref`d: `unref` only stops it holding the event loop open, it does not stop
|
|
131
|
+
* it firing and calling `abort()` on a controller the caller has since
|
|
132
|
+
* disposed (R-10).
|
|
133
|
+
*/
|
|
134
|
+
detach() {
|
|
135
|
+
if (this.detached)
|
|
136
|
+
return;
|
|
137
|
+
this.detached = true;
|
|
138
|
+
for (const off of this.unsubscribers.splice(0)) {
|
|
139
|
+
try {
|
|
140
|
+
off();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// A subscription that was already torn down is not a reason to leave the
|
|
144
|
+
// rest attached.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (this.budgetTimer) {
|
|
148
|
+
clearTimeout(this.budgetTimer);
|
|
149
|
+
this.budgetTimer = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
stats() {
|
|
153
|
+
return {
|
|
154
|
+
turns: this.turns,
|
|
155
|
+
usage: { ...this.usage },
|
|
156
|
+
lastAssistantText: this.lastAssistantText,
|
|
157
|
+
errored: this.errored,
|
|
158
|
+
stopReason: this.stopReason,
|
|
159
|
+
errorCode: this.errorCode,
|
|
160
|
+
errorMessage: this.errorMessage,
|
|
161
|
+
signalNumber: this.signalNumber,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* `0` success | `1` agent error | `3` budget | `128 + signo` for a signal.
|
|
166
|
+
*
|
|
167
|
+
* `3` IS ITS OWN CODE RATHER THAN `0` OR `1` (D-6). Both alternatives lose
|
|
168
|
+
* information a shell script needs and cannot recover without parsing JSON:
|
|
169
|
+
* `0` claims a complete answer that does not exist, `1` claims a malfunction
|
|
170
|
+
* that did not happen.
|
|
171
|
+
*/
|
|
172
|
+
exitCode() {
|
|
173
|
+
if (this.stopReason === 'interrupted')
|
|
174
|
+
return 128 + (this.signalNumber ?? 2);
|
|
175
|
+
if (this.stopReason === 'max_turns' || this.stopReason === 'timeout')
|
|
176
|
+
return 3;
|
|
177
|
+
return this.errored ? 1 : 0;
|
|
178
|
+
}
|
|
179
|
+
// -----------------------------------------------------------------------
|
|
180
|
+
// The run loop
|
|
181
|
+
// -----------------------------------------------------------------------
|
|
182
|
+
/**
|
|
183
|
+
* Drive the conversation until the source is exhausted or a budget fires.
|
|
184
|
+
*
|
|
185
|
+
* The inner loop is the todo follow-through continuation of `runHeadless`,
|
|
186
|
+
* with the IDENTICAL policy - both call `decideFollowThrough`, which is why
|
|
187
|
+
* the two faces cannot drift about what "unfinished" means.
|
|
188
|
+
*/
|
|
189
|
+
async run(source) {
|
|
190
|
+
const controller = this.controller;
|
|
191
|
+
if (!controller)
|
|
192
|
+
throw new Error('ExecRunner.run() before attach()');
|
|
193
|
+
const followThrough = this.opts.followThrough ?? 'notify';
|
|
194
|
+
for (;;) {
|
|
195
|
+
const next = await source.next();
|
|
196
|
+
// THE SOURCE IS POLLED BEFORE THE EXHAUSTION CHECK, SO A RUN THAT ENDS ON
|
|
197
|
+
// ITS OWN TERMS IS NEVER MISREPORTED (IF-3). `next === null` first is what makes
|
|
198
|
+
// that true: a conversation of exactly `--max-turns` turns whose caller has
|
|
199
|
+
// nothing more to say exits 0 with `end_turn`, not 3 with `max_turns`.
|
|
200
|
+
if (next === null)
|
|
201
|
+
break;
|
|
202
|
+
if (this.turnBudgetExhausted()) {
|
|
203
|
+
this.stopReason = 'max_turns';
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
const finished = await this.runFollowThroughChain(controller, next, followThrough);
|
|
207
|
+
if (finished)
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/** One caller message plus every auto-continuation it produces. */
|
|
212
|
+
async runFollowThroughChain(controller, initial, followThrough) {
|
|
213
|
+
let budget = emptyBudget();
|
|
214
|
+
let wasAutoContinuation = false;
|
|
215
|
+
let message = initial;
|
|
216
|
+
let source = 'caller';
|
|
217
|
+
for (;;) {
|
|
218
|
+
// PER-ITERATION RESET, exactly as `runHeadless` does it: `sawTurnEnd` is
|
|
219
|
+
// the silent-failure detector, and left sticky a swallowed throw on
|
|
220
|
+
// iteration 2 is invisible because iteration 1 set the flag.
|
|
221
|
+
this.sawTurnEnd = false;
|
|
222
|
+
this.emit({
|
|
223
|
+
type: 'user',
|
|
224
|
+
sessionId: this.sessionId,
|
|
225
|
+
turn: this.turns + 1,
|
|
226
|
+
text: message,
|
|
227
|
+
source,
|
|
228
|
+
});
|
|
229
|
+
this.startBudgetTimer();
|
|
230
|
+
await controller.prompt(message);
|
|
231
|
+
if (this.stopReason !== 'end_turn')
|
|
232
|
+
return true;
|
|
233
|
+
const snapshot = controller.getTodoSnapshot?.() ?? null;
|
|
234
|
+
budget = advanceBudget(budget, snapshot, wasAutoContinuation);
|
|
235
|
+
wasAutoContinuation = false;
|
|
236
|
+
const decision = decideFollowThrough({
|
|
237
|
+
mode: followThrough,
|
|
238
|
+
snapshot,
|
|
239
|
+
// `aborted` is false here for the same structural reason `-p` gives: an
|
|
240
|
+
// interrupt sets `stopReason` and returns above, so this branch is only
|
|
241
|
+
// reached by a run that ended on its own terms.
|
|
242
|
+
runEnd: { aborted: false, errored: this.errored },
|
|
243
|
+
budget,
|
|
244
|
+
interactive: false,
|
|
245
|
+
});
|
|
246
|
+
if (decision.kind === 'none')
|
|
247
|
+
return false;
|
|
248
|
+
if (decision.kind === 'notify') {
|
|
249
|
+
this.note(`[todo] ${decision.text}`);
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
// A CLI-generated continuation is still a turn the caller is paying for,
|
|
253
|
+
// so the ceiling binds here exactly as it does on a caller message.
|
|
254
|
+
if (this.turnBudgetExhausted()) {
|
|
255
|
+
this.stopReason = 'max_turns';
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
// CHARGED BEFORE THE RE-PROMPT. Headless has no grace timer, so without
|
|
259
|
+
// this line `maxAutoContinuesPerList` would never bind in the one
|
|
260
|
+
// environment with no human to press Esc.
|
|
261
|
+
budget = { ...budget, used: budget.used + 1 };
|
|
262
|
+
wasAutoContinuation = true;
|
|
263
|
+
this.note(`[todo] ${decision.notice}`);
|
|
264
|
+
message = decision.message;
|
|
265
|
+
source = 'todo_continue';
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// -----------------------------------------------------------------------
|
|
269
|
+
// Budgets (section 3.5)
|
|
270
|
+
// -----------------------------------------------------------------------
|
|
271
|
+
/**
|
|
272
|
+
* The wall clock starts at the FIRST `prompt()`, not at process start: the
|
|
273
|
+
* caller is bounding the run, and config resolution plus a skills scan are not
|
|
274
|
+
* part of what they asked to bound.
|
|
275
|
+
*/
|
|
276
|
+
startBudgetTimer() {
|
|
277
|
+
if (this.timerStarted || this.opts.maxDurationMs === undefined)
|
|
278
|
+
return;
|
|
279
|
+
this.timerStarted = true;
|
|
280
|
+
this.budgetTimer = setTimeout(() => {
|
|
281
|
+
this.stopReason = 'timeout';
|
|
282
|
+
this.controller?.abort();
|
|
283
|
+
}, this.opts.maxDurationMs);
|
|
284
|
+
// `unref` so it can never hold the event loop open, and it is cleared in
|
|
285
|
+
// `detach()` so it can never fire against a disposed controller (R-10).
|
|
286
|
+
this.budgetTimer.unref?.();
|
|
287
|
+
}
|
|
288
|
+
turnBudgetExhausted() {
|
|
289
|
+
return this.opts.maxTurns !== undefined && this.turns >= this.opts.maxTurns;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Enforced at `turn_start`, NOT at `turn_end` (implementation finding IF-3).
|
|
293
|
+
*
|
|
294
|
+
* The design said "count `turn_end`; on reaching `n`, abort", and the counting
|
|
295
|
+
* half is right - `turns` is exactly that count. Acting on it at `turn_end` is
|
|
296
|
+
* not: a run that COMPLETES in exactly `n` turns would trip the budget it
|
|
297
|
+
* never exceeded and report `stopReason: "max_turns"` with exit 3, so a CI
|
|
298
|
+
* script running `aragon exec --max-turns 20 ... || fail` would fail on a
|
|
299
|
+
* successful twenty-turn run. That is the single most likely way to make a
|
|
300
|
+
* safety ceiling unusable as a safety ceiling.
|
|
301
|
+
*
|
|
302
|
+
* A turn that is STARTING when the count already sits at `n` is, by contrast,
|
|
303
|
+
* unambiguous: the model asked for turn `n + 1` and the caller said no. AC-16
|
|
304
|
+
* is satisfied either way - "a model that would take five turns" with
|
|
305
|
+
* `--max-turns 2` still exits 3 with the partial answer in `result.result`.
|
|
306
|
+
*/
|
|
307
|
+
checkTurnBudget() {
|
|
308
|
+
if (!this.turnBudgetExhausted())
|
|
309
|
+
return;
|
|
310
|
+
if (this.stopReason !== 'end_turn')
|
|
311
|
+
return;
|
|
312
|
+
this.stopReason = 'max_turns';
|
|
313
|
+
// THE EXISTING ABORT PATH, the same one `Esc` uses, so subagents are torn
|
|
314
|
+
// down and the engine settles normally rather than being abandoned mid-turn.
|
|
315
|
+
this.controller?.abort();
|
|
316
|
+
}
|
|
317
|
+
// -----------------------------------------------------------------------
|
|
318
|
+
// Interrupts (section 3.6 / P0-1 / D-15)
|
|
319
|
+
// -----------------------------------------------------------------------
|
|
320
|
+
/**
|
|
321
|
+
* EXEC REPLACES THE TERMINATOR; IT DOES NOT ADD A LISTENER.
|
|
322
|
+
*
|
|
323
|
+
* `logging/install.ts` registers the `SIGINT` / `SIGTERM` / `SIGHUP` listeners
|
|
324
|
+
* once, for EVERY invocation, and its default terminator exits. A listener
|
|
325
|
+
* added here would be later in EventEmitter order and would never run - the
|
|
326
|
+
* process is already gone - so the run would vanish with no `result` line,
|
|
327
|
+
* which is precisely the "the wrapper has to time out to learn the run ended"
|
|
328
|
+
* outcome this whole section exists to prevent (P0-1 / R-15).
|
|
329
|
+
*
|
|
330
|
+
* FIRST SIGNAL: abort, record the reason, and RETURN WITHOUT EXITING. The run
|
|
331
|
+
* settles through the normal path - session persisted, `result` emitted,
|
|
332
|
+
* `process.exitCode = 128 + signo`.
|
|
333
|
+
*
|
|
334
|
+
* SECOND SIGNAL WITHIN 2s: exit immediately. That escape is what makes
|
|
335
|
+
* returning safe: a wedged settle is always one more Ctrl-C away from ending.
|
|
336
|
+
*
|
|
337
|
+
* AFTER `detach()`: exit immediately as well, because from that moment there
|
|
338
|
+
* is nothing left to settle and the caller's Ctrl-C must not become "nothing
|
|
339
|
+
* happens".
|
|
340
|
+
*
|
|
341
|
+
* `detach()` DOES NOT RESTORE THE PREVIOUS TERMINATOR, AND THAT IS THE POINT
|
|
342
|
+
* OF THE BRANCH ABOVE (implementation finding IF-7). The design asked for a
|
|
343
|
+
* restore in `finally`; `setSignalTerminator` returns `void` and there is no
|
|
344
|
+
* getter, so a literal restore would mean changing `logging/install.ts`, which
|
|
345
|
+
* the change plan holds to a doc-comment-only edit. The `detached` branch is
|
|
346
|
+
* what makes that safe rather than merely convenient: once detached this
|
|
347
|
+
* terminator does `exit(128 + signo)`, which is byte for byte what the default
|
|
348
|
+
* terminator does, so a left-behind one is INDISTINGUISHABLE from a restored
|
|
349
|
+
* one. Anything that ever gives this process a terminator worth returning to -
|
|
350
|
+
* the full-screen one that restores the terminal, or a second `runExec` in one
|
|
351
|
+
* process under the programmatic API section 2.2 hedges for - must revisit
|
|
352
|
+
* this and add the getter.
|
|
353
|
+
*/
|
|
354
|
+
installTerminator() {
|
|
355
|
+
this.signals.setTerminator((signo) => {
|
|
356
|
+
const at = this.now();
|
|
357
|
+
const secondSignal = at - this.lastSignalAt <= SECOND_SIGNAL_WINDOW_MS;
|
|
358
|
+
if (this.detached || (this.lastSignalAt > 0 && secondSignal)) {
|
|
359
|
+
this.signals.exit(128 + signo);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
this.lastSignalAt = at;
|
|
363
|
+
this.signalNumber = signo;
|
|
364
|
+
this.stopReason = 'interrupted';
|
|
365
|
+
this.controller?.abort();
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
// -----------------------------------------------------------------------
|
|
369
|
+
// Event translation
|
|
370
|
+
// -----------------------------------------------------------------------
|
|
371
|
+
emit(event) {
|
|
372
|
+
this.emitter.emit(event);
|
|
373
|
+
}
|
|
374
|
+
/** Caller-facing prose. STDERR ALWAYS - stdout carries nothing but the schema. */
|
|
375
|
+
note(text) {
|
|
376
|
+
if (this.opts.quiet)
|
|
377
|
+
return;
|
|
378
|
+
(this.opts.stderr ?? process.stderr).write(`${text}\n`);
|
|
379
|
+
}
|
|
380
|
+
onAgentEvent(event) {
|
|
381
|
+
switch (event.type) {
|
|
382
|
+
case 'message_update':
|
|
383
|
+
this.onStreamEvent(event.streamEvent);
|
|
384
|
+
break;
|
|
385
|
+
case 'turn_start':
|
|
386
|
+
// The ONE budget check that catches the ordinary case: a single
|
|
387
|
+
// `prompt()` drives many turns, so a ceiling enforced only between
|
|
388
|
+
// caller messages would never bind at all.
|
|
389
|
+
this.checkTurnBudget();
|
|
390
|
+
break;
|
|
391
|
+
case 'tool_execution_start':
|
|
392
|
+
this.emit({
|
|
393
|
+
type: 'tool_call',
|
|
394
|
+
sessionId: this.sessionId,
|
|
395
|
+
turn: this.turns + 1,
|
|
396
|
+
id: event.toolCallId,
|
|
397
|
+
name: event.toolName,
|
|
398
|
+
input: (event.args ?? {}),
|
|
399
|
+
});
|
|
400
|
+
break;
|
|
401
|
+
case 'tool_execution_end':
|
|
402
|
+
this.emit({
|
|
403
|
+
type: 'tool_result',
|
|
404
|
+
sessionId: this.sessionId,
|
|
405
|
+
turn: this.turns + 1,
|
|
406
|
+
id: event.toolCallId,
|
|
407
|
+
name: event.toolName,
|
|
408
|
+
isError: event.isError,
|
|
409
|
+
durationMs: event.duration,
|
|
410
|
+
output: toolResultText(event.result),
|
|
411
|
+
});
|
|
412
|
+
break;
|
|
413
|
+
case 'turn_end':
|
|
414
|
+
this.onTurnEnd(event.message, event.usage);
|
|
415
|
+
break;
|
|
416
|
+
case 'agent_end':
|
|
417
|
+
// A swallowed throw (missing key / empty stream) produces no `turn_end`.
|
|
418
|
+
if (!this.sawTurnEnd && !this.errored && this.stopReason === 'end_turn') {
|
|
419
|
+
this.errored = true;
|
|
420
|
+
this.recordError('agent_error', 'The run produced no assistant turn.');
|
|
421
|
+
}
|
|
422
|
+
break;
|
|
423
|
+
default:
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
onStreamEvent(se) {
|
|
428
|
+
if (se.type === 'text_delta') {
|
|
429
|
+
this.flushThinking();
|
|
430
|
+
if (this.opts.partialMessages) {
|
|
431
|
+
this.emit({
|
|
432
|
+
type: 'text_delta',
|
|
433
|
+
sessionId: this.sessionId,
|
|
434
|
+
turn: this.turns + 1,
|
|
435
|
+
delta: se.delta,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (se.type === 'thinking_delta') {
|
|
441
|
+
if (this.opts.includeThinking)
|
|
442
|
+
this.thinkingBuffer += se.delta;
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (se.type === 'error') {
|
|
446
|
+
this.errored = true;
|
|
447
|
+
this.recordError('stream_error', formatStreamError(se.error));
|
|
448
|
+
this.emit({
|
|
449
|
+
type: 'error',
|
|
450
|
+
sessionId: this.sessionId,
|
|
451
|
+
fatal: true,
|
|
452
|
+
code: 'stream_error',
|
|
453
|
+
message: formatStreamError(se.error),
|
|
454
|
+
});
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (se.type === 'retry_scheduled') {
|
|
458
|
+
this.emit({
|
|
459
|
+
type: 'retry',
|
|
460
|
+
sessionId: this.sessionId,
|
|
461
|
+
attempt: se.attempt,
|
|
462
|
+
maxRetries: se.maxRetries,
|
|
463
|
+
errorType: String(se.errorType),
|
|
464
|
+
delayMs: se.delayMs,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
onTurnEnd(message, usage) {
|
|
469
|
+
this.sawTurnEnd = true;
|
|
470
|
+
this.turns += 1;
|
|
471
|
+
this.usage.inputTokens += usage.inputTokens;
|
|
472
|
+
this.usage.outputTokens += usage.outputTokens;
|
|
473
|
+
this.flushThinking();
|
|
474
|
+
const text = assistantText(message);
|
|
475
|
+
if (text.length > 0) {
|
|
476
|
+
this.lastAssistantText = text;
|
|
477
|
+
this.emit({ type: 'assistant', sessionId: this.sessionId, turn: this.turns, text });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
flushThinking() {
|
|
481
|
+
if (this.thinkingBuffer.length === 0)
|
|
482
|
+
return;
|
|
483
|
+
const text = this.thinkingBuffer;
|
|
484
|
+
this.thinkingBuffer = '';
|
|
485
|
+
this.emit({ type: 'thinking', sessionId: this.sessionId, turn: this.turns + 1, text });
|
|
486
|
+
}
|
|
487
|
+
recordError(code, message) {
|
|
488
|
+
if (this.errorCode)
|
|
489
|
+
return;
|
|
490
|
+
this.errorCode = code;
|
|
491
|
+
this.errorMessage = message;
|
|
492
|
+
if (this.stopReason === 'end_turn')
|
|
493
|
+
this.stopReason = 'error';
|
|
494
|
+
}
|
|
495
|
+
emitTeam(event) {
|
|
496
|
+
if (event.type === 'dispatch_start') {
|
|
497
|
+
this.emit({
|
|
498
|
+
type: 'team',
|
|
499
|
+
sessionId: this.sessionId,
|
|
500
|
+
subtype: 'dispatch_start',
|
|
501
|
+
total: event.specs.length,
|
|
502
|
+
});
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (event.type === 'agent_update') {
|
|
506
|
+
const { run } = event;
|
|
507
|
+
this.emit({
|
|
508
|
+
type: 'team',
|
|
509
|
+
sessionId: this.sessionId,
|
|
510
|
+
subtype: 'agent_update',
|
|
511
|
+
label: run.label,
|
|
512
|
+
phase: run.phase,
|
|
513
|
+
description: run.description,
|
|
514
|
+
durationMs: Math.max(0, (run.endedAt ?? 0) - (run.startedAt ?? 0)),
|
|
515
|
+
toolCalls: run.toolCalls,
|
|
516
|
+
});
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (event.type === 'dispatch_end') {
|
|
520
|
+
const runs = event.outcome.runs;
|
|
521
|
+
this.emit({
|
|
522
|
+
type: 'team',
|
|
523
|
+
sessionId: this.sessionId,
|
|
524
|
+
subtype: 'dispatch_end',
|
|
525
|
+
ok: runs.filter((r) => r.phase === 'done' && !r.error).length,
|
|
526
|
+
total: runs.length,
|
|
527
|
+
aborted: event.outcome.aborted,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/** The final assistant text of a turn, or `''` when the turn was tool calls only. */
|
|
533
|
+
function assistantText(message) {
|
|
534
|
+
if (!message || !Array.isArray(message.content))
|
|
535
|
+
return '';
|
|
536
|
+
const parts = [];
|
|
537
|
+
for (const block of message.content) {
|
|
538
|
+
if (block.type === 'text')
|
|
539
|
+
parts.push(block.text);
|
|
540
|
+
}
|
|
541
|
+
return parts.join('').trim();
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Flatten a `ToolResult` to the text the MODEL saw.
|
|
545
|
+
*
|
|
546
|
+
* NO SECOND TRUNCATION (AC-26). The core executor already clips at roughly
|
|
547
|
+
* 100 KB; clipping again here would make the event a summary of a summary, and
|
|
548
|
+
* the one faithful choice is to carry what was actually in the conversation.
|
|
549
|
+
*/
|
|
550
|
+
function toolResultText(result) {
|
|
551
|
+
if (!result || !Array.isArray(result.content))
|
|
552
|
+
return '';
|
|
553
|
+
const parts = [];
|
|
554
|
+
for (const block of result.content) {
|
|
555
|
+
if (block.type === 'text')
|
|
556
|
+
parts.push(block.text);
|
|
557
|
+
}
|
|
558
|
+
return parts.join('');
|
|
559
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--input-format stream-json`: an NDJSON reader on stdin
|
|
3
|
+
* (cli-integration-surface section 4.1).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* TOLERANT BY CONTRACT (AC-20). A malformed line yields ONE non-fatal `error`
|
|
8
|
+
* event and is skipped; an unknown `type` is ignored entirely. The forward
|
|
9
|
+
* compatibility the schema promises consumers has to run in both directions, or
|
|
10
|
+
* a caller written against a newer version could not talk to an older CLI at
|
|
11
|
+
* all - and a run that dies on one bad line is the worst possible failure for a
|
|
12
|
+
* long-lived worker feeding turns down a pipe.
|
|
13
|
+
*
|
|
14
|
+
* Three message types are understood:
|
|
15
|
+
*
|
|
16
|
+
* { "type": "user", "text": "..." } the next turn
|
|
17
|
+
* { "type": "interrupt" } abort the turn in flight
|
|
18
|
+
* { "type": "end" } no more turns; settle and exit
|
|
19
|
+
*
|
|
20
|
+
* Reaching end-of-stream means the same as `end`, so a caller that simply closes
|
|
21
|
+
* the pipe is not punished for it.
|
|
22
|
+
*/
|
|
23
|
+
import type { ExecPromptSource } from './runner.js';
|
|
24
|
+
export interface StdinStreamOptions {
|
|
25
|
+
input: NodeJS.ReadableStream;
|
|
26
|
+
/** A non-fatal parse failure. The caller turns this into an `error` event. */
|
|
27
|
+
onBadLine: (message: string) => void;
|
|
28
|
+
/** `{"type":"interrupt"}`. The caller aborts the run in flight. */
|
|
29
|
+
onInterrupt: () => void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* A prompt source backed by NDJSON on stdin.
|
|
33
|
+
*
|
|
34
|
+
* IT BUFFERS RATHER THAN PAUSING THE STREAM. A caller may legitimately push
|
|
35
|
+
* three turns in one write before the first has finished, and a reader that only
|
|
36
|
+
* accepted a line when someone was waiting for one would deadlock the second and
|
|
37
|
+
* third behind a turn that is minutes long.
|
|
38
|
+
*/
|
|
39
|
+
export declare function createStdinPromptSource(opts: StdinStreamOptions): ExecPromptSource;
|
|
40
|
+
/**
|
|
41
|
+
* Serve a fixed list of prompts, then delegate.
|
|
42
|
+
*
|
|
43
|
+
* Used for `aragon exec --input-format stream-json "first turn"`, where the
|
|
44
|
+
* positional argument is the opening message and stdin supplies the rest.
|
|
45
|
+
*/
|
|
46
|
+
export declare function prefixSource(prefix: string[], rest: ExecPromptSource): ExecPromptSource;
|