@ferris1225/pi-subagents 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/spawn.ts CHANGED
@@ -1,361 +1,473 @@
1
- /**
2
- * Sub-agent dispatch: each agent runs as an isolated `pi` child process
3
- * (`--mode json -p --no-session`). The agent's system prompt (the .md body) is
4
- * written to a temp file and passed via `--append-system-prompt` (which accepts a
5
- * file path). Child stdout is a JSON-lines event stream; we accumulate assistant
6
- * messages from `message_end` events and stream partial output back via onUpdate.
7
- *
8
- * Adapted from the official pi example `examples/extensions/subagent`.
9
- */
10
-
11
- import { spawn } from "node:child_process";
12
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
13
- import { existsSync, unlinkSync, rmdirSync } from "node:fs";
14
- import { tmpdir } from "node:os";
15
- import { basename, join } from "node:path";
16
- import type { AgentToolResult } from "@earendil-works/pi-agent-core";
17
- import type { Message } from "@earendil-works/pi-ai";
18
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
19
- import type { AgentConfig, AgentSource } from "./agents.ts";
20
-
21
- export const MAX_PARALLEL_TASKS = 8;
22
- export const MAX_CONCURRENCY = 4;
23
- /** Max nesting depth for sub-agent -> sub-agent spawning (recursion guard). */
24
- export const MAX_SUBAGENT_DEPTH = 2;
25
- export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
26
-
27
- export interface UsageStats {
28
- input: number;
29
- output: number;
30
- cacheRead: number;
31
- cacheWrite: number;
32
- cost: number;
33
- contextTokens: number;
34
- turns: number;
35
- }
36
-
37
- export interface SingleResult {
38
- agent: string;
39
- agentSource: AgentSource | "unknown";
40
- task: string;
41
- exitCode: number; // -1 = still running
42
- messages: Message[];
43
- stderr: string;
44
- usage: UsageStats;
45
- model?: string;
46
- stopReason?: string;
47
- errorMessage?: string;
48
- }
49
-
50
- export interface SubagentDetails {
51
- mode: "single" | "parallel";
52
- results: SingleResult[];
53
- }
54
-
55
- export type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
56
-
57
- export type SubagentLiveEvent =
58
- | { kind: "status"; status: "queued" | "running" | "done" | "failed" }
59
- | { kind: "usage"; usage: UsageStats; model?: string }
60
- | { kind: "tool_start"; toolName: string; args: unknown }
61
- | { kind: "tool_end"; toolName: string; isError: boolean }
62
- | { kind: "text_delta"; delta: string };
63
-
64
- function emptyUsage(): UsageStats {
65
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
66
- }
67
-
68
- export function getFinalOutput(messages: Message[]): string {
69
- for (let i = messages.length - 1; i >= 0; i--) {
70
- const msg = messages[i];
71
- if (msg.role === "assistant") {
72
- for (const part of msg.content) {
73
- if (part.type === "text") return part.text;
74
- }
75
- }
76
- }
77
- return "";
78
- }
79
-
80
- export function isFailedResult(result: SingleResult): boolean {
81
- return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
82
- }
83
-
84
- export function getResultOutput(result: SingleResult): string {
85
- if (isFailedResult(result)) {
86
- return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
87
- }
88
- return getFinalOutput(result.messages) || "(no output)";
89
- }
90
-
91
- export async function mapWithConcurrencyLimit<TIn, TOut>(
92
- items: TIn[],
93
- concurrency: number,
94
- fn: (item: TIn, index: number) => Promise<TOut>,
95
- ): Promise<TOut[]> {
96
- if (items.length === 0) return [];
97
- const limit = Math.max(1, Math.min(concurrency, items.length));
98
- const results: TOut[] = new Array(items.length);
99
- let nextIndex = 0;
100
- const workers = new Array(limit).fill(null).map(async () => {
101
- while (true) {
102
- const current = nextIndex++;
103
- if (current >= items.length) return;
104
- results[current] = await fn(items[current], current);
105
- }
106
- });
107
- await Promise.all(workers);
108
- return results;
109
- }
110
-
111
- async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
112
- const dir = await mkdtemp(join(tmpdir(), "pi-subagents-"));
113
- const safeName = agentName.replace(/[^\w.-]+/g, "_");
114
- const filePath = join(dir, `prompt-${safeName}.md`);
115
- await withFileMutationQueue(filePath, async () => {
116
- await writeFile(filePath, prompt, "utf8");
117
- });
118
- return { dir, filePath };
119
- }
120
-
121
- /** Resolve how to invoke the SAME pi build as the current process. */
122
- export function getPiInvocation(args: string[]): { command: string; args: string[] } {
123
- const currentScript = process.argv[1];
124
- const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
125
- if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
126
- return { command: process.execPath, args: [currentScript, ...args] };
127
- }
128
- const execName = basename(process.execPath).toLowerCase();
129
- const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
130
- if (!isGenericRuntime) return { command: process.execPath, args };
131
- return { command: "pi", args };
132
- }
133
-
134
- export function currentSubagentDepth(env: NodeJS.ProcessEnv = process.env): number {
135
- const raw = env[DEPTH_ENV_VAR];
136
- const parsed = raw === undefined ? 0 : Number.parseInt(raw, 10);
137
- return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
138
- }
139
-
140
- export interface RunSingleOptions {
141
- defaultCwd: string;
142
- agent: AgentConfig | undefined;
143
- agentName: string;
144
- task: string;
145
- cwd?: string;
146
- signal?: AbortSignal;
147
- onUpdate?: OnUpdateCallback;
148
- onLive?: (e: SubagentLiveEvent) => void;
149
- makeDetails: (results: SingleResult[]) => SubagentDetails;
150
- env?: NodeJS.ProcessEnv;
151
- }
152
-
153
- /** Spawn one agent as an isolated pi child process and collect its output. */
154
- export async function runSingleAgent(options: RunSingleOptions): Promise<SingleResult> {
155
- const { agent, agentName, task, cwd, signal, onUpdate, onLive, makeDetails } = options;
156
-
157
- if (!agent) {
158
- return {
159
- agent: agentName,
160
- agentSource: "unknown",
161
- task,
162
- exitCode: 1,
163
- messages: [],
164
- stderr: `Unknown agent: "${agentName}".`,
165
- usage: emptyUsage(),
166
- };
167
- }
168
-
169
- const args: string[] = ["--mode", "json", "-p", "--no-session"];
170
- if (agent.model) args.push("--model", agent.model);
171
- if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
172
-
173
- let tmpPromptDir: string | null = null;
174
- let tmpPromptPath: string | null = null;
175
-
176
- const currentResult: SingleResult = {
177
- agent: agentName,
178
- agentSource: agent.source,
179
- task,
180
- exitCode: 0,
181
- messages: [],
182
- stderr: "",
183
- usage: emptyUsage(),
184
- model: agent.model,
185
- };
186
-
187
- const emitUpdate = (): void => {
188
- onUpdate?.({
189
- content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
190
- details: makeDetails([currentResult]),
191
- });
192
- };
193
-
194
- try {
195
- if (agent.systemPrompt.trim()) {
196
- const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
197
- tmpPromptDir = tmp.dir;
198
- tmpPromptPath = tmp.filePath;
199
- args.push("--append-system-prompt", tmpPromptPath);
200
- }
201
-
202
- args.push(`Task: ${task}`);
203
- let wasAborted = false;
204
-
205
- // Increment depth so nested sub-agents can be guarded against runaway recursion.
206
- const childDepth = currentSubagentDepth(options.env) + 1;
207
- const childEnv: NodeJS.ProcessEnv = {
208
- ...(options.env ?? process.env),
209
- [DEPTH_ENV_VAR]: String(childDepth),
210
- };
211
-
212
- const exitCode = await new Promise<number>((resolve) => {
213
- const invocation = getPiInvocation(args);
214
- const proc = spawn(invocation.command, invocation.args, {
215
- cwd: cwd ?? options.defaultCwd,
216
- shell: false,
217
- stdio: ["ignore", "pipe", "pipe"],
218
- env: childEnv,
219
- });
220
- let buffer = "";
221
-
222
- const processLine = (line: string): void => {
223
- if (!line.trim()) return;
224
- let event: any;
225
- try {
226
- event = JSON.parse(line);
227
- } catch {
228
- return;
229
- }
230
-
231
- // Live event: agent started
232
- if (event.type === "agent_start" || event.type === "turn_start") {
233
- if (onLive) {
234
- try {
235
- onLive({ kind: "status", status: "running" });
236
- } catch { /* never throw from event handling */ }
237
- }
238
- }
239
-
240
- // Live event: streamed assistant text
241
- if (event.type === "message_update" && event.assistantMessageEvent?.type === "text_delta") {
242
- if (onLive) {
243
- try {
244
- onLive({ kind: "text_delta", delta: event.assistantMessageEvent.delta ?? "" });
245
- } catch { /* never throw from event handling */ }
246
- }
247
- }
248
-
249
- // Live event: tool execution started
250
- if (event.type === "tool_execution_start") {
251
- if (onLive) {
252
- try {
253
- onLive({ kind: "tool_start", toolName: event.toolName ?? "unknown", args: event.args });
254
- } catch { /* never throw from event handling */ }
255
- }
256
- }
257
-
258
- // Live event: tool execution ended
259
- if (event.type === "tool_execution_end") {
260
- if (onLive) {
261
- try {
262
- onLive({ kind: "tool_end", toolName: event.toolName ?? "unknown", isError: Boolean(event.isError) });
263
- } catch { /* never throw from event handling */ }
264
- }
265
- }
266
-
267
- if (event.type === "message_end" && event.message) {
268
- const msg = event.message as Message;
269
- currentResult.messages.push(msg);
270
- if (msg.role === "assistant") {
271
- currentResult.usage.turns++;
272
- const usage = (msg as any).usage;
273
- if (usage) {
274
- currentResult.usage.input += usage.input || 0;
275
- currentResult.usage.output += usage.output || 0;
276
- currentResult.usage.cacheRead += usage.cacheRead || 0;
277
- currentResult.usage.cacheWrite += usage.cacheWrite || 0;
278
- currentResult.usage.cost += usage.cost?.total || 0;
279
- currentResult.usage.contextTokens = usage.totalTokens || 0;
280
- }
281
- if (!currentResult.model && (msg as any).model) currentResult.model = (msg as any).model;
282
- if ((msg as any).stopReason) currentResult.stopReason = (msg as any).stopReason;
283
- if ((msg as any).errorMessage) currentResult.errorMessage = (msg as any).errorMessage;
284
- }
285
- // Live event: usage snapshot after accumulation
286
- if (onLive) {
287
- try {
288
- onLive({ kind: "usage", usage: { ...currentResult.usage }, model: currentResult.model });
289
- } catch { /* never throw from event handling */ }
290
- }
291
- emitUpdate();
292
- }
293
-
294
- if (event.type === "tool_result_end" && event.message) {
295
- currentResult.messages.push(event.message as Message);
296
- emitUpdate();
297
- }
298
- };
299
- proc.stdout.on("data", (data) => {
300
- buffer += data.toString();
301
- const lines = buffer.split("\n");
302
- buffer = lines.pop() || "";
303
- for (const line of lines) processLine(line);
304
- });
305
-
306
- proc.stderr.on("data", (data) => {
307
- currentResult.stderr += data.toString();
308
- });
309
-
310
- proc.on("close", (code) => {
311
- if (buffer.trim()) processLine(buffer);
312
- // Live event: final status derived from exit code
313
- if (onLive) {
314
- try {
315
- const failed = (code ?? 0) !== 0 || currentResult.stopReason === "error" || currentResult.stopReason === "aborted";
316
- onLive({ kind: "status", status: failed ? "failed" : "done" });
317
- } catch { /* never throw from event handling */ }
318
- }
319
- resolve(code ?? 0);
320
- });
321
-
322
- proc.on("error", () => resolve(1));
323
-
324
- if (signal) {
325
- const killProc = (): void => {
326
- wasAborted = true;
327
- proc.kill("SIGTERM");
328
- setTimeout(() => {
329
- if (!proc.killed) proc.kill("SIGKILL");
330
- }, 5000);
331
- };
332
- if (signal.aborted) killProc();
333
- else signal.addEventListener("abort", killProc, { once: true });
334
- }
335
- });
336
-
337
- currentResult.exitCode = exitCode;
338
- if (wasAborted) {
339
- if (onLive) {
340
- try {
341
- onLive({ kind: "status", status: "failed" });
342
- } catch { /* never throw from event handling */ }
343
- }
344
- throw new Error("Subagent was aborted");
345
- }
346
- return currentResult;
347
- } finally {
348
- if (tmpPromptPath)
349
- try {
350
- unlinkSync(tmpPromptPath);
351
- } catch {
352
- /* ignore */
353
- }
354
- if (tmpPromptDir)
355
- try {
356
- rmdirSync(tmpPromptDir);
357
- } catch {
358
- /* ignore */
359
- }
360
- }
361
- }
1
+ /**
2
+ * Sub-agent dispatch: each agent runs as an isolated `pi` child process
3
+ * (`--mode json -p --no-session`). The agent's system prompt (the .md body) is
4
+ * written to a temp file and passed via `--append-system-prompt` (which accepts a
5
+ * file path). The task itself is sent through the child's stdin pipe, not another
6
+ * temp file or command-line argument. Child stdout is a JSON-lines event stream;
7
+ * we accumulate assistant messages from `message_end` events and stream partial
8
+ * output back via onUpdate.
9
+ *
10
+ * Adapted from the official pi example `examples/extensions/subagent`.
11
+ */
12
+
13
+ import { spawn, type ChildProcess } from "node:child_process";
14
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
15
+ import { existsSync, unlinkSync, rmdirSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { basename, join } from "node:path";
18
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
19
+ import type { Message } from "@earendil-works/pi-ai";
20
+ import type { AgentConfig, AgentSource } from "./agents.ts";
21
+ import { DEFAULT_THINKING_LEVEL, type ThinkingLevel } from "./config.ts";
22
+
23
+ export const MAX_PARALLEL_TASKS = 8;
24
+ export const MAX_CONCURRENCY = 4;
25
+ /** Default thinking level for sub-agents. pi clamps it to the resolved model's support. */
26
+ export const SUBAGENT_THINKING_LEVEL: ThinkingLevel = DEFAULT_THINKING_LEVEL;
27
+ /** Child processes are leaf agents: they never receive the subagent tool. */
28
+ export const MAX_SUBAGENT_DEPTH = 1;
29
+ export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
30
+ /** Absolute limit so a child that stops emitting events cannot hang the parent forever. */
31
+ export const SUBAGENT_TIMEOUT_MS = 10 * 60 * 1000;
32
+ export const SUBAGENT_KILL_GRACE_MS = 5_000;
33
+
34
+ export interface UsageStats {
35
+ input: number;
36
+ output: number;
37
+ cacheRead: number;
38
+ cacheWrite: number;
39
+ cost: number;
40
+ contextTokens: number;
41
+ turns: number;
42
+ }
43
+
44
+ export interface SingleResult {
45
+ agent: string;
46
+ agentSource: AgentSource | "unknown";
47
+ task: string;
48
+ exitCode: number; // -1 = still running
49
+ messages: Message[];
50
+ stderr: string;
51
+ usage: UsageStats;
52
+ model?: string;
53
+ stopReason?: string;
54
+ errorMessage?: string;
55
+ }
56
+
57
+ export interface SubagentDetails {
58
+ mode: "single" | "parallel";
59
+ results: SingleResult[];
60
+ }
61
+
62
+ export type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
63
+
64
+ export type SubagentLiveEvent =
65
+ | { kind: "status"; status: "queued" | "running" | "done" | "failed" }
66
+ | { kind: "usage"; usage: UsageStats; model?: string }
67
+ | { kind: "tool_start"; toolName: string; args: unknown }
68
+ | { kind: "tool_end"; toolName: string; isError: boolean }
69
+ | { kind: "thinking" }
70
+ | { kind: "text" };
71
+
72
+ function emptyUsage(): UsageStats {
73
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
74
+ }
75
+
76
+ export function getFinalOutput(messages: Message[]): string {
77
+ for (let i = messages.length - 1; i >= 0; i--) {
78
+ const msg = messages[i];
79
+ if (msg.role === "assistant") {
80
+ for (const part of msg.content) {
81
+ if (part.type === "text") return part.text;
82
+ }
83
+ }
84
+ }
85
+ return "";
86
+ }
87
+
88
+ export function isFailedResult(result: SingleResult): boolean {
89
+ return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
90
+ }
91
+
92
+ export function getResultOutput(result: SingleResult): string {
93
+ if (isFailedResult(result)) {
94
+ return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
95
+ }
96
+ return getFinalOutput(result.messages) || "(no output)";
97
+ }
98
+
99
+ export async function mapWithConcurrencyLimit<TIn, TOut>(
100
+ items: TIn[],
101
+ concurrency: number,
102
+ fn: (item: TIn, index: number) => Promise<TOut>,
103
+ ): Promise<TOut[]> {
104
+ if (items.length === 0) return [];
105
+ const limit = Math.max(1, Math.min(concurrency, items.length));
106
+ const results: TOut[] = new Array(items.length);
107
+ let nextIndex = 0;
108
+ const workers = new Array(limit).fill(null).map(async () => {
109
+ while (true) {
110
+ const current = nextIndex++;
111
+ if (current >= items.length) return;
112
+ results[current] = await fn(items[current], current);
113
+ }
114
+ });
115
+ await Promise.all(workers);
116
+ return results;
117
+ }
118
+
119
+ async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
120
+ const dir = await mkdtemp(join(tmpdir(), "pi-subagents-"));
121
+ const safeName = agentName.replace(/[^\w.-]+/g, "_");
122
+ const filePath = join(dir, `prompt-${safeName}.md`);
123
+ await writeFile(filePath, prompt, "utf8");
124
+ return { dir, filePath };
125
+ }
126
+
127
+ /** Resolve how to invoke the SAME pi build as the current process. */
128
+ export function getPiInvocation(args: string[]): { command: string; args: string[] } {
129
+ const currentScript = process.argv[1];
130
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
131
+ if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
132
+ return { command: process.execPath, args: [currentScript, ...args] };
133
+ }
134
+ const execName = basename(process.execPath).toLowerCase();
135
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
136
+ if (!isGenericRuntime) return { command: process.execPath, args };
137
+ return { command: "pi", args };
138
+ }
139
+
140
+ /** Terminate the child and any tool processes it left behind. */
141
+ function terminateProcessTree(proc: ChildProcess, force: boolean): void {
142
+ if (process.platform === "win32" && proc.pid !== undefined) {
143
+ const killer = spawn("taskkill", ["/pid", String(proc.pid), "/t", "/f"], {
144
+ stdio: "ignore",
145
+ windowsHide: true,
146
+ });
147
+ const fallback = (): void => {
148
+ try {
149
+ proc.kill(force ? "SIGKILL" : "SIGTERM");
150
+ } catch {
151
+ /* process may already be gone */
152
+ }
153
+ };
154
+ killer.on("error", fallback);
155
+ killer.on("close", (code) => {
156
+ if (code !== 0) fallback();
157
+ });
158
+ return;
159
+ }
160
+
161
+ try {
162
+ proc.kill(force ? "SIGKILL" : "SIGTERM");
163
+ } catch {
164
+ /* process may already be gone */
165
+ }
166
+ }
167
+
168
+ export function currentSubagentDepth(env: NodeJS.ProcessEnv = process.env): number {
169
+ const raw = env[DEPTH_ENV_VAR];
170
+ const parsed = raw === undefined ? 0 : Number.parseInt(raw, 10);
171
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
172
+ }
173
+
174
+ export interface RunSingleOptions {
175
+ defaultCwd: string;
176
+ agent: AgentConfig | undefined;
177
+ agentName: string;
178
+ task: string;
179
+ cwd?: string;
180
+ /** Thinking level passed to the child pi process. */
181
+ thinkingLevel?: ThinkingLevel;
182
+ /** Override the watchdog timeout; intended for tests and controlled callers. */
183
+ timeoutMs?: number;
184
+ signal?: AbortSignal;
185
+ onUpdate?: OnUpdateCallback;
186
+ onLive?: (e: SubagentLiveEvent) => void;
187
+ makeDetails: (results: SingleResult[]) => SubagentDetails;
188
+ env?: NodeJS.ProcessEnv;
189
+ }
190
+
191
+ /** Spawn one agent as an isolated pi child process and collect its output. */
192
+ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleResult> {
193
+ const {
194
+ agent,
195
+ agentName,
196
+ task,
197
+ cwd,
198
+ thinkingLevel = SUBAGENT_THINKING_LEVEL,
199
+ timeoutMs = SUBAGENT_TIMEOUT_MS,
200
+ signal,
201
+ onUpdate,
202
+ onLive,
203
+ makeDetails,
204
+ } = options;
205
+
206
+ if (!agent) {
207
+ return {
208
+ agent: agentName,
209
+ agentSource: "unknown",
210
+ task,
211
+ exitCode: 1,
212
+ messages: [],
213
+ stderr: `Unknown agent: "${agentName}".`,
214
+ usage: emptyUsage(),
215
+ };
216
+ }
217
+
218
+ // Defense in depth: even if another extension ignores the depth marker, a
219
+ // child process can never expose a tool named `subagent` back to its model.
220
+ const args: string[] = ["--mode", "json", "-p", "--no-session", "--exclude-tools", "subagent"];
221
+ if (agent.model) args.push("--model", agent.model);
222
+ // The configured level is clamped adaptively per model by pi.
223
+ args.push("--thinking", thinkingLevel);
224
+ if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
225
+
226
+ let tmpPromptDir: string | null = null;
227
+ let tmpPromptPath: string | null = null;
228
+
229
+ const currentResult: SingleResult = {
230
+ agent: agentName,
231
+ agentSource: agent.source,
232
+ task,
233
+ exitCode: 0,
234
+ messages: [],
235
+ stderr: "",
236
+ usage: emptyUsage(),
237
+ model: agent.model,
238
+ };
239
+
240
+ const emitUpdate = (): void => {
241
+ onUpdate?.({
242
+ content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
243
+ details: makeDetails([currentResult]),
244
+ });
245
+ };
246
+
247
+ try {
248
+ if (agent.systemPrompt.trim()) {
249
+ const tmp = await writePromptToTempFile(agent.name, agent.systemPrompt);
250
+ tmpPromptDir = tmp.dir;
251
+ tmpPromptPath = tmp.filePath;
252
+ args.push("--append-system-prompt", tmpPromptPath);
253
+ }
254
+
255
+ let wasAborted = false;
256
+ let timedOut = false;
257
+
258
+ // Increment depth so nested sub-agents can be guarded against runaway recursion.
259
+ const childDepth = currentSubagentDepth(options.env) + 1;
260
+ const childEnv: NodeJS.ProcessEnv = {
261
+ ...(options.env ?? process.env),
262
+ [DEPTH_ENV_VAR]: String(childDepth),
263
+ };
264
+
265
+ const exitCode = await new Promise<number>((resolve) => {
266
+ const invocation = getPiInvocation(args);
267
+ const proc = spawn(invocation.command, invocation.args, {
268
+ cwd: cwd ?? options.defaultCwd,
269
+ shell: false,
270
+ stdio: ["pipe", "pipe", "pipe"],
271
+ env: childEnv,
272
+ });
273
+ let buffer = "";
274
+ let closed = false;
275
+ let termSent = false;
276
+ let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
277
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
278
+ let abortHandler: (() => void) | undefined;
279
+
280
+ const finish = (code: number | null): void => {
281
+ if (closed) return;
282
+ closed = true;
283
+ if (forceKillTimer) clearTimeout(forceKillTimer);
284
+ if (timeoutTimer) clearTimeout(timeoutTimer);
285
+ if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
286
+ resolve(code ?? 1);
287
+ };
288
+
289
+ const terminate = (): void => {
290
+ if (closed) return;
291
+ if (!termSent) {
292
+ termSent = true;
293
+ terminateProcessTree(proc, false);
294
+ }
295
+ if (!forceKillTimer) {
296
+ forceKillTimer = setTimeout(() => {
297
+ if (!closed) terminateProcessTree(proc, true);
298
+ }, SUBAGENT_KILL_GRACE_MS);
299
+ }
300
+ };
301
+
302
+ const processLine = (line: string): void => {
303
+ if (!line.trim()) return;
304
+ let event: any;
305
+ try {
306
+ event = JSON.parse(line);
307
+ } catch {
308
+ return;
309
+ }
310
+
311
+ // Live event: agent started
312
+ if (event.type === "agent_start" || event.type === "turn_start") {
313
+ if (onLive) {
314
+ try {
315
+ onLive({ kind: "status", status: "running" });
316
+ } catch { /* never throw from event handling */ }
317
+ }
318
+ }
319
+
320
+ // Live event: streamed assistant reasoning / output text
321
+ if (event.type === "message_update") {
322
+ const t = event.assistantMessageEvent?.type;
323
+ if (t === "thinking_delta" || t === "text_delta") {
324
+ if (onLive) {
325
+ try {
326
+ onLive({ kind: t === "thinking_delta" ? "thinking" : "text" });
327
+ } catch { /* never throw from event handling */ }
328
+ }
329
+ }
330
+ }
331
+
332
+ // Live event: tool execution started
333
+ if (event.type === "tool_execution_start") {
334
+ if (onLive) {
335
+ try {
336
+ onLive({ kind: "tool_start", toolName: event.toolName ?? "unknown", args: event.args });
337
+ } catch { /* never throw from event handling */ }
338
+ }
339
+ }
340
+
341
+ // Live event: tool execution ended
342
+ if (event.type === "tool_execution_end") {
343
+ if (onLive) {
344
+ try {
345
+ onLive({ kind: "tool_end", toolName: event.toolName ?? "unknown", isError: Boolean(event.isError) });
346
+ } catch { /* never throw from event handling */ }
347
+ }
348
+ }
349
+
350
+ if (event.type === "message_end" && event.message) {
351
+ const msg = event.message as Message;
352
+ currentResult.messages.push(msg);
353
+ if (msg.role === "assistant") {
354
+ currentResult.usage.turns++;
355
+ const usage = (msg as any).usage;
356
+ if (usage) {
357
+ currentResult.usage.input += usage.input || 0;
358
+ currentResult.usage.output += usage.output || 0;
359
+ currentResult.usage.cacheRead += usage.cacheRead || 0;
360
+ currentResult.usage.cacheWrite += usage.cacheWrite || 0;
361
+ currentResult.usage.cost += usage.cost?.total || 0;
362
+ currentResult.usage.contextTokens = usage.totalTokens || 0;
363
+ }
364
+ if (!currentResult.model && (msg as any).model) currentResult.model = (msg as any).model;
365
+ if ((msg as any).stopReason) currentResult.stopReason = (msg as any).stopReason;
366
+ if ((msg as any).errorMessage) currentResult.errorMessage = (msg as any).errorMessage;
367
+ }
368
+ // Live event: usage snapshot after accumulation
369
+ if (onLive) {
370
+ try {
371
+ onLive({ kind: "usage", usage: { ...currentResult.usage }, model: currentResult.model });
372
+ } catch { /* never throw from event handling */ }
373
+ }
374
+ emitUpdate();
375
+ }
376
+
377
+ if (event.type === "tool_result_end" && event.message) {
378
+ currentResult.messages.push(event.message as Message);
379
+ emitUpdate();
380
+ }
381
+ };
382
+ // Send the task through the child stdin pipe instead of the process
383
+ // command line. This avoids OS argument-length limits and does not
384
+ // require another temporary file for conversation data.
385
+ proc.stdin?.on("error", () => undefined);
386
+ proc.stdin?.end(`Task: ${task}`);
387
+
388
+ proc.stdout.on("data", (data) => {
389
+ buffer += data.toString();
390
+ const lines = buffer.split("\n");
391
+ buffer = lines.pop() || "";
392
+ for (const line of lines) processLine(line);
393
+ });
394
+
395
+ proc.stderr.on("data", (data) => {
396
+ currentResult.stderr += data.toString();
397
+ });
398
+
399
+ proc.on("close", (code) => {
400
+ if (buffer.trim()) processLine(buffer);
401
+ // A null exit code means the process was terminated by a signal and
402
+ // must be reported as failure, never as a false clean completion.
403
+ const failed =
404
+ code !== 0 ||
405
+ wasAborted ||
406
+ timedOut ||
407
+ (signal?.aborted ?? false) ||
408
+ currentResult.stopReason === "error" ||
409
+ currentResult.stopReason === "aborted";
410
+ if (onLive) {
411
+ try {
412
+ onLive({ kind: "status", status: failed ? "failed" : "done" });
413
+ } catch { /* never throw from event handling */ }
414
+ }
415
+ finish(code);
416
+ });
417
+
418
+ proc.on("error", () => {
419
+ // Spawn itself failed; close may never fire, so finish the run here.
420
+ currentResult.stopReason = "error";
421
+ currentResult.errorMessage ??= "Failed to start the sub-agent process.";
422
+ if (onLive) {
423
+ try {
424
+ onLive({ kind: "status", status: "failed" });
425
+ } catch { /* never throw from event handling */ }
426
+ }
427
+ finish(1);
428
+ });
429
+
430
+ if (timeoutMs > 0) {
431
+ timeoutTimer = setTimeout(() => {
432
+ timedOut = true;
433
+ currentResult.stopReason = "error";
434
+ currentResult.errorMessage = `Subagent timed out after ${Math.ceil(timeoutMs / 1000)} seconds.`;
435
+ terminate();
436
+ }, timeoutMs);
437
+ }
438
+
439
+ if (signal) {
440
+ abortHandler = (): void => {
441
+ wasAborted = true;
442
+ terminate();
443
+ };
444
+ if (signal.aborted) abortHandler();
445
+ else signal.addEventListener("abort", abortHandler, { once: true });
446
+ }
447
+ });
448
+
449
+ currentResult.exitCode = exitCode;
450
+ if (wasAborted) {
451
+ if (onLive) {
452
+ try {
453
+ onLive({ kind: "status", status: "failed" });
454
+ } catch { /* never throw from event handling */ }
455
+ }
456
+ throw new Error("Subagent was aborted");
457
+ }
458
+ return currentResult;
459
+ } finally {
460
+ if (tmpPromptPath)
461
+ try {
462
+ unlinkSync(tmpPromptPath);
463
+ } catch {
464
+ /* ignore */
465
+ }
466
+ if (tmpPromptDir)
467
+ try {
468
+ rmdirSync(tmpPromptDir);
469
+ } catch {
470
+ /* ignore */
471
+ }
472
+ }
473
+ }