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/LICENSE +21 -0
- package/README.md +327 -0
- package/agents/code-architect.md +24 -0
- package/agents/code-reviwer.md +23 -0
- package/agents/code-writer.md +18 -0
- package/agents.ts +185 -0
- package/index.ts +865 -0
- package/package.json +68 -0
- package/render.ts +595 -0
- package/runner.ts +419 -0
- package/types.ts +135 -0
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
|
+
}
|