@aragon-agent/cli 0.6.38 → 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/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/shortcuts.d.ts +81 -0
- package/dist/ui/shortcuts.js +221 -0
- package/package.json +2 -2
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Summing N threads' accounts into one process-level answer
|
|
3
|
+
* (headless-threads-and-lifecycle-hooks section 2.3.7).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* `result` IS ONE EVENT FOR THE WHOLE PROCESS, so it cannot report any single
|
|
8
|
+
* thread's usage: it reports the sum. The two derived rules that are not obvious
|
|
9
|
+
* arithmetic are stated where they are implemented below.
|
|
10
|
+
*/
|
|
11
|
+
import type { ExecCost, ExecUsage } from '../events.js';
|
|
12
|
+
import type { ExecThreadEndReason } from './limits.js';
|
|
13
|
+
/** One thread's contribution to `result.threads.items` and to the process totals. */
|
|
14
|
+
export interface ThreadAccount {
|
|
15
|
+
threadId: string;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
requests: number;
|
|
18
|
+
turns: number;
|
|
19
|
+
usage: ExecUsage;
|
|
20
|
+
cost: ExecCost;
|
|
21
|
+
isError: boolean;
|
|
22
|
+
reason: ExecThreadEndReason;
|
|
23
|
+
}
|
|
24
|
+
export interface AggregateInput {
|
|
25
|
+
usage: ExecUsage;
|
|
26
|
+
cost: ExecCost;
|
|
27
|
+
totals: {
|
|
28
|
+
opened: number;
|
|
29
|
+
closed: number;
|
|
30
|
+
failed: number;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export declare function emptyUsage(): ExecUsage;
|
|
34
|
+
export declare function aggregateAccounts(accounts: readonly ThreadAccount[]): AggregateInput;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function emptyUsage() {
|
|
2
|
+
return { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
3
|
+
}
|
|
4
|
+
export function aggregateAccounts(accounts) {
|
|
5
|
+
const usage = emptyUsage();
|
|
6
|
+
let amount = 0;
|
|
7
|
+
// `every`, NOT `some`: a thread whose model has no price table makes the whole
|
|
8
|
+
// answer unknown. "One thread knew the price" must not launder the other thread's
|
|
9
|
+
// confident zero, which is the same rule `request/scope.ts` applies to `known`.
|
|
10
|
+
let known = accounts.length > 0;
|
|
11
|
+
let failed = 0;
|
|
12
|
+
for (const account of accounts) {
|
|
13
|
+
usage.inputTokens += account.usage.inputTokens;
|
|
14
|
+
usage.outputTokens += account.usage.outputTokens;
|
|
15
|
+
usage.totalTokens += account.usage.totalTokens;
|
|
16
|
+
amount += account.cost.amount;
|
|
17
|
+
known = known && account.cost.known;
|
|
18
|
+
if (account.isError)
|
|
19
|
+
failed += 1;
|
|
20
|
+
}
|
|
21
|
+
// A THREAD IS COUNTED AS CLOSED BECAUSE ITS RECORD EXISTS, not because its reason is
|
|
22
|
+
// literally `closed`: an aborted or timed-out thread is finished too, and counting
|
|
23
|
+
// only the settled ones would make `result.threads.closed` disagree with every
|
|
24
|
+
// `thread_state{phase:'closed'}` the caller already received.
|
|
25
|
+
return {
|
|
26
|
+
usage,
|
|
27
|
+
cost: { amount, currency: 'USD', known },
|
|
28
|
+
totals: { opened: accounts.length, closed: accounts.length, failed },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type CapacityCode = 'thread_limit' | 'thread_capacity';
|
|
2
|
+
export interface OpenCapacityInput {
|
|
3
|
+
/** Threads in `opening` / `open` / `closing`. Closed records do not count. */
|
|
4
|
+
active: number;
|
|
5
|
+
/** `--max-open-threads`, already clamped by the flag parser. */
|
|
6
|
+
maxOpenThreads: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* May another thread be opened?
|
|
10
|
+
*
|
|
11
|
+
* `>=` rather than `>`: `--max-open-threads 1` means exactly one thread, which is
|
|
12
|
+
* the default thread alone - a legal (if pointless) configuration that must not
|
|
13
|
+
* admit a second.
|
|
14
|
+
*/
|
|
15
|
+
export declare function canOpen(input: OpenCapacityInput): true | CapacityCode;
|
|
16
|
+
/**
|
|
17
|
+
* May this thread serve one more request?
|
|
18
|
+
*
|
|
19
|
+
* The ceiling is a leak guard, not a policy: a thread that has served a hundred
|
|
20
|
+
* thousand requests in one process has almost certainly been used as a queue, and
|
|
21
|
+
* the caller should have closed it and opened another.
|
|
22
|
+
*/
|
|
23
|
+
export declare function withinRequestCap(requests: number): boolean;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two capacities a thread run enforces (headless-threads-and-lifecycle-hooks
|
|
3
|
+
* section 2.3.10, I-T8).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* TWO CEILINGS, TWO QUESTIONS, AND THEY ARE NOT THE SAME ONE:
|
|
8
|
+
* - `maxOpenThreads` bounds how many conversations exist;
|
|
9
|
+
* - `thread-concurrency` (the gate) bounds how many are RUNNING a request.
|
|
10
|
+
*
|
|
11
|
+
* A closed thread stops occupying the first and never occupied the second, which is
|
|
12
|
+
* the rule that lets a wrapper cycle tenants through a bounded pool.
|
|
13
|
+
*
|
|
14
|
+
* SPLIT OUT OF `registry.ts` on purpose (review P2-6): the registry is a state
|
|
15
|
+
* machine, and mixing arithmetic into it made the file the first thing to cross the
|
|
16
|
+
* 400-line budget.
|
|
17
|
+
*/
|
|
18
|
+
import { THREAD_LIMITS } from './limits.js';
|
|
19
|
+
/**
|
|
20
|
+
* May another thread be opened?
|
|
21
|
+
*
|
|
22
|
+
* `>=` rather than `>`: `--max-open-threads 1` means exactly one thread, which is
|
|
23
|
+
* the default thread alone - a legal (if pointless) configuration that must not
|
|
24
|
+
* admit a second.
|
|
25
|
+
*/
|
|
26
|
+
export function canOpen(input) {
|
|
27
|
+
return input.active < input.maxOpenThreads ? true : 'thread_limit';
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* May this thread serve one more request?
|
|
31
|
+
*
|
|
32
|
+
* The ceiling is a leak guard, not a policy: a thread that has served a hundred
|
|
33
|
+
* thousand requests in one process has almost certainly been used as a queue, and
|
|
34
|
+
* the caller should have closed it and opened another.
|
|
35
|
+
*/
|
|
36
|
+
export function withinRequestCap(requests) {
|
|
37
|
+
return requests < THREAD_LIMITS.maxRequestsPerThread;
|
|
38
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single stdin reader in thread mode
|
|
3
|
+
* (headless-threads-and-lifecycle-hooks section 2.3.3; review P1-3).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* STDIN HAS EXACTLY ONE PARSER. `bridge/source.ts` attaches `data` / `end` / `error`
|
|
8
|
+
* listeners at construction, so a pre-built `prompts` source and this one would be two
|
|
9
|
+
* splitters on the same bytes: everything after the first newline would land in
|
|
10
|
+
* whichever of the two happened to be registered first. `exec/index.ts` therefore
|
|
11
|
+
* refuses the combination outright and `executeThreads` receives the STREAM, not a
|
|
12
|
+
* source.
|
|
13
|
+
*
|
|
14
|
+
* IT IS A ROUTER, NOT A STATE MACHINE. `thread_open` is asynchronous (it constructs a
|
|
15
|
+
* controller and takes a session lock) while `handleLine` is synchronous, so the
|
|
16
|
+
* routing decision below is "which port does this belong to" and nothing else: the
|
|
17
|
+
* registry owns the `opening` state and its own queue. That split is what lets a
|
|
18
|
+
* caller send `thread_open` and `prompt` back to back - the normal Web-backend shape -
|
|
19
|
+
* without a prompt being dropped or served early.
|
|
20
|
+
*/
|
|
21
|
+
import type { ExecPromptSource } from '../runner.js';
|
|
22
|
+
import { type BridgeHookAnswerFrame, type BridgeInputFrame } from '../bridge/protocol.js';
|
|
23
|
+
/** Everything the router needs. The registry supplies all of it. */
|
|
24
|
+
export interface ThreadDispatchPorts {
|
|
25
|
+
/** A `prompt` frame for a thread; the registry queues it (even mid-`opening`). */
|
|
26
|
+
deliverPrompt(threadId: string, text: string, frameId: string | undefined): void;
|
|
27
|
+
/** A control frame for a thread's own bridge server. */
|
|
28
|
+
deliverControl(threadId: string, frame: BridgeInputFrame): void;
|
|
29
|
+
/** `hook_answer` for a thread's hook dispatcher. */
|
|
30
|
+
deliverHookAnswer(threadId: string, frame: BridgeHookAnswerFrame): void;
|
|
31
|
+
/** `thread_open` / `thread_close` / `thread_list`. */
|
|
32
|
+
handleThreadFrame(frame: BridgeInputFrame): void;
|
|
33
|
+
/**
|
|
34
|
+
* `interrupt` WITHOUT a `threadId`. Process-level by definition (section 2.3.2): it
|
|
35
|
+
* never enters a thread's server, because "stop this run" cannot be answered by
|
|
36
|
+
* aborting one of N threads.
|
|
37
|
+
*/
|
|
38
|
+
interruptProcess(): void;
|
|
39
|
+
/** `interrupt` WITH a `threadId`: refused, and the refusal is the whole behaviour. */
|
|
40
|
+
rejectThreadedInterrupt(threadId: string, frameId: string | undefined): void;
|
|
41
|
+
/** `end` frame, EOF, or the process shutting down. */
|
|
42
|
+
finishAll(): void;
|
|
43
|
+
/** A malformed line, already carrying its stable `code`. */
|
|
44
|
+
onBadLine(code: string, message: string): void;
|
|
45
|
+
}
|
|
46
|
+
export interface ThreadDispatchSource {
|
|
47
|
+
/** The source one thread's runner consumes; `undefined` before the thread exists. */
|
|
48
|
+
sourceFor(threadId: string): ExecPromptSource | undefined;
|
|
49
|
+
/** End every thread's input (`end` frame or EOF). */
|
|
50
|
+
finishAll(): void;
|
|
51
|
+
/** Detach the listeners so the process can exit. */
|
|
52
|
+
close(): void;
|
|
53
|
+
isFinished(): boolean;
|
|
54
|
+
queueDepth(threadId: string): number;
|
|
55
|
+
}
|
|
56
|
+
export interface ThreadDispatchOptions {
|
|
57
|
+
input: NodeJS.ReadableStream;
|
|
58
|
+
ports: ThreadDispatchPorts;
|
|
59
|
+
}
|
|
60
|
+
export declare function createThreadDispatchSource(options: ThreadDispatchOptions): ThreadDispatchSource;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createNdjsonSplitter } from '../bridge/ndjson.js';
|
|
2
|
+
import { parseBridgeInput, } from '../bridge/protocol.js';
|
|
3
|
+
import { DEFAULT_THREAD_ID } from './identity.js';
|
|
4
|
+
function isThreadFrame(frame) {
|
|
5
|
+
return (frame.type === 'thread_open' ||
|
|
6
|
+
frame.type === 'thread_close' ||
|
|
7
|
+
frame.type === 'thread_list');
|
|
8
|
+
}
|
|
9
|
+
export function createThreadDispatchSource(options) {
|
|
10
|
+
const ports = options.ports;
|
|
11
|
+
let finished = false;
|
|
12
|
+
const finishAll = () => {
|
|
13
|
+
if (finished)
|
|
14
|
+
return;
|
|
15
|
+
finished = true;
|
|
16
|
+
ports.finishAll();
|
|
17
|
+
};
|
|
18
|
+
const handleLine = (line) => {
|
|
19
|
+
const parsed = parseBridgeInput(line);
|
|
20
|
+
if (!parsed.ok) {
|
|
21
|
+
ports.onBadLine(parsed.code, parsed.message);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const frame = parsed.frame;
|
|
25
|
+
if (frame.type === 'prompt') {
|
|
26
|
+
// AFTER `end` NOTHING IS A TURN - the v1 rule, preserved by `bridge/source.ts`
|
|
27
|
+
// and preserved here for the same reason: a caller that said "that is my last
|
|
28
|
+
// prompt" must not have a late one silently start a conversation.
|
|
29
|
+
if (finished)
|
|
30
|
+
return;
|
|
31
|
+
ports.deliverPrompt(frame.threadId ?? DEFAULT_THREAD_ID, frame.text, frame.id);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (frame.type === 'end') {
|
|
35
|
+
finishAll();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (frame.type === 'ignored')
|
|
39
|
+
return;
|
|
40
|
+
if (isThreadFrame(frame)) {
|
|
41
|
+
ports.handleThreadFrame(frame);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (frame.type === 'interrupt') {
|
|
45
|
+
// THE HARD DECISION, IN ONE PLACE, AND IT HAS THREE ANSWERS (section 2.3.3's routing
|
|
46
|
+
// table, last row). With a `threadId` this frame is refused: silently degrading it to
|
|
47
|
+
// `cancel_request` would make "the user pressed Ctrl-C" and "the backend withdrew one
|
|
48
|
+
// request" indistinguishable after the fact - and their exit codes, persistence and
|
|
49
|
+
// `result` differ completely.
|
|
50
|
+
if (frame.threadId !== undefined) {
|
|
51
|
+
ports.rejectThreadedInterrupt(frame.threadId, frame.id);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// WITH A `requestId` IT IS NOT A PROCESS INTERRUPT AT ALL: it names one queued turn,
|
|
55
|
+
// and v1 answers it by deleting that turn and acking `cancelled:'queued'`
|
|
56
|
+
// (`bridge/source.ts` -> the runner's `onInterruptFrame`). Promoting it to a
|
|
57
|
+
// process-level stop here would answer a routine "withdraw that message" by killing
|
|
58
|
+
// the run with exit 130 - the v1 caller regression I-C4 forbids.
|
|
59
|
+
if (frame.requestId !== undefined) {
|
|
60
|
+
ports.deliverControl(DEFAULT_THREAD_ID, frame);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
ports.interruptProcess();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (frame.type === 'hook_answer') {
|
|
67
|
+
ports.deliverHookAnswer(frame.threadId ?? DEFAULT_THREAD_ID, frame);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// Every other control frame (`decision`, `tool_result`, `steer`, `cancel_request`,
|
|
71
|
+
// `tool_register`, `hook_register`, `ping`) goes to that thread's own server. A
|
|
72
|
+
// missing `threadId` lands on `main`, which is what makes a v1 caller work
|
|
73
|
+
// unchanged (AC-11).
|
|
74
|
+
const route = 'threadId' in frame ? frame.threadId : undefined;
|
|
75
|
+
ports.deliverControl(route ?? DEFAULT_THREAD_ID, frame);
|
|
76
|
+
};
|
|
77
|
+
const splitter = createNdjsonSplitter({
|
|
78
|
+
onLine: handleLine,
|
|
79
|
+
onTooLarge: (bytes) => ports.onBadLine('bridge_frame_too_large', `Skipped a bridge frame of ${bytes} bytes.`),
|
|
80
|
+
});
|
|
81
|
+
const onData = (chunk) => splitter.push(chunk);
|
|
82
|
+
const onEnd = () => {
|
|
83
|
+
splitter.end();
|
|
84
|
+
finishAll();
|
|
85
|
+
};
|
|
86
|
+
const onError = () => finishAll();
|
|
87
|
+
options.input.setEncoding?.('utf-8');
|
|
88
|
+
options.input.on('data', onData);
|
|
89
|
+
options.input.on('end', onEnd);
|
|
90
|
+
options.input.on('error', onError);
|
|
91
|
+
return {
|
|
92
|
+
// THE REGISTRY OWNS THE SOURCES; this object only knows where stdin goes, so the
|
|
93
|
+
// one thing it cannot do is hand out a source of its own.
|
|
94
|
+
sourceFor: () => undefined,
|
|
95
|
+
finishAll,
|
|
96
|
+
close: () => {
|
|
97
|
+
finished = true;
|
|
98
|
+
options.input.removeListener('data', onData);
|
|
99
|
+
options.input.removeListener('end', onEnd);
|
|
100
|
+
options.input.removeListener('error', onError);
|
|
101
|
+
options.input.pause?.();
|
|
102
|
+
},
|
|
103
|
+
isFinished: () => finished,
|
|
104
|
+
queueDepth: () => 0,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `execute()`'s equivalent for thread mode: one process, N conversations
|
|
3
|
+
* (headless-threads-and-lifecycle-hooks sections 2.3.3 / 2.3.7 / 2.3.8).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* THIS FILE OWNS EXACTLY FOUR THINGS, and everything else belongs to a thread:
|
|
8
|
+
*
|
|
9
|
+
* 1. the ONE writer and the ONE emitter (`seq` gap-free, `init` first, `result` last);
|
|
10
|
+
* 2. the ONE admission gate and the ONE thread registry;
|
|
11
|
+
* 3. the ONE process-level signal owner and the ONE process-level timer;
|
|
12
|
+
* 4. the ONE `decideProcessOutcome` call - the only place a process stop reason and exit
|
|
13
|
+
* code are decided (I-T11).
|
|
14
|
+
*
|
|
15
|
+
* "PROCESS ENDS WHEN" HAS ONE ANSWER: stdin ended (the `end` frame or EOF) AND every opened
|
|
16
|
+
* thread is `closed`. `registry.drained()` is that condition, waited once at the end.
|
|
17
|
+
*/
|
|
18
|
+
import type { CliFlags } from '../../config/load.js';
|
|
19
|
+
import type { ExecOptions } from '../options.js';
|
|
20
|
+
import type { ResultSchemaSpec } from '../result/schema.js';
|
|
21
|
+
import type { RunExecContext } from '../index.js';
|
|
22
|
+
import { type ThreadPersistInput } from './runtime.js';
|
|
23
|
+
export interface ExecuteThreadsInput {
|
|
24
|
+
flags: CliFlags;
|
|
25
|
+
options: ExecOptions;
|
|
26
|
+
/** The process-level session identity, already planned. */
|
|
27
|
+
plan: {
|
|
28
|
+
id: string;
|
|
29
|
+
saving: boolean;
|
|
30
|
+
cwd: string;
|
|
31
|
+
};
|
|
32
|
+
ctx: RunExecContext;
|
|
33
|
+
out: NodeJS.WritableStream;
|
|
34
|
+
err: NodeJS.WritableStream;
|
|
35
|
+
now: () => number;
|
|
36
|
+
version: string;
|
|
37
|
+
/** The caller's schema, preflighted once and shared by every thread (section 2.5). */
|
|
38
|
+
resultSpec: ResultSchemaSpec | null;
|
|
39
|
+
/** The raw stdin stream. A pre-built source would be a second splitter (I-T12). */
|
|
40
|
+
stdin: NodeJS.ReadableStream;
|
|
41
|
+
/** Writes one thread's session file; `exec/index.ts` owns the persistence details. */
|
|
42
|
+
persist: (input: ThreadPersistInput) => void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Run a thread-mode invocation and resolve with the process exit code.
|
|
46
|
+
*
|
|
47
|
+
* EXIT 2 IS NOT PRODUCED HERE. Every flag, schema and session failure is decided before
|
|
48
|
+
* this function is reached, which is what keeps "exit 2 with nothing on stdout" a property
|
|
49
|
+
* of one module instead of two.
|
|
50
|
+
*/
|
|
51
|
+
export declare function executeThreads(input: ExecuteThreadsInput): Promise<number>;
|
|
52
|
+
/** Exported for the smoke test and for `exec/index.ts`'s capability stamp. */
|
|
53
|
+
export declare function threadCapabilities(): string[];
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { sessionPathFor, tryAcquireSessionLock } from '../../session/store.js';
|
|
2
|
+
import { createEmitter } from '../emitter.js';
|
|
3
|
+
import { execCapabilities } from '../events.js';
|
|
4
|
+
import { createBridgeWriter } from '../bridge/writer.js';
|
|
5
|
+
import { aggregateAccounts } from './aggregate.js';
|
|
6
|
+
import { createAdmissionGate } from './gate.js';
|
|
7
|
+
import { createInterruptOwner } from './interrupt.js';
|
|
8
|
+
import { DEFAULT_THREAD_ID, createThreadRegistry } from './registry.js';
|
|
9
|
+
import { createThreadRuntime, ThreadOpenError } from './runtime.js';
|
|
10
|
+
import { decideProcessOutcome } from './outcome.js';
|
|
11
|
+
import { createThreadDispatchSource } from './dispatch-source.js';
|
|
12
|
+
/**
|
|
13
|
+
* Run a thread-mode invocation and resolve with the process exit code.
|
|
14
|
+
*
|
|
15
|
+
* EXIT 2 IS NOT PRODUCED HERE. Every flag, schema and session failure is decided before
|
|
16
|
+
* this function is reached, which is what keeps "exit 2 with nothing on stdout" a property
|
|
17
|
+
* of one module instead of two.
|
|
18
|
+
*/
|
|
19
|
+
export async function executeThreads(input) {
|
|
20
|
+
const { options, plan, ctx, out, now } = input;
|
|
21
|
+
const startedAt = now();
|
|
22
|
+
/** Set by the writer when the caller stopped reading; the run must exit 1. */
|
|
23
|
+
const hardLimit = { fired: false };
|
|
24
|
+
// THE ONE WRITER. Every thread's frames go through it so `seq` stays global and gap-free
|
|
25
|
+
// (I-C3) - which is also why the writer is created here and passed DOWN, never per thread.
|
|
26
|
+
const writer = createBridgeWriter({
|
|
27
|
+
out,
|
|
28
|
+
sessionId: plan.id,
|
|
29
|
+
onHardLimit: () => {
|
|
30
|
+
hardLimit.fired = true;
|
|
31
|
+
// NO MORE WORK IS SERVED: the frames would only be dropped by the sealed writer, and
|
|
32
|
+
// the process has to reach its `result` to report exit 1 at all.
|
|
33
|
+
registry?.finishAll();
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const emitter = createEmitter(options.outputFormat, out, writer);
|
|
37
|
+
// ONE LATCH FOR THE ONE `result` (I-C2). The interrupt owner may call this from a signal
|
|
38
|
+
// handler *and* the normal tail calls it - both must produce exactly one line.
|
|
39
|
+
let resultWritten = false;
|
|
40
|
+
const buildResult = (stopReason, exitCode) => {
|
|
41
|
+
const accounts = registry?.accounts() ?? [];
|
|
42
|
+
const aggregate = aggregateAccounts(accounts);
|
|
43
|
+
return {
|
|
44
|
+
sessionId: plan.id,
|
|
45
|
+
isError: exitCode === 1 || hardLimit.fired,
|
|
46
|
+
stopReason: hardLimit.fired ? 'error' : stopReason,
|
|
47
|
+
exitCode,
|
|
48
|
+
result: '',
|
|
49
|
+
turns: accounts.reduce((sum, a) => sum + a.turns, 0),
|
|
50
|
+
durationMs: now() - startedAt,
|
|
51
|
+
usage: aggregate.usage,
|
|
52
|
+
cost: aggregate.cost,
|
|
53
|
+
model: mainModel,
|
|
54
|
+
todos: null,
|
|
55
|
+
acceptance: null,
|
|
56
|
+
threads: {
|
|
57
|
+
opened: aggregate.totals.opened,
|
|
58
|
+
closed: aggregate.totals.closed,
|
|
59
|
+
failed: aggregate.totals.failed,
|
|
60
|
+
items: accounts.map((account) => ({
|
|
61
|
+
threadId: account.threadId,
|
|
62
|
+
sessionId: account.sessionId,
|
|
63
|
+
requests: account.requests,
|
|
64
|
+
turns: account.turns,
|
|
65
|
+
usage: account.usage,
|
|
66
|
+
cost: account.cost,
|
|
67
|
+
isError: account.isError,
|
|
68
|
+
reason: account.reason,
|
|
69
|
+
})),
|
|
70
|
+
},
|
|
71
|
+
error: null,
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
const ensureResult = (stopReason) => {
|
|
75
|
+
if (resultWritten)
|
|
76
|
+
return;
|
|
77
|
+
resultWritten = true;
|
|
78
|
+
const decided = decideProcessOutcome({
|
|
79
|
+
interrupt: stopReason === 'interrupted' ? { signo: owner.signo() ?? 2 } : null,
|
|
80
|
+
timedOut: stopReason === 'timeout',
|
|
81
|
+
hardLimited: hardLimit.fired,
|
|
82
|
+
accounts: registry?.accounts() ?? [],
|
|
83
|
+
});
|
|
84
|
+
emitter.result(buildResult(decided.stopReason, decided.exitCode));
|
|
85
|
+
};
|
|
86
|
+
let registry = null;
|
|
87
|
+
let timedOut = false;
|
|
88
|
+
/** The default thread's model, so `result.model` names what actually ran. */
|
|
89
|
+
let mainModel = { provider: 'unknown', id: 'unknown' };
|
|
90
|
+
const gate = createAdmissionGate({ limit: options.threadConcurrency });
|
|
91
|
+
registry = createThreadRegistry({
|
|
92
|
+
baseSessionId: plan.id,
|
|
93
|
+
saving: plan.saving,
|
|
94
|
+
maxOpenThreads: options.maxOpenThreads,
|
|
95
|
+
gate,
|
|
96
|
+
write: (event) => writer.write(event),
|
|
97
|
+
acquireLock: (sessionId) => tryAcquireSessionLock(sessionId),
|
|
98
|
+
sessionFileFor: (sessionId) => (plan.saving ? sessionPathFor(sessionId) : null),
|
|
99
|
+
// `system/init` IS WRITTEN HERE, from the first thread's own controller (so `tools` is
|
|
100
|
+
// the real union and `model` is the resolved one) and BEFORE any `thread_state` - the
|
|
101
|
+
// writer flushes strictly in arrival order, so this is the only safe moment.
|
|
102
|
+
onFirstOpen: () => {
|
|
103
|
+
const main = registry?.runtimeFor(DEFAULT_THREAD_ID);
|
|
104
|
+
const described = main?.describe();
|
|
105
|
+
if (described)
|
|
106
|
+
mainModel = { provider: described.model.provider, id: described.model.id };
|
|
107
|
+
emitter.init({
|
|
108
|
+
sessionId: plan.id,
|
|
109
|
+
cli: input.version,
|
|
110
|
+
cwd: described?.cwd ?? plan.cwd,
|
|
111
|
+
startedAt,
|
|
112
|
+
model: described?.model ?? { provider: 'unknown', id: 'unknown', baseUrl: null },
|
|
113
|
+
permissionMode: options.permissionMode,
|
|
114
|
+
tools: described?.tools ?? [],
|
|
115
|
+
resumed: false,
|
|
116
|
+
sessionFile: described?.sessionFile ?? null,
|
|
117
|
+
bridge: true,
|
|
118
|
+
...(input.resultSpec ? { structuredResult: true } : {}),
|
|
119
|
+
// THE POOL, IN THE SHAPE AC-8 FIXES (`maxOpen` / `concurrency` / `default`):
|
|
120
|
+
// three numbers and one name, so a wrapper can decide whether opening a fifth
|
|
121
|
+
// conversation is worth the round trip, and which thread it is already talking to.
|
|
122
|
+
threads: {
|
|
123
|
+
maxOpen: options.maxOpenThreads,
|
|
124
|
+
concurrency: options.threadConcurrency,
|
|
125
|
+
default: DEFAULT_THREAD_ID,
|
|
126
|
+
},
|
|
127
|
+
hooks: true,
|
|
128
|
+
});
|
|
129
|
+
},
|
|
130
|
+
makeRuntime: ({ threadId, sessionId, sessionFile, lock, source }) => createThreadRuntime({
|
|
131
|
+
threadId,
|
|
132
|
+
sessionId,
|
|
133
|
+
sessionFile,
|
|
134
|
+
lock,
|
|
135
|
+
source,
|
|
136
|
+
flags: input.flags,
|
|
137
|
+
options,
|
|
138
|
+
version: input.version,
|
|
139
|
+
makeController: ctx.makeController,
|
|
140
|
+
emitter,
|
|
141
|
+
writer,
|
|
142
|
+
now,
|
|
143
|
+
resultSpec: input.resultSpec,
|
|
144
|
+
persist: input.persist,
|
|
145
|
+
hookTimeoutMs: options.hookTimeoutMs ?? 5_000,
|
|
146
|
+
// THE GATE'S RELEASE IS THE SOURCE'S (see `ThreadRuntimeDeps.source`): the slot is
|
|
147
|
+
// taken by the queue's `acquire()` and freed by its `release()`, at request
|
|
148
|
+
// granularity, inside the runner's own settle path. Releasing the gate from here as
|
|
149
|
+
// well would leave the queue believing it still holds a slot, and from that thread's
|
|
150
|
+
// second request onward `--thread-concurrency` would silently stop applying.
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
const owner = createInterruptOwner({ fanout: registry, ensureResult });
|
|
154
|
+
const dispatch = createThreadDispatchSource({
|
|
155
|
+
input: input.stdin,
|
|
156
|
+
ports: {
|
|
157
|
+
deliverPrompt: (threadId, text, frameId) => registry?.deliverPrompt(threadId, text, frameId),
|
|
158
|
+
deliverControl: (threadId, frame) => registry?.deliverControl(threadId, frame),
|
|
159
|
+
// `hook_answer` IS A CONTROL FRAME: the thread's own server owns the hook ports, so a
|
|
160
|
+
// second route would be a second place to forget.
|
|
161
|
+
deliverHookAnswer: (threadId, frame) => registry?.deliverControl(threadId, frame),
|
|
162
|
+
handleThreadFrame: (frame) => {
|
|
163
|
+
if (frame.type === 'thread_open')
|
|
164
|
+
registry?.handleOpen(frame);
|
|
165
|
+
else if (frame.type === 'thread_close')
|
|
166
|
+
registry?.handleClose(frame);
|
|
167
|
+
else if (frame.type === 'thread_list')
|
|
168
|
+
registry?.handleList(frame);
|
|
169
|
+
},
|
|
170
|
+
interruptProcess: () => owner.requestInterrupt(2),
|
|
171
|
+
rejectThreadedInterrupt: (threadId, frameId) => registry?.rejectThreadedInterrupt(threadId, frameId),
|
|
172
|
+
finishAll: () => registry?.finishAll(),
|
|
173
|
+
onBadLine: (code, message) => writer.write({
|
|
174
|
+
type: 'error',
|
|
175
|
+
sessionId: plan.id,
|
|
176
|
+
fatal: false,
|
|
177
|
+
code,
|
|
178
|
+
message,
|
|
179
|
+
}),
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
// THE ONE PROCESS-LEVEL TIMER (section 2.3.8b). Per-thread timers would fire at N
|
|
183
|
+
// different moments for one `result`, so the budget is measured once, here.
|
|
184
|
+
const durationTimer = options.maxDurationMs !== undefined
|
|
185
|
+
? setTimeout(() => {
|
|
186
|
+
timedOut = true;
|
|
187
|
+
registry?.timeoutAll();
|
|
188
|
+
}, options.maxDurationMs)
|
|
189
|
+
: null;
|
|
190
|
+
durationTimer?.unref?.();
|
|
191
|
+
// THE ONE PROCESS-LEVEL TERMINATOR (section 2.3.8a).
|
|
192
|
+
owner.install();
|
|
193
|
+
try {
|
|
194
|
+
await registry.openDefault();
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
dispatch.close();
|
|
198
|
+
gate.close();
|
|
199
|
+
const message = error instanceof ThreadOpenError
|
|
200
|
+
? error.message
|
|
201
|
+
: `The run could not start: ${String(error)}`;
|
|
202
|
+
input.err.write(`${message}\n`);
|
|
203
|
+
return 2;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
await registry.drained();
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
if (durationTimer)
|
|
210
|
+
clearTimeout(durationTimer);
|
|
211
|
+
dispatch.close();
|
|
212
|
+
gate.close();
|
|
213
|
+
}
|
|
214
|
+
const accounts = registry.accounts();
|
|
215
|
+
const decided = decideProcessOutcome({
|
|
216
|
+
interrupt: owner.signo() === null ? null : { signo: owner.signo() },
|
|
217
|
+
timedOut,
|
|
218
|
+
hardLimited: hardLimit.fired,
|
|
219
|
+
accounts,
|
|
220
|
+
});
|
|
221
|
+
// EVERYTHING QUEUED BEHIND `result` GOES OUT FIRST, then `result`, then the seal - the
|
|
222
|
+
// same order the single-run path uses, for the same reason (`result` must be last).
|
|
223
|
+
await writer.flush();
|
|
224
|
+
if (!resultWritten) {
|
|
225
|
+
resultWritten = true;
|
|
226
|
+
emitter.result(buildResult(decided.stopReason, decided.exitCode));
|
|
227
|
+
}
|
|
228
|
+
await writer.close();
|
|
229
|
+
return decided.exitCode;
|
|
230
|
+
}
|
|
231
|
+
/** Exported for the smoke test and for `exec/index.ts`'s capability stamp. */
|
|
232
|
+
export function threadCapabilities() {
|
|
233
|
+
return execCapabilities({ bridge: true, structuredResult: true, threads: true, hooks: true });
|
|
234
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The admission gate: how many threads may hold a request in flight
|
|
3
|
+
* (headless-threads-and-lifecycle-hooks section 2.3.5).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY - `src/exec/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* A COUNTING SEMAPHORE WITH THREE RULES, and no fourth:
|
|
8
|
+
* - `acquire` resolves when a slot is free, FIFO;
|
|
9
|
+
* - `release` frees one slot, once;
|
|
10
|
+
* - `close` makes every waiter (and every later acquirer) throw, so a process
|
|
11
|
+
* that is shutting down cannot be parked behind a slot that will never come.
|
|
12
|
+
*
|
|
13
|
+
* WHY FIFO RATHER THAN "WHOEVER ASKS NEXT": a caller that opened eight tenants in
|
|
14
|
+
* order expects them served in order. Preemption would let a noisy tenant starve a
|
|
15
|
+
* quiet one forever, and the slot is released at REQUEST granularity (not thread
|
|
16
|
+
* granularity), so fairness is cheap here.
|
|
17
|
+
*
|
|
18
|
+
* IT DOES NOT KNOW WHAT A REQUEST IS. The holder decides when to release; the gate
|
|
19
|
+
* only counts. That is what keeps this file 100 lines instead of a scheduler.
|
|
20
|
+
*/
|
|
21
|
+
export declare class GateClosedError extends Error {
|
|
22
|
+
constructor(message?: string);
|
|
23
|
+
}
|
|
24
|
+
export interface AdmissionGate {
|
|
25
|
+
/** Resolve once a slot is held. Rejects with `GateClosedError` after `close()`. */
|
|
26
|
+
acquire(threadId: string): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Free one slot. Idempotent per ACQUISITION, not per call: releasing twice for one
|
|
29
|
+
* acquire is a programming error and throws, because the alternative is a gate that
|
|
30
|
+
* silently admits more requests than `--thread-concurrency` allows.
|
|
31
|
+
*/
|
|
32
|
+
release(threadId: string): void;
|
|
33
|
+
/** Every waiter (and every later acquirer) throws `GateClosedError`. */
|
|
34
|
+
close(): void;
|
|
35
|
+
/** Slots currently held, for `thread_list` and for tests. */
|
|
36
|
+
inUse(): number;
|
|
37
|
+
}
|
|
38
|
+
export interface AdmissionGateOptions {
|
|
39
|
+
/** `--thread-concurrency`: the number of simultaneous slots. */
|
|
40
|
+
limit: number;
|
|
41
|
+
}
|
|
42
|
+
export declare function createAdmissionGate(options: AdmissionGateOptions): AdmissionGate;
|