@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/LICENSE +21 -21
- package/README-zh.md +153 -134
- package/README.md +167 -143
- package/agents/explore.md +42 -42
- package/agents/plan.md +41 -41
- package/agents/reviewer.md +45 -45
- package/agents/worker.md +44 -44
- package/package.json +54 -54
- package/src/agents.ts +157 -157
- package/src/config.ts +168 -155
- package/src/index.ts +437 -417
- package/src/models.ts +69 -0
- package/src/monitor.ts +275 -238
- package/src/prompt.ts +58 -57
- package/src/setup.ts +264 -222
- package/src/spawn.ts +473 -361
- package/src/ui.ts +231 -231
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).
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
17
|
-
import
|
|
18
|
-
import {
|
|
19
|
-
import type {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
export const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
export
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
await
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
return
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
return
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
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
|
-
|
|
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
|
+
}
|