pi-subagents 0.56.0 → 0.57.0
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 +53 -0
- package/agents/claude-code-writer.md +15 -0
- package/agents/claude-code.md +15 -0
- package/agents/codex-exec-writer.md +15 -0
- package/agents/codex-exec.md +15 -0
- package/agents/cursor-agent-writer.md +14 -0
- package/agents/cursor-agent.md +14 -0
- package/docs/agents.md +111 -15
- package/docs/configuration.md +37 -0
- package/docs/extension-api.md +2 -2
- package/docs/observability.md +8 -7
- package/docs/tool-reference.md +11 -1
- package/docs/workflows.md +20 -0
- package/package.json +1 -1
- package/skills/pi-subagents/references/execution-controls.md +1 -1
- package/src/agents/agent-management.ts +13 -5
- package/src/agents/agent-refinements.ts +4 -4
- package/src/agents/agents.ts +219 -48
- package/src/agents/builtin-names.ts +6 -0
- package/src/agents/runtime-agent-registry.ts +8 -2
- package/src/api/preflight.ts +2 -2
- package/src/extension/config.ts +70 -0
- package/src/extension/doctor.ts +3 -3
- package/src/extension/index.ts +18 -4
- package/src/extension/public-execution.ts +29 -13
- package/src/extension/rpc.ts +20 -5
- package/src/extension/schemas.ts +4 -3
- package/src/extension/tool-description.ts +11 -9
- package/src/inspectors/herdr/actions.ts +2 -1
- package/src/inspectors/herdr/inspector-runner.ts +2 -10
- package/src/inspectors/herdr/session-roots-codec.ts +42 -0
- package/src/integrations/herdr-status.ts +46 -3
- package/src/runs/background/active-async-capacity.ts +77 -10
- package/src/runs/background/async-execution.ts +100 -9
- package/src/runs/background/async-job-tracker.ts +5 -0
- package/src/runs/background/async-retention.ts +20 -3
- package/src/runs/background/async-status.ts +5 -0
- package/src/runs/background/chain-root-attachment.ts +15 -1
- package/src/runs/background/fleet-view.ts +16 -10
- package/src/runs/background/inspect-rpc.ts +8 -8
- package/src/runs/background/result-files.ts +27 -14
- package/src/runs/background/result-watcher.ts +9 -5
- package/src/runs/background/run-status.ts +33 -6
- package/src/runs/background/scheduled-runs.ts +7 -1
- package/src/runs/background/subagent-runner.ts +177 -44
- package/src/runs/background/wait-completions.ts +4 -0
- package/src/runs/foreground/execution.ts +72 -10
- package/src/runs/foreground/foreground-control.ts +6 -0
- package/src/runs/foreground/foreground-history.ts +22 -1
- package/src/runs/foreground/subagent-executor.ts +270 -81
- package/src/runs/foreground/workflow-detach-reconcile.ts +16 -1
- package/src/runs/shared/child-protocol.ts +21 -7
- package/src/runs/shared/claude-code-adapter.ts +129 -0
- package/src/runs/shared/codex-exec-adapter.ts +129 -0
- package/src/runs/shared/cursor-agent-adapter.ts +114 -0
- package/src/runs/shared/external-cli-contract.ts +167 -0
- package/src/runs/shared/external-cli-preflight.ts +122 -0
- package/src/runs/shared/external-cli-runner.ts +348 -55
- package/src/runs/shared/model-exclusions.ts +57 -7
- package/src/runs/shared/model-fallback.ts +23 -1
- package/src/runs/shared/mutation-evidence.ts +7 -2
- package/src/runs/shared/nested-events.ts +3 -1
- package/src/runs/shared/nested-render.ts +2 -2
- package/src/runs/shared/process-signal.ts +13 -0
- package/src/runs/shared/run-history.ts +21 -1
- package/src/shared/fork-context.ts +21 -0
- package/src/shared/formatters.ts +7 -1
- package/src/shared/pruned-fork.ts +450 -0
- package/src/shared/session-file-trust.ts +19 -0
- package/src/shared/session-tokens.ts +14 -3
- package/src/shared/settings.ts +10 -2
- package/src/shared/shortcuts.ts +17 -0
- package/src/shared/types.ts +118 -9
- package/src/shared/workflow-child-permit.ts +116 -0
- package/src/slash/delegation-adapters.ts +0 -1
- package/src/slash/slash-commands.ts +8 -6
- package/src/tui/fleet-status.ts +27 -10
- package/src/tui/fleet-transcript.ts +11 -5
- package/src/tui/fleet.ts +28 -13
- package/src/tui/render.ts +47 -21
- package/src/workflows/scripted-workflow.ts +248 -27
- package/src/workflows/workflow-child-summary.ts +117 -0
- package/src/workflows/workflow-receipt.ts +119 -4
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
const MAX_PROBE_OUTPUT_BYTES = 256 * 1024;
|
|
6
|
+
const MAX_PROBE_TIMEOUT_MS = 5_000;
|
|
7
|
+
const MAX_CACHE_ENTRIES = 64;
|
|
8
|
+
|
|
9
|
+
export type ExternalCliPreflightInvalidationReason = "launch" | "auth" | "parser" | "permission";
|
|
10
|
+
|
|
11
|
+
export interface ExternalCliPreflightSpec {
|
|
12
|
+
id: string;
|
|
13
|
+
versionArgs: readonly string[];
|
|
14
|
+
helpArgs: readonly string[];
|
|
15
|
+
evidenceArgs?: readonly string[];
|
|
16
|
+
evidenceLabel?: string;
|
|
17
|
+
probeTimeoutMs?: number;
|
|
18
|
+
validate?: (result: ExternalCliPreflightResult) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ExternalCliPreflightResult {
|
|
22
|
+
binaryPath: string;
|
|
23
|
+
binaryMtimeMs: number;
|
|
24
|
+
version: string;
|
|
25
|
+
help: string;
|
|
26
|
+
evidence?: string;
|
|
27
|
+
cacheHit: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type CachedPreflight = Omit<ExternalCliPreflightResult, "cacheHit">;
|
|
31
|
+
|
|
32
|
+
const cache = new Map<string, CachedPreflight>();
|
|
33
|
+
const lookup = new Map<string, string>();
|
|
34
|
+
|
|
35
|
+
function resolveBinary(command: string, env: NodeJS.ProcessEnv): string {
|
|
36
|
+
if (path.isAbsolute(command) || command.includes(path.sep)) {
|
|
37
|
+
const resolved = path.resolve(command);
|
|
38
|
+
fs.accessSync(resolved, fs.constants.X_OK);
|
|
39
|
+
return resolved;
|
|
40
|
+
}
|
|
41
|
+
const extensions = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";") : [""];
|
|
42
|
+
for (const directory of (env.PATH ?? "").split(path.delimiter)) {
|
|
43
|
+
if (!directory) continue;
|
|
44
|
+
for (const extension of extensions) {
|
|
45
|
+
const candidate = path.join(directory, `${command}${extension}`);
|
|
46
|
+
try {
|
|
47
|
+
fs.accessSync(candidate, fs.constants.X_OK);
|
|
48
|
+
return fs.realpathSync(candidate);
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`External CLI binary '${command}' was not found on PATH.`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function probeWithTimeout(binaryPath: string, args: readonly string[], env: NodeJS.ProcessEnv, label: string, timeoutMs: number, cwd?: string): string {
|
|
56
|
+
const result = spawnSync(binaryPath, [...args], {
|
|
57
|
+
cwd,
|
|
58
|
+
env,
|
|
59
|
+
encoding: "utf-8",
|
|
60
|
+
killSignal: "SIGKILL",
|
|
61
|
+
maxBuffer: MAX_PROBE_OUTPUT_BYTES,
|
|
62
|
+
timeout: timeoutMs,
|
|
63
|
+
windowsHide: true,
|
|
64
|
+
});
|
|
65
|
+
if (result.error) throw new Error(`External CLI ${label} preflight failed: ${result.error.message}`, { cause: result.error });
|
|
66
|
+
if (result.status !== 0) throw new Error(`External CLI ${label} preflight exited with code ${result.status}: ${(result.stderr || result.stdout).trim()}`);
|
|
67
|
+
return result.stdout.trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function narrowPositiveInteger(value: number | undefined, ceiling: number, label: string): number {
|
|
71
|
+
if (value === undefined) return ceiling;
|
|
72
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > ceiling) throw new Error(`${label} may only narrow the code-owned ceiling of ${ceiling}.`);
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function specKey(spec: ExternalCliPreflightSpec): string {
|
|
77
|
+
return JSON.stringify([spec.id, spec.versionArgs, spec.helpArgs, spec.evidenceArgs, spec.evidenceLabel, spec.probeTimeoutMs]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function preflightExternalCli(command: string, spec: ExternalCliPreflightSpec, env: NodeJS.ProcessEnv, cwd?: string): ExternalCliPreflightResult {
|
|
81
|
+
const binaryPath = resolveBinary(command, env);
|
|
82
|
+
const binaryMtimeMs = fs.statSync(binaryPath).mtimeMs;
|
|
83
|
+
const lookupKey = JSON.stringify([binaryPath, binaryMtimeMs, specKey(spec)]);
|
|
84
|
+
const cachedKey = lookup.get(lookupKey);
|
|
85
|
+
const cached = cachedKey ? cache.get(cachedKey) : undefined;
|
|
86
|
+
const probeTimeoutMs = narrowPositiveInteger(spec.probeTimeoutMs, MAX_PROBE_TIMEOUT_MS, "probeTimeoutMs");
|
|
87
|
+
const base = cached ?? {
|
|
88
|
+
binaryPath,
|
|
89
|
+
binaryMtimeMs,
|
|
90
|
+
version: probeWithTimeout(binaryPath, spec.versionArgs, env, "version", probeTimeoutMs, cwd),
|
|
91
|
+
help: probeWithTimeout(binaryPath, spec.helpArgs, env, "help", probeTimeoutMs, cwd),
|
|
92
|
+
};
|
|
93
|
+
const evidence = spec.evidenceArgs
|
|
94
|
+
? probeWithTimeout(binaryPath, spec.evidenceArgs, env, spec.evidenceLabel ?? "evidence", probeTimeoutMs, cwd)
|
|
95
|
+
: undefined;
|
|
96
|
+
const result = { ...base, ...(evidence !== undefined ? { evidence } : {}), cacheHit: Boolean(cached) };
|
|
97
|
+
spec.validate?.(result);
|
|
98
|
+
if (!cached) {
|
|
99
|
+
const cacheKey = JSON.stringify([binaryPath, base.version, binaryMtimeMs, specKey(spec)]);
|
|
100
|
+
cache.set(cacheKey, base);
|
|
101
|
+
lookup.set(lookupKey, cacheKey);
|
|
102
|
+
while (cache.size > MAX_CACHE_ENTRIES) {
|
|
103
|
+
const oldest = cache.keys().next().value as string;
|
|
104
|
+
cache.delete(oldest);
|
|
105
|
+
for (const [candidateLookup, candidateCache] of lookup) if (candidateCache === oldest) lookup.delete(candidateLookup);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function invalidateExternalCliPreflight(command: string, spec: ExternalCliPreflightSpec, _reason: ExternalCliPreflightInvalidationReason): void {
|
|
112
|
+
const key = specKey(spec);
|
|
113
|
+
for (const [cacheKey, entry] of cache) {
|
|
114
|
+
if (entry.binaryPath === command || entry.binaryPath.endsWith(`${path.sep}${command}`) || cacheKey.includes(key)) cache.delete(cacheKey);
|
|
115
|
+
}
|
|
116
|
+
for (const [lookupKey, cacheKey] of lookup) if (!cache.has(cacheKey)) lookup.delete(lookupKey);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function clearExternalCliPreflightCacheForTests(): void {
|
|
120
|
+
cache.clear();
|
|
121
|
+
lookup.clear();
|
|
122
|
+
}
|
|
@@ -1,19 +1,64 @@
|
|
|
1
|
-
import
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
3
|
import * as fs from "node:fs";
|
|
3
4
|
import * as path from "node:path";
|
|
4
5
|
import { finished } from "node:stream/promises";
|
|
5
|
-
import { trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
|
|
6
6
|
import type { ExternalProcessStatus } from "../../shared/types.ts";
|
|
7
|
+
import { createOwnedProcessTreeController, type OwnedProcessTreeController } from "../background/owned-process-tree.ts";
|
|
7
8
|
import { omitExtensionBindingsEnv } from "./extension-bindings.ts";
|
|
9
|
+
import {
|
|
10
|
+
invalidateExternalCliPreflight,
|
|
11
|
+
preflightExternalCli,
|
|
12
|
+
type ExternalCliPreflightResult,
|
|
13
|
+
type ExternalCliPreflightSpec,
|
|
14
|
+
} from "./external-cli-preflight.ts";
|
|
8
15
|
|
|
9
|
-
const HARD_KILL_DELAY_MS = 2_000;
|
|
10
16
|
const MAX_OUTPUT_TAIL_BYTES = 64 * 1024;
|
|
11
17
|
const MAX_ERROR_TAIL_BYTES = 64 * 1024;
|
|
18
|
+
const MAX_RAW_LOG_BYTES = 8 * 1024 * 1024;
|
|
19
|
+
const MAX_PARSER_LINE_BYTES = 256 * 1024;
|
|
20
|
+
const MAX_PARSER_STREAM_BYTES = 32 * 1024 * 1024;
|
|
21
|
+
const MAX_PARSER_OUTPUT_BYTES = 1024 * 1024;
|
|
22
|
+
const MAX_OVERSIZED_LINE_PREFIX_BYTES = 512;
|
|
23
|
+
const MAX_SKIPPABLE_LINE_BYTES = 1024 * 1024;
|
|
24
|
+
const PARSER_PROGRESS_INTERVAL_MS = 100;
|
|
12
25
|
|
|
13
26
|
export function buildExternalCliPrompt(systemInstructions: string, task: string): string {
|
|
14
27
|
return `<System instructions>\n${systemInstructions.trim()}\n\n<Task>\n${task}`;
|
|
15
28
|
}
|
|
16
29
|
|
|
30
|
+
export interface ExternalCliParserProgress {
|
|
31
|
+
phase: string;
|
|
32
|
+
eventCount: number;
|
|
33
|
+
message?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ExternalCliParserTerminal {
|
|
37
|
+
state: "completed" | "failed";
|
|
38
|
+
output?: string;
|
|
39
|
+
error?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ExternalCliParser {
|
|
43
|
+
parseLine(line: string): ExternalCliParserProgress | undefined;
|
|
44
|
+
/** Inspect only a bounded prefix when a non-terminal event exceeds the normal line cap. */
|
|
45
|
+
skipOversizedLine?(prefix: string, byteLength: number): ExternalCliParserProgress | undefined;
|
|
46
|
+
finish(): ExternalCliParserTerminal | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseExternalCliJsonlEvent(line: string, label: string, maxTypeLength: number): Record<string, unknown> {
|
|
50
|
+
let value: unknown;
|
|
51
|
+
try {
|
|
52
|
+
value = JSON.parse(line) as unknown;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new Error(`${label} emitted malformed JSONL: ${error instanceof Error ? error.message : String(error)}`);
|
|
55
|
+
}
|
|
56
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} emitted a JSONL event that is not an object.`);
|
|
57
|
+
const event = value as Record<string, unknown>;
|
|
58
|
+
if (typeof event.type !== "string" || !event.type || event.type.length > maxTypeLength) throw new Error(`${label} emitted a JSONL event with an invalid type.`);
|
|
59
|
+
return event;
|
|
60
|
+
}
|
|
61
|
+
|
|
17
62
|
export interface ExternalCliRunResult {
|
|
18
63
|
output: string;
|
|
19
64
|
exitCode: number | null;
|
|
@@ -22,6 +67,87 @@ export interface ExternalCliRunResult {
|
|
|
22
67
|
stopped?: boolean;
|
|
23
68
|
processSignal?: string | null;
|
|
24
69
|
externalProcess: ExternalProcessStatus;
|
|
70
|
+
parserTerminal?: ExternalCliParserTerminal;
|
|
71
|
+
preflight?: ExternalCliPreflightResult;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface StreamLimits {
|
|
75
|
+
stdoutLogBytes?: number;
|
|
76
|
+
stderrLogBytes?: number;
|
|
77
|
+
parserLineBytes?: number;
|
|
78
|
+
parserStreamBytes?: number;
|
|
79
|
+
parserOutputBytes?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function narrowLimit(value: number | undefined, ceiling: number, label: string): number {
|
|
83
|
+
if (value === undefined) return ceiling;
|
|
84
|
+
assert(Number.isInteger(value) && value > 0 && value <= ceiling, `${label} may only narrow the code-owned ${ceiling}-byte limit.`);
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function externalEnvironment(allowlist: readonly string[] | undefined, values: Readonly<Record<string, string>> | undefined): NodeJS.ProcessEnv {
|
|
89
|
+
if (!allowlist) return omitExtensionBindingsEnv(process.env);
|
|
90
|
+
const allowed = new Set(allowlist);
|
|
91
|
+
const env: NodeJS.ProcessEnv = {};
|
|
92
|
+
for (const key of allowed) {
|
|
93
|
+
if (!key || key.includes("=") || key.includes("\0")) throw new Error(`Invalid external CLI environment key: ${JSON.stringify(key)}.`);
|
|
94
|
+
if (process.env[key] !== undefined) env[key] = process.env[key];
|
|
95
|
+
}
|
|
96
|
+
for (const [key, value] of Object.entries(values ?? {})) {
|
|
97
|
+
if (!allowed.has(key)) throw new Error(`External CLI environment value '${key}' is not in the adapter allowlist.`);
|
|
98
|
+
env[key] = value;
|
|
99
|
+
}
|
|
100
|
+
return omitExtensionBindingsEnv(env);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createByteTail(maxBytes: number): { push(chunk: Buffer): void; text(): string } {
|
|
104
|
+
const chunks: Buffer[] = [];
|
|
105
|
+
let bytes = 0;
|
|
106
|
+
return {
|
|
107
|
+
push(chunk) {
|
|
108
|
+
chunks.push(chunk);
|
|
109
|
+
bytes += chunk.length;
|
|
110
|
+
while (bytes > maxBytes && chunks.length > 0) {
|
|
111
|
+
const first = chunks[0]!;
|
|
112
|
+
const excess = bytes - maxBytes;
|
|
113
|
+
if (first.length <= excess) {
|
|
114
|
+
chunks.shift();
|
|
115
|
+
bytes -= first.length;
|
|
116
|
+
} else {
|
|
117
|
+
chunks[0] = first.subarray(excess);
|
|
118
|
+
bytes -= excess;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
text: () => Buffer.concat(chunks, bytes).toString("utf-8"),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function writeBoundedLog(source: NodeJS.ReadableStream, stream: fs.WriteStream, chunk: Buffer, state: { bytes: number; total: number }, limit: number): void {
|
|
127
|
+
state.total += chunk.length;
|
|
128
|
+
const remaining = limit - state.bytes;
|
|
129
|
+
if (remaining <= 0) return;
|
|
130
|
+
const bytes = chunk.length > remaining ? chunk.subarray(0, remaining) : chunk;
|
|
131
|
+
state.bytes += bytes.length;
|
|
132
|
+
if (!stream.write(bytes)) {
|
|
133
|
+
source.pause();
|
|
134
|
+
stream.once("drain", () => source.resume());
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function classifyInvalidation(error: string): "auth" | "permission" | "launch" {
|
|
139
|
+
if (/auth|unauthori[sz]ed|credential|login/i.test(error)) return "auth";
|
|
140
|
+
if (/permission|forbidden|denied|read.?only/i.test(error)) return "permission";
|
|
141
|
+
return "launch";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function terminateExternalProcessTree(pid: number, controller: OwnedProcessTreeController): Promise<unknown> {
|
|
145
|
+
if (process.platform !== "win32") return controller.terminate();
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
const cleanup = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
148
|
+
cleanup.once("error", () => { void controller.terminate().then(resolve); });
|
|
149
|
+
cleanup.once("close", () => { void controller.terminate().then(resolve); });
|
|
150
|
+
});
|
|
25
151
|
}
|
|
26
152
|
|
|
27
153
|
export function runExternalCli(input: {
|
|
@@ -31,14 +157,29 @@ export function runExternalCli(input: {
|
|
|
31
157
|
prompt: string;
|
|
32
158
|
asyncDir: string;
|
|
33
159
|
stepIndex: number;
|
|
160
|
+
environment?: { allowlist: readonly string[]; values?: Readonly<Record<string, string>> };
|
|
161
|
+
preflight?: ExternalCliPreflightSpec;
|
|
162
|
+
parser?: ExternalCliParser;
|
|
163
|
+
finalOutputPath?: string;
|
|
164
|
+
promptFilePath?: string;
|
|
165
|
+
temporaryDirectories?: readonly string[];
|
|
166
|
+
limits?: StreamLimits;
|
|
34
167
|
registerTimeout?: (stop: (() => void) | undefined) => void;
|
|
35
168
|
registerStop?: (stop: (() => void) | undefined) => void;
|
|
36
169
|
timeoutMessage?: string;
|
|
37
170
|
stopMessage?: string;
|
|
38
171
|
onProcess?: (process: ExternalProcessStatus) => void;
|
|
172
|
+
onParserProgress?: (progress: ExternalCliParserProgress) => void;
|
|
39
173
|
onStdout?: (chunk: Buffer) => void;
|
|
40
174
|
onStderr?: (chunk: Buffer) => void;
|
|
41
175
|
}): Promise<ExternalCliRunResult> {
|
|
176
|
+
const limits = {
|
|
177
|
+
stdoutLogBytes: narrowLimit(input.limits?.stdoutLogBytes, MAX_RAW_LOG_BYTES, "stdoutLogBytes"),
|
|
178
|
+
stderrLogBytes: narrowLimit(input.limits?.stderrLogBytes, MAX_RAW_LOG_BYTES, "stderrLogBytes"),
|
|
179
|
+
parserLineBytes: narrowLimit(input.limits?.parserLineBytes, MAX_PARSER_LINE_BYTES, "parserLineBytes"),
|
|
180
|
+
parserStreamBytes: narrowLimit(input.limits?.parserStreamBytes, MAX_PARSER_STREAM_BYTES, "parserStreamBytes"),
|
|
181
|
+
parserOutputBytes: narrowLimit(input.limits?.parserOutputBytes, MAX_PARSER_OUTPUT_BYTES, "parserOutputBytes"),
|
|
182
|
+
};
|
|
42
183
|
return new Promise((resolve, reject) => {
|
|
43
184
|
const startedAt = Date.now();
|
|
44
185
|
const stdoutPath = path.join(input.asyncDir, `external-${input.stepIndex}.stdout.log`);
|
|
@@ -47,90 +188,242 @@ export function runExternalCli(input: {
|
|
|
47
188
|
const stdoutStream = fs.createWriteStream(stdoutPath, { flags: "w" });
|
|
48
189
|
const stderrStream = fs.createWriteStream(stderrPath, { flags: "w" });
|
|
49
190
|
const streamsFinished = Promise.allSettled([finished(stdoutStream), finished(stderrStream)]);
|
|
50
|
-
|
|
51
|
-
let
|
|
191
|
+
const createdDirectories: string[] = [];
|
|
192
|
+
let promptFileCreated = false;
|
|
193
|
+
const cleanupTemporaryPaths = () => {
|
|
194
|
+
if (input.promptFilePath && promptFileCreated) fs.rmSync(input.promptFilePath, { force: true });
|
|
195
|
+
for (const directory of createdDirectories.reverse()) fs.rmSync(directory, { recursive: true, force: true });
|
|
196
|
+
};
|
|
197
|
+
const env = externalEnvironment(input.environment?.allowlist, input.environment?.values);
|
|
198
|
+
let preflight: ExternalCliPreflightResult | undefined;
|
|
199
|
+
try {
|
|
200
|
+
for (const directory of input.temporaryDirectories ?? []) {
|
|
201
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
202
|
+
createdDirectories.push(directory);
|
|
203
|
+
}
|
|
204
|
+
if (input.promptFilePath) {
|
|
205
|
+
const promptDescriptor = fs.openSync(input.promptFilePath, "wx", 0o600);
|
|
206
|
+
promptFileCreated = true;
|
|
207
|
+
try { fs.writeFileSync(promptDescriptor, input.prompt, { encoding: "utf-8" }); }
|
|
208
|
+
finally { fs.closeSync(promptDescriptor); }
|
|
209
|
+
}
|
|
210
|
+
if (input.preflight) preflight = preflightExternalCli(input.command, input.preflight, env, input.cwd);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
const endedAt = Date.now();
|
|
213
|
+
const externalProcess = { startedAt, endedAt, durationMs: endedAt - startedAt, exitCode: 1, processSignal: null, stdoutPath, stderrPath, ...(input.finalOutputPath ? { finalOutputPath: input.finalOutputPath } : {}) } satisfies ExternalProcessStatus;
|
|
214
|
+
stdoutStream.end();
|
|
215
|
+
stderrStream.end();
|
|
216
|
+
void streamsFinished.then((streamResults) => {
|
|
217
|
+
try { cleanupTemporaryPaths(); }
|
|
218
|
+
catch (cleanupError) { reject(cleanupError); return; }
|
|
219
|
+
const streamFailure = streamResults.find((streamResult) => streamResult.status === "rejected");
|
|
220
|
+
if (streamFailure?.status === "rejected") reject(streamFailure.reason);
|
|
221
|
+
else resolve({ output: "", exitCode: 1, error: error instanceof Error ? error.message : String(error), processSignal: null, externalProcess });
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const stdoutTail = createByteTail(MAX_OUTPUT_TAIL_BYTES);
|
|
226
|
+
const stderrTail = createByteTail(MAX_ERROR_TAIL_BYTES);
|
|
227
|
+
const stdoutLog = { bytes: 0, total: 0 };
|
|
228
|
+
const stderrLog = { bytes: 0, total: 0 };
|
|
229
|
+
let parserBytes = 0;
|
|
230
|
+
let pendingLine = Buffer.alloc(0);
|
|
231
|
+
let pendingLineBytes = 0;
|
|
232
|
+
let pendingLineOversizedAccepted = false;
|
|
233
|
+
let parserError: Error | undefined;
|
|
234
|
+
let parserTerminal: ExternalCliParserTerminal | undefined;
|
|
235
|
+
let latestProgress: ExternalCliParserProgress | undefined;
|
|
236
|
+
let progressTimer: NodeJS.Timeout | undefined;
|
|
52
237
|
let timedOut = false;
|
|
53
238
|
let stopped = false;
|
|
54
239
|
let settled = false;
|
|
55
|
-
let
|
|
56
|
-
|
|
240
|
+
let processTree: OwnedProcessTreeController | undefined;
|
|
241
|
+
let processPid: number | undefined;
|
|
242
|
+
let termination: Promise<unknown> | undefined;
|
|
243
|
+
const flushProgress = () => {
|
|
244
|
+
if (!latestProgress) return;
|
|
245
|
+
input.onParserProgress?.(latestProgress);
|
|
246
|
+
latestProgress = undefined;
|
|
247
|
+
};
|
|
248
|
+
const reportProgress = (progress: ExternalCliParserProgress) => {
|
|
249
|
+
if (!progress.phase || progress.phase.length > 64 || !Number.isSafeInteger(progress.eventCount) || progress.eventCount < 0) {
|
|
250
|
+
failParser(new Error("External CLI parser returned invalid progress metadata."));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
latestProgress = { ...progress, ...(progress.message ? { message: progress.message.slice(0, 512) } : {}) };
|
|
254
|
+
if (progressTimer) return;
|
|
255
|
+
progressTimer = setTimeout(() => {
|
|
256
|
+
progressTimer = undefined;
|
|
257
|
+
flushProgress();
|
|
258
|
+
}, PARSER_PROGRESS_INTERVAL_MS);
|
|
259
|
+
progressTimer.unref?.();
|
|
260
|
+
};
|
|
261
|
+
const terminate = (reason: "timeout" | "stop") => {
|
|
262
|
+
if (settled || timedOut || stopped || parserError) return;
|
|
263
|
+
timedOut = reason === "timeout";
|
|
264
|
+
stopped = reason === "stop";
|
|
265
|
+
if (processTree && processPid !== undefined) termination = terminateExternalProcessTree(processPid, processTree);
|
|
266
|
+
};
|
|
267
|
+
const failParser = (error: unknown) => {
|
|
268
|
+
if (parserError) return;
|
|
269
|
+
parserError = error instanceof Error ? error : new Error(String(error));
|
|
270
|
+
if (input.preflight) invalidateExternalCliPreflight(input.command, input.preflight, "parser");
|
|
271
|
+
if (processTree && processPid !== undefined) termination = terminateExternalProcessTree(processPid, processTree);
|
|
272
|
+
};
|
|
273
|
+
const parseLine = (line: Buffer, byteLength = line.length): boolean => {
|
|
274
|
+
if (!input.parser || parserError) return false;
|
|
275
|
+
if (byteLength > limits.parserLineBytes) {
|
|
276
|
+
const progress = input.limits?.parserLineBytes === undefined
|
|
277
|
+
? input.parser.skipOversizedLine?.(line.subarray(0, MAX_OVERSIZED_LINE_PREFIX_BYTES).toString("utf-8"), byteLength)
|
|
278
|
+
: undefined;
|
|
279
|
+
if (progress) { reportProgress(progress); return true; }
|
|
280
|
+
failParser(new Error("External CLI parser line exceeded its byte limit."));
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
const progress = input.parser.parseLine(line.toString("utf-8"));
|
|
285
|
+
if (progress) reportProgress(progress);
|
|
286
|
+
} catch (error) { failParser(error); }
|
|
287
|
+
return false;
|
|
288
|
+
};
|
|
289
|
+
const appendPendingLine = (chunk: Buffer) => {
|
|
290
|
+
pendingLineBytes += chunk.length;
|
|
291
|
+
if (pendingLineOversizedAccepted) {
|
|
292
|
+
if (pendingLineBytes > MAX_SKIPPABLE_LINE_BYTES) failParser(new Error("External CLI parser line exceeded its byte limit."));
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (pendingLineBytes <= limits.parserLineBytes) {
|
|
296
|
+
pendingLine = Buffer.concat([pendingLine, chunk]);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (pendingLineBytes > MAX_SKIPPABLE_LINE_BYTES) {
|
|
300
|
+
failParser(new Error("External CLI parser line exceeded its byte limit."));
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (pendingLine.length > MAX_OVERSIZED_LINE_PREFIX_BYTES) pendingLine = pendingLine.subarray(0, MAX_OVERSIZED_LINE_PREFIX_BYTES);
|
|
304
|
+
const remainingPrefixBytes = MAX_OVERSIZED_LINE_PREFIX_BYTES - pendingLine.length;
|
|
305
|
+
if (remainingPrefixBytes > 0) pendingLine = Buffer.concat([pendingLine, chunk.subarray(0, remainingPrefixBytes)]);
|
|
306
|
+
pendingLineOversizedAccepted = parseLine(pendingLine, pendingLineBytes);
|
|
307
|
+
};
|
|
308
|
+
const finishPendingLine = () => {
|
|
309
|
+
if (!pendingLineOversizedAccepted) parseLine(pendingLine, pendingLineBytes);
|
|
310
|
+
pendingLine = Buffer.alloc(0);
|
|
311
|
+
pendingLineBytes = 0;
|
|
312
|
+
pendingLineOversizedAccepted = false;
|
|
313
|
+
};
|
|
314
|
+
const parseChunk = (chunk: Buffer) => {
|
|
315
|
+
if (!input.parser || parserError) return;
|
|
316
|
+
parserBytes += chunk.length;
|
|
317
|
+
if (parserBytes > limits.parserStreamBytes) {
|
|
318
|
+
failParser(new Error("External CLI parser stream exceeded its byte limit."));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
let start = 0;
|
|
322
|
+
for (let index = 0; index < chunk.length; index++) {
|
|
323
|
+
if (chunk[index] !== 0x0a) continue;
|
|
324
|
+
appendPendingLine(chunk.subarray(start, index));
|
|
325
|
+
finishPendingLine();
|
|
326
|
+
start = index + 1;
|
|
327
|
+
}
|
|
328
|
+
appendPendingLine(chunk.subarray(start));
|
|
329
|
+
};
|
|
330
|
+
const child = spawn(preflight?.binaryPath ?? input.command, input.args ?? [], {
|
|
57
331
|
cwd: input.cwd,
|
|
58
|
-
env
|
|
332
|
+
env,
|
|
59
333
|
stdio: ["pipe", "pipe", "pipe"],
|
|
60
334
|
shell: false,
|
|
61
335
|
windowsHide: true,
|
|
62
|
-
|
|
336
|
+
detached: process.platform !== "win32",
|
|
337
|
+
}) as ChildProcessWithoutNullStreams;
|
|
338
|
+
if (typeof child.pid === "number") {
|
|
339
|
+
processPid = child.pid;
|
|
340
|
+
processTree = createOwnedProcessTreeController(child.pid, { termGraceMs: 2_000 });
|
|
341
|
+
}
|
|
63
342
|
const initialProcess: ExternalProcessStatus = {
|
|
64
343
|
...(typeof child.pid === "number" ? { pid: child.pid } : {}),
|
|
65
344
|
startedAt,
|
|
66
345
|
stdoutPath,
|
|
67
346
|
stderrPath,
|
|
347
|
+
...(input.finalOutputPath ? { finalOutputPath: input.finalOutputPath } : {}),
|
|
68
348
|
};
|
|
69
349
|
input.onProcess?.(initialProcess);
|
|
70
350
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
71
|
-
|
|
351
|
+
parseChunk(chunk);
|
|
352
|
+
writeBoundedLog(child.stdout, stdoutStream, chunk, stdoutLog, limits.stdoutLogBytes);
|
|
72
353
|
input.onStdout?.(chunk);
|
|
73
|
-
stdoutTail
|
|
74
|
-
if (stdoutTail.length > MAX_OUTPUT_TAIL_BYTES) stdoutTail = stdoutTail.subarray(stdoutTail.length - MAX_OUTPUT_TAIL_BYTES);
|
|
354
|
+
stdoutTail.push(chunk);
|
|
75
355
|
});
|
|
76
356
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
77
|
-
stderrStream.
|
|
357
|
+
writeBoundedLog(child.stderr, stderrStream, chunk, stderrLog, limits.stderrLogBytes);
|
|
78
358
|
input.onStderr?.(chunk);
|
|
79
|
-
stderrTail
|
|
80
|
-
if (stderrTail.length > MAX_ERROR_TAIL_BYTES) stderrTail = stderrTail.subarray(stderrTail.length - MAX_ERROR_TAIL_BYTES);
|
|
359
|
+
stderrTail.push(chunk);
|
|
81
360
|
});
|
|
82
|
-
const terminate = (reason: "timeout" | "stop") => {
|
|
83
|
-
if (settled || timedOut || stopped) return;
|
|
84
|
-
timedOut = reason === "timeout";
|
|
85
|
-
stopped = reason === "stop";
|
|
86
|
-
trySignalChild(child, "SIGTERM");
|
|
87
|
-
hardKillTimer = setTimeout(() => {
|
|
88
|
-
if (!settled) trySignalChild(child, "SIGKILL");
|
|
89
|
-
}, HARD_KILL_DELAY_MS);
|
|
90
|
-
hardKillTimer.unref?.();
|
|
91
|
-
};
|
|
92
361
|
input.registerTimeout?.(() => terminate("timeout"));
|
|
93
362
|
input.registerStop?.(() => terminate("stop"));
|
|
94
363
|
child.stdin.on("error", () => {});
|
|
95
|
-
child.stdin.end(input.prompt);
|
|
364
|
+
child.stdin.end(input.promptFilePath ? undefined : input.prompt);
|
|
96
365
|
let spawnError: Error | undefined;
|
|
97
366
|
child.once("error", (error) => { spawnError = error; });
|
|
367
|
+
child.stdout.once("end", () => {
|
|
368
|
+
if (!input.parser || parserError) return;
|
|
369
|
+
if (pendingLineBytes > 0) finishPendingLine();
|
|
370
|
+
try {
|
|
371
|
+
parserTerminal = input.parser.finish();
|
|
372
|
+
if (!parserTerminal) failParser(new Error("External CLI parser did not produce a terminal state."));
|
|
373
|
+
else if (Buffer.byteLength(parserTerminal.output ?? "", "utf-8") > limits.parserOutputBytes) failParser(new Error("External CLI parser terminal output exceeded its byte limit."));
|
|
374
|
+
else if (Buffer.byteLength(parserTerminal.error ?? "", "utf-8") > 4 * 1024) failParser(new Error("External CLI parser terminal error exceeded its byte limit."));
|
|
375
|
+
} catch (error) { failParser(error); }
|
|
376
|
+
});
|
|
98
377
|
child.once("close", (exitCode, signal) => {
|
|
99
378
|
settled = true;
|
|
100
|
-
if (
|
|
379
|
+
if (progressTimer) clearTimeout(progressTimer);
|
|
380
|
+
flushProgress();
|
|
101
381
|
input.registerTimeout?.(undefined);
|
|
102
382
|
input.registerStop?.(undefined);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
endedAt
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
383
|
+
void (async () => {
|
|
384
|
+
if (termination) await termination;
|
|
385
|
+
else if (processTree) await processTree.finishAfterWriterClose();
|
|
386
|
+
const endedAt = Date.now();
|
|
387
|
+
const externalProcess: ExternalProcessStatus = {
|
|
388
|
+
...initialProcess,
|
|
389
|
+
endedAt,
|
|
390
|
+
durationMs: endedAt - startedAt,
|
|
391
|
+
exitCode,
|
|
392
|
+
processSignal: signal,
|
|
393
|
+
stdoutBytes: stdoutLog.total,
|
|
394
|
+
stderrBytes: stderrLog.total,
|
|
395
|
+
...(stdoutLog.total > stdoutLog.bytes ? { stdoutTruncated: true } : {}),
|
|
396
|
+
...(stderrLog.total > stderrLog.bytes ? { stderrTruncated: true } : {}),
|
|
397
|
+
};
|
|
398
|
+
input.onProcess?.(externalProcess);
|
|
399
|
+
const stderr = stderrTail.text().trim();
|
|
400
|
+
const parserFailure = parserError?.message ?? (parserTerminal?.state === "failed" ? parserTerminal.error ?? "External CLI parser reported terminal failure." : undefined);
|
|
401
|
+
const error = stopped
|
|
402
|
+
? input.stopMessage ?? "Subagent stopped by user."
|
|
403
|
+
: timedOut
|
|
404
|
+
? input.timeoutMessage ?? "Subagent timed out."
|
|
405
|
+
: spawnError?.message ?? parserFailure ?? (exitCode === 0 ? undefined : stderr || `External CLI exited with code ${exitCode}.`);
|
|
406
|
+
if (error && input.preflight && !parserError) invalidateExternalCliPreflight(input.command, input.preflight, classifyInvalidation(error));
|
|
407
|
+
const result: ExternalCliRunResult = {
|
|
408
|
+
output: (!parserError && parserTerminal?.state === "completed" ? parserTerminal.output ?? "" : stdoutTail.text()).trim(),
|
|
409
|
+
exitCode: timedOut || stopped || spawnError || parserFailure ? 1 : exitCode,
|
|
410
|
+
...(error ? { error } : {}),
|
|
411
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
412
|
+
...(stopped ? { stopped: true } : {}),
|
|
413
|
+
processSignal: signal,
|
|
414
|
+
externalProcess,
|
|
415
|
+
...(parserTerminal ? { parserTerminal } : {}),
|
|
416
|
+
...(preflight ? { preflight } : {}),
|
|
417
|
+
};
|
|
418
|
+
stdoutStream.end();
|
|
419
|
+
stderrStream.end();
|
|
420
|
+
const streamResults = await streamsFinished;
|
|
130
421
|
const streamFailure = streamResults.find((streamResult) => streamResult.status === "rejected");
|
|
422
|
+
try { cleanupTemporaryPaths(); }
|
|
423
|
+
catch (cleanupError) { reject(cleanupError); return; }
|
|
131
424
|
if (streamFailure?.status === "rejected") reject(streamFailure.reason);
|
|
132
425
|
else resolve(result);
|
|
133
|
-
});
|
|
426
|
+
})();
|
|
134
427
|
});
|
|
135
428
|
});
|
|
136
429
|
}
|