@d3ara1n/pi-subagent 0.6.0 → 0.7.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
@@ -16,73 +16,74 @@ import type { SubagentMessage, SubagentResult } from "./types.ts";
16
16
  /** Max chars for an inline channel block (context or task) before it spills to a temp @file. */
17
17
  const INLINE_LIMIT = 8000;
18
18
 
19
-
20
19
  const PI_CODING_AGENT_PACKAGE = "@earendil-works/pi-coding-agent";
21
20
 
22
21
  function isRunnableScript(filePath: string): boolean {
23
- try {
24
- if (!fs.existsSync(filePath)) return false;
25
- return /\.(?:mjs|cjs|js)$/i.test(filePath);
26
- } catch {
27
- return false;
28
- }
22
+ try {
23
+ if (!fs.existsSync(filePath)) return false;
24
+ return /\.(?:mjs|cjs|js)$/i.test(filePath);
25
+ } catch {
26
+ return false;
27
+ }
29
28
  }
30
29
 
31
30
  function findPiPackageRootFromEntry(entryPoint: string): string | undefined {
32
- let dir = path.dirname(entryPoint);
33
- while (dir !== path.dirname(dir)) {
34
- const pkgPath = path.join(dir, "package.json");
35
- if (fs.existsSync(pkgPath)) {
36
- try {
37
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { name?: unknown };
38
- if (pkg.name === PI_CODING_AGENT_PACKAGE) return dir;
39
- } catch {
40
- /* ignore */
41
- }
42
- }
43
- dir = path.dirname(dir);
44
- }
45
- return undefined;
31
+ let dir = path.dirname(entryPoint);
32
+ while (dir !== path.dirname(dir)) {
33
+ const pkgPath = path.join(dir, "package.json");
34
+ if (fs.existsSync(pkgPath)) {
35
+ try {
36
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { name?: unknown };
37
+ if (pkg.name === PI_CODING_AGENT_PACKAGE) return dir;
38
+ } catch {
39
+ /* ignore */
40
+ }
41
+ }
42
+ dir = path.dirname(dir);
43
+ }
44
+ return undefined;
46
45
  }
47
46
 
48
- function resolveWindowsPiCliScript(args: string[]): { command: string; args: string[] } | undefined {
49
- // Strategy 1: Use process.argv[1] if it's a runnable script
50
- // (works when pi is run via `bun pi` or `bunx pi` — argv[1] is the real CLI path)
51
- const argv1 = process.argv[1];
52
- if (argv1) {
53
- const argvPath = path.isAbsolute(argv1) ? argv1 : path.resolve(argv1);
54
- if (isRunnableScript(argvPath)) {
55
- return { command: process.execPath, args: [argvPath, ...args] };
56
- }
57
- }
58
-
59
- // Strategy 2: Resolve pi-coding-agent package via import.meta.resolve,
60
- // then read the bin field from its package.json
61
- try {
62
- const resolved = fileURLToPath(import.meta.resolve(PI_CODING_AGENT_PACKAGE));
63
- const root = findPiPackageRootFromEntry(resolved);
64
- if (root) {
65
- const pkgPath = path.join(root, "package.json");
66
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as {
67
- bin?: string | Record<string, string>;
68
- };
69
- const binField = pkg.bin;
70
- const binPath =
71
- typeof binField === "string"
72
- ? binField
73
- : binField?.pi ?? Object.values(binField ?? {})[0];
74
- if (binPath) {
75
- const candidate = path.resolve(root, binPath);
76
- if (isRunnableScript(candidate)) {
77
- return { command: process.execPath, args: [candidate, ...args] };
78
- }
79
- }
80
- }
81
- } catch {
82
- /* fall through */
83
- }
84
-
85
- return undefined;
47
+ function resolveWindowsPiCliScript(
48
+ args: string[],
49
+ ): { command: string; args: string[] } | undefined {
50
+ // Strategy 1: Use process.argv[1] if it's a runnable script
51
+ // (works when pi is run via `bun pi` or `bunx pi` — argv[1] is the real CLI path)
52
+ const argv1 = process.argv[1];
53
+ if (argv1) {
54
+ const argvPath = path.isAbsolute(argv1) ? argv1 : path.resolve(argv1);
55
+ if (isRunnableScript(argvPath)) {
56
+ return { command: process.execPath, args: [argvPath, ...args] };
57
+ }
58
+ }
59
+
60
+ // Strategy 2: Resolve pi-coding-agent package via import.meta.resolve,
61
+ // then read the bin field from its package.json
62
+ try {
63
+ const resolved = fileURLToPath(import.meta.resolve(PI_CODING_AGENT_PACKAGE));
64
+ const root = findPiPackageRootFromEntry(resolved);
65
+ if (root) {
66
+ const pkgPath = path.join(root, "package.json");
67
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as {
68
+ bin?: string | Record<string, string>;
69
+ };
70
+ const binField = pkg.bin;
71
+ const binPath =
72
+ typeof binField === "string"
73
+ ? binField
74
+ : (binField?.pi ?? Object.values(binField ?? {})[0]);
75
+ if (binPath) {
76
+ const candidate = path.resolve(root, binPath);
77
+ if (isRunnableScript(candidate)) {
78
+ return { command: process.execPath, args: [candidate, ...args] };
79
+ }
80
+ }
81
+ }
82
+ } catch {
83
+ /* fall through */
84
+ }
85
+
86
+ return undefined;
86
87
  }
87
88
 
88
89
  /**
@@ -101,11 +102,11 @@ function resolveWindowsPiCliScript(args: string[]): { command: string; args: str
101
102
  * to the child process, while still working when `pi` is not in PATH.
102
103
  */
103
104
  export function getPiInvocation(args: string[]): { command: string; args: string[] } {
104
- if (process.platform === "win32") {
105
- const winResult = resolveWindowsPiCliScript(args);
106
- if (winResult) return winResult;
107
- }
108
- return { command: "pi", args };
105
+ if (process.platform === "win32") {
106
+ const winResult = resolveWindowsPiCliScript(args);
107
+ if (winResult) return winResult;
108
+ }
109
+ return { command: "pi", args };
109
110
  }
110
111
 
111
112
  /**
@@ -118,343 +119,378 @@ export function getPiInvocation(args: string[]): { command: string; args: string
118
119
  * @returns SubagentResult with collected messages and usage stats
119
120
  */
120
121
  export async function spawnSubagent(
121
- modelRef: string,
122
- task: string,
123
- options: {
124
- cwd?: string;
125
- tools?: string[];
126
- systemPrompt?: string;
127
- /** Extra context delivered as a separate channel from the task. */
128
- context?: string;
129
- /** Reference file paths injected as independent @file args (child reads them directly). */
130
- contextFiles?: string[];
131
- subagentRoles?: string[];
132
- timeoutMs?: number;
133
- depth?: number;
134
- maxTurns?: number;
135
- maxCost?: number;
136
- signal?: AbortSignal;
137
- onProgress?: (update: Partial<SubagentResult>) => void;
138
- },
122
+ modelRef: string,
123
+ task: string,
124
+ options: {
125
+ cwd?: string;
126
+ tools?: string[];
127
+ systemPrompt?: string;
128
+ /** Extra context delivered as a separate channel from the task. */
129
+ context?: string;
130
+ /** Reference file paths injected as independent @file args (child reads them directly). */
131
+ contextFiles?: string[];
132
+ subagentRoles?: string[];
133
+ timeoutMs?: number;
134
+ depth?: number;
135
+ maxTurns?: number;
136
+ maxCost?: number;
137
+ signal?: AbortSignal;
138
+ onProgress?: (update: Partial<SubagentResult>) => void;
139
+ },
139
140
  ): Promise<SubagentResult> {
140
- const result: SubagentResult = {
141
- role: "",
142
- task,
143
- exitCode: 0,
144
- messages: [],
145
- output: "",
146
- stderr: "",
147
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
148
- activityLog: [],
149
- };
150
-
151
- let tmpDir: string | null = null;
152
-
153
- try {
154
- // Build CLI args
155
- const args: string[] = ["--mode", "json", "--no-session", "--model", modelRef];
156
-
157
- if (options.tools && options.tools.length > 0) {
158
- args.push("--tools", options.tools.join(","));
159
- }
160
-
161
- // Temp dir for: large-context/task spill files, and as PI_SUBAGENT_TMPDIR
162
- // for subagent bash work (e.g. git clone). The system prompt no longer uses it.
163
- tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
164
-
165
- // ── System prompt channel: inline text via --append-system-prompt ──
166
- // pi's resolvePromptInput treats an existing path as a file to read and any
167
- // non-path string as literal text, so we pass structured blocks directly —
168
- // no temp file, zero disk I/O. Multiple flags are joined with "\n\n".
169
- if (options.systemPrompt?.trim()) {
170
- args.push(
171
- "--append-system-prompt",
172
- `<subagent_role>\n${options.systemPrompt.trim()}\n</subagent_role>`,
173
- );
174
- }
175
- args.push(
176
- "--append-system-prompt",
177
- `<subagent_env>\nPI_SUBAGENT_TMPDIR=${tmpDir}\nAvailable as $PI_SUBAGENT_TMPDIR in bash. Use for git clone and scratch files.\n</subagent_env>`,
178
- );
179
-
180
- // ── Context channel: independent size gate ──
181
- // Large context spills to @ctx.md (pi auto-wraps in <file>); small context
182
- // inlines as a structured <context> tag. Decoupled from the task gate so a
183
- // large context never drags a short task into a spill file.
184
- let contextInline = false;
185
- if (options.context && options.context.trim()) {
186
- if (options.context.length > INLINE_LIMIT) {
187
- const ctxPath = path.join(tmpDir, "context.md");
188
- await fs.promises.writeFile(ctxPath, options.context, { encoding: "utf-8", mode: 0o600 });
189
- args.push(`@${ctxPath}`);
190
- } else {
191
- contextInline = true;
192
- }
193
- }
194
-
195
- // ── Reference files channel: each as an independent @ argument ──
196
- // pi reads each and wraps in <file name="...">. Content never enters the
197
- // parent model's context — the child reads it directly.
198
- if (options.contextFiles) {
199
- for (const f of options.contextFiles) {
200
- args.push(`@${f}`);
201
- }
202
- }
203
-
204
- // ── Task channel: always inline, always the final block ──
205
- // The task is an instruction, not reference material — it stays inline so the
206
- // child sees it as the primary directive. A pathologically long task spills.
207
- let taskInline = true;
208
- if (task.length > INLINE_LIMIT) {
209
- const taskPath = path.join(tmpDir, "task.md");
210
- await fs.promises.writeFile(taskPath, task, { encoding: "utf-8", mode: 0o600 });
211
- args.push(`@${taskPath}`);
212
- taskInline = false;
213
- }
214
-
215
- // ── Compose the message body (inline context + task) ──
216
- // @file args are injected by pi BEFORE this message (buildInitialMessage),
217
- // so the final shape the child sees is:
218
- // [<file>...spilled context / reference files...</file>]
219
- // [<context>...inline context...</context>]
220
- // [<task>...task...</task>]
221
- const messageParts: string[] = [];
222
- if (contextInline) messageParts.push(`<context>\n${options.context}\n</context>`);
223
- if (taskInline) messageParts.push(`<task>\n${task}\n</task>`);
224
- const message = messageParts.join("\n\n");
225
- if (message) args.push(message);
226
-
227
- // Spawn process
228
- const invocation = getPiInvocation(args);
229
- let wasAborted = false;
230
- let budgetExceeded = false;
231
- let wasTimeout = false;
232
- let buffer = "";
233
-
234
- const emitProgress = () => {
235
- options.onProgress?.({
236
- output: result.output,
237
- messages: [...result.messages],
238
- usage: { ...result.usage },
239
- model: result.model,
240
- stopReason: result.stopReason,
241
- activityLog: result.activityLog.map((a) => ({ ...a })),
242
- });
243
- };
244
-
245
- let thinkingCounter = 0;
246
- // O(1) lookup from toolCallId → activityLog index (was linear find → O(n²) on busy runs)
247
- const toolCallIndex = new Map<string, number>();
248
-
249
- // Kill the child when the configured turn/cost budget is exceeded.
250
- // Called after each assistant message_end (usage already accumulated).
251
- const checkBudget = () => {
252
- const mt = options.maxTurns ?? 0;
253
- const mc = options.maxCost ?? 0;
254
- if (budgetExceeded || wasTimeout) return;
255
- if ((mt > 0 && result.usage.turns >= mt) || (mc > 0 && result.usage.cost >= mc)) {
256
- budgetExceeded = true;
257
- killProc("budget");
258
- }
259
- };
260
-
261
- const processLine = (line: string) => {
262
- if (!line.trim()) return;
263
- let event: any;
264
- try {
265
- event = JSON.parse(line);
266
- } catch {
267
- return;
268
- }
269
-
270
- if (event.type === "message_end" && event.message) {
271
- const msg = event.message as SubagentMessage;
272
- result.messages.push(msg);
273
-
274
- if (msg.role === "assistant") {
275
- result.usage.turns++;
276
- const usage = msg.usage;
277
- if (usage) {
278
- result.usage.input += usage.input || 0;
279
- result.usage.output += usage.output || 0;
280
- result.usage.cacheRead += usage.cacheRead || 0;
281
- result.usage.cacheWrite += usage.cacheWrite || 0;
282
- result.usage.cost += usage.cost?.total || 0;
283
- // Peak context size, not last-turn size (accumulating is meaningless; max tells how close to the limit)
284
- result.usage.contextTokens = Math.max(result.usage.contextTokens, usage.totalTokens || 0);
285
- }
286
- if (!result.model && msg.model) result.model = msg.model;
287
- if (msg.stopReason) result.stopReason = msg.stopReason;
288
- if (msg.errorMessage) result.errorMessage = msg.errorMessage;
289
-
290
- // Track last assistant text
291
- for (const part of msg.content) {
292
- if (part.type === "text" && part.text) {
293
- result.output = part.text;
294
- }
295
- }
296
-
297
- checkBudget();
298
- }
299
-
300
- emitProgress();
301
- }
302
-
303
- // Activity log: track thinking blocks and tool calls in arrival order.
304
- // Both update in place so the TUI reflects real-time state.
305
- if (event.type === "tool_execution_start" && event.toolCallId) {
306
- toolCallIndex.set(event.toolCallId, result.activityLog.length);
307
- result.activityLog.push({
308
- kind: "toolCall",
309
- id: event.toolCallId,
310
- status: "running",
311
- toolName: event.toolName,
312
- args: event.args ?? {},
313
- });
314
- emitProgress();
315
- } else if (event.type === "tool_execution_end" && event.toolCallId) {
316
- const idx = toolCallIndex.get(event.toolCallId);
317
- if (idx !== undefined) result.activityLog[idx].status = event.isError ? "failed" : "done";
318
- emitProgress();
319
- }
320
-
321
- // Thinking-block lifecycle: pi wraps thinking_start/end inside
322
- // message_update.assistantMessageEvent. These arrive BEFORE message_end,
323
- // so we can't rely on messages[] to show real-time thinking state —
324
- // register them in the activity log directly.
325
- const aev = event.assistantMessageEvent;
326
- if (event.type === "message_update" && aev) {
327
- if (aev.type === "thinking_start") {
328
- result.activityLog.push({
329
- kind: "thinking",
330
- id: `thinking-${thinkingCounter++}`,
331
- status: "running",
332
- });
333
- emitProgress();
334
- } else if (aev.type === "thinking_end") {
335
- // Mark the most recent still-running thinking block as done.
336
- for (let i = result.activityLog.length - 1; i >= 0; i--) {
337
- if (result.activityLog[i].kind === "thinking" && result.activityLog[i].status === "running") {
338
- result.activityLog[i].status = "done";
339
- break;
340
- }
341
- }
342
- emitProgress();
343
- }
344
- }
345
- };
346
-
347
- // Build env with optional subagent allowlist and tmpdir for researcher role
348
- const childEnv: NodeJS.ProcessEnv = { ...process.env };
349
- if (options.subagentRoles && options.subagentRoles.length > 0) {
350
- childEnv.PI_SUBAGENT_ALLOWED = options.subagentRoles.join(",");
351
- }
352
- // Expose tmpdir as env var so subagent bash commands (e.g. git clone) can use it
353
- childEnv.PI_SUBAGENT_TMPDIR = tmpDir;
354
- // Propagate nesting depth so child delegate calls can bound recursion
355
- childEnv.PI_SUBAGENT_DEPTH = String(options.depth ?? 0);
356
-
357
- let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
358
- let proc: ChildProcess | undefined;
359
-
360
- // Shared kill helper used by abort, budget, and timeout paths.
361
- // Centralizes reason stopReason mapping and the SIGTERM 5s → SIGKILL escalation.
362
- const escalationTimers: ReturnType<typeof setTimeout>[] = [];
363
- const killProc = (reason: "abort" | "budget" | "timeout") => {
364
- if (reason === "abort") wasAborted = true;
365
- else if (reason === "budget") {
366
- result.stopReason = "budget_exceeded";
367
- // Human-readable so the caller/TUI never falls back to raw stderr noise.
368
- const mt = options.maxTurns ?? 0;
369
- const mc = options.maxCost ?? 0;
370
- const why = mt > 0 && result.usage.turns >= mt ? `${result.usage.turns} turns` : `$${result.usage.cost.toFixed(4)}`;
371
- result.errorMessage = `Budget exceeded (${why}; partial output returned)`;
372
- }
373
- else if (reason === "timeout") {
374
- result.stopReason = "timeout";
375
- wasTimeout = true;
376
- // Human-readable message so the caller/TUI never falls back to the
377
- // raw stderr (which is full of TUI teardown escape sequences).
378
- const secs = Math.round((options.timeoutMs ?? 0) / 1000);
379
- result.errorMessage = `Timed out after ${secs}s (completed ${result.usage.turns} turn${result.usage.turns === 1 ? "" : "s"})`;
380
- }
381
- try { proc?.kill("SIGTERM"); } catch { /* ignore */ }
382
- escalationTimers.push(setTimeout(() => {
383
- try { if (proc && !proc.killed) proc.kill("SIGKILL"); } catch { /* ignore */ }
384
- }, 5000));
385
- };
386
-
387
- const exitCode = await new Promise<number>((resolve) => {
388
- // Register abort BEFORE spawning to close the (tiny) registration window
389
- let onAbort: (() => void) | undefined;
390
- if (options.signal) {
391
- if (options.signal.aborted) { wasAborted = true; resolve(0); return; }
392
- onAbort = () => killProc("abort");
393
- options.signal.addEventListener("abort", onAbort, { once: true });
394
- }
395
-
396
- const p = spawn(invocation.command, invocation.args, {
397
- cwd: options.cwd,
398
- env: childEnv,
399
- shell: false,
400
- stdio: ["ignore", "pipe", "pipe"],
401
- });
402
- proc = p;
403
-
404
- p.stdout.on("data", (data: Buffer) => {
405
- buffer += data.toString();
406
- const lines = buffer.split("\n");
407
- buffer = lines.pop() || "";
408
- for (const line of lines) processLine(line);
409
- });
410
-
411
- p.stderr.on("data", (data: Buffer) => {
412
- result.stderr += data.toString();
413
- });
414
-
415
- p.on("close", (code, signal) => {
416
- if (timeoutHandle) clearTimeout(timeoutHandle);
417
- for (const t of escalationTimers) clearTimeout(t);
418
- if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
419
- if (buffer.trim()) processLine(buffer);
420
-
421
- // External signal death (OOM killer, segfault, kill -9 from elsewhere)
422
- // that we didn't trigger. Distinguish from our own budget/timeout/abort kills
423
- // which set the flags before we send the signal.
424
- const externalKill = signal !== null && !budgetExceeded && !wasTimeout && !wasAborted;
425
- if (externalKill) {
426
- result.errorMessage = result.errorMessage || `Subagent killed by signal ${signal}`;
427
- result.stopReason = "error";
428
- }
429
-
430
- // Budget stops are intentional (success); timeouts and external kills
431
- // are failures (non-zero); otherwise use the real exit code.
432
- resolve(budgetExceeded ? 0 : (wasTimeout || externalKill ? (code ?? 128) : (code ?? 0)));
433
- });
434
-
435
- p.on("error", (err) => {
436
- if (timeoutHandle) clearTimeout(timeoutHandle);
437
- for (const t of escalationTimers) clearTimeout(t);
438
- if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
439
- // Surface the real cause (e.g. ENOENT when pi is not in PATH) instead of "unknown error".
440
- result.errorMessage = err?.message || String(err);
441
- resolve(1);
442
- });
443
-
444
- // Handle timeout
445
- if (options.timeoutMs && options.timeoutMs > 0) {
446
- timeoutHandle = setTimeout(() => killProc("timeout"), options.timeoutMs);
447
- }
448
- });
449
-
450
- result.exitCode = exitCode;
451
- if (wasAborted) throw new Error("Subagent was aborted");
452
- // NOTE: large outputs are kept raw here compression/truncation happens in
453
- // the extension layer (index.ts) so the summary model can compress first.
454
- } finally {
455
- // Cleanup temp directory and all contents
456
- if (tmpDir) try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
457
- }
458
-
459
- return result;
141
+ const result: SubagentResult = {
142
+ role: "",
143
+ task,
144
+ exitCode: 0,
145
+ messages: [],
146
+ output: "",
147
+ stderr: "",
148
+ usage: {
149
+ input: 0,
150
+ output: 0,
151
+ cacheRead: 0,
152
+ cacheWrite: 0,
153
+ cost: 0,
154
+ contextTokens: 0,
155
+ turns: 0,
156
+ },
157
+ activityLog: [],
158
+ };
159
+
160
+ let tmpDir: string | null = null;
161
+
162
+ try {
163
+ // Build CLI args
164
+ const args: string[] = ["--mode", "json", "--no-session", "--model", modelRef];
165
+
166
+ if (options.tools && options.tools.length > 0) {
167
+ args.push("--tools", options.tools.join(","));
168
+ }
169
+
170
+ // Temp dir for: large-context/task spill files, and as PI_SUBAGENT_TMPDIR
171
+ // for subagent bash work (e.g. git clone). The system prompt no longer uses it.
172
+ tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-"));
173
+
174
+ // ── System prompt channel: inline text via --append-system-prompt ──
175
+ // pi's resolvePromptInput treats an existing path as a file to read and any
176
+ // non-path string as literal text, so we pass structured blocks directly —
177
+ // no temp file, zero disk I/O. Multiple flags are joined with "\n\n".
178
+ if (options.systemPrompt?.trim()) {
179
+ args.push(
180
+ "--append-system-prompt",
181
+ `<subagent_role>\n${options.systemPrompt.trim()}\n</subagent_role>`,
182
+ );
183
+ }
184
+ args.push(
185
+ "--append-system-prompt",
186
+ `<subagent_env>\nPI_SUBAGENT_TMPDIR=${tmpDir}\nAvailable as $PI_SUBAGENT_TMPDIR in bash. Use for git clone and scratch files.\n</subagent_env>`,
187
+ );
188
+
189
+ // ── Context channel: independent size gate ──
190
+ // Large context spills to @ctx.md (pi auto-wraps in <file>); small context
191
+ // inlines as a structured <context> tag. Decoupled from the task gate so a
192
+ // large context never drags a short task into a spill file.
193
+ let contextInline = false;
194
+ if (options.context && options.context.trim()) {
195
+ if (options.context.length > INLINE_LIMIT) {
196
+ const ctxPath = path.join(tmpDir, "context.md");
197
+ await fs.promises.writeFile(ctxPath, options.context, { encoding: "utf-8", mode: 0o600 });
198
+ args.push(`@${ctxPath}`);
199
+ } else {
200
+ contextInline = true;
201
+ }
202
+ }
203
+
204
+ // ── Reference files channel: each as an independent @ argument ──
205
+ // pi reads each and wraps in <file name="...">. Content never enters the
206
+ // parent model's context the child reads it directly.
207
+ if (options.contextFiles) {
208
+ for (const f of options.contextFiles) {
209
+ args.push(`@${f}`);
210
+ }
211
+ }
212
+
213
+ // ── Task channel: always inline, always the final block ──
214
+ // The task is an instruction, not reference material — it stays inline so the
215
+ // child sees it as the primary directive. A pathologically long task spills.
216
+ let taskInline = true;
217
+ if (task.length > INLINE_LIMIT) {
218
+ const taskPath = path.join(tmpDir, "task.md");
219
+ await fs.promises.writeFile(taskPath, task, { encoding: "utf-8", mode: 0o600 });
220
+ args.push(`@${taskPath}`);
221
+ taskInline = false;
222
+ }
223
+
224
+ // ── Compose the message body (inline context + task) ──
225
+ // @file args are injected by pi BEFORE this message (buildInitialMessage),
226
+ // so the final shape the child sees is:
227
+ // [<file>...spilled context / reference files...</file>]
228
+ // [<context>...inline context...</context>]
229
+ // [<task>...task...</task>]
230
+ const messageParts: string[] = [];
231
+ if (contextInline) messageParts.push(`<context>\n${options.context}\n</context>`);
232
+ if (taskInline) messageParts.push(`<task>\n${task}\n</task>`);
233
+ const message = messageParts.join("\n\n");
234
+ if (message) args.push(message);
235
+
236
+ // Spawn process
237
+ const invocation = getPiInvocation(args);
238
+ let wasAborted = false;
239
+ let budgetExceeded = false;
240
+ let wasTimeout = false;
241
+ let buffer = "";
242
+
243
+ const emitProgress = () => {
244
+ options.onProgress?.({
245
+ output: result.output,
246
+ messages: [...result.messages],
247
+ usage: { ...result.usage },
248
+ model: result.model,
249
+ stopReason: result.stopReason,
250
+ activityLog: result.activityLog.map((a) => ({ ...a })),
251
+ });
252
+ };
253
+
254
+ let thinkingCounter = 0;
255
+ // O(1) lookup from toolCallId → activityLog index (was linear find → O(n²) on busy runs)
256
+ const toolCallIndex = new Map<string, number>();
257
+
258
+ // Kill the child when the configured turn/cost budget is exceeded.
259
+ // Called after each assistant message_end (usage already accumulated).
260
+ const checkBudget = () => {
261
+ const mt = options.maxTurns ?? 0;
262
+ const mc = options.maxCost ?? 0;
263
+ if (budgetExceeded || wasTimeout) return;
264
+ if ((mt > 0 && result.usage.turns >= mt) || (mc > 0 && result.usage.cost >= mc)) {
265
+ budgetExceeded = true;
266
+ killProc("budget");
267
+ }
268
+ };
269
+
270
+ const processLine = (line: string) => {
271
+ if (!line.trim()) return;
272
+ let event: any;
273
+ try {
274
+ event = JSON.parse(line);
275
+ } catch {
276
+ return;
277
+ }
278
+
279
+ if (event.type === "message_end" && event.message) {
280
+ const msg = event.message as SubagentMessage;
281
+ result.messages.push(msg);
282
+
283
+ if (msg.role === "assistant") {
284
+ result.usage.turns++;
285
+ const usage = msg.usage;
286
+ if (usage) {
287
+ result.usage.input += usage.input || 0;
288
+ result.usage.output += usage.output || 0;
289
+ result.usage.cacheRead += usage.cacheRead || 0;
290
+ result.usage.cacheWrite += usage.cacheWrite || 0;
291
+ result.usage.cost += usage.cost?.total || 0;
292
+ // Peak context size, not last-turn size (accumulating is meaningless; max tells how close to the limit)
293
+ result.usage.contextTokens = Math.max(
294
+ result.usage.contextTokens,
295
+ usage.totalTokens || 0,
296
+ );
297
+ }
298
+ if (!result.model && msg.model) result.model = msg.model;
299
+ if (msg.stopReason) result.stopReason = msg.stopReason;
300
+ if (msg.errorMessage) result.errorMessage = msg.errorMessage;
301
+
302
+ // Track last assistant text
303
+ for (const part of msg.content) {
304
+ if (part.type === "text" && part.text) {
305
+ result.output = part.text;
306
+ }
307
+ }
308
+
309
+ checkBudget();
310
+ }
311
+
312
+ emitProgress();
313
+ }
314
+
315
+ // Activity log: track thinking blocks and tool calls in arrival order.
316
+ // Both update in place so the TUI reflects real-time state.
317
+ if (event.type === "tool_execution_start" && event.toolCallId) {
318
+ toolCallIndex.set(event.toolCallId, result.activityLog.length);
319
+ result.activityLog.push({
320
+ kind: "toolCall",
321
+ id: event.toolCallId,
322
+ status: "running",
323
+ toolName: event.toolName,
324
+ args: event.args ?? {},
325
+ });
326
+ emitProgress();
327
+ } else if (event.type === "tool_execution_end" && event.toolCallId) {
328
+ const idx = toolCallIndex.get(event.toolCallId);
329
+ if (idx !== undefined) result.activityLog[idx].status = event.isError ? "failed" : "done";
330
+ emitProgress();
331
+ }
332
+
333
+ // Thinking-block lifecycle: pi wraps thinking_start/end inside
334
+ // message_update.assistantMessageEvent. These arrive BEFORE message_end,
335
+ // so we can't rely on messages[] to show real-time thinking state —
336
+ // register them in the activity log directly.
337
+ const aev = event.assistantMessageEvent;
338
+ if (event.type === "message_update" && aev) {
339
+ if (aev.type === "thinking_start") {
340
+ result.activityLog.push({
341
+ kind: "thinking",
342
+ id: `thinking-${thinkingCounter++}`,
343
+ status: "running",
344
+ });
345
+ emitProgress();
346
+ } else if (aev.type === "thinking_end") {
347
+ // Mark the most recent still-running thinking block as done.
348
+ for (let i = result.activityLog.length - 1; i >= 0; i--) {
349
+ if (
350
+ result.activityLog[i].kind === "thinking" &&
351
+ result.activityLog[i].status === "running"
352
+ ) {
353
+ result.activityLog[i].status = "done";
354
+ break;
355
+ }
356
+ }
357
+ emitProgress();
358
+ }
359
+ }
360
+ };
361
+
362
+ // Build env with optional subagent allowlist and tmpdir for researcher role
363
+ const childEnv: NodeJS.ProcessEnv = { ...process.env };
364
+ if (options.subagentRoles && options.subagentRoles.length > 0) {
365
+ childEnv.PI_SUBAGENT_ALLOWED = options.subagentRoles.join(",");
366
+ }
367
+ // Expose tmpdir as env var so subagent bash commands (e.g. git clone) can use it
368
+ childEnv.PI_SUBAGENT_TMPDIR = tmpDir;
369
+ // Propagate nesting depth so child delegate calls can bound recursion
370
+ childEnv.PI_SUBAGENT_DEPTH = String(options.depth ?? 0);
371
+
372
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
373
+ let proc: ChildProcess | undefined;
374
+
375
+ // Shared kill helper used by abort, budget, and timeout paths.
376
+ // Centralizes reason → stopReason mapping and the SIGTERM → 5s → SIGKILL escalation.
377
+ const escalationTimers: ReturnType<typeof setTimeout>[] = [];
378
+ const killProc = (reason: "abort" | "budget" | "timeout") => {
379
+ if (reason === "abort") wasAborted = true;
380
+ else if (reason === "budget") {
381
+ result.stopReason = "budget_exceeded";
382
+ // Human-readable so the caller/TUI never falls back to raw stderr noise.
383
+ const mt = options.maxTurns ?? 0;
384
+ const mc = options.maxCost ?? 0;
385
+ const why =
386
+ mt > 0 && result.usage.turns >= mt
387
+ ? `${result.usage.turns} turns`
388
+ : `$${result.usage.cost.toFixed(4)}`;
389
+ result.errorMessage = `Budget exceeded (${why}; partial output returned)`;
390
+ } else if (reason === "timeout") {
391
+ result.stopReason = "timeout";
392
+ wasTimeout = true;
393
+ // Human-readable message so the caller/TUI never falls back to the
394
+ // raw stderr (which is full of TUI teardown escape sequences).
395
+ const secs = Math.round((options.timeoutMs ?? 0) / 1000);
396
+ result.errorMessage = `Timed out after ${secs}s (completed ${result.usage.turns} turn${result.usage.turns === 1 ? "" : "s"})`;
397
+ }
398
+ try {
399
+ proc?.kill("SIGTERM");
400
+ } catch {
401
+ /* ignore */
402
+ }
403
+ escalationTimers.push(
404
+ setTimeout(() => {
405
+ try {
406
+ if (proc && !proc.killed) proc.kill("SIGKILL");
407
+ } catch {
408
+ /* ignore */
409
+ }
410
+ }, 5000),
411
+ );
412
+ };
413
+
414
+ const exitCode = await new Promise<number>((resolve) => {
415
+ // Register abort BEFORE spawning to close the (tiny) registration window
416
+ let onAbort: (() => void) | undefined;
417
+ if (options.signal) {
418
+ if (options.signal.aborted) {
419
+ wasAborted = true;
420
+ resolve(0);
421
+ return;
422
+ }
423
+ onAbort = () => killProc("abort");
424
+ options.signal.addEventListener("abort", onAbort, { once: true });
425
+ }
426
+
427
+ const p = spawn(invocation.command, invocation.args, {
428
+ cwd: options.cwd,
429
+ env: childEnv,
430
+ shell: false,
431
+ stdio: ["ignore", "pipe", "pipe"],
432
+ });
433
+ proc = p;
434
+
435
+ p.stdout.on("data", (data: Buffer) => {
436
+ buffer += data.toString();
437
+ const lines = buffer.split("\n");
438
+ buffer = lines.pop() || "";
439
+ for (const line of lines) processLine(line);
440
+ });
441
+
442
+ p.stderr.on("data", (data: Buffer) => {
443
+ result.stderr += data.toString();
444
+ });
445
+
446
+ p.on("close", (code, signal) => {
447
+ if (timeoutHandle) clearTimeout(timeoutHandle);
448
+ for (const t of escalationTimers) clearTimeout(t);
449
+ if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
450
+ if (buffer.trim()) processLine(buffer);
451
+
452
+ // External signal death (OOM killer, segfault, kill -9 from elsewhere)
453
+ // that we didn't trigger. Distinguish from our own budget/timeout/abort kills
454
+ // which set the flags before we send the signal.
455
+ const externalKill = signal !== null && !budgetExceeded && !wasTimeout && !wasAborted;
456
+ if (externalKill) {
457
+ result.errorMessage = result.errorMessage || `Subagent killed by signal ${signal}`;
458
+ result.stopReason = "error";
459
+ }
460
+
461
+ // Budget stops are intentional (success); timeouts and external kills
462
+ // are failures (non-zero); otherwise use the real exit code.
463
+ resolve(budgetExceeded ? 0 : wasTimeout || externalKill ? (code ?? 128) : (code ?? 0));
464
+ });
465
+
466
+ p.on("error", (err) => {
467
+ if (timeoutHandle) clearTimeout(timeoutHandle);
468
+ for (const t of escalationTimers) clearTimeout(t);
469
+ if (onAbort && options.signal) options.signal.removeEventListener("abort", onAbort);
470
+ // Surface the real cause (e.g. ENOENT when pi is not in PATH) instead of "unknown error".
471
+ result.errorMessage = err?.message || String(err);
472
+ resolve(1);
473
+ });
474
+
475
+ // Handle timeout
476
+ if (options.timeoutMs && options.timeoutMs > 0) {
477
+ timeoutHandle = setTimeout(() => killProc("timeout"), options.timeoutMs);
478
+ }
479
+ });
480
+
481
+ result.exitCode = exitCode;
482
+ if (wasAborted) throw new Error("Subagent was aborted");
483
+ // NOTE: large outputs are kept raw here — compression/truncation happens in
484
+ // the extension layer (index.ts) so the summary model can compress first.
485
+ } finally {
486
+ // Cleanup temp directory and all contents
487
+ if (tmpDir)
488
+ try {
489
+ fs.rmSync(tmpDir, { recursive: true, force: true });
490
+ } catch {
491
+ /* ignore */
492
+ }
493
+ }
494
+
495
+ return result;
460
496
  }