pi-subagents 0.55.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 (97) hide show
  1. package/CHANGELOG.md +81 -3
  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/models.md +6 -0
  12. package/docs/observability.md +8 -7
  13. package/docs/tool-reference.md +11 -1
  14. package/docs/workflows.md +20 -0
  15. package/package.json +1 -1
  16. package/skills/pi-subagents/references/execution-controls.md +1 -1
  17. package/src/agents/agent-management.ts +13 -5
  18. package/src/agents/agent-refinements.ts +4 -4
  19. package/src/agents/agent-serializer.ts +2 -0
  20. package/src/agents/agents.ts +242 -49
  21. package/src/agents/builtin-names.ts +6 -0
  22. package/src/agents/runtime-agent-registry.ts +8 -2
  23. package/src/api/preflight.ts +22 -3
  24. package/src/extension/config.ts +70 -0
  25. package/src/extension/doctor.ts +3 -3
  26. package/src/extension/index.ts +18 -4
  27. package/src/extension/public-execution.ts +29 -13
  28. package/src/extension/rpc.ts +20 -5
  29. package/src/extension/schemas.ts +9 -3
  30. package/src/extension/tool-description.ts +11 -9
  31. package/src/inspectors/herdr/actions.ts +2 -1
  32. package/src/inspectors/herdr/inspector-runner.ts +2 -10
  33. package/src/inspectors/herdr/session-roots-codec.ts +42 -0
  34. package/src/integrations/herdr-status.ts +46 -3
  35. package/src/runs/background/active-async-capacity.ts +77 -10
  36. package/src/runs/background/async-execution.ts +148 -27
  37. package/src/runs/background/async-job-tracker.ts +5 -0
  38. package/src/runs/background/async-resume.ts +3 -0
  39. package/src/runs/background/async-retention.ts +20 -3
  40. package/src/runs/background/async-status.ts +5 -0
  41. package/src/runs/background/chain-root-attachment.ts +15 -1
  42. package/src/runs/background/fleet-view.ts +16 -10
  43. package/src/runs/background/inspect-rpc.ts +8 -8
  44. package/src/runs/background/result-files.ts +27 -14
  45. package/src/runs/background/result-watcher.ts +9 -5
  46. package/src/runs/background/run-status.ts +33 -6
  47. package/src/runs/background/scheduled-runs.ts +7 -1
  48. package/src/runs/background/subagent-runner.ts +254 -49
  49. package/src/runs/background/wait-completions.ts +4 -0
  50. package/src/runs/foreground/execution.ts +107 -16
  51. package/src/runs/foreground/foreground-control.ts +6 -0
  52. package/src/runs/foreground/foreground-history.ts +25 -3
  53. package/src/runs/foreground/subagent-executor.ts +324 -84
  54. package/src/runs/foreground/workflow-detach-reconcile.ts +31 -7
  55. package/src/runs/shared/acceptance.ts +10 -5
  56. package/src/runs/shared/agent-contract.ts +1 -1
  57. package/src/runs/shared/child-protocol.ts +21 -7
  58. package/src/runs/shared/claude-code-adapter.ts +129 -0
  59. package/src/runs/shared/codex-exec-adapter.ts +129 -0
  60. package/src/runs/shared/completion-guard.ts +3 -2
  61. package/src/runs/shared/cursor-agent-adapter.ts +114 -0
  62. package/src/runs/shared/dynamic-fanout.ts +1 -1
  63. package/src/runs/shared/extension-bindings.ts +78 -0
  64. package/src/runs/shared/external-cli-contract.ts +167 -0
  65. package/src/runs/shared/external-cli-preflight.ts +122 -0
  66. package/src/runs/shared/external-cli-runner.ts +349 -54
  67. package/src/runs/shared/fast-mode-extension.ts +10 -0
  68. package/src/runs/shared/model-exclusions.ts +59 -8
  69. package/src/runs/shared/model-fallback.ts +25 -8
  70. package/src/runs/shared/mutation-evidence.ts +150 -0
  71. package/src/runs/shared/nested-events.ts +3 -1
  72. package/src/runs/shared/nested-render.ts +2 -2
  73. package/src/runs/shared/parallel-utils.ts +3 -0
  74. package/src/runs/shared/pi-args.ts +47 -0
  75. package/src/runs/shared/process-signal.ts +13 -0
  76. package/src/runs/shared/run-history.ts +21 -1
  77. package/src/runs/shared/structured-output.ts +18 -4
  78. package/src/runs/shared/subagent-prompt-runtime.ts +9 -5
  79. package/src/shared/fork-context.ts +21 -0
  80. package/src/shared/formatters.ts +7 -1
  81. package/src/shared/launch-contract.ts +6 -0
  82. package/src/shared/pruned-fork.ts +450 -0
  83. package/src/shared/session-file-trust.ts +19 -0
  84. package/src/shared/session-tokens.ts +14 -3
  85. package/src/shared/settings.ts +16 -3
  86. package/src/shared/shortcuts.ts +17 -0
  87. package/src/shared/types.ts +166 -9
  88. package/src/shared/workflow-child-permit.ts +116 -0
  89. package/src/slash/delegation-adapters.ts +0 -1
  90. package/src/slash/slash-commands.ts +8 -6
  91. package/src/tui/fleet-status.ts +27 -10
  92. package/src/tui/fleet-transcript.ts +11 -5
  93. package/src/tui/fleet.ts +28 -13
  94. package/src/tui/render.ts +47 -21
  95. package/src/workflows/scripted-workflow.ts +298 -30
  96. package/src/workflows/workflow-child-summary.ts +117 -0
  97. package/src/workflows/workflow-receipt.ts +119 -4
@@ -1,18 +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";
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";
7
15
 
8
- const HARD_KILL_DELAY_MS = 2_000;
9
16
  const MAX_OUTPUT_TAIL_BYTES = 64 * 1024;
10
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;
11
25
 
12
26
  export function buildExternalCliPrompt(systemInstructions: string, task: string): string {
13
27
  return `<System instructions>\n${systemInstructions.trim()}\n\n<Task>\n${task}`;
14
28
  }
15
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
+
16
62
  export interface ExternalCliRunResult {
17
63
  output: string;
18
64
  exitCode: number | null;
@@ -21,6 +67,87 @@ export interface ExternalCliRunResult {
21
67
  stopped?: boolean;
22
68
  processSignal?: string | null;
23
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
+ });
24
151
  }
25
152
 
26
153
  export function runExternalCli(input: {
@@ -30,14 +157,29 @@ export function runExternalCli(input: {
30
157
  prompt: string;
31
158
  asyncDir: string;
32
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;
33
167
  registerTimeout?: (stop: (() => void) | undefined) => void;
34
168
  registerStop?: (stop: (() => void) | undefined) => void;
35
169
  timeoutMessage?: string;
36
170
  stopMessage?: string;
37
171
  onProcess?: (process: ExternalProcessStatus) => void;
172
+ onParserProgress?: (progress: ExternalCliParserProgress) => void;
38
173
  onStdout?: (chunk: Buffer) => void;
39
174
  onStderr?: (chunk: Buffer) => void;
40
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
+ };
41
183
  return new Promise((resolve, reject) => {
42
184
  const startedAt = Date.now();
43
185
  const stdoutPath = path.join(input.asyncDir, `external-${input.stepIndex}.stdout.log`);
@@ -46,89 +188,242 @@ export function runExternalCli(input: {
46
188
  const stdoutStream = fs.createWriteStream(stdoutPath, { flags: "w" });
47
189
  const stderrStream = fs.createWriteStream(stderrPath, { flags: "w" });
48
190
  const streamsFinished = Promise.allSettled([finished(stdoutStream), finished(stderrStream)]);
49
- let stdoutTail = Buffer.alloc(0);
50
- 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;
51
237
  let timedOut = false;
52
238
  let stopped = false;
53
239
  let settled = false;
54
- let hardKillTimer: NodeJS.Timeout | undefined;
55
- 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 ?? [], {
56
331
  cwd: input.cwd,
332
+ env,
57
333
  stdio: ["pipe", "pipe", "pipe"],
58
334
  shell: false,
59
335
  windowsHide: true,
60
- });
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
+ }
61
342
  const initialProcess: ExternalProcessStatus = {
62
343
  ...(typeof child.pid === "number" ? { pid: child.pid } : {}),
63
344
  startedAt,
64
345
  stdoutPath,
65
346
  stderrPath,
347
+ ...(input.finalOutputPath ? { finalOutputPath: input.finalOutputPath } : {}),
66
348
  };
67
349
  input.onProcess?.(initialProcess);
68
350
  child.stdout.on("data", (chunk: Buffer) => {
69
- stdoutStream.write(chunk);
351
+ parseChunk(chunk);
352
+ writeBoundedLog(child.stdout, stdoutStream, chunk, stdoutLog, limits.stdoutLogBytes);
70
353
  input.onStdout?.(chunk);
71
- stdoutTail = Buffer.concat([stdoutTail, chunk]);
72
- if (stdoutTail.length > MAX_OUTPUT_TAIL_BYTES) stdoutTail = stdoutTail.subarray(stdoutTail.length - MAX_OUTPUT_TAIL_BYTES);
354
+ stdoutTail.push(chunk);
73
355
  });
74
356
  child.stderr.on("data", (chunk: Buffer) => {
75
- stderrStream.write(chunk);
357
+ writeBoundedLog(child.stderr, stderrStream, chunk, stderrLog, limits.stderrLogBytes);
76
358
  input.onStderr?.(chunk);
77
- stderrTail = Buffer.concat([stderrTail, chunk]);
78
- if (stderrTail.length > MAX_ERROR_TAIL_BYTES) stderrTail = stderrTail.subarray(stderrTail.length - MAX_ERROR_TAIL_BYTES);
359
+ stderrTail.push(chunk);
79
360
  });
80
- const terminate = (reason: "timeout" | "stop") => {
81
- if (settled || timedOut || stopped) return;
82
- timedOut = reason === "timeout";
83
- stopped = reason === "stop";
84
- trySignalChild(child, "SIGTERM");
85
- hardKillTimer = setTimeout(() => {
86
- if (!settled) trySignalChild(child, "SIGKILL");
87
- }, HARD_KILL_DELAY_MS);
88
- hardKillTimer.unref?.();
89
- };
90
361
  input.registerTimeout?.(() => terminate("timeout"));
91
362
  input.registerStop?.(() => terminate("stop"));
92
363
  child.stdin.on("error", () => {});
93
- child.stdin.end(input.prompt);
364
+ child.stdin.end(input.promptFilePath ? undefined : input.prompt);
94
365
  let spawnError: Error | undefined;
95
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
+ });
96
377
  child.once("close", (exitCode, signal) => {
97
378
  settled = true;
98
- if (hardKillTimer) clearTimeout(hardKillTimer);
379
+ if (progressTimer) clearTimeout(progressTimer);
380
+ flushProgress();
99
381
  input.registerTimeout?.(undefined);
100
382
  input.registerStop?.(undefined);
101
- const endedAt = Date.now();
102
- const externalProcess: ExternalProcessStatus = {
103
- ...initialProcess,
104
- endedAt,
105
- durationMs: endedAt - startedAt,
106
- exitCode,
107
- processSignal: signal,
108
- };
109
- input.onProcess?.(externalProcess);
110
- const stderr = stderrTail.toString("utf-8").trim();
111
- const error = stopped
112
- ? input.stopMessage ?? "Subagent stopped by user."
113
- : timedOut
114
- ? input.timeoutMessage ?? "Subagent timed out."
115
- : spawnError?.message ?? (exitCode === 0 ? undefined : stderr || `External CLI exited with code ${exitCode}.`);
116
- const result: ExternalCliRunResult = {
117
- output: stdoutTail.toString("utf-8").trim(),
118
- exitCode: timedOut || stopped || spawnError ? 1 : exitCode,
119
- ...(error ? { error } : {}),
120
- ...(timedOut ? { timedOut: true } : {}),
121
- ...(stopped ? { stopped: true } : {}),
122
- processSignal: signal,
123
- externalProcess,
124
- };
125
- stdoutStream.end();
126
- stderrStream.end();
127
- 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;
128
421
  const streamFailure = streamResults.find((streamResult) => streamResult.status === "rejected");
422
+ try { cleanupTemporaryPaths(); }
423
+ catch (cleanupError) { reject(cleanupError); return; }
129
424
  if (streamFailure?.status === "rejected") reject(streamFailure.reason);
130
425
  else resolve(result);
131
- });
426
+ })();
132
427
  });
133
428
  });
134
429
  }
@@ -0,0 +1,10 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ function withPriorityServiceTier(payload: unknown): unknown {
4
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
5
+ return { ...payload, service_tier: "priority" };
6
+ }
7
+
8
+ export default function registerSubagentFastModeExtension(pi: ExtensionAPI): void {
9
+ pi.on("before_provider_request", (event) => ({ payload: withPriorityServiceTier(event.payload) }));
10
+ }
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { splitKnownThinkingSuffix } from "../../shared/model-info.ts";
3
4
  import { TEMP_ROOT_DIR } from "../../shared/types.ts";
4
5
 
5
6
  export const EXCLUSIONS_PATH_ENV = "PI_MODEL_EXCLUSIONS_PATH";
@@ -19,14 +20,30 @@ type RecordModelFailureOptions = ModelExclusionTarget & {
19
20
 
20
21
  let exclusions: ModelExclusion[] = [];
21
22
  let loaded = false;
22
- let defaultTTLMs = 24 * 60 * 60_000; // 24 hours, overridable via setDefaultTTL
23
+ /** Default duration for a new model exclusion when no per-record TTL is supplied. */
24
+ export const DEFAULT_MODEL_EXCLUSION_TTL_MS = 24 * 60 * 60_000;
25
+ /** Keeps a new expiry safely below JavaScript's maximum Date timestamp. */
26
+ export const MAX_MODEL_EXCLUSION_TTL_MS = 8_000_000_000_000_000;
27
+ const MAX_DATE_TIMESTAMP_MS = 8_640_000_000_000_000;
28
+ let defaultTTLMs = DEFAULT_MODEL_EXCLUSION_TTL_MS;
29
+ let loadedTTLCeilingMs: number | undefined;
23
30
  let persistTimer: ReturnType<typeof setTimeout> | null = null;
24
31
  let persistSeq = 0;
25
32
 
26
- /** Override the default exclusion TTL. */
27
- export function setDefaultTTL(ms: number): void {
28
- if (!Number.isFinite(ms) || ms <= 0) throw new Error("Default model exclusion TTL must be a finite positive number.");
33
+ /**
34
+ * Override the default TTL applied to newly recorded model exclusions.
35
+ *
36
+ * @param ms Duration in milliseconds. Must be finite and positive.
37
+ * @returns Nothing.
38
+ */
39
+ // TEST:test/unit/model-exclusions.test.ts[model exclusions — TTL expiry]
40
+ export function setDefaultTTL(ms: number, options?: { shortenExisting?: boolean }): void {
41
+ if (!Number.isFinite(ms) || ms <= 0 || ms > MAX_MODEL_EXCLUSION_TTL_MS) {
42
+ throw new Error(`Default model exclusion TTL must be a finite positive number no greater than ${MAX_MODEL_EXCLUSION_TTL_MS}.`);
43
+ }
29
44
  defaultTTLMs = ms;
45
+ loadedTTLCeilingMs = options?.shortenExisting ? ms : undefined;
46
+ if (loaded && loadedTTLCeilingMs !== undefined && shortenExclusionsToTTL(exclusions, loadedTTLCeilingMs, Date.now())) schedulePersist();
30
47
  }
31
48
 
32
49
  /**
@@ -77,9 +94,23 @@ function ensureLoaded(): void {
77
94
  const raw = fs.readFileSync(getExclusionsFilePath(), "utf-8");
78
95
  const data = JSON.parse(raw);
79
96
  if (data.version === 1) {
97
+ if (!Array.isArray(data.exclusions)) throw new Error("Model exclusion store version 1 must contain an exclusions array.");
98
+ for (let index = 0; index < data.exclusions.length; index++) {
99
+ const entry = data.exclusions[index];
100
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) throw new Error(`Model exclusion store entry ${index} must be an object.`);
101
+ if (entry.reason !== undefined && typeof entry.reason !== "string") throw new Error(`Model exclusion store entry ${index} has an invalid reason.`);
102
+ for (const field of ["recordedAt", "expiresAt"] as const) {
103
+ const timestamp = entry[field];
104
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp) || timestamp <= 0 || timestamp > MAX_DATE_TIMESTAMP_MS) {
105
+ throw new Error(`Model exclusion store entry ${index} has an invalid ${field}.`);
106
+ }
107
+ }
108
+ }
80
109
  const now = Date.now();
81
110
  exclusions = (data.exclusions ?? []).filter((e: ModelExclusion) => e.expiresAt > now);
111
+ const shortened = loadedTTLCeilingMs !== undefined && shortenExclusionsToTTL(exclusions, loadedTTLCeilingMs, now);
82
112
  exclusions = deduplicate(exclusions);
113
+ if (shortened) schedulePersist();
83
114
  }
84
115
  } catch (error) {
85
116
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
@@ -194,7 +225,7 @@ export function getExcludedCount(): number {
194
225
  * {@link recordModelFailure} is later recognised by the candidate filter.
195
226
  */
196
227
  export function parseModelKey(fullId: string): { provider?: string; modelId: string } {
197
- const base = fullId.includes(":") ? fullId.substring(0, fullId.lastIndexOf(":")) : fullId;
228
+ const base = splitKnownThinkingSuffix(fullId).baseModel;
198
229
  if (!base.includes("/")) return { modelId: base };
199
230
  const slash = base.indexOf("/");
200
231
  return { provider: base.slice(0, slash), modelId: base.slice(slash + 1) };
@@ -204,7 +235,10 @@ export function parseModelKey(fullId: string): { provider?: string; modelId: str
204
235
  * Filter a list of candidate fullIds, removing excluded models/providers and
205
236
  * duplicates while preserving order.
206
237
  */
207
- export function filterFallbackCandidates(candidates: string[], opts?: { now?: number }): string[] {
238
+ export function filterFallbackCandidates(candidates: string[], opts?: {
239
+ now?: number;
240
+ onExcluded?: (candidate: string, exclusion: Readonly<ModelExclusion>) => void;
241
+ }): string[] {
208
242
  ensureLoaded();
209
243
  const timestamp = opts?.now ?? Date.now();
210
244
  const seen = new Set<string>();
@@ -212,8 +246,11 @@ export function filterFallbackCandidates(candidates: string[], opts?: { now?: nu
212
246
  for (const raw of candidates) {
213
247
  if (!raw || seen.has(raw)) continue;
214
248
  const { provider: candidateProvider, modelId: candidateModelId } = parseModelKey(raw);
215
- const excluded = exclusions.some((entry) => entryMatches(entry, candidateModelId, candidateProvider, timestamp));
216
- if (excluded) continue;
249
+ const exclusion = exclusions.find((entry) => entryMatches(entry, candidateModelId, candidateProvider, timestamp));
250
+ if (exclusion) {
251
+ opts?.onExcluded?.(raw, exclusion);
252
+ continue;
253
+ }
217
254
  seen.add(raw);
218
255
  filtered.push(raw);
219
256
  }
@@ -240,3 +277,17 @@ function prune(items: ModelExclusion[], now: number): void {
240
277
  }
241
278
  items.length = write;
242
279
  }
280
+
281
+ function shortenExclusionsToTTL(items: ModelExclusion[], ttlMs: number, now: number): boolean {
282
+ let changed = false;
283
+ for (const entry of items) {
284
+ const configuredExpiry = entry.recordedAt + ttlMs;
285
+ if (entry.expiresAt > configuredExpiry) {
286
+ entry.expiresAt = configuredExpiry;
287
+ changed = true;
288
+ }
289
+ }
290
+ const previousLength = items.length;
291
+ prune(items, now);
292
+ return changed || items.length !== previousLength;
293
+ }
@@ -1,7 +1,8 @@
1
- import type { ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
1
+ import { splitKnownThinkingSuffix, type ModelInfo as AvailableModelInfo } from "../../shared/model-info.ts";
2
2
  import type { Usage } from "../../shared/types.ts";
3
3
  import { filterFallbackCandidates, parseModelKey, recordModelFailure } from "./model-exclusions.ts";
4
4
  import { checkModelScope, type ModelScopeCheckRule, type ModelScopeViolation, type ModelSource } from "./model-scope.ts";
5
+ import { redactSecretValues } from "./permissions.ts";
5
6
 
6
7
  export type { AvailableModelInfo };
7
8
 
@@ -14,12 +15,7 @@ interface ModelAttemptSummary {
14
15
  }
15
16
 
16
17
  export function splitThinkingSuffix(model: string): { baseModel: string; thinkingSuffix: string } {
17
- const colonIdx = model.lastIndexOf(":");
18
- if (colonIdx === -1) return { baseModel: model, thinkingSuffix: "" };
19
- return {
20
- baseModel: model.substring(0, colonIdx),
21
- thinkingSuffix: model.substring(colonIdx),
22
- };
18
+ return splitKnownThinkingSuffix(model);
23
19
  }
24
20
 
25
21
  export function formatSubagentModelVerificationError(expectedModel: string, observedModel: string, availableModels: AvailableModelInfo[] | undefined): string | undefined {
@@ -399,7 +395,12 @@ export function buildModelCandidates(
399
395
  seen.add(normalized);
400
396
  candidates.push(normalized);
401
397
  }
402
- return filterFallbackCandidates(candidates);
398
+ return filterFallbackCandidates(candidates, {
399
+ onExcluded(candidate, exclusion) {
400
+ const reason = redactSecretValues((exclusion.reason ?? "runtime-failure").replace(/[\u0000-\u001f\u007f]+/g, " ")).slice(0, 240);
401
+ console.warn(`[pi-subagents] Skipping model '${candidate}' due to a cached exclusion (reason: ${reason}; expires: ${new Date(exclusion.expiresAt).toISOString()}).`);
402
+ },
403
+ });
403
404
  }
404
405
 
405
406
  const RETRYABLE_MODEL_FAILURE_PATTERNS = [
@@ -424,6 +425,7 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
424
425
  /overloaded/i,
425
426
  /service unavailable/i,
426
427
  /temporar(?:ily)? unavailable/i,
428
+ /connection\s+(?:error|reset|closed|aborted)/i,
427
429
  /connection refused/i,
428
430
  /fetch failed/i,
429
431
  /network error/i,
@@ -456,6 +458,21 @@ export function isRetryableModelFailure(error: string | undefined): boolean {
456
458
  return RETRYABLE_MODEL_FAILURE_PATTERNS.some((pattern) => pattern.test(error));
457
459
  }
458
460
 
461
+ function messageError(message: unknown): string | undefined {
462
+ if (!message || typeof message !== "object") return undefined;
463
+ const value = (message as { errorMessage?: unknown }).errorMessage;
464
+ return typeof value === "string" ? value : undefined;
465
+ }
466
+
467
+ export function isRetryableModelFailureAttempt(input: { error: string | undefined; messages?: readonly unknown[]; toolCount?: number }): boolean {
468
+ if (!isRetryableModelFailure(input.error)) return false;
469
+ if ((input.toolCount ?? 0) > 0) return false;
470
+ if (input.error === "Subagent produced no output (possible model cold-start or empty response).") return true;
471
+ if ((input.toolCount ?? 0) === 0 && (input.messages?.length ?? 0) === 0) return true;
472
+ const error = input.error?.trim();
473
+ return Boolean(error && input.messages?.some((message) => messageError(message)?.trim() === error));
474
+ }
475
+
459
476
  export function recordRetryableModelFailure(model: string | undefined, error: string | undefined): void {
460
477
  if (!model || !isRetryableModelFailure(error)) return;
461
478
  const { provider, modelId } = parseModelKey(model);