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.
Files changed (83) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/agents/claude-code-writer.md +15 -0
  3. package/agents/claude-code.md +15 -0
  4. package/agents/codex-exec-writer.md +15 -0
  5. package/agents/codex-exec.md +15 -0
  6. package/agents/cursor-agent-writer.md +14 -0
  7. package/agents/cursor-agent.md +14 -0
  8. package/docs/agents.md +111 -15
  9. package/docs/configuration.md +37 -0
  10. package/docs/extension-api.md +2 -2
  11. package/docs/observability.md +8 -7
  12. package/docs/tool-reference.md +11 -1
  13. package/docs/workflows.md +20 -0
  14. package/package.json +1 -1
  15. package/skills/pi-subagents/references/execution-controls.md +1 -1
  16. package/src/agents/agent-management.ts +13 -5
  17. package/src/agents/agent-refinements.ts +4 -4
  18. package/src/agents/agents.ts +219 -48
  19. package/src/agents/builtin-names.ts +6 -0
  20. package/src/agents/runtime-agent-registry.ts +8 -2
  21. package/src/api/preflight.ts +2 -2
  22. package/src/extension/config.ts +70 -0
  23. package/src/extension/doctor.ts +3 -3
  24. package/src/extension/index.ts +18 -4
  25. package/src/extension/public-execution.ts +29 -13
  26. package/src/extension/rpc.ts +20 -5
  27. package/src/extension/schemas.ts +4 -3
  28. package/src/extension/tool-description.ts +11 -9
  29. package/src/inspectors/herdr/actions.ts +2 -1
  30. package/src/inspectors/herdr/inspector-runner.ts +2 -10
  31. package/src/inspectors/herdr/session-roots-codec.ts +42 -0
  32. package/src/integrations/herdr-status.ts +46 -3
  33. package/src/runs/background/active-async-capacity.ts +77 -10
  34. package/src/runs/background/async-execution.ts +100 -9
  35. package/src/runs/background/async-job-tracker.ts +5 -0
  36. package/src/runs/background/async-retention.ts +20 -3
  37. package/src/runs/background/async-status.ts +5 -0
  38. package/src/runs/background/chain-root-attachment.ts +15 -1
  39. package/src/runs/background/fleet-view.ts +16 -10
  40. package/src/runs/background/inspect-rpc.ts +8 -8
  41. package/src/runs/background/result-files.ts +27 -14
  42. package/src/runs/background/result-watcher.ts +9 -5
  43. package/src/runs/background/run-status.ts +33 -6
  44. package/src/runs/background/scheduled-runs.ts +7 -1
  45. package/src/runs/background/subagent-runner.ts +177 -44
  46. package/src/runs/background/wait-completions.ts +4 -0
  47. package/src/runs/foreground/execution.ts +72 -10
  48. package/src/runs/foreground/foreground-control.ts +6 -0
  49. package/src/runs/foreground/foreground-history.ts +22 -1
  50. package/src/runs/foreground/subagent-executor.ts +270 -81
  51. package/src/runs/foreground/workflow-detach-reconcile.ts +16 -1
  52. package/src/runs/shared/child-protocol.ts +21 -7
  53. package/src/runs/shared/claude-code-adapter.ts +129 -0
  54. package/src/runs/shared/codex-exec-adapter.ts +129 -0
  55. package/src/runs/shared/cursor-agent-adapter.ts +114 -0
  56. package/src/runs/shared/external-cli-contract.ts +167 -0
  57. package/src/runs/shared/external-cli-preflight.ts +122 -0
  58. package/src/runs/shared/external-cli-runner.ts +348 -55
  59. package/src/runs/shared/model-exclusions.ts +57 -7
  60. package/src/runs/shared/model-fallback.ts +23 -1
  61. package/src/runs/shared/mutation-evidence.ts +7 -2
  62. package/src/runs/shared/nested-events.ts +3 -1
  63. package/src/runs/shared/nested-render.ts +2 -2
  64. package/src/runs/shared/process-signal.ts +13 -0
  65. package/src/runs/shared/run-history.ts +21 -1
  66. package/src/shared/fork-context.ts +21 -0
  67. package/src/shared/formatters.ts +7 -1
  68. package/src/shared/pruned-fork.ts +450 -0
  69. package/src/shared/session-file-trust.ts +19 -0
  70. package/src/shared/session-tokens.ts +14 -3
  71. package/src/shared/settings.ts +10 -2
  72. package/src/shared/shortcuts.ts +17 -0
  73. package/src/shared/types.ts +118 -9
  74. package/src/shared/workflow-child-permit.ts +116 -0
  75. package/src/slash/delegation-adapters.ts +0 -1
  76. package/src/slash/slash-commands.ts +8 -6
  77. package/src/tui/fleet-status.ts +27 -10
  78. package/src/tui/fleet-transcript.ts +11 -5
  79. package/src/tui/fleet.ts +28 -13
  80. package/src/tui/render.ts +47 -21
  81. package/src/workflows/scripted-workflow.ts +248 -27
  82. package/src/workflows/workflow-child-summary.ts +117 -0
  83. 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 { spawn } from "node:child_process";
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
- let stdoutTail = Buffer.alloc(0);
51
- let stderrTail = Buffer.alloc(0);
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 hardKillTimer: NodeJS.Timeout | undefined;
56
- const child = spawn(input.command, input.args ?? [], {
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: omitExtensionBindingsEnv(process.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
- stdoutStream.write(chunk);
351
+ parseChunk(chunk);
352
+ writeBoundedLog(child.stdout, stdoutStream, chunk, stdoutLog, limits.stdoutLogBytes);
72
353
  input.onStdout?.(chunk);
73
- stdoutTail = Buffer.concat([stdoutTail, chunk]);
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.write(chunk);
357
+ writeBoundedLog(child.stderr, stderrStream, chunk, stderrLog, limits.stderrLogBytes);
78
358
  input.onStderr?.(chunk);
79
- stderrTail = Buffer.concat([stderrTail, chunk]);
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 (hardKillTimer) clearTimeout(hardKillTimer);
379
+ if (progressTimer) clearTimeout(progressTimer);
380
+ flushProgress();
101
381
  input.registerTimeout?.(undefined);
102
382
  input.registerStop?.(undefined);
103
- const endedAt = Date.now();
104
- const externalProcess: ExternalProcessStatus = {
105
- ...initialProcess,
106
- endedAt,
107
- durationMs: endedAt - startedAt,
108
- exitCode,
109
- processSignal: signal,
110
- };
111
- input.onProcess?.(externalProcess);
112
- const stderr = stderrTail.toString("utf-8").trim();
113
- const error = stopped
114
- ? input.stopMessage ?? "Subagent stopped by user."
115
- : timedOut
116
- ? input.timeoutMessage ?? "Subagent timed out."
117
- : spawnError?.message ?? (exitCode === 0 ? undefined : stderr || `External CLI exited with code ${exitCode}.`);
118
- const result: ExternalCliRunResult = {
119
- output: stdoutTail.toString("utf-8").trim(),
120
- exitCode: timedOut || stopped || spawnError ? 1 : exitCode,
121
- ...(error ? { error } : {}),
122
- ...(timedOut ? { timedOut: true } : {}),
123
- ...(stopped ? { stopped: true } : {}),
124
- processSignal: signal,
125
- externalProcess,
126
- };
127
- stdoutStream.end();
128
- stderrStream.end();
129
- void streamsFinished.then((streamResults) => {
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
  }