@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/package.json +3 -2
- package/preview.png +0 -0
- package/src/config.ts +38 -47
- package/src/index.ts +990 -810
- package/src/roles.ts +11 -16
- package/src/spawn.ts +438 -402
- package/src/utils.test.ts +243 -227
- package/src/utils.ts +196 -185
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
}
|