@ferris1225/pi-subagents 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,417 +1,461 @@
1
- /**
2
- * pi-subagents — focused sub-agent delegation for pi.
3
- *
4
- * Registers:
5
- * - a `subagent` tool that runs explore/plan/worker/reviewer agents as isolated
6
- * `pi` child processes (single or parallel),
7
- * - a `/subagents-setup` command for selection-only configuration,
8
- * - a `before_agent_start` hook that injects a delegation directive into the
9
- * parent system prompt so the main model uses the tool proactively.
10
- *
11
- * The tool is NOT registered inside nested sub-agent processes beyond
12
- * MAX_SUBAGENT_DEPTH, which both prevents runaway recursion and keeps child
13
- * context windows clean.
14
- */
15
-
16
- import type { AgentToolResult } from "@earendil-works/pi-agent-core";
17
- import { StringEnum } from "@earendil-works/pi-ai";
18
- import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
- import { Text, truncateToWidth } from "@earendil-works/pi-tui";
20
- import { Type } from "typebox";
21
- import { discoverAgents, type AgentConfig } from "./agents.ts";
22
- import { getConfigPath, loadConfig } from "./config.ts";
23
- import { buildDelegationDirective } from "./prompt.ts";
24
- import { runSetup } from "./setup.ts";
25
- import {
26
- MAX_CONCURRENCY,
27
- MAX_PARALLEL_TASKS,
28
- MAX_SUBAGENT_DEPTH,
29
- currentSubagentDepth,
30
- getFinalOutput,
31
- getResultOutput,
32
- isFailedResult,
33
- mapWithConcurrencyLimit,
34
- runSingleAgent,
35
- type OnUpdateCallback,
36
- type SingleResult,
37
- type SubagentDetails,
38
- type SubagentLiveEvent,
39
- type UsageStats,
40
- } from "./spawn.ts";
41
- import { formatToolActivity, monitor, statusColor, statusIcon, statusLabel } from "./monitor.ts";
42
-
43
- const TaskItem = Type.Object({
44
- agent: Type.String({ description: "Name of the agent to invoke" }),
45
- task: Type.String({ description: "Self-contained task to delegate (the agent has no memory of this conversation)" }),
46
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
47
- });
48
-
49
- const SubagentParams = Type.Object({
50
- agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
51
- task: Type.Optional(Type.String({ description: "Self-contained task to delegate (single mode)" })),
52
- tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
53
- cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
54
- });
55
-
56
- function emptyUsage(): UsageStats {
57
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
58
- }
59
-
60
- function aggregateUsage(results: SingleResult[]): UsageStats {
61
- const total = emptyUsage();
62
- for (const r of results) {
63
- total.input += r.usage.input;
64
- total.output += r.usage.output;
65
- total.cacheRead += r.usage.cacheRead;
66
- total.cacheWrite += r.usage.cacheWrite;
67
- total.cost += r.usage.cost;
68
- total.turns += r.usage.turns;
69
- }
70
- return total;
71
- }
72
-
73
- function formatTokens(count: number): string {
74
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
75
- if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
76
- return String(count);
77
- }
78
-
79
- function formatUsage(usage: UsageStats): string {
80
- const parts: string[] = [];
81
- if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
82
- if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
83
- if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
84
- if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
85
- if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
86
- return parts.join(" ");
87
- }
88
-
89
- export default function (pi: ExtensionAPI): void {
90
- const configPath = getConfigPath(getAgentDir());
91
-
92
- // Recursion guard: do not register the tool deep inside nested sub-agents.
93
- if (currentSubagentDepth() >= MAX_SUBAGENT_DEPTH) {
94
- pi.registerCommand("subagents-setup", {
95
- description: "Configure pi-subagents (disabled in nested sub-agent processes)",
96
- handler: async (_args, ctx) => {
97
- ctx.ui.notify("pi-subagents setup is unavailable inside a nested sub-agent.", "warning");
98
- },
99
- });
100
- return;
101
- }
102
-
103
- pi.registerTool({
104
- name: "subagent",
105
- label: "Subagent",
106
- description: [
107
- "Delegate a discrete, self-contained task to a specialized sub-agent running in an ISOLATED context window.",
108
- "Agents: explore (read-only codebase recon), plan (implementation plan, opt-in), worker (implement/fix/refactor/test, full tools), reviewer (adversarial pre-commit review, read-only).",
109
- "Modes: single ({agent, task}) or parallel ({tasks: [{agent, task}, ...]}).",
110
- "Use it to keep the main conversation clean: delegate the work, then orchestrate and verify the results yourself.",
111
- "Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output).",
112
- ].join(" "),
113
- promptSnippet:
114
- "Delegate discrete tasks to isolated sub-agents: explore (read-only search), worker (implement), reviewer (adversarial pre-commit review); plan is opt-in.",
115
- promptGuidelines: [
116
- "Use subagent to delegate discrete, self-contained tasks so the main context stays clean; do orchestration and verification yourself.",
117
- "Use subagent with agent 'explore' for broad or open-ended code search before large changes.",
118
- "Use subagent with agent 'worker' to implement a well-scoped task; it plans internally.",
119
- "Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
120
- "Run independent tasks in parallel by passing a tasks array to subagent; keep dependent work sequential.",
121
- ],
122
- parameters: SubagentParams,
123
-
124
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
125
- monitor.beginTurn();
126
- const config = await loadConfig(configPath);
127
-
128
- // Finished runs leave the widget immediately; the main window gets a
129
- // notification instead (the tool result remains the durable record).
130
- const finishRun = (runId: number, status: "done" | "failed"): void => {
131
- monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
132
- const run = monitor.removeRun(runId);
133
- if (!run) return; // already finished — stay idempotent
134
- const icon = status === "done" ? "✓" : "✗";
135
- ctx.ui.notify(`${icon} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
136
- };
137
-
138
- // Live sub-agent activity → concise one-line status ("thinking",
139
- // "read src/index.ts", ...), never a raw args blob.
140
- const makeLiveHandler = (runId: number) => (e: SubagentLiveEvent): void => {
141
- switch (e.kind) {
142
- case "status":
143
- if (e.status === "done" || e.status === "failed") finishRun(runId, e.status);
144
- else monitor.setStatus(runId, e.status);
145
- break;
146
- case "usage":
147
- monitor.setUsage(runId, e.usage, e.model);
148
- break;
149
- case "tool_start":
150
- monitor.setActivity(runId, formatToolActivity(e.toolName, e.args));
151
- break;
152
- case "tool_end":
153
- if (e.isError) monitor.setActivity(runId, `✗ ${e.toolName} failed`);
154
- break;
155
- case "thinking":
156
- monitor.setActivity(runId, "thinking");
157
- break;
158
- case "text":
159
- monitor.setActivity(runId, "writing");
160
- break;
161
- }
162
- };
163
- const discovery = discoverAgents(ctx.cwd, {
164
- scope: config.agentScope,
165
- enabledNames: config.enabledAgents,
166
- });
167
-
168
- // Effective model precedence: setup override > current session model > frontmatter default.
169
- const sessionRef = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
170
- const agents: AgentConfig[] = discovery.agents.map((agent) => ({
171
- ...agent,
172
- model: config.agentModels[agent.name] ?? sessionRef ?? agent.model,
173
- }));
174
-
175
- const hasTasks = (params.tasks?.length ?? 0) > 0;
176
- const hasSingle = Boolean(params.agent && params.task);
177
-
178
- const makeDetails =
179
- (mode: "single" | "parallel") =>
180
- (results: SingleResult[]): SubagentDetails => ({ mode, results });
181
-
182
- const catalog = agents.map((a) => a.name).join(", ") || "none";
183
-
184
- if (Number(hasTasks) + Number(hasSingle) !== 1) {
185
- return {
186
- content: [
187
- {
188
- type: "text",
189
- text: `Invalid parameters. Provide exactly one mode: single {agent, task} or parallel {tasks: [...]}. Enabled agents: ${catalog}.`,
190
- },
191
- ],
192
- details: makeDetails("single")([]),
193
- };
194
- }
195
-
196
- // ---- Parallel mode ----
197
- if (params.tasks && params.tasks.length > 0) {
198
- if (params.tasks.length > MAX_PARALLEL_TASKS) {
199
- return {
200
- content: [
201
- { type: "text", text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.` },
202
- ],
203
- details: makeDetails("parallel")([]),
204
- };
205
- }
206
-
207
- const allResults: SingleResult[] = params.tasks.map((t) => ({
208
- agent: t.agent,
209
- agentSource: "unknown",
210
- task: t.task,
211
- exitCode: -1,
212
- messages: [],
213
- stderr: "",
214
- usage: emptyUsage(),
215
- }));
216
-
217
- const emitParallelUpdate = (): void => {
218
- if (!onUpdate) return;
219
- const done = allResults.filter((r) => r.exitCode !== -1).length;
220
- onUpdate({
221
- content: [{ type: "text", text: `Parallel: ${done}/${allResults.length} done...` }],
222
- details: makeDetails("parallel")([...allResults]),
223
- });
224
- };
225
-
226
- const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
227
- const resolvedModel = agents.find((a) => a.name === t.agent)?.model;
228
- const runId = monitor.addRun(t.agent, resolvedModel);
229
- const onLive = makeLiveHandler(runId);
230
- const perTaskUpdate: OnUpdateCallback | undefined = onUpdate
231
- ? (partial) => {
232
- const current = partial.details?.results[0];
233
- if (current) {
234
- allResults[index] = current;
235
- emitParallelUpdate();
236
- }
237
- }
238
- : undefined;
239
- let result: SingleResult;
240
- try {
241
- result = await runSingleAgent({
242
- defaultCwd: ctx.cwd,
243
- agent: agents.find((a) => a.name === t.agent),
244
- agentName: t.agent,
245
- task: t.task,
246
- cwd: t.cwd,
247
- signal,
248
- onUpdate: perTaskUpdate,
249
- onLive,
250
- makeDetails: makeDetails("parallel"),
251
- });
252
- } catch (err) {
253
- finishRun(runId, "failed");
254
- throw err;
255
- }
256
- allResults[index] = result;
257
- emitParallelUpdate();
258
- return result;
259
- });
260
-
261
- const successCount = results.filter((r) => !isFailedResult(r)).length;
262
- const summaries = results.map((r) => {
263
- const output = getResultOutput(r);
264
- const status = isFailedResult(r) ? "failed" : "completed";
265
- const usage = formatUsage(r.usage);
266
- return `### [${r.agent}] ${status}${usage ? ` (${usage})` : ""}\n\n${output}`;
267
- });
268
- return {
269
- content: [
270
- {
271
- type: "text",
272
- text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`,
273
- },
274
- ],
275
- details: makeDetails("parallel")(results),
276
- };
277
- }
278
-
279
- // ---- Single mode ----
280
- const resolvedModel = agents.find((a) => a.name === params.agent)?.model;
281
- const runId = monitor.addRun(params.agent as string, resolvedModel);
282
- const onLive = makeLiveHandler(runId);
283
- let result: SingleResult;
284
- try {
285
- result = await runSingleAgent({
286
- defaultCwd: ctx.cwd,
287
- agent: agents.find((a) => a.name === params.agent),
288
- agentName: params.agent as string,
289
- task: params.task as string,
290
- cwd: params.cwd,
291
- signal,
292
- onUpdate,
293
- onLive,
294
- makeDetails: makeDetails("single"),
295
- });
296
- } catch (err) {
297
- finishRun(runId, "failed");
298
- throw err;
299
- }
300
-
301
- if (isFailedResult(result)) {
302
- return {
303
- content: [{ type: "text", text: `Agent ${result.agent} ${result.stopReason || "failed"}: ${getResultOutput(result)}` }],
304
- details: makeDetails("single")([result]),
305
- isError: true,
306
- };
307
- }
308
- return {
309
- content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
310
- details: makeDetails("single")([result]),
311
- };
312
- },
313
-
314
- renderCall(args, theme) {
315
- if (args.tasks && args.tasks.length > 0) {
316
- let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
317
- for (const t of args.tasks.slice(0, 4)) {
318
- const preview = t.task.length > 48 ? `${t.task.slice(0, 48)}…` : t.task;
319
- text += `\n ${theme.fg("accent", t.agent)} ${theme.fg("dim", preview)}`;
320
- }
321
- if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
322
- return new Text(text, 0, 0);
323
- }
324
- const task: string = args.task ?? "";
325
- const preview = task.length > 60 ? `${task.slice(0, 60)}…` : task;
326
- return new Text(
327
- `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")} ${theme.fg("dim", preview)}`,
328
- 0,
329
- 0,
330
- );
331
- },
332
-
333
- renderResult(result, _options, theme) {
334
- const details = result.details as SubagentDetails | undefined;
335
- if (!details || details.results.length === 0) return new Text(theme.fg("dim", "(no output)"), 0, 0);
336
-
337
- if (details.mode === "single") {
338
- const r = details.results[0];
339
- const icon = statusIcon(isFailedResult(r) ? "failed" : "done", theme);
340
- const usage = formatUsage(r.usage);
341
- const model = r.model ?? "?";
342
- const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${usage ? ` · ${usage}` : ""}`)}`;
343
- return new Text(line, 0, 0);
344
- }
345
-
346
- // Parallel mode: header + one compact line per agent
347
- const lines: string[] = [
348
- `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`,
349
- ];
350
- for (const r of details.results) {
351
- const icon = statusIcon(isFailedResult(r) ? "failed" : "done", theme);
352
- const usage = formatUsage(r.usage);
353
- const model = r.model ?? "?";
354
- lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${usage ? ` · ${usage}` : ""}`)}`);
355
- }
356
- return new Text(lines.join("\n"), 0, 0);
357
- },
358
- });
359
-
360
- pi.registerCommand("subagents-setup", {
361
- description: "Configure pi-subagents: enable agents, pick per-agent models, toggle proactive injection",
362
- handler: async (_args, ctx) => {
363
- await runSetup(ctx, configPath);
364
- },
365
- });
366
-
367
- // Persistent widget above the editor showing live sub-agent status.
368
- pi.on("session_start", (_e, ctx) => {
369
- if (ctx.mode !== "tui") return;
370
- ctx.ui.setWidget(
371
- "pi-subagents",
372
- (tui, theme) => {
373
- const unsub = monitor.subscribe(() => tui.requestRender());
374
- // Tick once a second so elapsed time stays live while runs are active.
375
- const timer = setInterval(() => {
376
- if (monitor.getRuns().some((r) => r.status === "queued" || r.status === "running")) {
377
- tui.requestRender();
378
- }
379
- }, 1000);
380
- return {
381
- render(width: number): string[] {
382
- const runs = monitor.getRuns();
383
- if (runs.length === 0) return [];
384
- const lines: string[] = [];
385
- for (const r of runs) {
386
- const icon = statusIcon(r.status, theme);
387
- const label = theme.fg(statusColor(r.status), statusLabel(r.status));
388
- lines.push(truncateToWidth(` ${icon} ${monitor.summarize(r)} · ${label}`, width, ""));
389
- // Activity sits one indent level below the agent name.
390
- if (r.activity) lines.push(truncateToWidth(theme.fg("dim", ` ${r.activity}`), width, ""));
391
- }
392
- return lines;
393
- },
394
- invalidate() {},
395
- dispose() {
396
- unsub();
397
- clearInterval(timer);
398
- },
399
- };
400
- },
401
- { placement: "aboveEditor" },
402
- );
403
- });
404
-
405
- // Proactive dispatch: inject the delegation directive into the parent system prompt.
406
- pi.on("before_agent_start", async (event, ctx) => {
407
- const config = await loadConfig(configPath);
408
- if (!config.proactiveInjection) return undefined;
409
- const { agents } = discoverAgents(ctx.cwd, {
410
- scope: config.agentScope,
411
- enabledNames: config.enabledAgents,
412
- });
413
- const directive = buildDelegationDirective(agents);
414
- if (!directive) return undefined;
415
- return { systemPrompt: `${event.systemPrompt}\n${directive}` };
416
- });
417
- }
1
+ /**
2
+ * pi-subagents — focused sub-agent delegation for pi.
3
+ *
4
+ * Registers:
5
+ * - a `subagent` tool that runs explore/plan/worker/reviewer agents as isolated
6
+ * `pi` child processes (single or parallel),
7
+ * - a `/subagents-setup` command for selection-only configuration,
8
+ * - a `before_agent_start` hook that injects a delegation directive into the
9
+ * parent system prompt so the main model uses the tool proactively.
10
+ *
11
+ * The tool is not registered inside child sub-agent processes, which prevents
12
+ * runaway recursion and keeps child context windows clean.
13
+ */
14
+
15
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
16
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
18
+ import { Type } from "typebox";
19
+ import { discoverAgents, type AgentConfig } from "./agents.ts";
20
+ import { BackgroundTaskQueue } from "./background.ts";
21
+ import { getConfigPath, loadConfig, saveConfig } from "./config.ts";
22
+ import { repairUnavailableModelOverrides } from "./models.ts";
23
+ import { buildDelegationDirective } from "./prompt.ts";
24
+ import { runSetup } from "./setup.ts";
25
+ import {
26
+ MAX_CONCURRENCY,
27
+ MAX_PARALLEL_TASKS,
28
+ MAX_SUBAGENT_DEPTH,
29
+ currentSubagentDepth,
30
+ getFinalOutput,
31
+ getResultOutput,
32
+ isFailedResult,
33
+ runSingleAgent,
34
+ type SingleResult,
35
+ type SubagentDetails,
36
+ type SubagentLiveEvent,
37
+ type UsageStats,
38
+ } from "./spawn.ts";
39
+ import { formatToolActivity, monitor, statusColor, statusIcon, statusLabel } from "./monitor.ts";
40
+
41
+ const TaskItem = Type.Object({
42
+ agent: Type.String({ description: "Name of the agent to invoke" }),
43
+ task: Type.String({ description: "Self-contained task to delegate (the agent has no memory of this conversation)" }),
44
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
45
+ });
46
+
47
+ const SubagentParams = Type.Object({
48
+ agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
49
+ task: Type.Optional(Type.String({ description: "Self-contained task to delegate (single mode)" })),
50
+ tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
51
+ cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
52
+ });
53
+
54
+ function emptyUsage(): UsageStats {
55
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
56
+ }
57
+
58
+ function queuedResult(agent: AgentConfig, task: string): SingleResult {
59
+ return {
60
+ agent: agent.name,
61
+ agentSource: agent.source,
62
+ task,
63
+ exitCode: -1,
64
+ messages: [],
65
+ stderr: "",
66
+ usage: emptyUsage(),
67
+ model: agent.model,
68
+ };
69
+ }
70
+
71
+ function failedStartResult(agentName: string, task: string, errorMessage: string): SingleResult {
72
+ return {
73
+ agent: agentName,
74
+ agentSource: "unknown",
75
+ task,
76
+ exitCode: 1,
77
+ messages: [],
78
+ stderr: errorMessage,
79
+ usage: emptyUsage(),
80
+ errorMessage,
81
+ };
82
+ }
83
+
84
+ function aggregateUsage(results: SingleResult[]): UsageStats {
85
+ const total = emptyUsage();
86
+ for (const r of results) {
87
+ total.input += r.usage.input;
88
+ total.output += r.usage.output;
89
+ total.cacheRead += r.usage.cacheRead;
90
+ total.cacheWrite += r.usage.cacheWrite;
91
+ total.cost += r.usage.cost;
92
+ total.turns += r.usage.turns;
93
+ }
94
+ return total;
95
+ }
96
+
97
+ function formatTokens(count: number): string {
98
+ if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
99
+ if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
100
+ return String(count);
101
+ }
102
+
103
+ function formatUsage(usage: UsageStats): string {
104
+ const parts: string[] = [];
105
+ if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
106
+ if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
107
+ if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
108
+ if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
109
+ if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
110
+ return parts.join(" ");
111
+ }
112
+
113
+ export default function (pi: ExtensionAPI): void {
114
+ const configPath = getConfigPath(getAgentDir());
115
+ const backgroundQueue = new BackgroundTaskQueue(MAX_CONCURRENCY);
116
+ let sessionActive = true;
117
+
118
+ // Recursion guard: child sub-agents are leaf processes and cannot delegate again.
119
+ if (currentSubagentDepth() >= MAX_SUBAGENT_DEPTH) {
120
+ pi.registerCommand("subagents-setup", {
121
+ description: "Configure pi-subagents (disabled in nested sub-agent processes)",
122
+ handler: async (_args, ctx) => {
123
+ ctx.ui.notify("pi-subagents setup is unavailable inside a nested sub-agent.", "warning");
124
+ },
125
+ });
126
+ return;
127
+ }
128
+
129
+ pi.registerMessageRenderer("subagent-result", (message, _options, theme) =>
130
+ new Text(
131
+ `${theme.fg("toolTitle", theme.bold("subagent result"))}\n${message.content}`,
132
+ 0,
133
+ 0,
134
+ ),
135
+ );
136
+
137
+ pi.on("session_shutdown", () => {
138
+ sessionActive = false;
139
+ backgroundQueue.cancelAll();
140
+ });
141
+
142
+ pi.registerTool({
143
+ name: "subagent",
144
+ label: "Subagent",
145
+ description: [
146
+ "Delegate a discrete, self-contained task to a specialized sub-agent running in an ISOLATED context window.",
147
+ "Agents: explore (read-only codebase recon), plan (implementation plan, opt-in), worker (implement/fix/refactor/test, full tools), reviewer (adversarial pre-commit review, read-only).",
148
+ "Modes: single ({agent, task}) or parallel ({tasks: [{agent, task}, ...]}).",
149
+ "It starts agents in the background and immediately returns control to the main window; completed results arrive in a later user prompt.",
150
+ "Each agent has no memory of this conversation — brief it fully (goal, exact paths, constraints, expected output)."
151
+ ].join(" "),
152
+ promptSnippet:
153
+ "Start background subagents: explore (read-only search), worker (implement), reviewer (adversarial review); completed results arrive in a later prompt.",
154
+ promptGuidelines: [
155
+ "Use subagent to delegate discrete, self-contained tasks so the main context stays clean; do orchestration and verification yourself.",
156
+ "Use subagent with agent 'explore' for broad or open-ended code search before large changes.",
157
+ "Use subagent with agent 'worker' to implement a well-scoped task; it plans internally.",
158
+ "Use subagent with agent 'reviewer' for a fresh read-only review before reporting work done or committing.",
159
+ "subagent launches work in the background and ends the current turn; do not assume a result is available until a later user prompt.",
160
+ "Run independent tasks in parallel by passing a tasks array to subagent; start dependent work only after its result arrives.",
161
+ ],
162
+ parameters: SubagentParams,
163
+
164
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
165
+ monitor.beginTurn();
166
+ let config = await loadConfig(configPath);
167
+ const repairedModels = repairUnavailableModelOverrides(ctx, config.agentModels);
168
+ if (repairedModels.changed) {
169
+ config = { ...config, agentModels: repairedModels.agentModels };
170
+ try {
171
+ await saveConfig(config, configPath);
172
+ ctx.ui.notify(
173
+ repairedModels.fallbackRef
174
+ ? `Unavailable sub-agent models switched to ${repairedModels.fallbackRef} and saved to config.`
175
+ : "Unavailable sub-agent model overrides removed; no main-window model is available.",
176
+ "warning",
177
+ );
178
+ } catch (error) {
179
+ ctx.ui.notify(
180
+ `Could not persist repaired sub-agent model config: ${error instanceof Error ? error.message : String(error)}`,
181
+ "warning",
182
+ );
183
+ }
184
+ }
185
+
186
+ // Finished runs leave the widget immediately. Their final findings arrive
187
+ // as a custom message before the next foreground prompt.
188
+ const finishRun = (runId: number, status: "done" | "failed"): void => {
189
+ monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
190
+ const run = monitor.removeRun(runId);
191
+ if (!run) return; // already finished — stay idempotent
192
+ if (!sessionActive) return;
193
+ const icon = status === "done" ? "✓" : "✗";
194
+ ctx.ui.notify(`${icon} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
195
+ };
196
+
197
+ // Live sub-agent activity concise one-line status ("thinking",
198
+ // "read src/index.ts", ...), never a raw args blob.
199
+ const makeLiveHandler = (runId: number) => (e: SubagentLiveEvent): void => {
200
+ switch (e.kind) {
201
+ case "status":
202
+ if (e.status === "done" || e.status === "failed") finishRun(runId, e.status);
203
+ else monitor.setStatus(runId, e.status);
204
+ break;
205
+ case "usage":
206
+ monitor.setUsage(runId, e.usage, e.model);
207
+ break;
208
+ case "tool_start":
209
+ monitor.setActivity(runId, formatToolActivity(e.toolName, e.args));
210
+ break;
211
+ case "tool_end":
212
+ if (e.isError) monitor.setActivity(runId, `✗ ${e.toolName} failed`);
213
+ break;
214
+ case "thinking":
215
+ monitor.setActivity(runId, "thinking");
216
+ break;
217
+ case "text":
218
+ // A text delta is model output, not a filesystem write.
219
+ monitor.setActivity(runId, "responding");
220
+ break;
221
+ }
222
+ };
223
+ const discovery = discoverAgents(ctx.cwd, {
224
+ scope: config.agentScope,
225
+ enabledNames: config.enabledAgents,
226
+ });
227
+
228
+ // Effective model precedence: setup override > current session model > frontmatter default.
229
+ const sessionRef = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
230
+ const agents: AgentConfig[] = discovery.agents.map((agent) => ({
231
+ ...agent,
232
+ model: config.agentModels[agent.name] ?? sessionRef ?? agent.model,
233
+ }));
234
+
235
+ const hasTasks = (params.tasks?.length ?? 0) > 0;
236
+ const hasSingle = Boolean(params.agent && params.task);
237
+
238
+ const makeDetails =
239
+ (mode: "single" | "parallel", background = false) =>
240
+ (results: SingleResult[]): SubagentDetails => ({ mode, results, background });
241
+
242
+ const catalog = agents.map((a) => a.name).join(", ") || "none";
243
+
244
+ if (Number(hasTasks) + Number(hasSingle) !== 1) {
245
+ return {
246
+ content: [
247
+ {
248
+ type: "text",
249
+ text: `Invalid parameters. Provide exactly one mode: single {agent, task} or parallel {tasks: [...]}. Enabled agents: ${catalog}.`,
250
+ },
251
+ ],
252
+ details: makeDetails("single")([]),
253
+ };
254
+ }
255
+
256
+ const startBackground = (agentName: string, task: string, cwd?: string): SingleResult => {
257
+ const agent = agents.find((candidate) => candidate.name === agentName);
258
+ if (!agent) return failedStartResult(agentName, task, `Unknown agent: "${agentName}".`);
259
+
260
+ const pending = queuedResult(agent, task);
261
+ const runId = monitor.addRun(agent.name, agent.model);
262
+ const onLive = makeLiveHandler(runId);
263
+
264
+ backgroundQueue.enqueue(
265
+ async (backgroundSignal) => {
266
+ let result: SingleResult;
267
+ try {
268
+ result = await runSingleAgent({
269
+ defaultCwd: ctx.cwd,
270
+ agent,
271
+ agentName,
272
+ task,
273
+ cwd,
274
+ thinkingLevel: config.thinkingLevel,
275
+ signal: backgroundSignal,
276
+ onLive,
277
+ makeDetails: makeDetails("single", true),
278
+ });
279
+ } catch (error) {
280
+ const errorMessage = error instanceof Error ? error.message : String(error);
281
+ result = {
282
+ ...pending,
283
+ exitCode: 1,
284
+ stderr: errorMessage,
285
+ stopReason: backgroundSignal.aborted ? "aborted" : "error",
286
+ errorMessage,
287
+ };
288
+ finishRun(runId, "failed");
289
+ }
290
+
291
+ if (!sessionActive) return;
292
+ const status = isFailedResult(result) ? "failed" : "completed";
293
+ const usage = formatUsage(result.usage);
294
+ pi.sendMessage(
295
+ {
296
+ customType: "subagent-result",
297
+ content: `### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}\n\n${getResultOutput(result)}`,
298
+ display: true,
299
+ },
300
+ { deliverAs: "nextTurn" },
301
+ );
302
+ },
303
+ () => finishRun(runId, "failed"),
304
+ );
305
+
306
+ return pending;
307
+ };
308
+
309
+ // Sub-agents intentionally detach from the foreground turn. This makes the
310
+ // editor available immediately; completed findings arrive before the next prompt.
311
+ if (params.tasks && params.tasks.length > 0) {
312
+ if (params.tasks.length > MAX_PARALLEL_TASKS) {
313
+ return {
314
+ content: [
315
+ { type: "text", text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.` },
316
+ ],
317
+ details: makeDetails("parallel", true)([]),
318
+ };
319
+ }
320
+
321
+ const results = params.tasks.map((task) => startBackground(task.agent, task.task, task.cwd));
322
+ const started = results.filter((result) => result.exitCode === -1).length;
323
+ const failures = results.filter((result) => result.exitCode !== -1);
324
+ return {
325
+ content: [
326
+ {
327
+ type: "text",
328
+ text:
329
+ started > 0
330
+ ? `Started ${started} background subagent${started === 1 ? "" : "s"}. Completed results will be added before a later user prompt.`
331
+ : failures.map((result) => getResultOutput(result)).join("\n"),
332
+ },
333
+ ],
334
+ details: makeDetails("parallel", true)(results),
335
+ isError: failures.length > 0,
336
+ terminate: true,
337
+ };
338
+ }
339
+
340
+ const result = startBackground(params.agent as string, params.task as string, params.cwd);
341
+ if (result.exitCode !== -1) {
342
+ return {
343
+ content: [{ type: "text", text: getResultOutput(result) }],
344
+ details: makeDetails("single")([result]),
345
+ isError: true,
346
+ };
347
+ }
348
+ return {
349
+ content: [{ type: "text", text: `Started ${result.agent} in the background. Its completed result will be added before a later user prompt.` }],
350
+ details: makeDetails("single", true)([result]),
351
+ terminate: true,
352
+ };
353
+
354
+ },
355
+
356
+ renderCall(args, theme) {
357
+ if (args.tasks && args.tasks.length > 0) {
358
+ let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
359
+ for (const t of args.tasks.slice(0, 4)) {
360
+ const preview = t.task.length > 48 ? `${t.task.slice(0, 48)}…` : t.task;
361
+ text += `\n ${theme.fg("accent", t.agent)} ${theme.fg("dim", preview)}`;
362
+ }
363
+ if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
364
+ return new Text(text, 0, 0);
365
+ }
366
+ const task: string = args.task ?? "";
367
+ const preview = task.length > 60 ? `${task.slice(0, 60)}…` : task;
368
+ return new Text(
369
+ `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")} ${theme.fg("dim", preview)}`,
370
+ 0,
371
+ 0,
372
+ );
373
+ },
374
+
375
+ renderResult(result, _options, theme) {
376
+ const details = result.details as SubagentDetails | undefined;
377
+ if (!details || details.results.length === 0) return new Text(theme.fg("dim", "(no output)"), 0, 0);
378
+
379
+ if (details.mode === "single") {
380
+ const r = details.results[0];
381
+ const pending = r.exitCode === -1;
382
+ const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
383
+ const usage = formatUsage(r.usage);
384
+ const model = r.model ?? "?";
385
+ const line = `${theme.fg("toolTitle", theme.bold("subagent "))}${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`;
386
+ return new Text(line, 0, 0);
387
+ }
388
+
389
+ // Parallel mode: header + one compact line per agent
390
+ const lines: string[] = [
391
+ `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${details.results.length})`)}`,
392
+ ];
393
+ for (const r of details.results) {
394
+ const pending = r.exitCode === -1;
395
+ const icon = statusIcon(pending ? "running" : isFailedResult(r) ? "failed" : "done", theme);
396
+ const usage = formatUsage(r.usage);
397
+ const model = r.model ?? "?";
398
+ lines.push(` ${icon} ${theme.fg("accent", r.agent)} ${theme.fg("dim", `· ${model}${pending ? " · background" : ""}${usage ? ` · ${usage}` : ""}`)}`);
399
+ }
400
+ return new Text(lines.join("\n"), 0, 0);
401
+ },
402
+ });
403
+
404
+ pi.registerCommand("subagents-setup", {
405
+ description: "Configure pi-subagents: enable agents, pick per-agent models, toggle proactive injection",
406
+ handler: async (_args, ctx) => {
407
+ await runSetup(ctx, configPath);
408
+ },
409
+ });
410
+
411
+ // Persistent widget above the editor showing live sub-agent status.
412
+ pi.on("session_start", (_e, ctx) => {
413
+ if (ctx.mode !== "tui") return;
414
+ ctx.ui.setWidget(
415
+ "pi-subagents",
416
+ (tui, theme) => {
417
+ const unsub = monitor.subscribe(() => tui.requestRender());
418
+ // Tick once a second so elapsed time stays live while runs are active.
419
+ const timer = setInterval(() => {
420
+ if (monitor.getRuns().some((r) => r.status === "queued" || r.status === "running")) {
421
+ tui.requestRender();
422
+ }
423
+ }, 1000);
424
+ return {
425
+ render(width: number): string[] {
426
+ const runs = monitor.getRuns();
427
+ if (runs.length === 0) return [];
428
+ const lines: string[] = [];
429
+ for (const r of runs) {
430
+ const icon = statusIcon(r.status, theme);
431
+ const label = theme.fg(statusColor(r.status), statusLabel(r.status));
432
+ lines.push(truncateToWidth(` ${icon} ${monitor.summarize(r)} · ${label}`, width, ""));
433
+ // Activity sits one indent level below the agent name.
434
+ if (r.activity) lines.push(truncateToWidth(theme.fg("dim", ` ${r.activity}`), width, ""));
435
+ }
436
+ return lines;
437
+ },
438
+ invalidate() {},
439
+ dispose() {
440
+ unsub();
441
+ clearInterval(timer);
442
+ },
443
+ };
444
+ },
445
+ { placement: "aboveEditor" },
446
+ );
447
+ });
448
+
449
+ // Proactive dispatch: inject the delegation directive into the parent system prompt.
450
+ pi.on("before_agent_start", async (event, ctx) => {
451
+ const config = await loadConfig(configPath);
452
+ if (!config.proactiveInjection) return undefined;
453
+ const { agents } = discoverAgents(ctx.cwd, {
454
+ scope: config.agentScope,
455
+ enabledNames: config.enabledAgents,
456
+ });
457
+ const directive = buildDelegationDirective(agents);
458
+ if (!directive) return undefined;
459
+ return { systemPrompt: `${event.systemPrompt}\n${directive}` };
460
+ });
461
+ }