oira666_pi-subagent 0.1.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/index.ts ADDED
@@ -0,0 +1,865 @@
1
+ /**
2
+ * Pi Subagent Extension
3
+ *
4
+ * Delegates tasks to specialized subagents, each running as an isolated `pi`
5
+ * process.
6
+ *
7
+ * The tool always accepts a `tasks` array:
8
+ * - One task: treated as a single-agent delegation.
9
+ * - Multiple tasks: treated as a parallel delegation.
10
+ *
11
+ * And two context modes:
12
+ * - spawn (default): child gets only the task prompt.
13
+ * - fork: child gets a forked snapshot of current session context + task prompt.
14
+ */
15
+
16
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
17
+ import { Type } from "@sinclair/typebox";
18
+ import { type AgentConfig, discoverAgents } from "./agents.js";
19
+ import { renderCall, renderResult } from "./render.js";
20
+ import { mapConcurrent, runAgent } from "./runner.js";
21
+ import {
22
+ type DelegationMode,
23
+ type SingleResult,
24
+ type SubagentDetails,
25
+ DEFAULT_DELEGATION_MODE,
26
+ emptyUsage,
27
+ getFinalOutput,
28
+ isResultError,
29
+ } from "./types.js";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Limits
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const DEFAULT_MAX_PARALLEL_TASKS = 16;
36
+ const DEFAULT_MAX_CONCURRENCY = 8;
37
+ const PARALLEL_HEARTBEAT_MS = 1000;
38
+ const DEFAULT_MAX_DELEGATION_DEPTH = 3;
39
+ const DEFAULT_PREVENT_CYCLE_DELEGATION = false;
40
+ const DEFAULT_PROJECT_AGENT_CONFIRMATION = "ask";
41
+ const SUBAGENT_DEPTH_ENV = "PI_SUBAGENT_DEPTH";
42
+ const SUBAGENT_MAX_DEPTH_ENV = "PI_SUBAGENT_MAX_DEPTH";
43
+ const SUBAGENT_STACK_ENV = "PI_SUBAGENT_STACK";
44
+ const SUBAGENT_PREVENT_CYCLES_ENV = "PI_SUBAGENT_PREVENT_CYCLES";
45
+ const SUBAGENT_MAX_PARALLEL_TASKS_ENV = "PI_SUBAGENT_MAX_PARALLEL_TASKS";
46
+ const SUBAGENT_MAX_CONCURRENCY_ENV = "PI_SUBAGENT_MAX_CONCURRENCY";
47
+ const SUBAGENT_CONFIRM_PROJECT_AGENTS_ENV = "PI_SUBAGENT_CONFIRM_PROJECT_AGENTS";
48
+
49
+ type ProjectAgentConfirmationSetting = "ask" | "never" | "session";
50
+ type ProjectAgentApproval = "once" | "session" | "no";
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Tool parameter schema
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const TaskItem = Type.Object({
57
+ agent: Type.String({
58
+ description: "Name of an available agent (must match exactly)",
59
+ }),
60
+ task: Type.String({
61
+ description:
62
+ "Task description for this delegated run. In spawn mode include all required context; in fork mode the subagent also sees your current session context.",
63
+ }),
64
+ cwd: Type.Optional(
65
+ Type.String({ description: "Working directory for this agent's process" }),
66
+ ),
67
+ });
68
+
69
+ const SubagentParams = Type.Object({
70
+ tasks: Type.Array(TaskItem, {
71
+ minItems: 1,
72
+ description:
73
+ "Array of {agent, task} objects. One task behaves like a single-agent delegation; multiple tasks run concurrently.",
74
+ }),
75
+ mode: Type.Optional(
76
+ Type.String({
77
+ description:
78
+ "Context mode for delegated runs. 'spawn' (default) sends only the task prompt (best for isolated, reproducible runs with lower token/cost and less context leakage). 'fork' adds a snapshot of current session context plus task prompt (best for follow-up work, but usually higher token/cost and may include sensitive context).",
79
+ default: DEFAULT_DELEGATION_MODE,
80
+ }),
81
+ ),
82
+ });
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Helpers
86
+ // ---------------------------------------------------------------------------
87
+
88
+ interface DelegationDepthConfig {
89
+ currentDepth: number;
90
+ maxDepth: number;
91
+ canDelegate: boolean;
92
+ ancestorAgentStack: string[];
93
+ preventCycles: boolean;
94
+ }
95
+
96
+ interface SessionSnapshotSource {
97
+ getHeader: () => unknown;
98
+ getBranch: () => unknown[];
99
+ }
100
+
101
+ function parseDelegationMode(raw: unknown): DelegationMode | null {
102
+ if (raw === undefined) return DEFAULT_DELEGATION_MODE;
103
+ if (typeof raw !== "string") return null;
104
+ const normalized = raw.trim().toLowerCase();
105
+ if (normalized === "spawn" || normalized === "fork") {
106
+ return normalized;
107
+ }
108
+ return null;
109
+ }
110
+
111
+ function buildForkSessionSnapshotJsonl(
112
+ sessionManager: SessionSnapshotSource,
113
+ ): string | null {
114
+ const header = sessionManager.getHeader();
115
+ if (!header || typeof header !== "object") return null;
116
+
117
+ const branchEntries = sessionManager.getBranch();
118
+ const lines = [JSON.stringify(header)];
119
+ for (const entry of branchEntries) lines.push(JSON.stringify(entry));
120
+ return `${lines.join("\n")}\n`;
121
+ }
122
+
123
+ function parseNonNegativeInt(raw: unknown): number | null {
124
+ if (typeof raw !== "string") return null;
125
+ const trimmed = raw.trim();
126
+ if (!/^\d+$/.test(trimmed)) return null;
127
+ const parsed = Number(trimmed);
128
+ return Number.isSafeInteger(parsed) ? parsed : null;
129
+ }
130
+
131
+ function parseBoolean(raw: unknown): boolean | null {
132
+ if (typeof raw === "boolean") return raw;
133
+ if (typeof raw !== "string") return null;
134
+ const normalized = raw.trim().toLowerCase();
135
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
136
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
137
+ return null;
138
+ }
139
+
140
+ function parseProjectAgentConfirmationSetting(
141
+ raw: unknown,
142
+ ): ProjectAgentConfirmationSetting | null {
143
+ if (raw === undefined) return DEFAULT_PROJECT_AGENT_CONFIRMATION;
144
+
145
+ const parsedBoolean = parseBoolean(raw);
146
+ if (parsedBoolean === true) return "ask";
147
+ if (parsedBoolean === false) return "never";
148
+
149
+ if (typeof raw !== "string") return null;
150
+ const normalized = raw.trim().toLowerCase();
151
+ if (["ask", "prompt", "once"].includes(normalized)) return "ask";
152
+ if (["never", "allow", "skip"].includes(normalized)) return "never";
153
+ if (["session", "remember", "yes-for-session"].includes(normalized)) {
154
+ return "session";
155
+ }
156
+ return null;
157
+ }
158
+
159
+ function resolveProjectAgentConfirmationSetting(
160
+ raw: unknown,
161
+ ): ProjectAgentConfirmationSetting {
162
+ const parsed = parseProjectAgentConfirmationSetting(raw);
163
+ if (raw !== undefined && parsed === null) {
164
+ console.warn(
165
+ `[pi-subagent] Ignoring invalid ${SUBAGENT_CONFIRM_PROJECT_AGENTS_ENV}="${String(raw)}". Expected one of: true, false, ask, never, session.`,
166
+ );
167
+ }
168
+ return parsed ?? DEFAULT_PROJECT_AGENT_CONFIRMATION;
169
+ }
170
+
171
+ function getProjectAgentConfirmationSetting(): ProjectAgentConfirmationSetting {
172
+ return resolveProjectAgentConfirmationSetting(
173
+ process.env[SUBAGENT_CONFIRM_PROJECT_AGENTS_ENV],
174
+ );
175
+ }
176
+
177
+ function parseAgentStack(raw: unknown): string[] | null {
178
+ if (raw === undefined) return [];
179
+ if (typeof raw !== "string") return null;
180
+ if (!raw.trim()) return [];
181
+
182
+ let parsed: unknown;
183
+ try {
184
+ parsed = JSON.parse(raw);
185
+ } catch {
186
+ return null;
187
+ }
188
+
189
+ if (!Array.isArray(parsed)) return null;
190
+ if (!parsed.every((value) => typeof value === "string")) return null;
191
+ return parsed
192
+ .map((value) => value.trim())
193
+ .filter((value) => value.length > 0);
194
+ }
195
+
196
+ function getMaxDepthFlagFromArgv(argv: string[]): string | null {
197
+ for (let i = 2; i < argv.length; i++) {
198
+ const arg = argv[i];
199
+ if (arg === "--subagent-max-depth") {
200
+ return argv[i + 1] ?? "";
201
+ }
202
+ if (arg.startsWith("--subagent-max-depth=")) {
203
+ return arg.slice("--subagent-max-depth=".length);
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+
209
+ function getPreventCyclesFlagFromArgv(
210
+ argv: string[],
211
+ ): string | boolean | null {
212
+ for (let i = 2; i < argv.length; i++) {
213
+ const arg = argv[i];
214
+ if (arg === "--subagent-prevent-cycles") {
215
+ const maybeValue = argv[i + 1];
216
+ if (maybeValue !== undefined && !maybeValue.startsWith("--")) {
217
+ return maybeValue;
218
+ }
219
+ return true;
220
+ }
221
+ if (arg === "--no-subagent-prevent-cycles") return false;
222
+ if (arg.startsWith("--subagent-prevent-cycles=")) {
223
+ return arg.slice("--subagent-prevent-cycles=".length);
224
+ }
225
+ }
226
+ return null;
227
+ }
228
+
229
+ function resolveDelegationDepthConfig(pi: ExtensionAPI): DelegationDepthConfig {
230
+ const depthRaw = process.env[SUBAGENT_DEPTH_ENV];
231
+ const parsedDepth = parseNonNegativeInt(depthRaw);
232
+ if (depthRaw !== undefined && parsedDepth === null) {
233
+ console.warn(
234
+ `[pi-subagent] Ignoring invalid ${SUBAGENT_DEPTH_ENV}="${depthRaw}". Expected a non-negative integer.`,
235
+ );
236
+ }
237
+ const currentDepth = parsedDepth ?? 0;
238
+
239
+ const stackRaw = process.env[SUBAGENT_STACK_ENV];
240
+ const ancestorAgentStack = parseAgentStack(stackRaw);
241
+ if (stackRaw !== undefined && ancestorAgentStack === null) {
242
+ console.warn(
243
+ `[pi-subagent] Ignoring invalid ${SUBAGENT_STACK_ENV} value. Expected a JSON array of agent names.`,
244
+ );
245
+ }
246
+
247
+ const envMaxDepthRaw = process.env[SUBAGENT_MAX_DEPTH_ENV];
248
+ const envMaxDepth = parseNonNegativeInt(envMaxDepthRaw);
249
+ if (envMaxDepthRaw !== undefined && envMaxDepth === null) {
250
+ console.warn(
251
+ `[pi-subagent] Ignoring invalid ${SUBAGENT_MAX_DEPTH_ENV}="${envMaxDepthRaw}". Expected a non-negative integer.`,
252
+ );
253
+ }
254
+
255
+ const argvFlagRaw = getMaxDepthFlagFromArgv(process.argv);
256
+ const argvFlagMaxDepth =
257
+ argvFlagRaw !== null ? parseNonNegativeInt(argvFlagRaw) : null;
258
+ if (argvFlagRaw !== null && argvFlagMaxDepth === null) {
259
+ console.warn(
260
+ `[pi-subagent] Ignoring invalid --subagent-max-depth value "${argvFlagRaw}". Expected a non-negative integer.`,
261
+ );
262
+ }
263
+
264
+ const runtimeFlagValue = pi.getFlag("subagent-max-depth");
265
+ const runtimeFlagMaxDepth =
266
+ typeof runtimeFlagValue === "string"
267
+ ? parseNonNegativeInt(runtimeFlagValue)
268
+ : null;
269
+ if (
270
+ argvFlagRaw === null &&
271
+ typeof runtimeFlagValue === "string" &&
272
+ runtimeFlagMaxDepth === null
273
+ ) {
274
+ console.warn(
275
+ `[pi-subagent] Ignoring invalid --subagent-max-depth value "${runtimeFlagValue}". Expected a non-negative integer.`,
276
+ );
277
+ }
278
+
279
+ const envPreventCyclesRaw = process.env[SUBAGENT_PREVENT_CYCLES_ENV];
280
+ const envPreventCycles = parseBoolean(envPreventCyclesRaw);
281
+ if (envPreventCyclesRaw !== undefined && envPreventCycles === null) {
282
+ console.warn(
283
+ `[pi-subagent] Ignoring invalid ${SUBAGENT_PREVENT_CYCLES_ENV}="${envPreventCyclesRaw}". Expected true/false.`,
284
+ );
285
+ }
286
+
287
+ const argvPreventCyclesRaw = getPreventCyclesFlagFromArgv(process.argv);
288
+ const argvPreventCycles =
289
+ typeof argvPreventCyclesRaw === "boolean"
290
+ ? argvPreventCyclesRaw
291
+ : parseBoolean(argvPreventCyclesRaw);
292
+ if (
293
+ typeof argvPreventCyclesRaw === "string" &&
294
+ argvPreventCycles === null
295
+ ) {
296
+ console.warn(
297
+ `[pi-subagent] Ignoring invalid --subagent-prevent-cycles value "${argvPreventCyclesRaw}". Expected true/false.`,
298
+ );
299
+ }
300
+
301
+ const runtimePreventCyclesRaw = pi.getFlag("subagent-prevent-cycles");
302
+ const runtimePreventCycles = parseBoolean(runtimePreventCyclesRaw);
303
+ if (
304
+ argvPreventCyclesRaw === null &&
305
+ runtimePreventCyclesRaw !== undefined &&
306
+ runtimePreventCycles === null
307
+ ) {
308
+ console.warn(
309
+ `[pi-subagent] Ignoring invalid --subagent-prevent-cycles value "${String(runtimePreventCyclesRaw)}". Expected true/false.`,
310
+ );
311
+ }
312
+
313
+ const flagMaxDepth = argvFlagMaxDepth ?? runtimeFlagMaxDepth;
314
+ const maxDepth = flagMaxDepth ?? envMaxDepth ?? DEFAULT_MAX_DELEGATION_DEPTH;
315
+ const preventCycles =
316
+ argvPreventCycles ??
317
+ runtimePreventCycles ??
318
+ envPreventCycles ??
319
+ DEFAULT_PREVENT_CYCLE_DELEGATION;
320
+
321
+ return {
322
+ currentDepth,
323
+ maxDepth,
324
+ canDelegate: currentDepth < maxDepth,
325
+ ancestorAgentStack: ancestorAgentStack ?? [],
326
+ preventCycles,
327
+ };
328
+ }
329
+
330
+ function makeDetailsFactory(
331
+ projectAgentsDir: string | null,
332
+ delegationMode: DelegationMode,
333
+ ) {
334
+ return (mode: "single" | "parallel") =>
335
+ (results: SingleResult[]): SubagentDetails => ({
336
+ mode,
337
+ delegationMode,
338
+ projectAgentsDir,
339
+ results,
340
+ });
341
+ }
342
+
343
+ function formatAgentNames(agents: AgentConfig[]): string {
344
+ return agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
345
+ }
346
+
347
+ function getCycleViolations(
348
+ requestedNames: Set<string>,
349
+ ancestorAgentStack: string[],
350
+ ): string[] {
351
+ if (requestedNames.size === 0 || ancestorAgentStack.length === 0) return [];
352
+ const stackSet = new Set(ancestorAgentStack);
353
+ return Array.from(requestedNames).filter((name) => stackSet.has(name));
354
+ }
355
+
356
+ /** Get project-local agents referenced by the current request. */
357
+ function getRequestedProjectAgents(
358
+ agents: AgentConfig[],
359
+ requestedNames: Set<string>,
360
+ ): AgentConfig[] {
361
+ return Array.from(requestedNames)
362
+ .map((name) => agents.find((a) => a.name === name))
363
+ .filter((a): a is AgentConfig => a?.source === "project");
364
+ }
365
+
366
+ /**
367
+ * Prompt the user to confirm project-local agents if needed.
368
+ */
369
+ async function confirmProjectAgentsIfNeeded(
370
+ projectAgents: AgentConfig[],
371
+ projectAgentsDir: string | null,
372
+ ctx: { ui: { select: (title: string, options: string[]) => Promise<string | undefined> } },
373
+ ): Promise<ProjectAgentApproval> {
374
+ if (projectAgents.length === 0) return "once";
375
+
376
+ const names = projectAgents.map((a) => a.name).join(", ");
377
+ const dir = projectAgentsDir ?? "(unknown)";
378
+ const selection = await ctx.ui.select(
379
+ `Run project-local agents?\nAgents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
380
+ ["Yes once", "Yes for this session", "No"],
381
+ );
382
+
383
+ if (selection === "Yes once") return "once";
384
+ if (selection === "Yes for this session") return "session";
385
+ return "no";
386
+ }
387
+
388
+ function getProjectAgentSessionKey(projectAgentsDir: string | null): string {
389
+ return projectAgentsDir ?? "(unknown-project-agents-dir)";
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // Extension entry point
394
+ // ---------------------------------------------------------------------------
395
+
396
+ export default function (pi: ExtensionAPI) {
397
+ pi.registerFlag("subagent-max-depth", {
398
+ description: "Maximum allowed subagent delegation depth (default: 3).",
399
+ type: "string",
400
+ });
401
+ pi.registerFlag("subagent-prevent-cycles", {
402
+ description:
403
+ "Block delegating to agents already in the current delegation stack (default: true).",
404
+ type: "boolean",
405
+ });
406
+
407
+ const depthConfig = resolveDelegationDepthConfig(pi);
408
+ const { currentDepth, maxDepth, canDelegate, ancestorAgentStack, preventCycles } =
409
+ depthConfig;
410
+
411
+ let discoveredAgents: AgentConfig[] = [];
412
+ const approvedProjectAgentDirsForSession = new Set<string>();
413
+
414
+ // Auto-discover agents on session start
415
+ pi.on("session_start", async (_event, ctx) => {
416
+ if (!canDelegate) return;
417
+
418
+ const discovery = discoverAgents(ctx.cwd, "both");
419
+ discoveredAgents = discovery.agents;
420
+
421
+ if (discoveredAgents.length > 0 && ctx.hasUI) {
422
+ const list = discoveredAgents
423
+ .map((a) => ` - ${a.name} (${a.source})`)
424
+ .join("\n");
425
+ ctx.ui.notify(
426
+ `Found ${discoveredAgents.length} subagent(s):\n${list}`,
427
+ "info",
428
+ );
429
+ }
430
+ });
431
+
432
+ // Inject available agents into the system prompt
433
+ pi.on("before_agent_start", async (event) => {
434
+ if (!canDelegate) return;
435
+ if (discoveredAgents.length === 0) return;
436
+
437
+ const agentList = discoveredAgents
438
+ .map((a) => `- **${a.name}**: ${a.description}`)
439
+ .join("\n");
440
+ return {
441
+ systemPrompt:
442
+ event.systemPrompt +
443
+ `\n\n## Available Subagents
444
+
445
+ The following subagents are available via the \`subagent\` tool:
446
+
447
+ ${agentList}
448
+
449
+ ### How to call the subagent tool
450
+
451
+ Each subagent runs in an **isolated process**.
452
+
453
+ Context behavior is controlled by optional 'mode':
454
+ - 'spawn' (default): child receives only the provided task prompt. Best for isolated, reproducible tasks with lower token/cost and less context leakage.
455
+ - 'fork': child receives a forked snapshot of current session context plus the task prompt. Best for follow-up tasks that rely on prior context; usually higher token/cost and may include sensitive context.
456
+
457
+ The tool always accepts a \`tasks\` array:
458
+ - one item = single-agent delegation
459
+ - multiple items = parallel delegation
460
+
461
+ **Single-task delegation**:
462
+ \`\`\`json
463
+ { "tasks": [{ "agent": "agent-name", "task": "Detailed task..." }], "mode": "spawn" }
464
+ \`\`\`
465
+
466
+ **Multi-task delegation**:
467
+ \`\`\`json
468
+ { "tasks": [{ "agent": "agent-name", "task": "..." }, { "agent": "other-agent", "task": "..." }], "mode": "fork" }
469
+ \`\`\`
470
+
471
+ ### Runtime delegation guards
472
+
473
+ - Max depth: current depth ${currentDepth}, max depth ${maxDepth}
474
+ - Cycle prevention: ${preventCycles ? "enabled" : "disabled"}
475
+ - Current delegation stack: ${ancestorAgentStack.length > 0 ? ancestorAgentStack.join(" -> ") : "(root)"}
476
+ `,
477
+ };
478
+ });
479
+
480
+ // Register the subagent tool
481
+ if (canDelegate) {
482
+ pi.registerTool({
483
+ name: "subagent",
484
+ label: "Subagent",
485
+ description: [
486
+ "Delegate work to specialized subagents running in isolated pi processes.",
487
+ "",
488
+ "The tool always accepts a `tasks` array:",
489
+ " - one task: single-agent delegation",
490
+ " - multiple tasks: parallel delegation",
491
+ "",
492
+ "Optional context mode switch:",
493
+ " mode: \"spawn\" (default) -> child gets only your task prompt.",
494
+ " Best for isolated/reproducible work; lower token/cost and less context leakage.",
495
+ " mode: \"fork\" -> child gets current session context + your task prompt.",
496
+ " Best for follow-up work that depends on prior context; higher token/cost and may include sensitive context.",
497
+ "",
498
+ 'Example single: { tasks: [{ agent: "writer", task: "Rewrite README.md" }], mode: "spawn" }',
499
+ 'Example parallel: { tasks: [{ agent: "writer", task: "..." }, { agent: "tester", task: "..." }], mode: "fork" }',
500
+ ].join("\n"),
501
+ parameters: SubagentParams,
502
+
503
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
504
+ const discovery = discoverAgents(ctx.cwd, "both");
505
+ const { agents } = discovery;
506
+
507
+ const delegationMode = parseDelegationMode(params.mode);
508
+ if (!delegationMode) {
509
+ const fallbackDetails = makeDetailsFactory(
510
+ discovery.projectAgentsDir,
511
+ DEFAULT_DELEGATION_MODE,
512
+ );
513
+ return {
514
+ content: [
515
+ {
516
+ type: "text",
517
+ text: `Invalid mode \"${String(params.mode)}\". Expected \"spawn\" or \"fork\".\nAvailable agents: ${formatAgentNames(agents)}`,
518
+ },
519
+ ],
520
+ details: fallbackDetails("single")([]),
521
+ isError: true,
522
+ };
523
+ }
524
+
525
+ const makeDetails = makeDetailsFactory(
526
+ discovery.projectAgentsDir,
527
+ delegationMode,
528
+ );
529
+
530
+ let forkSessionSnapshotJsonl: string | undefined;
531
+ if (delegationMode === "fork") {
532
+ forkSessionSnapshotJsonl = buildForkSessionSnapshotJsonl(
533
+ ctx.sessionManager,
534
+ );
535
+ if (!forkSessionSnapshotJsonl) {
536
+ return {
537
+ content: [
538
+ {
539
+ type: "text",
540
+ text: "Cannot use mode=\"fork\": failed to snapshot current session context.",
541
+ },
542
+ ],
543
+ details: makeDetails("single")([]),
544
+ isError: true,
545
+ };
546
+ }
547
+ }
548
+
549
+ const tasks = params.tasks ?? [];
550
+ if (tasks.length === 0) {
551
+ return {
552
+ content: [
553
+ {
554
+ type: "text",
555
+ text: `Invalid parameters. Provide a non-empty tasks array.\nAvailable agents: ${formatAgentNames(agents)}`,
556
+ },
557
+ ],
558
+ details: makeDetails("single")([]),
559
+ };
560
+ }
561
+
562
+ const executionMode = tasks.length === 1 ? "single" : "parallel";
563
+
564
+ // Security: guard project-local agents before running
565
+ const requested = new Set<string>();
566
+ for (const t of tasks) requested.add(t.agent);
567
+
568
+ if (preventCycles) {
569
+ const cycleViolations = getCycleViolations(
570
+ requested,
571
+ ancestorAgentStack,
572
+ );
573
+ if (cycleViolations.length > 0) {
574
+ const stackText =
575
+ ancestorAgentStack.length > 0
576
+ ? ancestorAgentStack.join(" -> ")
577
+ : "(root)";
578
+ return {
579
+ content: [
580
+ {
581
+ type: "text",
582
+ text: `Blocked: delegation cycle detected. Requested agent(s) already in the delegation stack: ${cycleViolations.join(", ")}.
583
+ Current stack: ${stackText}
584
+
585
+ This guard prevents self-recursion and cyclic handoffs (for example A -> B -> A).`,
586
+ },
587
+ ],
588
+ details: makeDetails(executionMode)([]),
589
+ isError: true,
590
+ };
591
+ }
592
+ }
593
+
594
+ const requestedProjectAgents = getRequestedProjectAgents(
595
+ agents,
596
+ requested,
597
+ );
598
+ const projectAgentConfirmationSetting =
599
+ getProjectAgentConfirmationSetting();
600
+ const projectAgentSessionKey = getProjectAgentSessionKey(
601
+ discovery.projectAgentsDir,
602
+ );
603
+ const shouldConfirmProjectAgents =
604
+ requestedProjectAgents.length > 0 &&
605
+ projectAgentConfirmationSetting === "ask" &&
606
+ !approvedProjectAgentDirsForSession.has(projectAgentSessionKey);
607
+ if (shouldConfirmProjectAgents) {
608
+ if (ctx.hasUI) {
609
+ const approval = await confirmProjectAgentsIfNeeded(
610
+ requestedProjectAgents,
611
+ discovery.projectAgentsDir,
612
+ ctx,
613
+ );
614
+ if (approval === "no") {
615
+ return {
616
+ content: [
617
+ {
618
+ type: "text",
619
+ text: "Canceled: project-local agents not approved.",
620
+ },
621
+ ],
622
+ details: makeDetails(executionMode)([]),
623
+ };
624
+ }
625
+ if (approval === "session") {
626
+ approvedProjectAgentDirsForSession.add(projectAgentSessionKey);
627
+ }
628
+ } else {
629
+ const names = requestedProjectAgents.map((a) => a.name).join(", ");
630
+ const dir = discovery.projectAgentsDir ?? "(unknown)";
631
+ return {
632
+ content: [
633
+ {
634
+ type: "text",
635
+ text: `Blocked: project-local agent confirmation is required in non-UI mode.\nAgents: ${names}\nSource: ${dir}\n\nSet ${SUBAGENT_CONFIRM_PROJECT_AGENTS_ENV}=false or =session only if this repository is trusted.`,
636
+ },
637
+ ],
638
+ details: makeDetails(executionMode)([]),
639
+ isError: true,
640
+ };
641
+ }
642
+ }
643
+
644
+ if (tasks.length === 1) {
645
+ const [task] = tasks;
646
+ return executeSingle(
647
+ task.agent,
648
+ task.task,
649
+ task.cwd,
650
+ delegationMode,
651
+ forkSessionSnapshotJsonl,
652
+ agents,
653
+ ctx.cwd,
654
+ signal,
655
+ onUpdate,
656
+ makeDetails,
657
+ );
658
+ }
659
+
660
+ return executeParallel(
661
+ tasks,
662
+ delegationMode,
663
+ forkSessionSnapshotJsonl,
664
+ agents,
665
+ ctx.cwd,
666
+ signal,
667
+ onUpdate,
668
+ makeDetails,
669
+ );
670
+ },
671
+
672
+ renderCall: (args, theme) => renderCall(args, theme),
673
+ renderResult: (result, { expanded }, theme) =>
674
+ renderResult(result, expanded, theme),
675
+ });
676
+ }
677
+
678
+ // -----------------------------------------------------------------------
679
+ // Mode implementations
680
+ // -----------------------------------------------------------------------
681
+
682
+ async function executeSingle(
683
+ agentName: string,
684
+ task: string,
685
+ cwd: string | undefined,
686
+ delegationMode: DelegationMode,
687
+ forkSessionSnapshotJsonl: string | undefined,
688
+ agents: AgentConfig[],
689
+ defaultCwd: string,
690
+ signal: AbortSignal | undefined,
691
+ onUpdate: ((partial: any) => void) | undefined,
692
+ makeDetails: ReturnType<typeof makeDetailsFactory>,
693
+ ) {
694
+ const result = await runAgent({
695
+ cwd: defaultCwd,
696
+ agents,
697
+ agentName,
698
+ task,
699
+ taskCwd: cwd,
700
+ delegationMode,
701
+ forkSessionSnapshotJsonl,
702
+ parentDepth: currentDepth,
703
+ parentAgentStack: ancestorAgentStack,
704
+ maxDepth,
705
+ preventCycles,
706
+ signal,
707
+ onUpdate,
708
+ makeDetails: makeDetails("single"),
709
+ });
710
+
711
+ if (isResultError(result)) {
712
+ const errorMsg =
713
+ result.errorMessage ||
714
+ result.stderr ||
715
+ getFinalOutput(result.messages) ||
716
+ "(no output)";
717
+ return {
718
+ content: [
719
+ {
720
+ type: "text" as const,
721
+ text: `Agent ${result.stopReason || "failed"}: ${errorMsg}`,
722
+ },
723
+ ],
724
+ details: makeDetails("single")([result]),
725
+ isError: true,
726
+ };
727
+ }
728
+ return {
729
+ content: [
730
+ {
731
+ type: "text" as const,
732
+ text: getFinalOutput(result.messages) || "(no output)",
733
+ },
734
+ ],
735
+ details: makeDetails("single")([result]),
736
+ };
737
+ }
738
+
739
+ async function executeParallel(
740
+ tasks: Array<{ agent: string; task: string; cwd?: string }>,
741
+ delegationMode: DelegationMode,
742
+ forkSessionSnapshotJsonl: string | undefined,
743
+ agents: AgentConfig[],
744
+ defaultCwd: string,
745
+ signal: AbortSignal | undefined,
746
+ onUpdate: ((partial: any) => void) | undefined,
747
+ makeDetails: ReturnType<typeof makeDetailsFactory>,
748
+ ) {
749
+ const maxParallelTasksRaw = process.env[SUBAGENT_MAX_PARALLEL_TASKS_ENV];
750
+ const maxParallelTasksParsed = parseNonNegativeInt(maxParallelTasksRaw);
751
+ if (maxParallelTasksRaw !== undefined && maxParallelTasksParsed === null) {
752
+ console.warn(
753
+ `[pi-subagent] Ignoring invalid ${SUBAGENT_MAX_PARALLEL_TASKS_ENV}="${maxParallelTasksRaw}". Expected a non-negative integer.`
754
+ );
755
+ }
756
+ const maxParallelTasks = maxParallelTasksParsed ?? DEFAULT_MAX_PARALLEL_TASKS;
757
+
758
+ const maxConcurrencyRaw = process.env[SUBAGENT_MAX_CONCURRENCY_ENV];
759
+ const maxConcurrencyParsed = parseNonNegativeInt(maxConcurrencyRaw);
760
+ if (maxConcurrencyRaw !== undefined && maxConcurrencyParsed === null) {
761
+ console.warn(
762
+ `[pi-subagent] Ignoring invalid ${SUBAGENT_MAX_CONCURRENCY_ENV}="${maxConcurrencyRaw}". Expected a non-negative integer.`
763
+ );
764
+ }
765
+ const maxConcurrency = maxConcurrencyParsed ?? DEFAULT_MAX_CONCURRENCY;
766
+
767
+ if (tasks.length > maxParallelTasks) {
768
+ return {
769
+ content: [
770
+ {
771
+ type: "text" as const,
772
+ text: `Too many parallel tasks (${tasks.length}). Max is ${maxParallelTasks}.`,
773
+ },
774
+ ],
775
+ details: makeDetails("parallel")([]),
776
+ };
777
+ }
778
+
779
+ // Initialize placeholder results for streaming
780
+ const allResults: SingleResult[] = tasks.map((t) => ({
781
+ agent: t.agent,
782
+ agentSource: "unknown" as const,
783
+ task: t.task,
784
+ exitCode: -1,
785
+ messages: [],
786
+ stderr: "",
787
+ usage: emptyUsage(),
788
+ }));
789
+
790
+ const emitProgress = () => {
791
+ if (!onUpdate) return;
792
+ const running = allResults.filter((r) => r.exitCode === -1).length;
793
+ const done = allResults.filter((r) => r.exitCode !== -1).length;
794
+ onUpdate({
795
+ content: [
796
+ {
797
+ type: "text",
798
+ text: `Parallel: ${done}/${allResults.length} done, ${running} running...`,
799
+ },
800
+ ],
801
+ details: makeDetails("parallel")([...allResults]),
802
+ });
803
+ };
804
+
805
+ let heartbeat: NodeJS.Timeout | undefined;
806
+ if (onUpdate) {
807
+ emitProgress();
808
+ heartbeat = setInterval(() => {
809
+ if (allResults.some((r) => r.exitCode === -1)) emitProgress();
810
+ }, PARALLEL_HEARTBEAT_MS);
811
+ }
812
+
813
+ let results: SingleResult[];
814
+ try {
815
+ results = await mapConcurrent(
816
+ tasks,
817
+ maxConcurrency,
818
+ async (t, index) => {
819
+ const result = await runAgent({
820
+ cwd: defaultCwd,
821
+ agents,
822
+ agentName: t.agent,
823
+ task: t.task,
824
+ taskCwd: t.cwd,
825
+ delegationMode,
826
+ forkSessionSnapshotJsonl,
827
+ parentDepth: currentDepth,
828
+ parentAgentStack: ancestorAgentStack,
829
+ maxDepth,
830
+ preventCycles,
831
+ signal,
832
+ onUpdate: (partial) => {
833
+ if (partial.details?.results[0]) {
834
+ allResults[index] = partial.details.results[0];
835
+ emitProgress();
836
+ }
837
+ },
838
+ makeDetails: makeDetails("parallel"),
839
+ });
840
+ allResults[index] = result;
841
+ emitProgress();
842
+ return result;
843
+ },
844
+ );
845
+ } finally {
846
+ if (heartbeat) clearInterval(heartbeat);
847
+ }
848
+
849
+ const successCount = results.filter((r) => r.exitCode === 0).length;
850
+ const summaries = results.map((r) => {
851
+ const output = getFinalOutput(r.messages);
852
+ return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${output || "(no output)"}`;
853
+ });
854
+
855
+ return {
856
+ content: [
857
+ {
858
+ type: "text" as const,
859
+ text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
860
+ },
861
+ ],
862
+ details: makeDetails("parallel")(results),
863
+ };
864
+ }
865
+ }