oira666_pi-subagent 0.2.21 → 0.2.22

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/runner.ts CHANGED
@@ -1,1059 +1,1059 @@
1
- /**
2
- * Subagent process runner.
3
- *
4
- * Spawns isolated `pi` processes and streams results back via callbacks.
5
- */
6
-
7
- import { spawn } from "node:child_process";
8
- import * as fs from "node:fs";
9
- import * as os from "node:os";
10
- import * as path from "node:path";
11
- import type { AgentToolResult } from "@mariozechner/pi-agent-core";
12
- import type { Message } from "@mariozechner/pi-ai";
13
- import type { AgentConfig } from "./agents.js";
14
- import {
15
- type LiveLogEntry,
16
- type SingleResult,
17
- type SubagentDetails,
18
- MAX_LIVE_LOG_ENTRIES,
19
- emptyUsage,
20
- extractToolCalls,
21
- getFinalOutput,
22
- getNestedSubagentErrorSummary,
23
- } from "./types.js";
24
- import { SUBAGENT_SESSION_ROOT_ENV } from "./resume.js";
25
- import {
26
- DEFAULT_MAX_PARALLEL_TASKS,
27
- DEFAULT_MAX_CONCURRENCY,
28
- PARALLEL_HEARTBEAT_MS,
29
- RESUME_MODEL_ID,
30
- RESUME_PROVIDER,
31
- SUBAGENT_MAX_PARALLEL_TASKS_ENV,
32
- SUBAGENT_MAX_CONCURRENCY_ENV,
33
- parseNonNegativeInt,
34
- mapConcurrent,
35
- } from "./shared.js";
36
-
37
- const SIGKILL_TIMEOUT_MS = 5000;
38
- const HANG_GUARD_DELAY_MS = 5000;
39
- const DEFAULT_STARTUP_TIMEOUT_MS = 120_000; // only for startup (before first assistant turn)
40
- const SUBAGENT_STARTUP_TIMEOUT_ENV = "PI_SUBAGENT_STARTUP_TIMEOUT";
41
- const SUBAGENT_PI_COMMAND_ENV = "PI_SUBAGENT_PI_COMMAND";
42
- const SUBAGENT_PI_ARGS_PREFIX_ENV = "PI_SUBAGENT_PI_ARGS_PREFIX";
43
-
44
- /**
45
- * Stop reasons that indicate the agent has truly finished its work.
46
- * "tool_use" is NOT terminal — the agent is still working (calling a tool).
47
- */
48
- // pi emits "stop" (and occasionally "end_turn") as the terminal reason; include both.
49
- // "toolUse"/"tool_use" are NOT terminal — the agent is still mid-turn calling a tool.
50
- const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop", "max_tokens", "error", "stop_sequence"]);
51
-
52
- function isTerminalStopReason(reason: string | undefined): boolean {
53
- return reason !== undefined && TERMINAL_STOP_REASONS.has(reason);
54
- }
55
-
56
- function endedWithSyntheticResumeFailure(messages: Message[]): boolean {
57
- const lastAssistant = [...messages].reverse().find((message: any) => message?.role === "assistant") as any;
58
- if (lastAssistant?.provider !== RESUME_PROVIDER || lastAssistant?.model !== RESUME_MODEL_ID) return false;
59
- const content = Array.isArray(lastAssistant.content) ? lastAssistant.content : [];
60
- const handedOffToRealModel = messages.some((message: any) => message?.role === "assistant" && message.provider !== RESUME_PROVIDER);
61
- const hasToolCall = content.some((part: any) => part?.type === "toolCall");
62
- return !handedOffToRealModel && !hasToolCall;
63
- }
64
- const SUBAGENT_DEPTH_ENV = "PI_SUBAGENT_DEPTH";
65
- const SUBAGENT_MAX_DEPTH_ENV = "PI_SUBAGENT_MAX_DEPTH";
66
- const SUBAGENT_STACK_ENV = "PI_SUBAGENT_STACK";
67
- const SUBAGENT_PREVENT_CYCLES_ENV = "PI_SUBAGENT_PREVENT_CYCLES";
68
- const SUBAGENT_FALLBACK_MODEL_ENV = "PI_SUBAGENT_FALLBACK_MODEL";
69
-
70
- // PI_OFFLINE intentionally removed: setting it on child processes blocks all API
71
- // calls and renders subagents unable to do any LLM work. Children inherit the
72
- // parent's PI_OFFLINE value via process.env spread if needed.
73
-
74
- type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
75
-
76
- export interface RunningSubagentHandle {
77
- steer(message: string): void;
78
- }
79
-
80
- export type RunningSubagentStartedCallback = (handle: RunningSubagentHandle) => void;
81
-
82
- // ---------------------------------------------------------------------------
83
- // Temp file helpers
84
- // ---------------------------------------------------------------------------
85
-
86
- function writePromptToTempFile(
87
- agentName: string,
88
- prompt: string,
89
- ): { dir: string; filePath: string } {
90
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
91
- const safeName = agentName.replace(/[^\w.-]+/g, "_");
92
- const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
93
- fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
94
- return { dir: tmpDir, filePath };
95
- }
96
-
97
- function cleanupTempDir(dir: string | null): void {
98
- if (!dir) return;
99
- try {
100
- fs.rmSync(dir, { recursive: true, force: true });
101
- } catch {
102
- /* ignore */
103
- }
104
- }
105
-
106
- function getCurrentPiCliScript(): string | null {
107
- const script = process.argv[1];
108
- if (!script) return null;
109
-
110
- // When this extension is loaded by pi, process.argv[1] is the pi CLI JS
111
- // entrypoint. Reusing it with process.execPath avoids relying on PATH while
112
- // still running the exact same pi installation as the parent process.
113
- const normalized = script.replace(/\\/g, "/");
114
- if (!normalized.includes("/pi-coding-agent/") || !normalized.endsWith("/dist/cli.js")) {
115
- return null;
116
- }
117
-
118
- return script;
119
- }
120
-
121
- function findPiCliScriptOnPath(): string | null {
122
- const pathEnv = process.env.PATH ?? "";
123
- for (const dir of pathEnv.split(path.delimiter)) {
124
- if (!dir) continue;
125
- for (const shimName of process.platform === "win32" ? ["pi.cmd", "pi"] : ["pi"]) {
126
- const shimPath = path.join(dir, shimName);
127
- if (!fs.existsSync(shimPath)) continue;
128
- let text = "";
129
- try {
130
- text = fs.readFileSync(shimPath, "utf8");
131
- } catch {
132
- continue;
133
- }
134
- const match = text.match(/node_modules[\\/]([^\s"']*pi-coding-agent)[\\/]dist[\\/]cli\.js/);
135
- if (!match) continue;
136
- const cliPath = path.join(dir, "node_modules", match[1], "dist", "cli.js");
137
- if (fs.existsSync(cliPath)) return cliPath;
138
- }
139
- }
140
- return null;
141
- }
142
-
143
- function getPiSpawnCommand(override?: { command: string; argsPrefix?: string[] }): { command: string; argsPrefix: string[] } {
144
- if (override?.command) return { command: override.command, argsPrefix: override.argsPrefix ?? [] };
145
-
146
- const overrideCommand = process.env[SUBAGENT_PI_COMMAND_ENV];
147
- if (overrideCommand) {
148
- let argsPrefix: string[] = [];
149
- const rawPrefix = process.env[SUBAGENT_PI_ARGS_PREFIX_ENV];
150
- if (rawPrefix) {
151
- try {
152
- const parsed = JSON.parse(rawPrefix);
153
- if (Array.isArray(parsed) && parsed.every((value) => typeof value === "string")) {
154
- argsPrefix = parsed;
155
- }
156
- } catch {
157
- // Ignore invalid test/debug override and run the command without a prefix.
158
- }
159
- }
160
- return { command: overrideCommand, argsPrefix };
161
- }
162
-
163
- const cliScript = getCurrentPiCliScript() ?? findPiCliScriptOnPath();
164
- if (cliScript) return { command: process.execPath, argsPrefix: [cliScript] };
165
- return { command: "pi", argsPrefix: [] };
166
- }
167
-
168
- function resolveExtensionArg(value: string): string {
169
- if (!value) return value;
170
- if (value.startsWith("npm:") || value.startsWith("git:")) return value;
171
- if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
172
- if (path.isAbsolute(value)) return value;
173
-
174
- const resolved = path.resolve(process.cwd(), value);
175
- return fs.existsSync(resolved) ? resolved : value;
176
- }
177
-
178
- interface InheritedCliArgs {
179
- /** --extension/-e and --no-extensions/-ne args (with path resolution) */
180
- extensionArgs: string[];
181
- /** All other non-blocked flags to forward verbatim to every child */
182
- alwaysProxy: string[];
183
- /** Parent --model value; used only when agent config doesn't specify model */
184
- fallbackModel: string | undefined;
185
- /** Parent --thinking value; used only when agent config doesn't specify thinking */
186
- fallbackThinking: string | undefined;
187
- /** Parent --tools value; used only when agent config doesn't specify tools */
188
- fallbackTools: string | undefined;
189
- /** Parent passed --no-tools; used only when agent config doesn't specify tools */
190
- fallbackNoTools: boolean;
191
- }
192
-
193
- /**
194
- * Parse process.argv into categorised groups for child-process arg construction.
195
- *
196
- * Categories:
197
- * - BLOCKED : flags the extension manages itself — never forwarded
198
- * - extensionArgs : --extension/-e and --no-extensions/-ne (with path resolution)
199
- * - alwaysProxy : all other non-blocked flags forwarded verbatim
200
- * - fallback* : flags the agent config may override
201
- *
202
- * Handles both "--flag value" and "--flag=value" forms.
203
- * Unknown flags use a heuristic: if the next token doesn't start with "-",
204
- * it is treated as the flag's value.
205
- */
206
- function parseInheritedCliArgs(argv: string[]): InheritedCliArgs {
207
- const extensionArgs: string[] = [];
208
- const alwaysProxy: string[] = [];
209
- let fallbackModel: string | undefined;
210
- let fallbackThinking: string | undefined;
211
- let fallbackTools: string | undefined;
212
- let fallbackNoTools = false;
213
-
214
- let i = 2; // skip "node" and "pi"
215
- while (i < argv.length) {
216
- const raw = argv[i];
217
- // Positional args (prompt text, @file refs) — skip, not proxied to children
218
- if (!raw.startsWith("-")) { i++; continue; }
219
-
220
- // Normalise: detect --flag=value inline form
221
- const eqIdx = raw.indexOf("=");
222
- const flagName = eqIdx !== -1 ? raw.slice(0, eqIdx) : raw;
223
- const inlineValue: string | undefined = eqIdx !== -1 ? raw.slice(eqIdx + 1) : undefined;
224
-
225
- const nextToken = argv[i + 1];
226
- const nextIsValue = nextToken !== undefined && !nextToken.startsWith("-");
227
-
228
- // Returns [resolvedValue | undefined, tokensToConsume]
229
- const getVal = (): [string | undefined, number] => {
230
- if (inlineValue !== undefined) return [inlineValue, 1];
231
- if (nextIsValue) return [nextToken, 2];
232
- return [undefined, 1];
233
- };
234
-
235
- // ── BLOCKED: value flags ─────────────────────────────────────────────────
236
- // Extension manages these; consume flag + value, never proxy.
237
- if ([
238
- "--mode", "--session", "--append-system-prompt",
239
- "--export", "--subagent-max-depth",
240
- ].includes(flagName)) {
241
- const [, skip] = getVal();
242
- i += skip; continue;
243
- }
244
-
245
- // --subagent-prevent-cycles takes an optional value
246
- if (flagName === "--subagent-prevent-cycles") {
247
- if (inlineValue !== undefined || nextIsValue) { i += inlineValue !== undefined ? 1 : 2; }
248
- else { i++; }
249
- continue;
250
- }
251
-
252
- // --list-models has an optional search term
253
- if (flagName === "--list-models") {
254
- if (inlineValue !== undefined || nextIsValue) { i += inlineValue !== undefined ? 1 : 2; }
255
- else { i++; }
256
- continue;
257
- }
258
-
259
- // ── BLOCKED: boolean flags ────────────────────────────────────────────────
260
- if ([
261
- "--print", "-p", "--no-session",
262
- "--continue", "-c", "--resume", "-r",
263
- "--offline", "--help", "-h", "--version", "-v",
264
- "--no-subagent-prevent-cycles",
265
- ].includes(flagName)) {
266
- i++; continue;
267
- }
268
-
269
- // ── EXTENSION FLAGS: handled separately with path resolution ─────────────
270
- if (flagName === "--no-extensions" || flagName === "-ne") {
271
- extensionArgs.push(flagName);
272
- i++; continue;
273
- }
274
- if (flagName === "--extension" || flagName === "-e") {
275
- const [value, skip] = getVal();
276
- if (value !== undefined) extensionArgs.push(flagName, resolveExtensionArg(value));
277
- i += skip; continue;
278
- }
279
-
280
- // ── ALWAYS-PROXY: known value flags ──────────────────────────────────────
281
- if ([
282
- "--provider", "--api-key", "--system-prompt",
283
- "--models", "--skill", "--prompt-template", "--theme",
284
- ].includes(flagName)) {
285
- const [value, skip] = getVal();
286
- if (value !== undefined) alwaysProxy.push(flagName, value);
287
- i += skip; continue;
288
- }
289
-
290
- // ── ALWAYS-PROXY: known boolean flags ────────────────────────────────────
291
- if ([
292
- "--no-skills", "-ns", "--no-prompt-templates", "-np",
293
- "--no-themes", "--verbose",
294
- ].includes(flagName)) {
295
- alwaysProxy.push(flagName);
296
- i++; continue;
297
- }
298
-
299
- // ── FALLBACK: agent config may override ───────────────────────────────────
300
- if (flagName === "--model") {
301
- const [value, skip] = getVal();
302
- if (value !== undefined) fallbackModel = value;
303
- i += skip; continue;
304
- }
305
- if (flagName === "--thinking") {
306
- const [value, skip] = getVal();
307
- if (value !== undefined) fallbackThinking = value;
308
- i += skip; continue;
309
- }
310
- if (flagName === "--tools") {
311
- const [value, skip] = getVal();
312
- if (value !== undefined) fallbackTools = value;
313
- i += skip; continue;
314
- }
315
- if (flagName === "--no-tools") {
316
- fallbackNoTools = true;
317
- i++; continue;
318
- }
319
-
320
- // ── UNKNOWN: heuristic passthrough ───────────────────────────────────────
321
- // Likely a custom extension flag. Forward with value if next token looks like one.
322
- if (inlineValue !== undefined) {
323
- alwaysProxy.push(flagName, inlineValue);
324
- i++; continue;
325
- }
326
- if (nextIsValue) {
327
- alwaysProxy.push(flagName, nextToken);
328
- i += 2; continue;
329
- }
330
- alwaysProxy.push(flagName);
331
- i++;
332
- }
333
-
334
- return { extensionArgs, alwaysProxy, fallbackModel, fallbackThinking, fallbackTools, fallbackNoTools };
335
- }
336
-
337
- /** Cached once — process.argv is immutable at runtime */
338
- const _inheritedCliArgs = parseInheritedCliArgs(process.argv);
339
-
340
- // ---------------------------------------------------------------------------
341
- // JSON-line stream processing
342
- // ---------------------------------------------------------------------------
343
-
344
- function pushLiveLog(result: SingleResult, entry: LiveLogEntry): void {
345
- result.liveLog.push(entry);
346
- if (result.liveLog.length > MAX_LIVE_LOG_ENTRIES) result.liveLog.shift();
347
- }
348
-
349
- function messageDedupKey(message: Message): string {
350
- const anyMessage = message as any;
351
- if (typeof anyMessage.id === "string") return `id:${anyMessage.id}`;
352
- return JSON.stringify({
353
- role: anyMessage.role,
354
- provider: anyMessage.provider,
355
- model: anyMessage.model,
356
- stopReason: anyMessage.stopReason,
357
- toolCallId: anyMessage.toolCallId,
358
- toolName: anyMessage.toolName,
359
- content: anyMessage.content,
360
- usage: anyMessage.usage,
361
- });
362
- }
363
-
364
- function hasMessage(result: SingleResult, message: Message): boolean {
365
- const key = messageDedupKey(message);
366
- return result.messages.some((existing) => messageDedupKey(existing) === key);
367
- }
368
-
369
- function sessionDirExists(dir: string | undefined): boolean {
370
- if (!dir) return false;
371
- try {
372
- return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
373
- } catch {
374
- return false;
375
- }
376
- }
377
-
378
- export function processJsonLine(line: string, result: SingleResult): boolean {
379
- if (!line.trim()) return false;
380
-
381
- let event: any;
382
- try {
383
- event = JSON.parse(line);
384
- } catch {
385
- return false;
386
- }
387
-
388
- // Guard: JSON.parse can return null, a number, boolean, or array — none of which have .type
389
- if (!event || typeof event !== "object" || Array.isArray(event)) return false;
390
-
391
- if (event.type === "message_end" && event.message) {
392
- const msg = event.message as Message;
393
- if (hasMessage(result, msg)) return true;
394
- result.messages.push(msg);
395
-
396
- if (msg.role === "assistant") {
397
- result.usage.turns++;
398
- const usage = msg.usage;
399
- if (usage) {
400
- result.usage.input += usage.input || 0;
401
- result.usage.output += usage.output || 0;
402
- result.usage.cacheRead += usage.cacheRead || 0;
403
- result.usage.cacheWrite += usage.cacheWrite || 0;
404
- result.usage.cost += usage.cost?.total || 0;
405
- result.usage.contextTokens = usage.totalTokens || 0;
406
- }
407
- if (msg.model && msg.model !== "synthetic-tool-call") result.model = msg.model;
408
- if (msg.stopReason) result.stopReason = msg.stopReason;
409
- if (msg.errorMessage) result.errorMessage = msg.errorMessage;
410
- }
411
- return true;
412
- }
413
-
414
- if (event.type === "tool_result_end" && event.message) {
415
- const msg = event.message as Message;
416
- if (!hasMessage(result, msg)) result.messages.push(msg);
417
- return true;
418
- }
419
-
420
- if (event.type === "turn_start") {
421
- result.turnInProgress = true;
422
- pushLiveLog(result, { kind: "turn_start" });
423
- return true;
424
- }
425
-
426
- if (event.type === "turn_end") {
427
- result.completedTurns++;
428
- result.turnInProgress = false;
429
- const u = event.message?.usage;
430
- pushLiveLog(result, {
431
- kind: "turn_end",
432
- turn: result.completedTurns,
433
- inputTokens: u?.input ?? 0,
434
- outputTokens: u?.output ?? 0,
435
- });
436
- return true;
437
- }
438
-
439
- if (event.type === "tool_execution_start") {
440
- result.liveToolExecutions ??= {};
441
- result.liveToolExecutions[event.toolCallId] = {
442
- toolName: event.toolName,
443
- args: event.args,
444
- };
445
- pushLiveLog(result, { kind: "tool_start", toolName: event.toolName, args: event.args });
446
- return true;
447
- }
448
-
449
- if (event.type === "tool_execution_end") {
450
- if (result.liveToolExecutions) {
451
- delete result.liveToolExecutions[event.toolCallId];
452
- }
453
- pushLiveLog(result, { kind: "tool_end", toolName: event.toolName });
454
- return true;
455
- }
456
-
457
- return false;
458
- }
459
-
460
- // ---------------------------------------------------------------------------
461
- // Build pi CLI arguments
462
- // ---------------------------------------------------------------------------
463
-
464
- function buildPiArgs(
465
- agent: AgentConfig,
466
- systemPromptPath: string | null,
467
- task: string,
468
- sessionDir: string | undefined,
469
- resumeSession: boolean,
470
- fallbackModelOverride?: string,
471
- ): { args: string[]; prompt: string } {
472
- const args: string[] = [
473
- "--mode",
474
- "rpc",
475
- ..._inheritedCliArgs.extensionArgs,
476
- ..._inheritedCliArgs.alwaysProxy,
477
- ];
478
-
479
- if (sessionDir) args.push("--session-dir", sessionDir);
480
- if (resumeSession) args.push("--continue");
481
-
482
- // Agent config takes priority; fall back to parent CLI value
483
- const model = agent.model ?? fallbackModelOverride ?? process.env[SUBAGENT_FALLBACK_MODEL_ENV] ?? _inheritedCliArgs.fallbackModel;
484
- if (model) args.push("--model", model);
485
-
486
- const thinking = agent.thinking ?? _inheritedCliArgs.fallbackThinking;
487
- if (thinking) args.push("--thinking", thinking);
488
-
489
- // agent.tools is set only when the agent file specifies tools (length > 0)
490
- if (agent.tools && agent.tools.length > 0) {
491
- // Always include "subagent" so children can re-delegate when depth allows.
492
- // The child extension only registers the tool when canDelegate is true,
493
- // so listing it here is harmless when nested delegation is disabled.
494
- const toolsWithSubagent = agent.tools.includes("subagent")
495
- ? agent.tools
496
- : [...agent.tools, "subagent"];
497
- args.push("--tools", toolsWithSubagent.join(","));
498
- } else if (agent.tools === undefined) {
499
- // Agent didn't restrict tools — inherit parent's preference
500
- if (_inheritedCliArgs.fallbackTools !== undefined) {
501
- args.push("--tools", _inheritedCliArgs.fallbackTools);
502
- } else if (_inheritedCliArgs.fallbackNoTools) {
503
- args.push("--no-tools");
504
- }
505
- }
506
-
507
- if (systemPromptPath) args.push("--append-system-prompt", systemPromptPath);
508
- return {
509
- args,
510
- prompt: resumeSession
511
- ? `Continue the previous task from where you left off. Original task: ${task}`
512
- : `Task: ${task}`,
513
- };
514
- }
515
-
516
- // ---------------------------------------------------------------------------
517
- // Public API
518
- // ---------------------------------------------------------------------------
519
-
520
- export interface RunAgentOptions {
521
- /** Working directory inherited by every subagent process. */
522
- cwd: string;
523
- /** All available agent configs. */
524
- agents: AgentConfig[];
525
- /** Name of the agent to run. */
526
- agentName: string;
527
- /** Task description. */
528
- task: string;
529
- /** Current delegation depth of the caller process. */
530
- parentDepth: number;
531
- /** Delegation stack from the caller process (ancestor agent names). */
532
- parentAgentStack: string[];
533
- /** Maximum allowed delegation depth to propagate to child processes. */
534
- maxDepth: number;
535
- /** Whether cycle prevention should be enforced in child processes. */
536
- preventCycles: boolean;
537
- /** Abort signal for cancellation. */
538
- signal?: AbortSignal;
539
- /** Streaming update callback. */
540
- onUpdate?: OnUpdateCallback;
541
- /** Factory to wrap results into SubagentDetails. */
542
- makeDetails: (results: SingleResult[]) => SubagentDetails;
543
- /** Dedicated session directory for this subagent process. */
544
- sessionDir?: string;
545
- /** Top-level root for all subagent session directories in this delegation tree. */
546
- sessionRoot?: string;
547
- /** Continue the most recent session in sessionDir instead of creating a new one. */
548
- resumeSession?: boolean;
549
- /** Previously captured state for this same subagent, used to render resumed nested trees. */
550
- initialResult?: SingleResult;
551
- /** Fallback model to use when the agent config does not pin one. */
552
- fallbackModel?: string;
553
- /** Test/debug override for the spawned pi executable. */
554
- piCommandOverride?: { command: string; argsPrefix?: string[] };
555
- /** Test/debug override for startup timeout. */
556
- startupTimeoutMsOverride?: number;
557
- /** Called once the child RPC process is ready to receive steering messages. */
558
- onHandle?: RunningSubagentStartedCallback;
559
- }
560
-
561
- /**
562
- * Spawn a single subagent process and collect its results.
563
- *
564
- * Returns a SingleResult even on failure (exitCode > 0, stderr populated).
565
- */
566
- export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleResult> {
567
- const {
568
- cwd,
569
- agents,
570
- agentName,
571
- task,
572
- parentDepth,
573
- parentAgentStack,
574
- maxDepth,
575
- preventCycles,
576
- signal,
577
- onUpdate,
578
- makeDetails,
579
- sessionDir,
580
- sessionRoot,
581
- resumeSession = false,
582
- initialResult,
583
- fallbackModel,
584
- piCommandOverride,
585
- startupTimeoutMsOverride,
586
- } = opts;
587
-
588
- const agent = agents.find((a) => a.name === agentName);
589
- if (!agent) {
590
- const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
591
- return {
592
- agent: agentName,
593
- agentSource: "unknown",
594
- task,
595
- exitCode: 1,
596
- messages: [],
597
- stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
598
- usage: emptyUsage(),
599
- toolCalls: {},
600
- completedTurns: 0,
601
- turnInProgress: false,
602
- liveLog: [],
603
- sessionDir: opts.sessionDir,
604
- };
605
- }
606
-
607
- const shouldContinueSession = resumeSession && (!sessionDir || sessionDirExists(sessionDir));
608
-
609
- const result: SingleResult = {
610
- agent: agentName,
611
- agentSource: agent.source,
612
- task,
613
- exitCode: -1,
614
- messages: initialResult?.messages ? [...initialResult.messages] : [],
615
- stderr: initialResult?.stderr ?? "",
616
- usage: initialResult?.usage ? { ...initialResult.usage } : emptyUsage(),
617
- toolCalls: initialResult?.toolCalls ? { ...initialResult.toolCalls } : {},
618
- model: initialResult?.model ?? agent.model,
619
- completedTurns: initialResult?.completedTurns ?? 0,
620
- turnInProgress: false,
621
- liveToolExecutions: initialResult?.liveToolExecutions,
622
- liveLog: initialResult?.liveLog ? [...initialResult.liveLog] : [],
623
- sessionDir,
624
- };
625
-
626
- const emitUpdate = () => {
627
- onUpdate?.({
628
- content: [
629
- {
630
- type: "text",
631
- text: getFinalOutput(result.messages) || "(running...)",
632
- },
633
- ],
634
- details: makeDetails([result]),
635
- });
636
- };
637
-
638
- emitUpdate();
639
-
640
- // Write system prompt to temp file if needed
641
- let promptTmpDir: string | null = null;
642
- let promptTmpPath: string | null = null;
643
- if (agent.systemPrompt.trim()) {
644
- const tmp = writePromptToTempFile(agent.name, agent.systemPrompt);
645
- promptTmpDir = tmp.dir;
646
- promptTmpPath = tmp.filePath;
647
- }
648
-
649
- try {
650
- const { args: piArgs, prompt } = buildPiArgs(
651
- agent,
652
- promptTmpPath,
653
- task,
654
- sessionDir,
655
- shouldContinueSession,
656
- fallbackModel,
657
- );
658
- let wasAborted = false;
659
-
660
- const exitCode = await new Promise<number>((resolve) => {
661
- const nextDepth = Math.max(0, Math.floor(parentDepth)) + 1;
662
- const propagatedMaxDepth = Math.max(0, Math.floor(maxDepth));
663
- const propagatedStack = [...parentAgentStack, agentName];
664
- // On Windows, `pi` is a .CMD shim that requires the shell to execute,
665
- // but shell:true splits arguments on whitespace — breaking task strings.
666
- // Fix: reuse the running node binary + the pi CLI script path directly,
667
- // so the child is spawned without a shell and args are passed safely.
668
- const piSpawn = getPiSpawnCommand(piCommandOverride);
669
- const spawnCmd = piSpawn.command;
670
- const spawnArgs = [...piSpawn.argsPrefix, ...piArgs];
671
- const proc = spawn(spawnCmd, spawnArgs, {
672
- cwd,
673
- shell: false,
674
- stdio: ["pipe", "pipe", "pipe"],
675
- env: {
676
- ...process.env,
677
- [SUBAGENT_DEPTH_ENV]: String(nextDepth),
678
- [SUBAGENT_MAX_DEPTH_ENV]: String(propagatedMaxDepth),
679
- [SUBAGENT_STACK_ENV]: JSON.stringify(propagatedStack),
680
- [SUBAGENT_PREVENT_CYCLES_ENV]: preventCycles ? "1" : "0",
681
- ...(sessionRoot ? { [SUBAGENT_SESSION_ROOT_ENV]: sessionRoot } : {}),
682
- ...(fallbackModel ? { [SUBAGENT_FALLBACK_MODEL_ENV]: fallbackModel } : {}),
683
- // PI_OFFLINE is NOT forced here — see explanation near PI_OFFLINE_ENV.
684
- },
685
- });
686
-
687
- let buffer = "";
688
- let resolved = false;
689
- let hangTimer: ReturnType<typeof setTimeout> | undefined;
690
- let startupTimer: ReturnType<typeof setTimeout> | undefined;
691
- let receivedFirstEvent = false;
692
-
693
- const sendRpc = (command: Record<string, unknown>) => {
694
- proc.stdin?.write(`${JSON.stringify(command)}\n`);
695
- };
696
-
697
- opts.onHandle?.({
698
- steer(message: string) {
699
- sendRpc({ type: "steer", message });
700
- },
701
- });
702
-
703
- sendRpc({ type: "prompt", message: prompt });
704
-
705
- // Startup timeout: kill the process if it never produces its first
706
- // JSON event. Once the first event arrives, this timer is permanently
707
- // disabled — from that point, tool calls can run for as long as they
708
- // need, and only the terminal-stopReason hang guard applies.
709
- const startupTimeoutMs = (() => {
710
- if (startupTimeoutMsOverride !== undefined) return startupTimeoutMsOverride;
711
- const raw = process.env[SUBAGENT_STARTUP_TIMEOUT_ENV];
712
- if (raw === undefined) return DEFAULT_STARTUP_TIMEOUT_MS;
713
- const parsed = parseNonNegativeInt(raw);
714
- return parsed !== null ? parsed : DEFAULT_STARTUP_TIMEOUT_MS;
715
- })();
716
-
717
- const doResolve = (code: number) => {
718
- if (resolved) return;
719
- resolved = true;
720
- if (hangTimer) { clearTimeout(hangTimer); hangTimer = undefined; }
721
- if (startupTimer) { clearTimeout(startupTimer); startupTimer = undefined; }
722
- if (buffer.trim()) flushLine(buffer);
723
- resolve(code);
724
- };
725
-
726
- /**
727
- * Cancel any pending hang guard timer.
728
- * Called on every new activity to prove the child is still working.
729
- */
730
- const cancelHangGuard = () => {
731
- if (hangTimer) { clearTimeout(hangTimer); hangTimer = undefined; }
732
- };
733
-
734
- /**
735
- * Schedule a hang guard: if the child process produced a terminal
736
- * stopReason (agent finished) but doesn't exit on its own (due to
737
- * open handles like MCP connections, dangling timers, etc.),
738
- * force-kill it so the parent doesn't hang forever.
739
- *
740
- * The guard is reset on every new activity and only armed for
741
- * truly terminal stop reasons (not "tool_use").
742
- */
743
- const scheduleHangGuard = () => {
744
- if (resolved) return;
745
- cancelHangGuard();
746
- hangTimer = setTimeout(() => {
747
- if (resolved) return;
748
- // Process produced all output but won't exit — force-kill it
749
- try { proc.kill("SIGTERM"); } catch { /* already dead */ }
750
- setTimeout(() => {
751
- if (!resolved) {
752
- try { proc.kill("SIGKILL"); } catch { /* already dead */ }
753
- }
754
- }, SIGKILL_TIMEOUT_MS);
755
- }, HANG_GUARD_DELAY_MS);
756
- };
757
-
758
- const flushLine = (line: string) => {
759
- let event: any;
760
- try { event = JSON.parse(line); } catch { event = null; }
761
- if (event?.type === "agent_end") {
762
- if (result.exitCode === -1) result.exitCode = 0;
763
- try { proc.kill("SIGTERM"); } catch { /* already dead */ }
764
- doResolve(0);
765
- return;
766
- }
767
- const accepted = processJsonLine(line, result);
768
- if (accepted) {
769
- // Cancel the startup timer as soon as the subprocess proves it has
770
- // reached the LLM-call phase. Two conditions qualify:
771
- // 1. A turn has started (turn_start sets turnInProgress=true) —
772
- // the subprocess has initialised, loaded all extensions (including
773
- // MCP adapters), and sent its first request to the LLM. The LLM
774
- // may now take any amount of time to respond (especially with
775
- // extended thinking enabled) and must NOT be killed by the startup
776
- // timer.
777
- // 2. A complete assistant turn has arrived (turns > 0) — the LLM
778
- // already responded; startup trivially succeeded.
779
- // User message echoes alone (before turn_start) don't qualify:
780
- // the process could still stall before dispatching the LLM call,
781
- // e.g. in a hanging before_agent_start extension hook.
782
- if (!receivedFirstEvent && (result.usage.turns > 0 || result.turnInProgress)) {
783
- receivedFirstEvent = true;
784
- if (startupTimer) { clearTimeout(startupTimer); startupTimer = undefined; }
785
- }
786
- emitUpdate();
787
- // Any accepted message means the child is alive and producing
788
- // output — cancel any pending hang guard so we don't kill it
789
- // while it's still working (e.g. during tool execution).
790
- cancelHangGuard();
791
- // Only arm the hang guard when the agent has truly finished.
792
- // "tool_use" means the agent is still working — NOT terminal.
793
- if (isTerminalStopReason(result.stopReason)) {
794
- scheduleHangGuard();
795
- }
796
- }
797
- };
798
-
799
- // Start the startup timer — if the child process never reaches the
800
- // LLM-call phase (hung during init, broken binary, slow MCP adapter, etc.),
801
- // kill it. The timer is cancelled permanently on the first turn_start or
802
- // completed assistant turn, whichever comes first.
803
- if (startupTimeoutMs > 0) {
804
- startupTimer = setTimeout(() => {
805
- if (resolved || receivedFirstEvent) return;
806
- result.stderr += `\n[pi-subagent] Killed: no JSON output after ${startupTimeoutMs}ms (startup timeout).`;
807
- try { proc.kill("SIGTERM"); } catch { /* already dead */ }
808
- setTimeout(() => {
809
- if (!resolved) {
810
- try { proc.kill("SIGKILL"); } catch { /* already dead */ }
811
- }
812
- }, SIGKILL_TIMEOUT_MS);
813
- }, startupTimeoutMs);
814
- }
815
-
816
- proc.stdout.on("data", (chunk: Buffer) => {
817
- buffer += chunk.toString();
818
- const lines = buffer.split("\n");
819
- buffer = lines.pop() || "";
820
- for (const line of lines) flushLine(line);
821
- });
822
-
823
- proc.stderr.on("data", (chunk: Buffer) => {
824
- result.stderr += chunk.toString();
825
- });
826
-
827
- proc.on("close", (code) => {
828
- doResolve(code ?? 0);
829
- });
830
-
831
- proc.on("exit", (code) => {
832
- // If the process exits, resolve as soon as possible.
833
- // Give a tiny grace period for any remaining buffered stdout data.
834
- setTimeout(() => doResolve(code ?? 0), 100);
835
- });
836
-
837
- proc.on("error", (err) => {
838
- result.stderr += `Spawn error: ${err.message}`;
839
- result.stopReason = "error";
840
- result.errorMessage = `Failed to spawn pi process: ${err.message}`;
841
- doResolve(1);
842
- });
843
-
844
- // Abort handling
845
- if (signal) {
846
- const kill = () => {
847
- wasAborted = true;
848
- proc.kill("SIGTERM");
849
- setTimeout(() => {
850
- if (!proc.killed) proc.kill("SIGKILL");
851
- }, SIGKILL_TIMEOUT_MS);
852
- };
853
- if (signal.aborted) kill();
854
- else signal.addEventListener("abort", kill, { once: true });
855
- }
856
- });
857
-
858
- result.exitCode = exitCode;
859
- result.toolCalls = extractToolCalls(result.messages); // populate from parsed messages
860
- if (wasAborted) {
861
- result.exitCode = 130;
862
- result.stopReason = "aborted";
863
- result.errorMessage = "Subagent was aborted.";
864
- if (!result.stderr.trim()) result.stderr = "Subagent was aborted.";
865
- }
866
-
867
- if (result.exitCode === 0 && endedWithSyntheticResumeFailure(result.messages)) {
868
- result.exitCode = 1;
869
- result.stopReason = "error";
870
- result.errorMessage = "Subagent resume failed before the real model continued.";
871
- if (!result.stderr.trim()) result.stderr = result.errorMessage;
872
- }
873
-
874
- if (result.exitCode === 0) {
875
- const nestedErrorSummary = getNestedSubagentErrorSummary(result.messages);
876
- if (nestedErrorSummary) {
877
- result.exitCode = 1;
878
- result.stopReason = "error";
879
- result.errorMessage = nestedErrorSummary;
880
- if (!result.stderr.trim()) result.stderr = nestedErrorSummary;
881
- }
882
- }
883
-
884
- return result;
885
- } catch (err) {
886
- const msg = err instanceof Error ? err.message : String(err);
887
- result.exitCode = result.exitCode === -1 ? 1 : result.exitCode;
888
- result.stopReason = result.stopReason ?? "error";
889
- result.errorMessage = result.errorMessage ?? msg;
890
- if (!result.stderr.trim()) result.stderr = msg;
891
- return result;
892
- } finally {
893
- cleanupTempDir(promptTmpDir);
894
- }
895
- }
896
-
897
-
898
- // ---------------------------------------------------------------------------
899
- // Parallel execution (subprocess runner).
900
- // ---------------------------------------------------------------------------
901
-
902
-
903
- export async function executeParallelSubprocess(
904
- tasks: Array<{ agent: string; task: string }>,
905
- agents: AgentConfig[],
906
- defaultCwd: string,
907
- parentDepth: number,
908
- maxDepth: number,
909
- parentAgentStack: string[],
910
- preventCycles: boolean,
911
- signal: AbortSignal | undefined,
912
- onUpdate: OnUpdateCallback | undefined,
913
- makeDetails: (results: SingleResult[]) => SubagentDetails,
914
- resumeResults?: SingleResult[],
915
- getSessionDir?: (index: number, task: { agent: string; task: string }) => string | undefined,
916
- resumeExistingSessions = false,
917
- sessionRoot?: string,
918
- fallbackModel?: string,
919
- onHandleForTask?: (index: number, task: { agent: string; task: string }, handle: RunningSubagentHandle) => void,
920
- onTaskDone?: (index: number, task: { agent: string; task: string }) => void,
921
- ): Promise<{
922
- content: Array<{ type: "text"; text: string }>;
923
- details: SubagentDetails;
924
- }> {
925
- const maxParallelTasksRaw = process.env[SUBAGENT_MAX_PARALLEL_TASKS_ENV];
926
- const maxParallelTasksParsed = parseNonNegativeInt(maxParallelTasksRaw);
927
- if (maxParallelTasksRaw !== undefined && maxParallelTasksParsed === null) {
928
- console.warn(
929
- `[pi-subagent] Ignoring invalid ${SUBAGENT_MAX_PARALLEL_TASKS_ENV}="${maxParallelTasksRaw}". Expected a non-negative integer.`,
930
- );
931
- }
932
- const maxParallelTasks = maxParallelTasksParsed ?? DEFAULT_MAX_PARALLEL_TASKS;
933
-
934
- const maxConcurrencyRaw = process.env[SUBAGENT_MAX_CONCURRENCY_ENV];
935
- const maxConcurrencyParsed = parseNonNegativeInt(maxConcurrencyRaw);
936
- if (maxConcurrencyRaw !== undefined && maxConcurrencyParsed === null) {
937
- console.warn(
938
- `[pi-subagent] Ignoring invalid ${SUBAGENT_MAX_CONCURRENCY_ENV}="${maxConcurrencyRaw}". Expected a non-negative integer.`,
939
- );
940
- }
941
- const maxConcurrency = maxConcurrencyParsed ?? DEFAULT_MAX_CONCURRENCY;
942
-
943
- if (tasks.length > maxParallelTasks) {
944
- return {
945
- content: [
946
- {
947
- type: "text" as const,
948
- text: `Too many parallel tasks (${tasks.length}). Max is ${maxParallelTasks}.`,
949
- },
950
- ],
951
- details: makeDetails([]),
952
- };
953
- }
954
-
955
- const allResults: SingleResult[] = tasks.map((t, index) => resumeResults?.[index] ?? ({
956
- agent: t.agent,
957
- agentSource: "unknown" as const,
958
- task: t.task,
959
- exitCode: -1,
960
- messages: [],
961
- stderr: "",
962
- usage: emptyUsage(),
963
- toolCalls: {},
964
- completedTurns: 0,
965
- turnInProgress: false,
966
- liveLog: [],
967
- }));
968
-
969
- const emitProgress = () => {
970
- if (!onUpdate) return;
971
- const running = allResults.filter((r) => r.exitCode === -1).length;
972
- const done = allResults.filter((r) => r.exitCode !== -1).length;
973
- onUpdate({
974
- content: [
975
- {
976
- type: "text",
977
- text: `Parallel: ${done}/${allResults.length} done, ${running} running...`,
978
- },
979
- ],
980
- details: makeDetails([...allResults]),
981
- });
982
- };
983
-
984
- let heartbeat: NodeJS.Timeout | undefined;
985
- if (onUpdate) {
986
- emitProgress();
987
- heartbeat = setInterval(() => {
988
- if (allResults.some((r) => r.exitCode === -1)) emitProgress();
989
- }, PARALLEL_HEARTBEAT_MS);
990
- }
991
-
992
- let results: SingleResult[];
993
- try {
994
- results = await mapConcurrent(tasks, maxConcurrency, async (t, index) => {
995
- const previousResult = resumeResults?.[index];
996
- if (previousResult?.exitCode === 0) {
997
- allResults[index] = previousResult;
998
- emitProgress();
999
- return previousResult;
1000
- }
1001
- const savedSessionDir = previousResult?.sessionDir;
1002
- const savedSessionDirExists = sessionDirExists(savedSessionDir);
1003
- const shouldResumeThisSession = resumeExistingSessions && (!previousResult || !savedSessionDir || savedSessionDirExists);
1004
- const sessionDir = shouldResumeThisSession && savedSessionDirExists
1005
- ? savedSessionDir
1006
- : getSessionDir?.(index, t);
1007
- let result: SingleResult;
1008
- try {
1009
- result = await runAgentSubprocess({
1010
- cwd: defaultCwd,
1011
- agents,
1012
- agentName: t.agent,
1013
- task: t.task,
1014
- parentDepth,
1015
- parentAgentStack,
1016
- maxDepth,
1017
- preventCycles,
1018
- signal,
1019
- sessionDir,
1020
- sessionRoot,
1021
- resumeSession: shouldResumeThisSession && !!sessionDir,
1022
- initialResult: previousResult,
1023
- fallbackModel,
1024
- onHandle: (handle) => onHandleForTask?.(index, t, handle),
1025
- onUpdate: (partial) => {
1026
- if (partial.details?.results[0]) {
1027
- allResults[index] = partial.details.results[0];
1028
- emitProgress();
1029
- }
1030
- },
1031
- makeDetails,
1032
- });
1033
- } finally {
1034
- onTaskDone?.(index, t);
1035
- }
1036
- allResults[index] = result;
1037
- emitProgress();
1038
- return result;
1039
- });
1040
- } finally {
1041
- if (heartbeat) clearInterval(heartbeat);
1042
- }
1043
-
1044
- const successCount = results.filter((r) => r.exitCode === 0).length;
1045
- const summaries = results.map((r) => {
1046
- const output = getFinalOutput(r.messages);
1047
- return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${output || "(no output)"}`;
1048
- });
1049
-
1050
- return {
1051
- content: [
1052
- {
1053
- type: "text" as const,
1054
- text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
1055
- },
1056
- ],
1057
- details: makeDetails(results),
1058
- };
1059
- }
1
+ /**
2
+ * Subagent process runner.
3
+ *
4
+ * Spawns isolated `pi` processes and streams results back via callbacks.
5
+ */
6
+
7
+ import { spawn } from "node:child_process";
8
+ import * as fs from "node:fs";
9
+ import * as os from "node:os";
10
+ import * as path from "node:path";
11
+ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
12
+ import type { Message } from "@mariozechner/pi-ai";
13
+ import type { AgentConfig } from "./agents.js";
14
+ import {
15
+ type LiveLogEntry,
16
+ type SingleResult,
17
+ type SubagentDetails,
18
+ MAX_LIVE_LOG_ENTRIES,
19
+ emptyUsage,
20
+ extractToolCalls,
21
+ getFinalOutput,
22
+ getNestedSubagentErrorSummary,
23
+ } from "./types.js";
24
+ import { SUBAGENT_SESSION_ROOT_ENV } from "./resume.js";
25
+ import {
26
+ DEFAULT_MAX_PARALLEL_TASKS,
27
+ DEFAULT_MAX_CONCURRENCY,
28
+ PARALLEL_HEARTBEAT_MS,
29
+ RESUME_MODEL_ID,
30
+ RESUME_PROVIDER,
31
+ SUBAGENT_MAX_PARALLEL_TASKS_ENV,
32
+ SUBAGENT_MAX_CONCURRENCY_ENV,
33
+ parseNonNegativeInt,
34
+ mapConcurrent,
35
+ } from "./shared.js";
36
+
37
+ const SIGKILL_TIMEOUT_MS = 5000;
38
+ const HANG_GUARD_DELAY_MS = 5000;
39
+ const DEFAULT_STARTUP_TIMEOUT_MS = 120_000; // only for startup (before first assistant turn)
40
+ const SUBAGENT_STARTUP_TIMEOUT_ENV = "PI_SUBAGENT_STARTUP_TIMEOUT";
41
+ const SUBAGENT_PI_COMMAND_ENV = "PI_SUBAGENT_PI_COMMAND";
42
+ const SUBAGENT_PI_ARGS_PREFIX_ENV = "PI_SUBAGENT_PI_ARGS_PREFIX";
43
+
44
+ /**
45
+ * Stop reasons that indicate the agent has truly finished its work.
46
+ * "tool_use" is NOT terminal — the agent is still working (calling a tool).
47
+ */
48
+ // pi emits "stop" (and occasionally "end_turn") as the terminal reason; include both.
49
+ // "toolUse"/"tool_use" are NOT terminal — the agent is still mid-turn calling a tool.
50
+ const TERMINAL_STOP_REASONS = new Set(["end_turn", "stop", "max_tokens", "error", "stop_sequence"]);
51
+
52
+ function isTerminalStopReason(reason: string | undefined): boolean {
53
+ return reason !== undefined && TERMINAL_STOP_REASONS.has(reason);
54
+ }
55
+
56
+ function endedWithSyntheticResumeFailure(messages: Message[]): boolean {
57
+ const lastAssistant = [...messages].reverse().find((message: any) => message?.role === "assistant") as any;
58
+ if (lastAssistant?.provider !== RESUME_PROVIDER || lastAssistant?.model !== RESUME_MODEL_ID) return false;
59
+ const content = Array.isArray(lastAssistant.content) ? lastAssistant.content : [];
60
+ const handedOffToRealModel = messages.some((message: any) => message?.role === "assistant" && message.provider !== RESUME_PROVIDER);
61
+ const hasToolCall = content.some((part: any) => part?.type === "toolCall");
62
+ return !handedOffToRealModel && !hasToolCall;
63
+ }
64
+ const SUBAGENT_DEPTH_ENV = "PI_SUBAGENT_DEPTH";
65
+ const SUBAGENT_MAX_DEPTH_ENV = "PI_SUBAGENT_MAX_DEPTH";
66
+ const SUBAGENT_STACK_ENV = "PI_SUBAGENT_STACK";
67
+ const SUBAGENT_PREVENT_CYCLES_ENV = "PI_SUBAGENT_PREVENT_CYCLES";
68
+ const SUBAGENT_FALLBACK_MODEL_ENV = "PI_SUBAGENT_FALLBACK_MODEL";
69
+
70
+ // PI_OFFLINE intentionally removed: setting it on child processes blocks all API
71
+ // calls and renders subagents unable to do any LLM work. Children inherit the
72
+ // parent's PI_OFFLINE value via process.env spread if needed.
73
+
74
+ type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
75
+
76
+ export interface RunningSubagentHandle {
77
+ steer(message: string): void;
78
+ }
79
+
80
+ export type RunningSubagentStartedCallback = (handle: RunningSubagentHandle) => void;
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Temp file helpers
84
+ // ---------------------------------------------------------------------------
85
+
86
+ function writePromptToTempFile(
87
+ agentName: string,
88
+ prompt: string,
89
+ ): { dir: string; filePath: string } {
90
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
91
+ const safeName = agentName.replace(/[^\w.-]+/g, "_");
92
+ const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
93
+ fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
94
+ return { dir: tmpDir, filePath };
95
+ }
96
+
97
+ function cleanupTempDir(dir: string | null): void {
98
+ if (!dir) return;
99
+ try {
100
+ fs.rmSync(dir, { recursive: true, force: true });
101
+ } catch {
102
+ /* ignore */
103
+ }
104
+ }
105
+
106
+ function getCurrentPiCliScript(): string | null {
107
+ const script = process.argv[1];
108
+ if (!script) return null;
109
+
110
+ // When this extension is loaded by pi, process.argv[1] is the pi CLI JS
111
+ // entrypoint. Reusing it with process.execPath avoids relying on PATH while
112
+ // still running the exact same pi installation as the parent process.
113
+ const normalized = script.replace(/\\/g, "/");
114
+ if (!normalized.includes("/pi-coding-agent/") || !normalized.endsWith("/dist/cli.js")) {
115
+ return null;
116
+ }
117
+
118
+ return script;
119
+ }
120
+
121
+ function findPiCliScriptOnPath(): string | null {
122
+ const pathEnv = process.env.PATH ?? "";
123
+ for (const dir of pathEnv.split(path.delimiter)) {
124
+ if (!dir) continue;
125
+ for (const shimName of process.platform === "win32" ? ["pi.cmd", "pi"] : ["pi"]) {
126
+ const shimPath = path.join(dir, shimName);
127
+ if (!fs.existsSync(shimPath)) continue;
128
+ let text = "";
129
+ try {
130
+ text = fs.readFileSync(shimPath, "utf8");
131
+ } catch {
132
+ continue;
133
+ }
134
+ const match = text.match(/node_modules[\\/]([^\s"']*pi-coding-agent)[\\/]dist[\\/]cli\.js/);
135
+ if (!match) continue;
136
+ const cliPath = path.join(dir, "node_modules", match[1], "dist", "cli.js");
137
+ if (fs.existsSync(cliPath)) return cliPath;
138
+ }
139
+ }
140
+ return null;
141
+ }
142
+
143
+ function getPiSpawnCommand(override?: { command: string; argsPrefix?: string[] }): { command: string; argsPrefix: string[] } {
144
+ if (override?.command) return { command: override.command, argsPrefix: override.argsPrefix ?? [] };
145
+
146
+ const overrideCommand = process.env[SUBAGENT_PI_COMMAND_ENV];
147
+ if (overrideCommand) {
148
+ let argsPrefix: string[] = [];
149
+ const rawPrefix = process.env[SUBAGENT_PI_ARGS_PREFIX_ENV];
150
+ if (rawPrefix) {
151
+ try {
152
+ const parsed = JSON.parse(rawPrefix);
153
+ if (Array.isArray(parsed) && parsed.every((value) => typeof value === "string")) {
154
+ argsPrefix = parsed;
155
+ }
156
+ } catch {
157
+ // Ignore invalid test/debug override and run the command without a prefix.
158
+ }
159
+ }
160
+ return { command: overrideCommand, argsPrefix };
161
+ }
162
+
163
+ const cliScript = getCurrentPiCliScript() ?? findPiCliScriptOnPath();
164
+ if (cliScript) return { command: process.execPath, argsPrefix: [cliScript] };
165
+ return { command: "pi", argsPrefix: [] };
166
+ }
167
+
168
+ function resolveExtensionArg(value: string): string {
169
+ if (!value) return value;
170
+ if (value.startsWith("npm:") || value.startsWith("git:")) return value;
171
+ if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
172
+ if (path.isAbsolute(value)) return value;
173
+
174
+ const resolved = path.resolve(process.cwd(), value);
175
+ return fs.existsSync(resolved) ? resolved : value;
176
+ }
177
+
178
+ interface InheritedCliArgs {
179
+ /** --extension/-e and --no-extensions/-ne args (with path resolution) */
180
+ extensionArgs: string[];
181
+ /** All other non-blocked flags to forward verbatim to every child */
182
+ alwaysProxy: string[];
183
+ /** Parent --model value; used only when agent config doesn't specify model */
184
+ fallbackModel: string | undefined;
185
+ /** Parent --thinking value; used only when agent config doesn't specify thinking */
186
+ fallbackThinking: string | undefined;
187
+ /** Parent --tools value; used only when agent config doesn't specify tools */
188
+ fallbackTools: string | undefined;
189
+ /** Parent passed --no-tools; used only when agent config doesn't specify tools */
190
+ fallbackNoTools: boolean;
191
+ }
192
+
193
+ /**
194
+ * Parse process.argv into categorised groups for child-process arg construction.
195
+ *
196
+ * Categories:
197
+ * - BLOCKED : flags the extension manages itself — never forwarded
198
+ * - extensionArgs : --extension/-e and --no-extensions/-ne (with path resolution)
199
+ * - alwaysProxy : all other non-blocked flags forwarded verbatim
200
+ * - fallback* : flags the agent config may override
201
+ *
202
+ * Handles both "--flag value" and "--flag=value" forms.
203
+ * Unknown flags use a heuristic: if the next token doesn't start with "-",
204
+ * it is treated as the flag's value.
205
+ */
206
+ function parseInheritedCliArgs(argv: string[]): InheritedCliArgs {
207
+ const extensionArgs: string[] = [];
208
+ const alwaysProxy: string[] = [];
209
+ let fallbackModel: string | undefined;
210
+ let fallbackThinking: string | undefined;
211
+ let fallbackTools: string | undefined;
212
+ let fallbackNoTools = false;
213
+
214
+ let i = 2; // skip "node" and "pi"
215
+ while (i < argv.length) {
216
+ const raw = argv[i];
217
+ // Positional args (prompt text, @file refs) — skip, not proxied to children
218
+ if (!raw.startsWith("-")) { i++; continue; }
219
+
220
+ // Normalise: detect --flag=value inline form
221
+ const eqIdx = raw.indexOf("=");
222
+ const flagName = eqIdx !== -1 ? raw.slice(0, eqIdx) : raw;
223
+ const inlineValue: string | undefined = eqIdx !== -1 ? raw.slice(eqIdx + 1) : undefined;
224
+
225
+ const nextToken = argv[i + 1];
226
+ const nextIsValue = nextToken !== undefined && !nextToken.startsWith("-");
227
+
228
+ // Returns [resolvedValue | undefined, tokensToConsume]
229
+ const getVal = (): [string | undefined, number] => {
230
+ if (inlineValue !== undefined) return [inlineValue, 1];
231
+ if (nextIsValue) return [nextToken, 2];
232
+ return [undefined, 1];
233
+ };
234
+
235
+ // ── BLOCKED: value flags ─────────────────────────────────────────────────
236
+ // Extension manages these; consume flag + value, never proxy.
237
+ if ([
238
+ "--mode", "--session", "--append-system-prompt",
239
+ "--export", "--subagent-max-depth",
240
+ ].includes(flagName)) {
241
+ const [, skip] = getVal();
242
+ i += skip; continue;
243
+ }
244
+
245
+ // --subagent-prevent-cycles takes an optional value
246
+ if (flagName === "--subagent-prevent-cycles") {
247
+ if (inlineValue !== undefined || nextIsValue) { i += inlineValue !== undefined ? 1 : 2; }
248
+ else { i++; }
249
+ continue;
250
+ }
251
+
252
+ // --list-models has an optional search term
253
+ if (flagName === "--list-models") {
254
+ if (inlineValue !== undefined || nextIsValue) { i += inlineValue !== undefined ? 1 : 2; }
255
+ else { i++; }
256
+ continue;
257
+ }
258
+
259
+ // ── BLOCKED: boolean flags ────────────────────────────────────────────────
260
+ if ([
261
+ "--print", "-p", "--no-session",
262
+ "--continue", "-c", "--resume", "-r",
263
+ "--offline", "--help", "-h", "--version", "-v",
264
+ "--no-subagent-prevent-cycles",
265
+ ].includes(flagName)) {
266
+ i++; continue;
267
+ }
268
+
269
+ // ── EXTENSION FLAGS: handled separately with path resolution ─────────────
270
+ if (flagName === "--no-extensions" || flagName === "-ne") {
271
+ extensionArgs.push(flagName);
272
+ i++; continue;
273
+ }
274
+ if (flagName === "--extension" || flagName === "-e") {
275
+ const [value, skip] = getVal();
276
+ if (value !== undefined) extensionArgs.push(flagName, resolveExtensionArg(value));
277
+ i += skip; continue;
278
+ }
279
+
280
+ // ── ALWAYS-PROXY: known value flags ──────────────────────────────────────
281
+ if ([
282
+ "--provider", "--api-key", "--system-prompt",
283
+ "--models", "--skill", "--prompt-template", "--theme",
284
+ ].includes(flagName)) {
285
+ const [value, skip] = getVal();
286
+ if (value !== undefined) alwaysProxy.push(flagName, value);
287
+ i += skip; continue;
288
+ }
289
+
290
+ // ── ALWAYS-PROXY: known boolean flags ────────────────────────────────────
291
+ if ([
292
+ "--no-skills", "-ns", "--no-prompt-templates", "-np",
293
+ "--no-themes", "--verbose",
294
+ ].includes(flagName)) {
295
+ alwaysProxy.push(flagName);
296
+ i++; continue;
297
+ }
298
+
299
+ // ── FALLBACK: agent config may override ───────────────────────────────────
300
+ if (flagName === "--model") {
301
+ const [value, skip] = getVal();
302
+ if (value !== undefined) fallbackModel = value;
303
+ i += skip; continue;
304
+ }
305
+ if (flagName === "--thinking") {
306
+ const [value, skip] = getVal();
307
+ if (value !== undefined) fallbackThinking = value;
308
+ i += skip; continue;
309
+ }
310
+ if (flagName === "--tools") {
311
+ const [value, skip] = getVal();
312
+ if (value !== undefined) fallbackTools = value;
313
+ i += skip; continue;
314
+ }
315
+ if (flagName === "--no-tools") {
316
+ fallbackNoTools = true;
317
+ i++; continue;
318
+ }
319
+
320
+ // ── UNKNOWN: heuristic passthrough ───────────────────────────────────────
321
+ // Likely a custom extension flag. Forward with value if next token looks like one.
322
+ if (inlineValue !== undefined) {
323
+ alwaysProxy.push(flagName, inlineValue);
324
+ i++; continue;
325
+ }
326
+ if (nextIsValue) {
327
+ alwaysProxy.push(flagName, nextToken);
328
+ i += 2; continue;
329
+ }
330
+ alwaysProxy.push(flagName);
331
+ i++;
332
+ }
333
+
334
+ return { extensionArgs, alwaysProxy, fallbackModel, fallbackThinking, fallbackTools, fallbackNoTools };
335
+ }
336
+
337
+ /** Cached once — process.argv is immutable at runtime */
338
+ const _inheritedCliArgs = parseInheritedCliArgs(process.argv);
339
+
340
+ // ---------------------------------------------------------------------------
341
+ // JSON-line stream processing
342
+ // ---------------------------------------------------------------------------
343
+
344
+ function pushLiveLog(result: SingleResult, entry: LiveLogEntry): void {
345
+ result.liveLog.push(entry);
346
+ if (result.liveLog.length > MAX_LIVE_LOG_ENTRIES) result.liveLog.shift();
347
+ }
348
+
349
+ function messageDedupKey(message: Message): string {
350
+ const anyMessage = message as any;
351
+ if (typeof anyMessage.id === "string") return `id:${anyMessage.id}`;
352
+ return JSON.stringify({
353
+ role: anyMessage.role,
354
+ provider: anyMessage.provider,
355
+ model: anyMessage.model,
356
+ stopReason: anyMessage.stopReason,
357
+ toolCallId: anyMessage.toolCallId,
358
+ toolName: anyMessage.toolName,
359
+ content: anyMessage.content,
360
+ usage: anyMessage.usage,
361
+ });
362
+ }
363
+
364
+ function hasMessage(result: SingleResult, message: Message): boolean {
365
+ const key = messageDedupKey(message);
366
+ return result.messages.some((existing) => messageDedupKey(existing) === key);
367
+ }
368
+
369
+ function sessionDirExists(dir: string | undefined): boolean {
370
+ if (!dir) return false;
371
+ try {
372
+ return fs.existsSync(dir) && fs.statSync(dir).isDirectory();
373
+ } catch {
374
+ return false;
375
+ }
376
+ }
377
+
378
+ export function processJsonLine(line: string, result: SingleResult): boolean {
379
+ if (!line.trim()) return false;
380
+
381
+ let event: any;
382
+ try {
383
+ event = JSON.parse(line);
384
+ } catch {
385
+ return false;
386
+ }
387
+
388
+ // Guard: JSON.parse can return null, a number, boolean, or array — none of which have .type
389
+ if (!event || typeof event !== "object" || Array.isArray(event)) return false;
390
+
391
+ if (event.type === "message_end" && event.message) {
392
+ const msg = event.message as Message;
393
+ if (hasMessage(result, msg)) return true;
394
+ result.messages.push(msg);
395
+
396
+ if (msg.role === "assistant") {
397
+ result.usage.turns++;
398
+ const usage = msg.usage;
399
+ if (usage) {
400
+ result.usage.input += usage.input || 0;
401
+ result.usage.output += usage.output || 0;
402
+ result.usage.cacheRead += usage.cacheRead || 0;
403
+ result.usage.cacheWrite += usage.cacheWrite || 0;
404
+ result.usage.cost += usage.cost?.total || 0;
405
+ result.usage.contextTokens = usage.totalTokens || 0;
406
+ }
407
+ if (msg.model && msg.model !== "synthetic-tool-call") result.model = msg.model;
408
+ if (msg.stopReason) result.stopReason = msg.stopReason;
409
+ if (msg.errorMessage) result.errorMessage = msg.errorMessage;
410
+ }
411
+ return true;
412
+ }
413
+
414
+ if (event.type === "tool_result_end" && event.message) {
415
+ const msg = event.message as Message;
416
+ if (!hasMessage(result, msg)) result.messages.push(msg);
417
+ return true;
418
+ }
419
+
420
+ if (event.type === "turn_start") {
421
+ result.turnInProgress = true;
422
+ pushLiveLog(result, { kind: "turn_start" });
423
+ return true;
424
+ }
425
+
426
+ if (event.type === "turn_end") {
427
+ result.completedTurns++;
428
+ result.turnInProgress = false;
429
+ const u = event.message?.usage;
430
+ pushLiveLog(result, {
431
+ kind: "turn_end",
432
+ turn: result.completedTurns,
433
+ inputTokens: u?.input ?? 0,
434
+ outputTokens: u?.output ?? 0,
435
+ });
436
+ return true;
437
+ }
438
+
439
+ if (event.type === "tool_execution_start") {
440
+ result.liveToolExecutions ??= {};
441
+ result.liveToolExecutions[event.toolCallId] = {
442
+ toolName: event.toolName,
443
+ args: event.args,
444
+ };
445
+ pushLiveLog(result, { kind: "tool_start", toolName: event.toolName, args: event.args });
446
+ return true;
447
+ }
448
+
449
+ if (event.type === "tool_execution_end") {
450
+ if (result.liveToolExecutions) {
451
+ delete result.liveToolExecutions[event.toolCallId];
452
+ }
453
+ pushLiveLog(result, { kind: "tool_end", toolName: event.toolName });
454
+ return true;
455
+ }
456
+
457
+ return false;
458
+ }
459
+
460
+ // ---------------------------------------------------------------------------
461
+ // Build pi CLI arguments
462
+ // ---------------------------------------------------------------------------
463
+
464
+ function buildPiArgs(
465
+ agent: AgentConfig,
466
+ systemPromptPath: string | null,
467
+ task: string,
468
+ sessionDir: string | undefined,
469
+ resumeSession: boolean,
470
+ fallbackModelOverride?: string,
471
+ ): { args: string[]; prompt: string } {
472
+ const args: string[] = [
473
+ "--mode",
474
+ "rpc",
475
+ ..._inheritedCliArgs.extensionArgs,
476
+ ..._inheritedCliArgs.alwaysProxy,
477
+ ];
478
+
479
+ if (sessionDir) args.push("--session-dir", sessionDir);
480
+ if (resumeSession) args.push("--continue");
481
+
482
+ // Agent config takes priority; fall back to parent CLI value
483
+ const model = agent.model ?? fallbackModelOverride ?? process.env[SUBAGENT_FALLBACK_MODEL_ENV] ?? _inheritedCliArgs.fallbackModel;
484
+ if (model) args.push("--model", model);
485
+
486
+ const thinking = agent.thinking ?? _inheritedCliArgs.fallbackThinking;
487
+ if (thinking) args.push("--thinking", thinking);
488
+
489
+ // agent.tools is set only when the agent file specifies tools (length > 0)
490
+ if (agent.tools && agent.tools.length > 0) {
491
+ // Always include "subagent" so children can re-delegate when depth allows.
492
+ // The child extension only registers the tool when canDelegate is true,
493
+ // so listing it here is harmless when nested delegation is disabled.
494
+ const toolsWithSubagent = agent.tools.includes("subagent")
495
+ ? agent.tools
496
+ : [...agent.tools, "subagent"];
497
+ args.push("--tools", toolsWithSubagent.join(","));
498
+ } else if (agent.tools === undefined) {
499
+ // Agent didn't restrict tools — inherit parent's preference
500
+ if (_inheritedCliArgs.fallbackTools !== undefined) {
501
+ args.push("--tools", _inheritedCliArgs.fallbackTools);
502
+ } else if (_inheritedCliArgs.fallbackNoTools) {
503
+ args.push("--no-tools");
504
+ }
505
+ }
506
+
507
+ if (systemPromptPath) args.push("--append-system-prompt", systemPromptPath);
508
+ return {
509
+ args,
510
+ prompt: resumeSession
511
+ ? `Continue the previous task from where you left off. Original task: ${task}`
512
+ : `Task: ${task}`,
513
+ };
514
+ }
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // Public API
518
+ // ---------------------------------------------------------------------------
519
+
520
+ export interface RunAgentOptions {
521
+ /** Working directory inherited by every subagent process. */
522
+ cwd: string;
523
+ /** All available agent configs. */
524
+ agents: AgentConfig[];
525
+ /** Name of the agent to run. */
526
+ agentName: string;
527
+ /** Task description. */
528
+ task: string;
529
+ /** Current delegation depth of the caller process. */
530
+ parentDepth: number;
531
+ /** Delegation stack from the caller process (ancestor agent names). */
532
+ parentAgentStack: string[];
533
+ /** Maximum allowed delegation depth to propagate to child processes. */
534
+ maxDepth: number;
535
+ /** Whether cycle prevention should be enforced in child processes. */
536
+ preventCycles: boolean;
537
+ /** Abort signal for cancellation. */
538
+ signal?: AbortSignal;
539
+ /** Streaming update callback. */
540
+ onUpdate?: OnUpdateCallback;
541
+ /** Factory to wrap results into SubagentDetails. */
542
+ makeDetails: (results: SingleResult[]) => SubagentDetails;
543
+ /** Dedicated session directory for this subagent process. */
544
+ sessionDir?: string;
545
+ /** Top-level root for all subagent session directories in this delegation tree. */
546
+ sessionRoot?: string;
547
+ /** Continue the most recent session in sessionDir instead of creating a new one. */
548
+ resumeSession?: boolean;
549
+ /** Previously captured state for this same subagent, used to render resumed nested trees. */
550
+ initialResult?: SingleResult;
551
+ /** Fallback model to use when the agent config does not pin one. */
552
+ fallbackModel?: string;
553
+ /** Test/debug override for the spawned pi executable. */
554
+ piCommandOverride?: { command: string; argsPrefix?: string[] };
555
+ /** Test/debug override for startup timeout. */
556
+ startupTimeoutMsOverride?: number;
557
+ /** Called once the child RPC process is ready to receive steering messages. */
558
+ onHandle?: RunningSubagentStartedCallback;
559
+ }
560
+
561
+ /**
562
+ * Spawn a single subagent process and collect its results.
563
+ *
564
+ * Returns a SingleResult even on failure (exitCode > 0, stderr populated).
565
+ */
566
+ export async function runAgentSubprocess(opts: RunAgentOptions): Promise<SingleResult> {
567
+ const {
568
+ cwd,
569
+ agents,
570
+ agentName,
571
+ task,
572
+ parentDepth,
573
+ parentAgentStack,
574
+ maxDepth,
575
+ preventCycles,
576
+ signal,
577
+ onUpdate,
578
+ makeDetails,
579
+ sessionDir,
580
+ sessionRoot,
581
+ resumeSession = false,
582
+ initialResult,
583
+ fallbackModel,
584
+ piCommandOverride,
585
+ startupTimeoutMsOverride,
586
+ } = opts;
587
+
588
+ const agent = agents.find((a) => a.name === agentName);
589
+ if (!agent) {
590
+ const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
591
+ return {
592
+ agent: agentName,
593
+ agentSource: "unknown",
594
+ task,
595
+ exitCode: 1,
596
+ messages: [],
597
+ stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
598
+ usage: emptyUsage(),
599
+ toolCalls: {},
600
+ completedTurns: 0,
601
+ turnInProgress: false,
602
+ liveLog: [],
603
+ sessionDir: opts.sessionDir,
604
+ };
605
+ }
606
+
607
+ const shouldContinueSession = resumeSession && (!sessionDir || sessionDirExists(sessionDir));
608
+
609
+ const result: SingleResult = {
610
+ agent: agentName,
611
+ agentSource: agent.source,
612
+ task,
613
+ exitCode: -1,
614
+ messages: initialResult?.messages ? [...initialResult.messages] : [],
615
+ stderr: initialResult?.stderr ?? "",
616
+ usage: initialResult?.usage ? { ...initialResult.usage } : emptyUsage(),
617
+ toolCalls: initialResult?.toolCalls ? { ...initialResult.toolCalls } : {},
618
+ model: initialResult?.model ?? agent.model,
619
+ completedTurns: initialResult?.completedTurns ?? 0,
620
+ turnInProgress: false,
621
+ liveToolExecutions: initialResult?.liveToolExecutions,
622
+ liveLog: initialResult?.liveLog ? [...initialResult.liveLog] : [],
623
+ sessionDir,
624
+ };
625
+
626
+ const emitUpdate = () => {
627
+ onUpdate?.({
628
+ content: [
629
+ {
630
+ type: "text",
631
+ text: getFinalOutput(result.messages) || "(running...)",
632
+ },
633
+ ],
634
+ details: makeDetails([result]),
635
+ });
636
+ };
637
+
638
+ emitUpdate();
639
+
640
+ // Write system prompt to temp file if needed
641
+ let promptTmpDir: string | null = null;
642
+ let promptTmpPath: string | null = null;
643
+ if (agent.systemPrompt.trim()) {
644
+ const tmp = writePromptToTempFile(agent.name, agent.systemPrompt);
645
+ promptTmpDir = tmp.dir;
646
+ promptTmpPath = tmp.filePath;
647
+ }
648
+
649
+ try {
650
+ const { args: piArgs, prompt } = buildPiArgs(
651
+ agent,
652
+ promptTmpPath,
653
+ task,
654
+ sessionDir,
655
+ shouldContinueSession,
656
+ fallbackModel,
657
+ );
658
+ let wasAborted = false;
659
+
660
+ const exitCode = await new Promise<number>((resolve) => {
661
+ const nextDepth = Math.max(0, Math.floor(parentDepth)) + 1;
662
+ const propagatedMaxDepth = Math.max(0, Math.floor(maxDepth));
663
+ const propagatedStack = [...parentAgentStack, agentName];
664
+ // On Windows, `pi` is a .CMD shim that requires the shell to execute,
665
+ // but shell:true splits arguments on whitespace — breaking task strings.
666
+ // Fix: reuse the running node binary + the pi CLI script path directly,
667
+ // so the child is spawned without a shell and args are passed safely.
668
+ const piSpawn = getPiSpawnCommand(piCommandOverride);
669
+ const spawnCmd = piSpawn.command;
670
+ const spawnArgs = [...piSpawn.argsPrefix, ...piArgs];
671
+ const proc = spawn(spawnCmd, spawnArgs, {
672
+ cwd,
673
+ shell: false,
674
+ stdio: ["pipe", "pipe", "pipe"],
675
+ env: {
676
+ ...process.env,
677
+ [SUBAGENT_DEPTH_ENV]: String(nextDepth),
678
+ [SUBAGENT_MAX_DEPTH_ENV]: String(propagatedMaxDepth),
679
+ [SUBAGENT_STACK_ENV]: JSON.stringify(propagatedStack),
680
+ [SUBAGENT_PREVENT_CYCLES_ENV]: preventCycles ? "1" : "0",
681
+ ...(sessionRoot ? { [SUBAGENT_SESSION_ROOT_ENV]: sessionRoot } : {}),
682
+ ...(fallbackModel ? { [SUBAGENT_FALLBACK_MODEL_ENV]: fallbackModel } : {}),
683
+ // PI_OFFLINE is NOT forced here — see explanation near PI_OFFLINE_ENV.
684
+ },
685
+ });
686
+
687
+ let buffer = "";
688
+ let resolved = false;
689
+ let hangTimer: ReturnType<typeof setTimeout> | undefined;
690
+ let startupTimer: ReturnType<typeof setTimeout> | undefined;
691
+ let receivedFirstEvent = false;
692
+
693
+ const sendRpc = (command: Record<string, unknown>) => {
694
+ proc.stdin?.write(`${JSON.stringify(command)}\n`);
695
+ };
696
+
697
+ opts.onHandle?.({
698
+ steer(message: string) {
699
+ sendRpc({ type: "steer", message });
700
+ },
701
+ });
702
+
703
+ sendRpc({ type: "prompt", message: prompt });
704
+
705
+ // Startup timeout: kill the process if it never produces its first
706
+ // JSON event. Once the first event arrives, this timer is permanently
707
+ // disabled — from that point, tool calls can run for as long as they
708
+ // need, and only the terminal-stopReason hang guard applies.
709
+ const startupTimeoutMs = (() => {
710
+ if (startupTimeoutMsOverride !== undefined) return startupTimeoutMsOverride;
711
+ const raw = process.env[SUBAGENT_STARTUP_TIMEOUT_ENV];
712
+ if (raw === undefined) return DEFAULT_STARTUP_TIMEOUT_MS;
713
+ const parsed = parseNonNegativeInt(raw);
714
+ return parsed !== null ? parsed : DEFAULT_STARTUP_TIMEOUT_MS;
715
+ })();
716
+
717
+ const doResolve = (code: number) => {
718
+ if (resolved) return;
719
+ resolved = true;
720
+ if (hangTimer) { clearTimeout(hangTimer); hangTimer = undefined; }
721
+ if (startupTimer) { clearTimeout(startupTimer); startupTimer = undefined; }
722
+ if (buffer.trim()) flushLine(buffer);
723
+ resolve(code);
724
+ };
725
+
726
+ /**
727
+ * Cancel any pending hang guard timer.
728
+ * Called on every new activity to prove the child is still working.
729
+ */
730
+ const cancelHangGuard = () => {
731
+ if (hangTimer) { clearTimeout(hangTimer); hangTimer = undefined; }
732
+ };
733
+
734
+ /**
735
+ * Schedule a hang guard: if the child process produced a terminal
736
+ * stopReason (agent finished) but doesn't exit on its own (due to
737
+ * open handles like MCP connections, dangling timers, etc.),
738
+ * force-kill it so the parent doesn't hang forever.
739
+ *
740
+ * The guard is reset on every new activity and only armed for
741
+ * truly terminal stop reasons (not "tool_use").
742
+ */
743
+ const scheduleHangGuard = () => {
744
+ if (resolved) return;
745
+ cancelHangGuard();
746
+ hangTimer = setTimeout(() => {
747
+ if (resolved) return;
748
+ // Process produced all output but won't exit — force-kill it
749
+ try { proc.kill("SIGTERM"); } catch { /* already dead */ }
750
+ setTimeout(() => {
751
+ if (!resolved) {
752
+ try { proc.kill("SIGKILL"); } catch { /* already dead */ }
753
+ }
754
+ }, SIGKILL_TIMEOUT_MS);
755
+ }, HANG_GUARD_DELAY_MS);
756
+ };
757
+
758
+ const flushLine = (line: string) => {
759
+ let event: any;
760
+ try { event = JSON.parse(line); } catch { event = null; }
761
+ if (event?.type === "agent_end") {
762
+ if (result.exitCode === -1) result.exitCode = 0;
763
+ try { proc.kill("SIGTERM"); } catch { /* already dead */ }
764
+ doResolve(0);
765
+ return;
766
+ }
767
+ const accepted = processJsonLine(line, result);
768
+ if (accepted) {
769
+ // Cancel the startup timer as soon as the subprocess proves it has
770
+ // reached the LLM-call phase. Two conditions qualify:
771
+ // 1. A turn has started (turn_start sets turnInProgress=true) —
772
+ // the subprocess has initialised, loaded all extensions (including
773
+ // MCP adapters), and sent its first request to the LLM. The LLM
774
+ // may now take any amount of time to respond (especially with
775
+ // extended thinking enabled) and must NOT be killed by the startup
776
+ // timer.
777
+ // 2. A complete assistant turn has arrived (turns > 0) — the LLM
778
+ // already responded; startup trivially succeeded.
779
+ // User message echoes alone (before turn_start) don't qualify:
780
+ // the process could still stall before dispatching the LLM call,
781
+ // e.g. in a hanging before_agent_start extension hook.
782
+ if (!receivedFirstEvent && (result.usage.turns > 0 || result.turnInProgress)) {
783
+ receivedFirstEvent = true;
784
+ if (startupTimer) { clearTimeout(startupTimer); startupTimer = undefined; }
785
+ }
786
+ emitUpdate();
787
+ // Any accepted message means the child is alive and producing
788
+ // output — cancel any pending hang guard so we don't kill it
789
+ // while it's still working (e.g. during tool execution).
790
+ cancelHangGuard();
791
+ // Only arm the hang guard when the agent has truly finished.
792
+ // "tool_use" means the agent is still working — NOT terminal.
793
+ if (isTerminalStopReason(result.stopReason)) {
794
+ scheduleHangGuard();
795
+ }
796
+ }
797
+ };
798
+
799
+ // Start the startup timer — if the child process never reaches the
800
+ // LLM-call phase (hung during init, broken binary, slow MCP adapter, etc.),
801
+ // kill it. The timer is cancelled permanently on the first turn_start or
802
+ // completed assistant turn, whichever comes first.
803
+ if (startupTimeoutMs > 0) {
804
+ startupTimer = setTimeout(() => {
805
+ if (resolved || receivedFirstEvent) return;
806
+ result.stderr += `\n[pi-subagent] Killed: no JSON output after ${startupTimeoutMs}ms (startup timeout).`;
807
+ try { proc.kill("SIGTERM"); } catch { /* already dead */ }
808
+ setTimeout(() => {
809
+ if (!resolved) {
810
+ try { proc.kill("SIGKILL"); } catch { /* already dead */ }
811
+ }
812
+ }, SIGKILL_TIMEOUT_MS);
813
+ }, startupTimeoutMs);
814
+ }
815
+
816
+ proc.stdout.on("data", (chunk: Buffer) => {
817
+ buffer += chunk.toString();
818
+ const lines = buffer.split("\n");
819
+ buffer = lines.pop() || "";
820
+ for (const line of lines) flushLine(line);
821
+ });
822
+
823
+ proc.stderr.on("data", (chunk: Buffer) => {
824
+ result.stderr += chunk.toString();
825
+ });
826
+
827
+ proc.on("close", (code) => {
828
+ doResolve(code ?? 0);
829
+ });
830
+
831
+ proc.on("exit", (code) => {
832
+ // If the process exits, resolve as soon as possible.
833
+ // Give a tiny grace period for any remaining buffered stdout data.
834
+ setTimeout(() => doResolve(code ?? 0), 100);
835
+ });
836
+
837
+ proc.on("error", (err) => {
838
+ result.stderr += `Spawn error: ${err.message}`;
839
+ result.stopReason = "error";
840
+ result.errorMessage = `Failed to spawn pi process: ${err.message}`;
841
+ doResolve(1);
842
+ });
843
+
844
+ // Abort handling
845
+ if (signal) {
846
+ const kill = () => {
847
+ wasAborted = true;
848
+ proc.kill("SIGTERM");
849
+ setTimeout(() => {
850
+ if (!proc.killed) proc.kill("SIGKILL");
851
+ }, SIGKILL_TIMEOUT_MS);
852
+ };
853
+ if (signal.aborted) kill();
854
+ else signal.addEventListener("abort", kill, { once: true });
855
+ }
856
+ });
857
+
858
+ result.exitCode = exitCode;
859
+ result.toolCalls = extractToolCalls(result.messages); // populate from parsed messages
860
+ if (wasAborted) {
861
+ result.exitCode = 130;
862
+ result.stopReason = "aborted";
863
+ result.errorMessage = "Subagent was aborted.";
864
+ if (!result.stderr.trim()) result.stderr = "Subagent was aborted.";
865
+ }
866
+
867
+ if (result.exitCode === 0 && endedWithSyntheticResumeFailure(result.messages)) {
868
+ result.exitCode = 1;
869
+ result.stopReason = "error";
870
+ result.errorMessage = "Subagent resume failed before the real model continued.";
871
+ if (!result.stderr.trim()) result.stderr = result.errorMessage;
872
+ }
873
+
874
+ if (result.exitCode === 0) {
875
+ const nestedErrorSummary = getNestedSubagentErrorSummary(result.messages);
876
+ if (nestedErrorSummary) {
877
+ result.exitCode = 1;
878
+ result.stopReason = "error";
879
+ result.errorMessage = nestedErrorSummary;
880
+ if (!result.stderr.trim()) result.stderr = nestedErrorSummary;
881
+ }
882
+ }
883
+
884
+ return result;
885
+ } catch (err) {
886
+ const msg = err instanceof Error ? err.message : String(err);
887
+ result.exitCode = result.exitCode === -1 ? 1 : result.exitCode;
888
+ result.stopReason = result.stopReason ?? "error";
889
+ result.errorMessage = result.errorMessage ?? msg;
890
+ if (!result.stderr.trim()) result.stderr = msg;
891
+ return result;
892
+ } finally {
893
+ cleanupTempDir(promptTmpDir);
894
+ }
895
+ }
896
+
897
+
898
+ // ---------------------------------------------------------------------------
899
+ // Parallel execution (subprocess runner).
900
+ // ---------------------------------------------------------------------------
901
+
902
+
903
+ export async function executeParallelSubprocess(
904
+ tasks: Array<{ agent: string; task: string }>,
905
+ agents: AgentConfig[],
906
+ defaultCwd: string,
907
+ parentDepth: number,
908
+ maxDepth: number,
909
+ parentAgentStack: string[],
910
+ preventCycles: boolean,
911
+ signal: AbortSignal | undefined,
912
+ onUpdate: OnUpdateCallback | undefined,
913
+ makeDetails: (results: SingleResult[]) => SubagentDetails,
914
+ resumeResults?: SingleResult[],
915
+ getSessionDir?: (index: number, task: { agent: string; task: string }) => string | undefined,
916
+ resumeExistingSessions = false,
917
+ sessionRoot?: string,
918
+ fallbackModel?: string,
919
+ onHandleForTask?: (index: number, task: { agent: string; task: string }, handle: RunningSubagentHandle) => void,
920
+ onTaskDone?: (index: number, task: { agent: string; task: string }) => void,
921
+ ): Promise<{
922
+ content: Array<{ type: "text"; text: string }>;
923
+ details: SubagentDetails;
924
+ }> {
925
+ const maxParallelTasksRaw = process.env[SUBAGENT_MAX_PARALLEL_TASKS_ENV];
926
+ const maxParallelTasksParsed = parseNonNegativeInt(maxParallelTasksRaw);
927
+ if (maxParallelTasksRaw !== undefined && maxParallelTasksParsed === null) {
928
+ console.warn(
929
+ `[pi-subagent] Ignoring invalid ${SUBAGENT_MAX_PARALLEL_TASKS_ENV}="${maxParallelTasksRaw}". Expected a non-negative integer.`,
930
+ );
931
+ }
932
+ const maxParallelTasks = maxParallelTasksParsed ?? DEFAULT_MAX_PARALLEL_TASKS;
933
+
934
+ const maxConcurrencyRaw = process.env[SUBAGENT_MAX_CONCURRENCY_ENV];
935
+ const maxConcurrencyParsed = parseNonNegativeInt(maxConcurrencyRaw);
936
+ if (maxConcurrencyRaw !== undefined && maxConcurrencyParsed === null) {
937
+ console.warn(
938
+ `[pi-subagent] Ignoring invalid ${SUBAGENT_MAX_CONCURRENCY_ENV}="${maxConcurrencyRaw}". Expected a non-negative integer.`,
939
+ );
940
+ }
941
+ const maxConcurrency = maxConcurrencyParsed ?? DEFAULT_MAX_CONCURRENCY;
942
+
943
+ if (tasks.length > maxParallelTasks) {
944
+ return {
945
+ content: [
946
+ {
947
+ type: "text" as const,
948
+ text: `Too many parallel tasks (${tasks.length}). Max is ${maxParallelTasks}.`,
949
+ },
950
+ ],
951
+ details: makeDetails([]),
952
+ };
953
+ }
954
+
955
+ const allResults: SingleResult[] = tasks.map((t, index) => resumeResults?.[index] ?? ({
956
+ agent: t.agent,
957
+ agentSource: "unknown" as const,
958
+ task: t.task,
959
+ exitCode: -1,
960
+ messages: [],
961
+ stderr: "",
962
+ usage: emptyUsage(),
963
+ toolCalls: {},
964
+ completedTurns: 0,
965
+ turnInProgress: false,
966
+ liveLog: [],
967
+ }));
968
+
969
+ const emitProgress = () => {
970
+ if (!onUpdate) return;
971
+ const running = allResults.filter((r) => r.exitCode === -1).length;
972
+ const done = allResults.filter((r) => r.exitCode !== -1).length;
973
+ onUpdate({
974
+ content: [
975
+ {
976
+ type: "text",
977
+ text: `Parallel: ${done}/${allResults.length} done, ${running} running...`,
978
+ },
979
+ ],
980
+ details: makeDetails([...allResults]),
981
+ });
982
+ };
983
+
984
+ let heartbeat: NodeJS.Timeout | undefined;
985
+ if (onUpdate) {
986
+ emitProgress();
987
+ heartbeat = setInterval(() => {
988
+ if (allResults.some((r) => r.exitCode === -1)) emitProgress();
989
+ }, PARALLEL_HEARTBEAT_MS);
990
+ }
991
+
992
+ let results: SingleResult[];
993
+ try {
994
+ results = await mapConcurrent(tasks, maxConcurrency, async (t, index) => {
995
+ const previousResult = resumeResults?.[index];
996
+ if (previousResult?.exitCode === 0) {
997
+ allResults[index] = previousResult;
998
+ emitProgress();
999
+ return previousResult;
1000
+ }
1001
+ const savedSessionDir = previousResult?.sessionDir;
1002
+ const savedSessionDirExists = sessionDirExists(savedSessionDir);
1003
+ const shouldResumeThisSession = resumeExistingSessions && (!previousResult || !savedSessionDir || savedSessionDirExists);
1004
+ const sessionDir = shouldResumeThisSession && savedSessionDirExists
1005
+ ? savedSessionDir
1006
+ : getSessionDir?.(index, t);
1007
+ let result: SingleResult;
1008
+ try {
1009
+ result = await runAgentSubprocess({
1010
+ cwd: defaultCwd,
1011
+ agents,
1012
+ agentName: t.agent,
1013
+ task: t.task,
1014
+ parentDepth,
1015
+ parentAgentStack,
1016
+ maxDepth,
1017
+ preventCycles,
1018
+ signal,
1019
+ sessionDir,
1020
+ sessionRoot,
1021
+ resumeSession: shouldResumeThisSession && !!sessionDir,
1022
+ initialResult: previousResult,
1023
+ fallbackModel,
1024
+ onHandle: (handle) => onHandleForTask?.(index, t, handle),
1025
+ onUpdate: (partial) => {
1026
+ if (partial.details?.results[0]) {
1027
+ allResults[index] = partial.details.results[0];
1028
+ emitProgress();
1029
+ }
1030
+ },
1031
+ makeDetails,
1032
+ });
1033
+ } finally {
1034
+ onTaskDone?.(index, t);
1035
+ }
1036
+ allResults[index] = result;
1037
+ emitProgress();
1038
+ return result;
1039
+ });
1040
+ } finally {
1041
+ if (heartbeat) clearInterval(heartbeat);
1042
+ }
1043
+
1044
+ const successCount = results.filter((r) => r.exitCode === 0).length;
1045
+ const summaries = results.map((r) => {
1046
+ const output = getFinalOutput(r.messages);
1047
+ return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${output || "(no output)"}`;
1048
+ });
1049
+
1050
+ return {
1051
+ content: [
1052
+ {
1053
+ type: "text" as const,
1054
+ text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
1055
+ },
1056
+ ],
1057
+ details: makeDetails(results),
1058
+ };
1059
+ }