@aragon-agent/cli 0.6.37 → 0.6.39
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 +2523 -2326
- package/README.md +3614 -3173
- package/dist/agent/controller.d.ts +142 -1
- package/dist/agent/controller.js +171 -3
- package/dist/agent/reducer.d.ts +41 -1
- package/dist/agent/reducer.js +25 -0
- package/dist/agent/system-prompt.d.ts +14 -0
- package/dist/agent/system-prompt.js +5 -0
- package/dist/cli.js +85 -27
- package/dist/commands/builtins.js +13 -1
- package/dist/commands/keys-command.d.ts +23 -0
- package/dist/commands/keys-command.js +38 -0
- package/dist/commands/perf.d.ts +7 -1
- package/dist/commands/perf.js +2 -2
- package/dist/commands/registry.d.ts +39 -0
- package/dist/commands/task-command.d.ts +39 -0
- package/dist/commands/task-command.js +102 -0
- package/dist/config/cli-commands.d.ts +23 -0
- package/dist/config/cli-commands.js +36 -0
- package/dist/config/env.js +17 -4
- package/dist/config/load.js +6 -1
- package/dist/config/schema.d.ts +43 -4
- package/dist/config/schema.js +31 -5
- package/dist/config/store.js +17 -2
- package/dist/diagnostics/doctor.d.ts +9 -0
- package/dist/diagnostics/doctor.js +34 -0
- package/dist/diagnostics/info.d.ts +71 -0
- package/dist/diagnostics/info.js +91 -5
- package/dist/exec/bridge/caller-tools.d.ts +96 -0
- package/dist/exec/bridge/caller-tools.js +213 -0
- package/dist/exec/bridge/decisions.d.ts +78 -0
- package/dist/exec/bridge/decisions.js +116 -0
- package/dist/exec/bridge/human-answers.d.ts +38 -0
- package/dist/exec/bridge/human-answers.js +164 -0
- package/dist/exec/bridge/limits.d.ts +68 -0
- package/dist/exec/bridge/limits.js +68 -0
- package/dist/exec/bridge/ndjson.d.ts +17 -0
- package/dist/exec/bridge/ndjson.js +83 -0
- package/dist/exec/bridge/pending.d.ts +70 -0
- package/dist/exec/bridge/pending.js +132 -0
- package/dist/exec/bridge/protocol.d.ts +346 -0
- package/dist/exec/bridge/protocol.js +256 -0
- package/dist/exec/bridge/server.d.ts +142 -0
- package/dist/exec/bridge/server.js +502 -0
- package/dist/exec/bridge/source.d.ts +58 -0
- package/dist/exec/bridge/source.js +145 -0
- package/dist/exec/bridge/writer.d.ts +26 -0
- package/dist/exec/bridge/writer.js +156 -0
- package/dist/exec/cli-commands.js +30 -2
- package/dist/exec/emitter.d.ts +16 -2
- package/dist/exec/emitter.js +26 -6
- package/dist/exec/events.d.ts +465 -10
- package/dist/exec/events.js +47 -1
- package/dist/exec/hooks/dispatch.d.ts +76 -0
- package/dist/exec/hooks/dispatch.js +169 -0
- package/dist/exec/hooks/index.d.ts +83 -0
- package/dist/exec/hooks/index.js +205 -0
- package/dist/exec/hooks/limits.d.ts +56 -0
- package/dist/exec/hooks/limits.js +49 -0
- package/dist/exec/hooks/registry.d.ts +49 -0
- package/dist/exec/hooks/registry.js +91 -0
- package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
- package/dist/exec/hooks/tool-wrapper.js +154 -0
- package/dist/exec/hooks/turn.d.ts +61 -0
- package/dist/exec/hooks/turn.js +59 -0
- package/dist/exec/hooks/validate.d.ts +85 -0
- package/dist/exec/hooks/validate.js +310 -0
- package/dist/exec/index.d.ts +130 -4
- package/dist/exec/index.js +501 -10
- package/dist/exec/options.d.ts +124 -2
- package/dist/exec/options.js +413 -6
- package/dist/exec/permission.d.ts +9 -1
- package/dist/exec/permission.js +7 -1
- package/dist/exec/request/limits.d.ts +51 -0
- package/dist/exec/request/limits.js +49 -0
- package/dist/exec/request/scope.d.ts +106 -0
- package/dist/exec/request/scope.js +149 -0
- package/dist/exec/result/limits.d.ts +74 -0
- package/dist/exec/result/limits.js +72 -0
- package/dist/exec/result/prompt.d.ts +28 -0
- package/dist/exec/result/prompt.js +74 -0
- package/dist/exec/result/schema.d.ts +99 -0
- package/dist/exec/result/schema.js +464 -0
- package/dist/exec/result/sink.d.ts +56 -0
- package/dist/exec/result/sink.js +50 -0
- package/dist/exec/result/tool.d.ts +37 -0
- package/dist/exec/result/tool.js +118 -0
- package/dist/exec/runner.d.ts +395 -1
- package/dist/exec/runner.js +860 -8
- package/dist/exec/threads/aggregate.d.ts +34 -0
- package/dist/exec/threads/aggregate.js +30 -0
- package/dist/exec/threads/capacity.d.ts +23 -0
- package/dist/exec/threads/capacity.js +38 -0
- package/dist/exec/threads/dispatch-source.d.ts +60 -0
- package/dist/exec/threads/dispatch-source.js +106 -0
- package/dist/exec/threads/execute-threads.d.ts +53 -0
- package/dist/exec/threads/execute-threads.js +234 -0
- package/dist/exec/threads/gate.d.ts +42 -0
- package/dist/exec/threads/gate.js +81 -0
- package/dist/exec/threads/identity.d.ts +36 -0
- package/dist/exec/threads/identity.js +67 -0
- package/dist/exec/threads/interrupt.d.ts +29 -0
- package/dist/exec/threads/interrupt.js +79 -0
- package/dist/exec/threads/limits.d.ts +58 -0
- package/dist/exec/threads/limits.js +35 -0
- package/dist/exec/threads/outcome.d.ts +47 -0
- package/dist/exec/threads/outcome.js +26 -0
- package/dist/exec/threads/registry.d.ts +81 -0
- package/dist/exec/threads/registry.js +427 -0
- package/dist/exec/threads/runtime.d.ts +107 -0
- package/dist/exec/threads/runtime.js +523 -0
- package/dist/exec/threads/thread-source.d.ts +20 -0
- package/dist/exec/threads/thread-source.js +134 -0
- package/dist/fast/limits.d.ts +54 -0
- package/dist/fast/limits.js +54 -0
- package/dist/fast/summary-call.d.ts +72 -0
- package/dist/fast/summary-call.js +133 -0
- package/dist/fast/summary-prompt.d.ts +106 -0
- package/dist/fast/summary-prompt.js +320 -0
- package/dist/fast/task-summary-types.d.ts +66 -0
- package/dist/fast/task-summary-types.js +17 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/proc/limits.d.ts +7 -0
- package/dist/proc/limits.js +7 -0
- package/dist/proc/output-streams.d.ts +50 -0
- package/dist/proc/output-streams.js +54 -0
- package/dist/team/runtime.d.ts +9 -0
- package/dist/team/runtime.js +8 -0
- package/dist/team/subagent.d.ts +15 -0
- package/dist/team/subagent.js +45 -1
- package/dist/tools/bash-tool.d.ts +10 -0
- package/dist/tools/bash-tool.js +71 -6
- package/dist/tools/index.d.ts +93 -1
- package/dist/tools/index.js +112 -3
- package/dist/tools/tool-gate.d.ts +100 -0
- package/dist/tools/tool-gate.js +87 -0
- package/dist/ui/App.js +224 -11
- package/dist/ui/Header.d.ts +28 -16
- package/dist/ui/Header.js +21 -19
- package/dist/ui/interaction-hints.js +6 -0
- package/dist/ui/layout/OverlayFrame.d.ts +18 -0
- package/dist/ui/layout/OverlayFrame.js +19 -4
- package/dist/ui/layout/header-hints.d.ts +55 -62
- package/dist/ui/layout/header-hints.js +59 -166
- package/dist/ui/layout/header-left.d.ts +115 -0
- package/dist/ui/layout/header-left.js +206 -0
- package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
- package/dist/ui/layout/shortcut-panel-layout.js +321 -0
- package/dist/ui/layout/status-detail-layout.js +5 -1
- package/dist/ui/layout/task-summary-cell.d.ts +61 -0
- package/dist/ui/layout/task-summary-cell.js +65 -0
- package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
- package/dist/ui/overlays/HelpOverlay.js +29 -2
- package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
- package/dist/ui/overlays/ShortcutPanel.js +106 -0
- package/dist/ui/scrollback/canvas.d.ts +3 -2
- package/dist/ui/scrollback/canvas.js +3 -2
- package/dist/ui/scrollback/scrollback-writer.d.ts +13 -1
- package/dist/ui/scrollback/scrollback-writer.js +109 -10
- package/dist/ui/shortcuts.d.ts +81 -0
- package/dist/ui/shortcuts.js +221 -0
- package/package.json +2 -2
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--input-format bridge`: one stdin stream, demultiplexed into turns and control.
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* TWO KINDS OF FRAME ARRIVE ON THE SAME PIPE and they must not be confused:
|
|
7
|
+
*
|
|
8
|
+
* TURN frames `prompt` (and the v1 `user`) queue a message for the runner.
|
|
9
|
+
* CONTROL frames `decision`, `tool_result`, `tool_register`, `ping`, `steer`,
|
|
10
|
+
* `interrupt` are answers to something already in flight and are
|
|
11
|
+
* routed straight to the server.
|
|
12
|
+
*
|
|
13
|
+
* The distinction is not cosmetic. A control frame that waited behind a turn would
|
|
14
|
+
* deadlock the run it is trying to unblock - a `decision` answer arriving while the
|
|
15
|
+
* tool that asked is still executing is the NORMAL case, not an edge case.
|
|
16
|
+
*
|
|
17
|
+
* UNLIKE `stdin-stream.ts` THIS SOURCE REMEMBERS METADATA PER PROMPT. A bridge
|
|
18
|
+
* prompt carries the caller's own id, and the turn that id starts has to be
|
|
19
|
+
* reportable in `user` / `turn_state` / `assistant` / `tool_call` / `tool_result`
|
|
20
|
+
* events. `lastPrompt()` is that channel; it is read once, synchronously, right
|
|
21
|
+
* after `next()` resolves, and it is deliberately NOT part of `next()`'s return
|
|
22
|
+
* value so `stdin-stream.ts` can keep serving plain strings (and its existing test
|
|
23
|
+
* can keep asserting exactly that).
|
|
24
|
+
*/
|
|
25
|
+
import type { ExecPrompt, ExecPromptSource } from '../runner.js';
|
|
26
|
+
import type { BridgeServer } from './server.js';
|
|
27
|
+
export interface BridgeSourceOptions {
|
|
28
|
+
input: NodeJS.ReadableStream;
|
|
29
|
+
server: BridgeServer;
|
|
30
|
+
/** A non-fatal frame problem, already carrying its `error.code`. */
|
|
31
|
+
onBadLine: (code: string, message: string) => void;
|
|
32
|
+
}
|
|
33
|
+
export interface BridgePromptSource extends ExecPromptSource {
|
|
34
|
+
/**
|
|
35
|
+
* Remove a QUEUED turn by the caller's id.
|
|
36
|
+
*
|
|
37
|
+
* Used by `interrupt{requestId}`: a caller that deletes its own queued message
|
|
38
|
+
* before it runs is the one case of cancellation that needs no abort at all.
|
|
39
|
+
*/
|
|
40
|
+
cancelQueued(requestId: string): 'cancelled' | 'in_flight' | 'unknown';
|
|
41
|
+
isFinished(): boolean;
|
|
42
|
+
/** How many turns are waiting. Reported for tests and for `/status`-style views. */
|
|
43
|
+
queueDepth(): number;
|
|
44
|
+
/**
|
|
45
|
+
* Stop reading stdin and let the process exit.
|
|
46
|
+
*
|
|
47
|
+
* A CALLER THAT SENDS `end` HAS ALREADY SAID IT HAS NOTHING MORE TO SAY, and it
|
|
48
|
+
* is entitled to keep its own pipe open while it reads `result` - the reference
|
|
49
|
+
* host does exactly that. A `data` listener left attached would hold this
|
|
50
|
+
* process's event loop open forever, so the run would print `result`, exit
|
|
51
|
+
* nothing, and look hung while reporting success. Removing the listeners and
|
|
52
|
+
* pausing the stream is what turns "the run is over" into "the process ends".
|
|
53
|
+
*/
|
|
54
|
+
close(): void;
|
|
55
|
+
}
|
|
56
|
+
export declare function createBridgePromptSource(options: BridgeSourceOptions): BridgePromptSource;
|
|
57
|
+
/** The bridge source's own view of the id space, for tests and for `ack` wiring. */
|
|
58
|
+
export declare function bridgePromptId(prompt: ExecPrompt | undefined): string | undefined;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createNdjsonSplitter } from './ndjson.js';
|
|
2
|
+
import { parseBridgeInput } from './protocol.js';
|
|
3
|
+
export function createBridgePromptSource(options) {
|
|
4
|
+
const queue = [];
|
|
5
|
+
const waiting = [];
|
|
6
|
+
let finished = false;
|
|
7
|
+
/** A prompt has been handed over and the runner has not asked for the next. */
|
|
8
|
+
let inFlight = false;
|
|
9
|
+
let last;
|
|
10
|
+
let deliveredId;
|
|
11
|
+
/** Ids already handed to the runner, so `interrupt` can tell queued from running. */
|
|
12
|
+
const taken = [];
|
|
13
|
+
const finish = () => {
|
|
14
|
+
if (finished)
|
|
15
|
+
return;
|
|
16
|
+
finished = true;
|
|
17
|
+
// NO MORE ANSWERS CAN ARRIVE, SO NOTHING MAY KEEP WAITING FOR ONE (section
|
|
18
|
+
// 2.3.8 rows 1-2). Without this, `end` or EOF with an unanswered decision
|
|
19
|
+
// leaves the run parked for the whole `--decision-timeout` (300 s by default)
|
|
20
|
+
// and then reports `timeout` instead of the truth, which is that the caller
|
|
21
|
+
// went away. It runs BEFORE the parked `next()` is released: the reverse order
|
|
22
|
+
// would let the run finish and report `result` while its tool still waited.
|
|
23
|
+
options.server.abortInFlight();
|
|
24
|
+
// Everything still parked resolves with `null` so a `next()` in flight when
|
|
25
|
+
// the pipe closes ends the run instead of hanging it forever.
|
|
26
|
+
while (waiting.length > 0)
|
|
27
|
+
waiting.shift()?.(null);
|
|
28
|
+
};
|
|
29
|
+
const acceptPrompt = (frame) => {
|
|
30
|
+
// `queued` IS CAPTURED HERE, AT ARRIVAL, because that is the only moment the
|
|
31
|
+
// question means anything: "was something already ahead of me?". By the time
|
|
32
|
+
// the runner picks this up the answer is always "no", which would make the
|
|
33
|
+
// field useless to a caller deciding whether to send a fourth message.
|
|
34
|
+
const queued = inFlight || queue.length > 0;
|
|
35
|
+
const requestId = frame.id ?? `prompt_${taken.length + queue.length + 1}`;
|
|
36
|
+
const resolve = waiting.shift();
|
|
37
|
+
if (resolve) {
|
|
38
|
+
inFlight = true;
|
|
39
|
+
taken.push(requestId);
|
|
40
|
+
deliveredId = requestId;
|
|
41
|
+
last = { text: frame.text, requestId, queued };
|
|
42
|
+
resolve(frame.text);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// NOTHING IS RESET HERE. `last` describes the prompt just HANDED OVER, and the
|
|
46
|
+
// runner reads it one microtask later; clearing it for a queued arrival would
|
|
47
|
+
// wipe the metadata of a delivery that happened in the same chunk - the turn
|
|
48
|
+
// would lose its `requestId` and its `ack` entirely. `next()` clears it when
|
|
49
|
+
// there is nothing left to describe.
|
|
50
|
+
queue.push({ text: frame.text, requestId, queued });
|
|
51
|
+
};
|
|
52
|
+
const handleLine = (line) => {
|
|
53
|
+
const parsed = parseBridgeInput(line);
|
|
54
|
+
if (!parsed.ok) {
|
|
55
|
+
options.onBadLine(parsed.code, parsed.message);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// `end` MEANS "NO MORE TURNS", NOT "NO MORE FRAMES".
|
|
59
|
+
//
|
|
60
|
+
// The v1 rule - after `end`, nothing else is a turn - is preserved exactly. But
|
|
61
|
+
// a bridge caller legitimately says "that is my last prompt" while a turn is
|
|
62
|
+
// still running, and then answers the decisions that turn produces; dropping
|
|
63
|
+
// those frames would deadlock the run until the decision deadline fired. So
|
|
64
|
+
// turn frames stop at `end` and control frames do not. After `result`, the
|
|
65
|
+
// server is closed and everything is ignored, which is the rule the design
|
|
66
|
+
// actually states.
|
|
67
|
+
if (parsed.frame.type === 'prompt') {
|
|
68
|
+
if (finished)
|
|
69
|
+
return;
|
|
70
|
+
acceptPrompt(parsed.frame);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (parsed.frame.type === 'end') {
|
|
74
|
+
finish();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (parsed.frame.type === 'ignored')
|
|
78
|
+
return;
|
|
79
|
+
options.server.handleFrame(parsed.frame);
|
|
80
|
+
};
|
|
81
|
+
const splitter = createNdjsonSplitter({
|
|
82
|
+
onLine: handleLine,
|
|
83
|
+
onTooLarge: (bytes) => {
|
|
84
|
+
options.onBadLine('bridge_frame_too_large', `Skipped a bridge frame of ${bytes} bytes.`);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const onData = (chunk) => splitter.push(chunk);
|
|
88
|
+
const onEnd = () => {
|
|
89
|
+
splitter.end();
|
|
90
|
+
finish();
|
|
91
|
+
};
|
|
92
|
+
const onError = () => finish();
|
|
93
|
+
options.input.setEncoding?.('utf-8');
|
|
94
|
+
options.input.on('data', onData);
|
|
95
|
+
options.input.on('end', onEnd);
|
|
96
|
+
options.input.on('error', onError);
|
|
97
|
+
return {
|
|
98
|
+
next: () => {
|
|
99
|
+
const queued = queue.shift();
|
|
100
|
+
if (queued) {
|
|
101
|
+
inFlight = true;
|
|
102
|
+
taken.push(queued.requestId);
|
|
103
|
+
deliveredId = queued.requestId;
|
|
104
|
+
last = { text: queued.text, requestId: queued.requestId, queued: queued.queued };
|
|
105
|
+
return Promise.resolve(queued.text);
|
|
106
|
+
}
|
|
107
|
+
// NOBODY IS QUEUED, SO NO TURN IS RUNNING. This is also what makes the next
|
|
108
|
+
// arriving prompt report `queued:false`, which is how a caller knows its
|
|
109
|
+
// message started driving the run.
|
|
110
|
+
inFlight = false;
|
|
111
|
+
last = undefined;
|
|
112
|
+
if (finished)
|
|
113
|
+
return Promise.resolve(null);
|
|
114
|
+
return new Promise((resolve) => waiting.push(resolve));
|
|
115
|
+
},
|
|
116
|
+
lastPrompt: () => last,
|
|
117
|
+
cancelQueued(requestId) {
|
|
118
|
+
const index = queue.findIndex((item) => item.requestId === requestId);
|
|
119
|
+
if (index >= 0) {
|
|
120
|
+
queue.splice(index, 1);
|
|
121
|
+
return 'cancelled';
|
|
122
|
+
}
|
|
123
|
+
if (requestId === deliveredId && inFlight)
|
|
124
|
+
return 'in_flight';
|
|
125
|
+
// A FINISHED TURN IS `unknown`, NOT `in_flight`. `taken` remembers every id
|
|
126
|
+
// ever delivered, so answering `in_flight` for one of those would make the
|
|
127
|
+
// runner abort a healthy run because the caller cancelled a message that had
|
|
128
|
+
// already been answered.
|
|
129
|
+
return 'unknown';
|
|
130
|
+
},
|
|
131
|
+
isFinished: () => finished,
|
|
132
|
+
queueDepth: () => queue.length,
|
|
133
|
+
close: () => {
|
|
134
|
+
finished = true;
|
|
135
|
+
options.input.removeListener('data', onData);
|
|
136
|
+
options.input.removeListener('end', onEnd);
|
|
137
|
+
options.input.removeListener('error', onError);
|
|
138
|
+
options.input.pause?.();
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** The bridge source's own view of the id space, for tests and for `ack` wiring. */
|
|
143
|
+
export function bridgePromptId(prompt) {
|
|
144
|
+
return prompt?.requestId;
|
|
145
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface BridgeWriterOptions {
|
|
2
|
+
out: NodeJS.WritableStream;
|
|
3
|
+
/** Stamped onto the `caller_not_reading` error this writer emits itself. */
|
|
4
|
+
sessionId: string;
|
|
5
|
+
softPendingBytes?: number;
|
|
6
|
+
hardPendingBytes?: number;
|
|
7
|
+
/** Invoked ONCE, when the hard watermark is crossed. The run aborts from here. */
|
|
8
|
+
onHardLimit?: () => void;
|
|
9
|
+
}
|
|
10
|
+
export interface BridgeWriter {
|
|
11
|
+
/** Stamp, serialize and write (or queue, before `open()`). */
|
|
12
|
+
write(event: Record<string, unknown>): void;
|
|
13
|
+
/** True once `system/init` has been written. */
|
|
14
|
+
isOpened(): boolean;
|
|
15
|
+
/** The highest `seq` written so far; `0` before anything. */
|
|
16
|
+
currentSeq(): number;
|
|
17
|
+
/** Bytes written and not yet accepted by the transport. */
|
|
18
|
+
pendingBytes(): number;
|
|
19
|
+
/** Await `drain` at a TURN BOUNDARY only - never per frame. */
|
|
20
|
+
drainIfNeeded(): Promise<void>;
|
|
21
|
+
/** Everything queued plus everything in flight. Called before `result` is written. */
|
|
22
|
+
flush(): Promise<void>;
|
|
23
|
+
/** Idempotent. No writes are accepted afterwards. */
|
|
24
|
+
close(): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
export declare function createBridgeWriter(options: BridgeWriterOptions): BridgeWriter;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONLY object that writes stdout in bridge mode (headless-host-bridge section 2.4.5).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* THREE JOBS, AND EACH OF THEM IS A CONTRACT SOMETHING ELSE RELIES ON:
|
|
7
|
+
*
|
|
8
|
+
* 1. `seq`. Every line that leaves this process gets a gap-free, monotonically
|
|
9
|
+
* increasing number, assigned HERE and nowhere else, which is what lets a
|
|
10
|
+
* caller detect a dropped line and sort what it received.
|
|
11
|
+
* 2. `opened`. Nothing at all is written before `system/init`. The stdin source
|
|
12
|
+
* starts reading about eighty lines before the emitter exists, so an early
|
|
13
|
+
* `tool_register` or `prompt` can legitimately be answered before `init` has
|
|
14
|
+
* been built; without this queue the `ack` would win the race and break the
|
|
15
|
+
* one contract every wrapper builds its read loop on - `init` first,
|
|
16
|
+
* `result` last (`exec/index.ts:70-79` records why that matters: a wrapper
|
|
17
|
+
* that gets a `result` with no `init` cannot tell "never started" from
|
|
18
|
+
* "failed").
|
|
19
|
+
* 3. Backpressure. A caller that never drains stdout must not be able to wedge
|
|
20
|
+
* the agent inside a `write()` call, and must not be able to make this
|
|
21
|
+
* process buffer without limit either. Soft and hard watermarks, both from
|
|
22
|
+
* `BRIDGE_LIMITS`; the hard one fails the run loudly instead of growing.
|
|
23
|
+
*/
|
|
24
|
+
import { BRIDGE_LIMITS } from './limits.js';
|
|
25
|
+
/** Identify the init event without importing the event module (keeps this pure). */
|
|
26
|
+
function isInitEvent(event) {
|
|
27
|
+
return event.type === 'system' && event.subtype === 'init';
|
|
28
|
+
}
|
|
29
|
+
export function createBridgeWriter(options) {
|
|
30
|
+
const out = options.out;
|
|
31
|
+
const soft = options.softPendingBytes ?? BRIDGE_LIMITS.softPendingBytes;
|
|
32
|
+
const hard = options.hardPendingBytes ?? BRIDGE_LIMITS.hardPendingBytes;
|
|
33
|
+
let seq = 0;
|
|
34
|
+
let opened = false;
|
|
35
|
+
let closed = false;
|
|
36
|
+
let hardLimited = false;
|
|
37
|
+
let pendingBytes = 0;
|
|
38
|
+
const queue = [];
|
|
39
|
+
let drainWaiters = [];
|
|
40
|
+
const releaseDrainWaiters = () => {
|
|
41
|
+
const waiters = drainWaiters;
|
|
42
|
+
drainWaiters = [];
|
|
43
|
+
for (const resolve of waiters)
|
|
44
|
+
resolve();
|
|
45
|
+
};
|
|
46
|
+
// ONE listener for the life of the writer, attached lazily: a test double with
|
|
47
|
+
// no `on()` simply never reports backpressure, which is the honest reading of a
|
|
48
|
+
// stream that always accepts.
|
|
49
|
+
if (typeof out.on === 'function') {
|
|
50
|
+
out.on('drain', () => {
|
|
51
|
+
pendingBytes = 0;
|
|
52
|
+
releaseDrainWaiters();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
const rawWrite = (event) => {
|
|
56
|
+
seq += 1;
|
|
57
|
+
const line = `${JSON.stringify({ ...event, seq })}\n`;
|
|
58
|
+
try {
|
|
59
|
+
// ONLY A REFUSED WRITE IS BACKLOG. `write()` returning `true` means the
|
|
60
|
+
// stream took the chunk - counting it anyway would report a caller as "not
|
|
61
|
+
// reading" merely because it is reading normally, and every flush would wait
|
|
62
|
+
// for a `drain` event that is never coming.
|
|
63
|
+
const accepted = out.write(line);
|
|
64
|
+
if (accepted === false)
|
|
65
|
+
pendingBytes += Buffer.byteLength(line, 'utf-8');
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// A write that throws means the pipe is gone. The run is over either way;
|
|
69
|
+
// throwing from here would replace the caller-facing `result` with a stack
|
|
70
|
+
// trace, which is strictly less information.
|
|
71
|
+
pendingBytes = 0;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const enforceHardLimit = () => {
|
|
75
|
+
if (hardLimited)
|
|
76
|
+
return;
|
|
77
|
+
hardLimited = true;
|
|
78
|
+
// THE ONE FRAME THAT BYPASSES THE QUEUE. Telling the caller it is not reading
|
|
79
|
+
// is worthless if the telling itself waits behind the backlog it is about.
|
|
80
|
+
rawWrite({
|
|
81
|
+
type: 'error',
|
|
82
|
+
sessionId: options.sessionId,
|
|
83
|
+
fatal: true,
|
|
84
|
+
code: 'caller_not_reading',
|
|
85
|
+
message: `The caller has not read stdout for ${hard} bytes. ` +
|
|
86
|
+
'The run is aborting rather than buffering without limit.',
|
|
87
|
+
});
|
|
88
|
+
options.onHardLimit?.();
|
|
89
|
+
};
|
|
90
|
+
const writeNow = (event) => {
|
|
91
|
+
if (pendingBytes >= hard)
|
|
92
|
+
enforceHardLimit();
|
|
93
|
+
rawWrite(event);
|
|
94
|
+
};
|
|
95
|
+
const write = (event) => {
|
|
96
|
+
if (closed)
|
|
97
|
+
return;
|
|
98
|
+
if (isInitEvent(event)) {
|
|
99
|
+
writeNow(event);
|
|
100
|
+
opened = true;
|
|
101
|
+
// ARRIVAL ORDER, not priority: an `ack` that came before `init` was
|
|
102
|
+
// scheduled keeps its position relative to every other queued frame.
|
|
103
|
+
for (const queued of queue.splice(0))
|
|
104
|
+
writeNow(queued);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!opened) {
|
|
108
|
+
queue.push(event);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
writeNow(event);
|
|
112
|
+
};
|
|
113
|
+
const drainIfNeeded = async () => {
|
|
114
|
+
if (pendingBytes < soft)
|
|
115
|
+
return;
|
|
116
|
+
// A caller that is reading will emit `drain`; one that is not will cross the
|
|
117
|
+
// hard watermark instead, and `enforceHardLimit` wakes this waiter.
|
|
118
|
+
await new Promise((resolve) => {
|
|
119
|
+
if (pendingBytes < soft) {
|
|
120
|
+
resolve();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
drainWaiters.push(resolve);
|
|
124
|
+
const timer = setTimeout(resolve, 1_000);
|
|
125
|
+
timer.unref?.();
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
write,
|
|
130
|
+
isOpened: () => opened,
|
|
131
|
+
currentSeq: () => seq,
|
|
132
|
+
pendingBytes: () => pendingBytes,
|
|
133
|
+
drainIfNeeded,
|
|
134
|
+
async flush() {
|
|
135
|
+
if (pendingBytes <= 0)
|
|
136
|
+
return;
|
|
137
|
+
await new Promise((resolve) => {
|
|
138
|
+
drainWaiters.push(resolve);
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
pendingBytes = 0;
|
|
141
|
+
releaseDrainWaiters();
|
|
142
|
+
resolve();
|
|
143
|
+
}, 200);
|
|
144
|
+
timer.unref?.();
|
|
145
|
+
});
|
|
146
|
+
},
|
|
147
|
+
async close() {
|
|
148
|
+
if (closed)
|
|
149
|
+
return;
|
|
150
|
+
for (const queued of queue.splice(0))
|
|
151
|
+
writeNow(queued);
|
|
152
|
+
closed = true;
|
|
153
|
+
releaseDrainWaiters();
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -24,7 +24,7 @@ export function registerExecCommand(program, toFlags, deps) {
|
|
|
24
24
|
.description('Headless, machine-facing run: JSON events, sessions, tool permissions, budgets')
|
|
25
25
|
.argument('[prompt]', 'The prompt (or use --prompt-file, or pipe it on stdin)')
|
|
26
26
|
.option('--output-format <fmt>', 'text | json | stream-json (default: text)')
|
|
27
|
-
.option('--input-format <fmt>', 'text | stream-json (
|
|
27
|
+
.option('--input-format <fmt>', 'text | stream-json | bridge (the last two need --output-format stream-json)')
|
|
28
28
|
.option('--partial-messages', 'Emit text_delta events (stream-json only)')
|
|
29
29
|
.option('--include-thinking', 'Emit thinking events (stream-json only)')
|
|
30
30
|
.option('--prompt-file <path>', 'Read the prompt from a file')
|
|
@@ -35,7 +35,35 @@ export function registerExecCommand(program, toFlags, deps) {
|
|
|
35
35
|
.option('--max-turns <n>', 'Stop after n assistant turns (exit 3)')
|
|
36
36
|
.option('--max-duration <ms>', 'Stop after ms of wall clock (exit 3). --idle-timeout still applies to a silent ' +
|
|
37
37
|
'stretch inside the run, and --tool-timeout to a single tool call')
|
|
38
|
-
.option('--permission-mode <m>', 'auto | plan | strict (default: auto)'
|
|
38
|
+
.option('--permission-mode <m>', 'auto | plan | strict | ask (default: auto). `ask` needs --input-format bridge: ' +
|
|
39
|
+
'it makes each mutating tool wait for the caller to allow it')
|
|
40
|
+
// THE FOUR BRIDGE BUDGETS. Declared here rather than hidden in `bridge/` so
|
|
41
|
+
// `aragon exec --help` is the complete list of what a caller can tune; the
|
|
42
|
+
// cross-checks that refuse them outside bridge mode live in `options.ts`.
|
|
43
|
+
.option('--decision-timeout <ms>', 'How long the caller has to answer one decision (bridge only; default 300000)')
|
|
44
|
+
.option('--on-decision-timeout <mode>', 'deny | error: what an unanswered decision means for the turn (bridge only; ' +
|
|
45
|
+
'default deny)')
|
|
46
|
+
.option('--caller-tool-timeout <ms>', 'Ceiling for one caller tool call (bridge only; default 60000)')
|
|
47
|
+
// THE STRUCTURED RESULT AND THE TWO PER-REQUEST BUDGETS. Declared here for the
|
|
48
|
+
// reason the four bridge budgets are: `aragon exec --help` has to be the complete
|
|
49
|
+
// list of what a caller can tune, and the cross-checks that refuse a combination
|
|
50
|
+
// live in `options.ts`, where they are unit-testable.
|
|
51
|
+
.option('--result-schema <path>', 'Validate the final answer against this JSON Schema and return it as ' +
|
|
52
|
+
'`result.structured` (json / stream-json / bridge)')
|
|
53
|
+
.option('--result-repair-turns <n>', 'How many times the model is asked to fix a rejected result (0-2; default 1)')
|
|
54
|
+
.option('--on-result-unsatisfied <mode>', 'error | continue: what an unsubmitted result means for the run (default error)')
|
|
55
|
+
.option('--request-max-turns <n>', 'Stop ONE request after n assistant turns, keeping the process alive (bridge only)')
|
|
56
|
+
.option('--request-max-duration <ms>', 'Stop ONE request after ms of wall clock, keeping the process alive (bridge only)')
|
|
57
|
+
// THE THREAD AND HOOK FLAGS (headless-threads-and-lifecycle-hooks section 4.1).
|
|
58
|
+
// Declared here for the reason the four bridge budgets are: `aragon exec --help` has to be
|
|
59
|
+
// the complete list of what a caller can tune, and the cross-checks that refuse a
|
|
60
|
+
// combination live in `options.ts`, where they are unit-testable.
|
|
61
|
+
.option('--threads', 'Serve N isolated conversations from one process (needs --input-format bridge and ' +
|
|
62
|
+
'--output-format stream-json). Open them with thread_open on stdin')
|
|
63
|
+
.option('--max-open-threads <n>', 'How many threads may exist at once, 1-8 (thread mode; default 4)')
|
|
64
|
+
.option('--thread-concurrency <n>', 'How many threads may hold a request in flight (thread mode; default 2)')
|
|
65
|
+
.option('--hook-timeout <ms>', 'Ceiling for one caller lifecycle hook round trip (bridge only; default 5000)')
|
|
66
|
+
.option('--on-hook-timeout <mode>', 'deny | allow | error: what an unanswered hook means (bridge only; default deny)')
|
|
39
67
|
.option('--allow-tool <names>', 'Add tools to the baseline (repeatable; comma-separated accepted)', collect)
|
|
40
68
|
.option('--deny-tool <names>', 'Remove tools; wins over --allow-tool (repeatable; comma-separated accepted)', collect)
|
|
41
69
|
.option('--append-system-prompt <text>', 'Append text to the builtin system prompt')
|
package/dist/exec/emitter.d.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* error, budget and signal - rather than true for the paths someone remembered.
|
|
17
17
|
*/
|
|
18
18
|
import type { ExecEvent, ExecInitParams, ExecResultEvent, ExecResultParams } from './events.js';
|
|
19
|
+
import type { BridgeWriter } from './bridge/writer.js';
|
|
19
20
|
export interface ExecEmitter {
|
|
20
21
|
init(params: ExecInitParams): void;
|
|
21
22
|
/** Every non-`result` event. Ignored wholesale by `text` and `json`. */
|
|
@@ -70,11 +71,24 @@ export declare class JsonEmitter implements ExecEmitter {
|
|
|
70
71
|
*/
|
|
71
72
|
export declare class StreamJsonEmitter implements ExecEmitter {
|
|
72
73
|
private readonly out;
|
|
74
|
+
private readonly writer?;
|
|
73
75
|
private done;
|
|
74
|
-
|
|
76
|
+
/**
|
|
77
|
+
* `writer` is ABSENT IN EVERY NON-BRIDGE RUN, and that absence is the whole
|
|
78
|
+
* compatibility story (I-1): with no writer the three methods below fall through
|
|
79
|
+
* to the exact `this.out.write(toNdjsonLine(...))` they have always done, so
|
|
80
|
+
* `stream-json` stays byte-identical while bridge mode gains `seq`, the `init`
|
|
81
|
+
* queue and backpressure from one object.
|
|
82
|
+
*
|
|
83
|
+
* It is not the emitter that stamps `seq` - a stamp belongs where EVERY line
|
|
84
|
+
* passes, and bridge events (`ack`, `decision_request`) are produced by the
|
|
85
|
+
* server, which never sees this class.
|
|
86
|
+
*/
|
|
87
|
+
constructor(out: NodeJS.WritableStream, writer?: BridgeWriter | undefined);
|
|
75
88
|
init(params: ExecInitParams): void;
|
|
76
89
|
emit(event: ExecEvent): void;
|
|
77
90
|
result(params: ExecResultParams): void;
|
|
91
|
+
private write;
|
|
78
92
|
emitted(): ExecResultEvent | null;
|
|
79
93
|
}
|
|
80
|
-
export declare function createEmitter(format: 'text' | 'json' | 'stream-json', out: NodeJS.WritableStream): ExecEmitter;
|
|
94
|
+
export declare function createEmitter(format: 'text' | 'json' | 'stream-json', out: NodeJS.WritableStream, writer?: BridgeWriter): ExecEmitter;
|
package/dist/exec/emitter.js
CHANGED
|
@@ -94,34 +94,54 @@ export class JsonEmitter {
|
|
|
94
94
|
*/
|
|
95
95
|
export class StreamJsonEmitter {
|
|
96
96
|
out;
|
|
97
|
+
writer;
|
|
97
98
|
done = null;
|
|
98
|
-
|
|
99
|
+
/**
|
|
100
|
+
* `writer` is ABSENT IN EVERY NON-BRIDGE RUN, and that absence is the whole
|
|
101
|
+
* compatibility story (I-1): with no writer the three methods below fall through
|
|
102
|
+
* to the exact `this.out.write(toNdjsonLine(...))` they have always done, so
|
|
103
|
+
* `stream-json` stays byte-identical while bridge mode gains `seq`, the `init`
|
|
104
|
+
* queue and backpressure from one object.
|
|
105
|
+
*
|
|
106
|
+
* It is not the emitter that stamps `seq` - a stamp belongs where EVERY line
|
|
107
|
+
* passes, and bridge events (`ack`, `decision_request`) are produced by the
|
|
108
|
+
* server, which never sees this class.
|
|
109
|
+
*/
|
|
110
|
+
constructor(out, writer) {
|
|
99
111
|
this.out = out;
|
|
112
|
+
this.writer = writer;
|
|
100
113
|
}
|
|
101
114
|
init(params) {
|
|
102
115
|
if (this.done)
|
|
103
116
|
return;
|
|
104
|
-
this.
|
|
117
|
+
this.write(buildInitEvent(params));
|
|
105
118
|
}
|
|
106
119
|
emit(event) {
|
|
107
120
|
if (this.done)
|
|
108
121
|
return;
|
|
109
|
-
this.
|
|
122
|
+
this.write(event);
|
|
110
123
|
}
|
|
111
124
|
result(params) {
|
|
112
125
|
if (this.done)
|
|
113
126
|
return;
|
|
114
127
|
this.done = buildResultEvent(params);
|
|
115
|
-
this.
|
|
128
|
+
this.write(this.done);
|
|
129
|
+
}
|
|
130
|
+
write(event) {
|
|
131
|
+
if (this.writer) {
|
|
132
|
+
this.writer.write(event);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.out.write(toNdjsonLine(event));
|
|
116
136
|
}
|
|
117
137
|
emitted() {
|
|
118
138
|
return this.done;
|
|
119
139
|
}
|
|
120
140
|
}
|
|
121
|
-
export function createEmitter(format, out) {
|
|
141
|
+
export function createEmitter(format, out, writer) {
|
|
122
142
|
if (format === 'json')
|
|
123
143
|
return new JsonEmitter(out);
|
|
124
144
|
if (format === 'stream-json')
|
|
125
|
-
return new StreamJsonEmitter(out);
|
|
145
|
+
return new StreamJsonEmitter(out, writer);
|
|
126
146
|
return new TextEmitter();
|
|
127
147
|
}
|