main
1/**
2 * Subagent Tool - Delegate tasks to specialized agents
3 *
4 * Spawns a separate `pi` process for each subagent invocation,
5 * giving it an isolated context window.
6 *
7 * Supports three modes:
8 * - Single: { agent: "name", task: "..." }
9 * - Parallel: { tasks: [{ agent: "name", task: "..." }, ...] }
10 * - Chain: { chain: [{ agent: "name", task: "... {previous} ..." }, ...] }
11 *
12 * Uses JSON mode to capture structured output from subagents.
13 */
14
15import { spawn } from "node:child_process";
16import * as fs from "node:fs";
17import * as os from "node:os";
18import * as path from "node:path";
19import type { AgentToolResult } from "@earendil-works/pi-agent-core";
20import type { Message } from "@earendil-works/pi-ai";
21import { StringEnum } from "@earendil-works/pi-ai";
22import { type ExtensionAPI, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
23import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
24import { Type } from "@sinclair/typebox";
25import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
26import { resolveSubagentModel, type Rejection } from "./model-routing.ts";
27
28const MAX_PARALLEL_TASKS = 16;
29const MAX_CONCURRENCY = 8;
30const COLLAPSED_ITEM_COUNT = 10;
31
32// Default provider preference order for model resolution
33// Can be overridden via settings.json "subagentProviderPreference" or --subagent-providers flag
34// Empty array means: try all providers, preferring those with API keys
35const DEFAULT_PROVIDER_PREFERENCE: string[] = [];
36
37// Cache of available models, loaded once on extension startup
38interface ModelCacheEntry {
39 provider: string;
40 modelId: string;
41}
42
43type SubagentRoutingSettings = {
44 providerPreference: string[];
45 modelFallbacks: Record<string, string[]>;
46 providerModelMasks: Record<string, string[]>;
47};
48
49let modelCache: ModelCacheEntry[] | null = null;
50
51/**
52 * Load available models from pi --list-models into cache
53 * Called once on extension startup
54 */
55async function loadModelCache(pi: any): Promise<void> {
56 if (modelCache !== null) return; // Already loaded
57
58 try {
59 const result = await pi.exec("pi", ["--list-models"], { timeout: 10000 });
60 if (result.code === 0 && result.stdout) {
61 const entries: ModelCacheEntry[] = [];
62 const lines = result.stdout.split("\n");
63
64 // Skip header line, parse each model entry
65 for (let i = 1; i < lines.length; i++) {
66 const line = lines[i].trim();
67 if (!line) continue;
68
69 // Format: "provider model-id context max-out thinking images"
70 const parts = line.split(/\s+/);
71 if (parts.length >= 2) {
72 entries.push({
73 provider: parts[0],
74 modelId: parts[1],
75 });
76 }
77 }
78
79 modelCache = entries;
80 } else {
81 // Fallback to empty cache if pi --list-models fails
82 modelCache = [];
83 }
84 } catch (error) {
85 // If exec fails, use empty cache (will fall back to exact matching)
86 modelCache = [];
87 }
88}
89
90function formatTokens(count: number): string {
91 if (count < 1000) return count.toString();
92 if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
93 if (count < 1000000) return `${Math.round(count / 1000)}k`;
94 return `${(count / 1000000).toFixed(1)}M`;
95}
96
97function formatUsageStats(
98 usage: {
99 input: number;
100 output: number;
101 cacheRead: number;
102 cacheWrite: number;
103 cost: number;
104 contextTokens?: number;
105 turns?: number;
106 },
107 model?: string,
108): string {
109 const parts: string[] = [];
110 if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
111 if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
112 if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
113 if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
114 if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
115 if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
116 if (usage.contextTokens && usage.contextTokens > 0) {
117 parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
118 }
119 if (model) parts.push(model);
120 return parts.join(" ");
121}
122
123function formatToolCall(
124 toolName: string,
125 args: Record<string, unknown>,
126 themeFg: (color: any, text: string) => string,
127): string {
128 const shortenPath = (p: string) => {
129 const home = os.homedir();
130 return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
131 };
132
133 switch (toolName) {
134 case "bash": {
135 const command = (args.command as string) || "...";
136 const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
137 return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
138 }
139 case "read": {
140 const rawPath = (args.file_path || args.path || "...") as string;
141 const filePath = shortenPath(rawPath);
142 const offset = args.offset as number | undefined;
143 const limit = args.limit as number | undefined;
144 let text = themeFg("accent", filePath);
145 if (offset !== undefined || limit !== undefined) {
146 const startLine = offset ?? 1;
147 const endLine = limit !== undefined ? startLine + limit - 1 : "";
148 text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
149 }
150 return themeFg("muted", "read ") + text;
151 }
152 case "write": {
153 const rawPath = (args.file_path || args.path || "...") as string;
154 const filePath = shortenPath(rawPath);
155 const content = (args.content || "") as string;
156 const lines = content.split("\n").length;
157 let text = themeFg("muted", "write ") + themeFg("accent", filePath);
158 if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
159 return text;
160 }
161 case "edit": {
162 const rawPath = (args.file_path || args.path || "...") as string;
163 return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
164 }
165 case "ls": {
166 const rawPath = (args.path || ".") as string;
167 return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
168 }
169 case "find": {
170 const pattern = (args.pattern || "*") as string;
171 const rawPath = (args.path || ".") as string;
172 return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`);
173 }
174 case "grep": {
175 const pattern = (args.pattern || "") as string;
176 const rawPath = (args.path || ".") as string;
177 return (
178 themeFg("muted", "grep ") +
179 themeFg("accent", `/${pattern}/`) +
180 themeFg("dim", ` in ${shortenPath(rawPath)}`)
181 );
182 }
183 default: {
184 const argsStr = JSON.stringify(args);
185 const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
186 return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
187 }
188 }
189}
190
191interface UsageStats {
192 input: number;
193 output: number;
194 cacheRead: number;
195 cacheWrite: number;
196 cost: number;
197 contextTokens: number;
198 turns: number;
199}
200
201interface SingleResult {
202 agent: string;
203 agentSource: "user" | "project" | "unknown";
204 task: string;
205 exitCode: number;
206 messages: Message[];
207 stderr: string;
208 usage: UsageStats;
209 model?: string;
210 stopReason?: string;
211 errorMessage?: string;
212 step?: number;
213}
214
215interface SubagentDetails {
216 mode: "single" | "parallel" | "chain";
217 agentScope: AgentScope;
218 projectAgentsDir: string | null;
219 results: SingleResult[];
220}
221
222function getFinalOutput(messages: Message[]): string {
223 for (let i = messages.length - 1; i >= 0; i--) {
224 const msg = messages[i];
225 if (msg.role === "assistant") {
226 for (const part of msg.content) {
227 if (part.type === "text") return part.text;
228 }
229 }
230 }
231 return "";
232}
233
234type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
235
236function getDisplayItems(messages: Message[]): DisplayItem[] {
237 const items: DisplayItem[] = [];
238 for (const msg of messages) {
239 if (msg.role === "assistant") {
240 for (const part of msg.content) {
241 if (part.type === "text") items.push({ type: "text", text: part.text });
242 else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments });
243 }
244 }
245 }
246 return items;
247}
248
249async function mapWithConcurrencyLimit<TIn, TOut>(
250 items: TIn[],
251 concurrency: number,
252 fn: (item: TIn, index: number) => Promise<TOut>,
253): Promise<TOut[]> {
254 if (items.length === 0) return [];
255 const limit = Math.max(1, Math.min(concurrency, items.length));
256 const results: TOut[] = new Array(items.length);
257 let nextIndex = 0;
258 const workers = new Array(limit).fill(null).map(async () => {
259 while (true) {
260 const current = nextIndex++;
261 if (current >= items.length) return;
262 results[current] = await fn(items[current], current);
263 }
264 });
265 await Promise.all(workers);
266 return results;
267}
268
269function stringArray(value: unknown): string[] {
270 return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
271}
272
273function stringArrayMap(value: unknown): Record<string, string[]> {
274 if (!value || typeof value !== "object" || Array.isArray(value)) return {};
275 return Object.fromEntries(Object.entries(value).map(([name, models]) => [name, stringArray(models)]));
276}
277
278function loadRoutingSettings(flagValue: string | undefined): SubagentRoutingSettings {
279 const defaults: SubagentRoutingSettings = {
280 providerPreference: flagValue?.split(",").map((item) => item.trim()).filter(Boolean) ?? DEFAULT_PROVIDER_PREFERENCE,
281 modelFallbacks: {},
282 providerModelMasks: {},
283 };
284
285 try {
286 const settings = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".pi", "agent", "settings.json"), "utf-8"));
287 return {
288 providerPreference: flagValue ? defaults.providerPreference : stringArray(settings.subagentProviderPreference),
289 modelFallbacks: stringArrayMap(settings.subagentModelFallbacks),
290 providerModelMasks: stringArrayMap(settings.subagentProviderModelMasks),
291 };
292 } catch {
293 return defaults;
294 }
295}
296
297function formatRoutingFailure(requestedModel: string, rejected: Rejection[]): string {
298 const lines = [`No usable subagent model for ${requestedModel}.`];
299 for (const entry of rejected) lines.push(`- ${entry.provider}/${entry.modelId}: ${entry.reason}`);
300 return lines.join("\n");
301}
302
303async function credentialReadyModels(modelRegistry: any): Promise<Set<string>> {
304 const ready = new Set<string>();
305 for (const entry of modelCache ?? []) {
306 const model = modelRegistry.find(entry.provider, entry.modelId);
307 if (!model) continue;
308 const auth = await modelRegistry.getApiKeyAndHeaders(model);
309 if (auth.ok && auth.apiKey) ready.add(`${entry.provider}\u0000${entry.modelId}`);
310 }
311 return ready;
312}
313
314function writePromptToTempFile(agentName: string, prompt: string): { dir: string; filePath: string } {
315 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
316 const safeName = agentName.replace(/[^\w.-]+/g, "_");
317 const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
318 fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
319 return { dir: tmpDir, filePath };
320}
321
322type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
323
324async function runSingleAgent(
325 defaultCwd: string,
326 agents: AgentConfig[],
327 agentName: string,
328 task: string,
329 cwd: string | undefined,
330 step: number | undefined,
331 signal: AbortSignal | undefined,
332 onUpdate: OnUpdateCallback | undefined,
333 makeDetails: (results: SingleResult[]) => SubagentDetails,
334 modelRegistry: any,
335 parentProvider: string | undefined,
336 routing: SubagentRoutingSettings,
337): Promise<SingleResult> {
338 const agent = agents.find((a) => a.name === agentName);
339
340 if (!agent) {
341 return {
342 agent: agentName,
343 agentSource: "unknown",
344 task,
345 exitCode: 1,
346 messages: [],
347 stderr: `Unknown agent: ${agentName}`,
348 usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
349 step,
350 };
351 }
352
353 const args: string[] = ["--mode", "json", "-p", "--no-session"];
354
355 if (agent.model) {
356 const resolution = resolveSubagentModel({
357 parentProvider,
358 pinnedProvider: agent.provider,
359 providerPreference: routing.providerPreference,
360 requestedModel: agent.model,
361 modelFallbacks: routing.modelFallbacks,
362 providerModelMasks: routing.providerModelMasks,
363 catalog: modelCache ?? [],
364 ready: await credentialReadyModels(modelRegistry),
365 });
366 if (!resolution.selected) {
367 return {
368 agent: agentName,
369 agentSource: agent.source,
370 task,
371 exitCode: 1,
372 messages: [],
373 stderr: formatRoutingFailure(agent.model, resolution.rejected),
374 usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
375 step,
376 };
377 }
378 args.push("--provider", resolution.selected.provider);
379 args.push("--model", resolution.selected.modelId);
380 }
381
382 if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
383
384 let tmpPromptDir: string | null = null;
385 let tmpPromptPath: string | null = null;
386
387 const currentResult: SingleResult = {
388 agent: agentName,
389 agentSource: agent.source,
390 task,
391 exitCode: 0,
392 messages: [],
393 stderr: "",
394 usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
395 model: agent.model,
396 step,
397 };
398
399 const emitUpdate = () => {
400 if (onUpdate) {
401 onUpdate({
402 content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
403 details: makeDetails([currentResult]),
404 });
405 }
406 };
407
408 try {
409 if (agent.systemPrompt.trim()) {
410 const tmp = writePromptToTempFile(agent.name, agent.systemPrompt);
411 tmpPromptDir = tmp.dir;
412 tmpPromptPath = tmp.filePath;
413 args.push("--append-system-prompt", tmpPromptPath);
414 }
415
416 args.push(`Task: ${task}`);
417 let wasAborted = false;
418
419 const exitCode = await new Promise<number>((resolve) => {
420 const workingDir = cwd ?? defaultCwd;
421 if (!workingDir) {
422 throw new Error(`Working directory is undefined. cwd=${cwd}, defaultCwd=${defaultCwd}`);
423 }
424 const proc = spawn("pi", args, { cwd: workingDir, shell: false, stdio: ["ignore", "pipe", "pipe"] });
425 let buffer = "";
426
427 const processLine = (line: string) => {
428 if (!line.trim()) return;
429 let event: any;
430 try {
431 event = JSON.parse(line);
432 } catch {
433 return;
434 }
435
436 if (event.type === "message_end" && event.message) {
437 const msg = event.message as Message;
438 currentResult.messages.push(msg);
439
440 if (msg.role === "assistant") {
441 currentResult.usage.turns++;
442 const usage = msg.usage;
443 if (usage) {
444 currentResult.usage.input += usage.input || 0;
445 currentResult.usage.output += usage.output || 0;
446 currentResult.usage.cacheRead += usage.cacheRead || 0;
447 currentResult.usage.cacheWrite += usage.cacheWrite || 0;
448 currentResult.usage.cost += usage.cost?.total || 0;
449 currentResult.usage.contextTokens = usage.totalTokens || 0;
450 }
451 if (!currentResult.model && msg.model) currentResult.model = msg.model;
452 if (msg.stopReason) currentResult.stopReason = msg.stopReason;
453 if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
454 }
455 emitUpdate();
456 }
457
458 if (event.type === "tool_result_end" && event.message) {
459 currentResult.messages.push(event.message as Message);
460 emitUpdate();
461 }
462 };
463
464 proc.stdout.on("data", (data) => {
465 buffer += data.toString();
466 const lines = buffer.split("\n");
467 buffer = lines.pop() || "";
468 for (const line of lines) processLine(line);
469 });
470
471 proc.stderr.on("data", (data) => {
472 currentResult.stderr += data.toString();
473 });
474
475 proc.on("close", (code) => {
476 if (buffer.trim()) processLine(buffer);
477 resolve(code ?? 0);
478 });
479
480 proc.on("error", () => {
481 resolve(1);
482 });
483
484 if (signal) {
485 const killProc = () => {
486 wasAborted = true;
487 proc.kill("SIGTERM");
488 setTimeout(() => {
489 if (!proc.killed) proc.kill("SIGKILL");
490 }, 5000);
491 };
492 if (signal.aborted) killProc();
493 else signal.addEventListener("abort", killProc, { once: true });
494 }
495 });
496
497 currentResult.exitCode = exitCode;
498 if (wasAborted) throw new Error("Subagent was aborted");
499 return currentResult;
500 } finally {
501 if (tmpPromptPath)
502 try {
503 fs.unlinkSync(tmpPromptPath);
504 } catch {
505 /* ignore */
506 }
507 if (tmpPromptDir)
508 try {
509 fs.rmdirSync(tmpPromptDir);
510 } catch {
511 /* ignore */
512 }
513 }
514}
515
516const TaskItem = Type.Object({
517 agent: Type.String({ description: "Name of the agent to invoke" }),
518 task: Type.String({ description: "Task to delegate to the agent" }),
519 cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
520});
521
522const ChainItem = Type.Object({
523 agent: Type.String({ description: "Name of the agent to invoke" }),
524 task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
525 cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
526});
527
528const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
529 description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
530 default: "user",
531});
532
533const SubagentParams = Type.Object({
534 agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })),
535 task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })),
536 tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
537 chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })),
538 agentScope: Type.Optional(AgentScopeSchema),
539 confirmProjectAgents: Type.Optional(
540 Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }),
541 ),
542 cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
543});
544
545export default function (pi: ExtensionAPI) {
546 // Load model cache on session start
547 pi.on("session_start", async (_event, _ctx) => {
548 await loadModelCache(pi);
549 });
550
551 pi.registerTool({
552 name: "subagent",
553 label: "Subagent",
554 description: [
555 "Delegate tasks to specialized subagents with isolated context.",
556 "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
557 'Default agent scope is "user" (from ~/.pi/agent/agents).',
558 'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").',
559 ].join(" "),
560 parameters: SubagentParams,
561
562 async execute(_toolCallId, params, signal, onUpdate, ctx) {
563 if (!ctx.cwd) {
564 return {
565 content: [{ type: "text", text: `Error: ctx.cwd is undefined. This should not happen.` }],
566 details: { mode: "single", agentScope: "user", projectAgentsDir: null, results: [] },
567 };
568 }
569 const agentScope: AgentScope = params.agentScope ?? "user";
570 const discovery = discoverAgents(ctx.cwd, agentScope);
571 const agents = discovery.agents;
572 const confirmProjectAgents = params.confirmProjectAgents ?? true;
573
574 const routing = loadRoutingSettings(pi.getFlag("--subagent-providers") as string | undefined);
575
576 const hasChain = (params.chain?.length ?? 0) > 0;
577 const hasTasks = (params.tasks?.length ?? 0) > 0;
578 const hasSingle = Boolean(params.agent && params.task);
579 const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
580
581 const makeDetails =
582 (mode: "single" | "parallel" | "chain") =>
583 (results: SingleResult[]): SubagentDetails => ({
584 mode,
585 agentScope,
586 projectAgentsDir: discovery.projectAgentsDir,
587 results,
588 });
589
590 if (modeCount !== 1) {
591 const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
592 return {
593 content: [
594 {
595 type: "text",
596 text: `Invalid parameters. Provide exactly one mode.\nAvailable agents: ${available}`,
597 },
598 ],
599 details: makeDetails("single")([]),
600 };
601 }
602
603 if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) {
604 const requestedAgentNames = new Set<string>();
605 if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
606 if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
607 if (params.agent) requestedAgentNames.add(params.agent);
608
609 const projectAgentsRequested = Array.from(requestedAgentNames)
610 .map((name) => agents.find((a) => a.name === name))
611 .filter((a): a is AgentConfig => a?.source === "project");
612
613 if (projectAgentsRequested.length > 0) {
614 const names = projectAgentsRequested.map((a) => a.name).join(", ");
615 const dir = discovery.projectAgentsDir ?? "(unknown)";
616 const ok = await ctx.ui.confirm(
617 "Run project-local agents?",
618 `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
619 );
620 if (!ok)
621 return {
622 content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
623 details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
624 };
625 }
626 }
627
628 if (params.chain && params.chain.length > 0) {
629 const results: SingleResult[] = [];
630 let previousOutput = "";
631
632 for (let i = 0; i < params.chain.length; i++) {
633 const step = params.chain[i];
634 const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
635
636 // Create update callback that includes all previous results
637 const chainUpdate: OnUpdateCallback | undefined = onUpdate
638 ? (partial) => {
639 // Combine completed results with current streaming result
640 const currentResult = partial.details?.results[0];
641 if (currentResult) {
642 const allResults = [...results, currentResult];
643 onUpdate({
644 content: partial.content,
645 details: makeDetails("chain")(allResults),
646 });
647 }
648 }
649 : undefined;
650
651 const result = await runSingleAgent(
652 ctx.cwd,
653 agents,
654 step.agent,
655 taskWithContext,
656 step.cwd,
657 i + 1,
658 signal,
659 chainUpdate,
660 makeDetails("chain"),
661 ctx.modelRegistry,
662 ctx.model?.provider,
663 routing,
664 );
665 results.push(result);
666
667 const isError =
668 result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
669 if (isError) {
670 const errorMsg =
671 result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
672 return {
673 content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
674 details: makeDetails("chain")(results),
675 isError: true,
676 };
677 }
678 previousOutput = getFinalOutput(result.messages);
679 }
680 return {
681 content: [{ type: "text", text: getFinalOutput(results[results.length - 1].messages) || "(no output)" }],
682 details: makeDetails("chain")(results),
683 };
684 }
685
686 if (params.tasks && params.tasks.length > 0) {
687 if (params.tasks.length > MAX_PARALLEL_TASKS)
688 return {
689 content: [
690 {
691 type: "text",
692 text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
693 },
694 ],
695 details: makeDetails("parallel")([]),
696 };
697
698 // Track all results for streaming updates
699 const allResults: SingleResult[] = new Array(params.tasks.length);
700
701 // Initialize placeholder results
702 for (let i = 0; i < params.tasks.length; i++) {
703 allResults[i] = {
704 agent: params.tasks[i].agent,
705 agentSource: "unknown",
706 task: params.tasks[i].task,
707 exitCode: -1, // -1 = still running
708 messages: [],
709 stderr: "",
710 usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
711 };
712 }
713
714 const emitParallelUpdate = () => {
715 if (onUpdate) {
716 const running = allResults.filter((r) => r.exitCode === -1).length;
717 const done = allResults.filter((r) => r.exitCode !== -1).length;
718 onUpdate({
719 content: [
720 { type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` },
721 ],
722 details: makeDetails("parallel")([...allResults]),
723 });
724 }
725 };
726
727 const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
728 const result = await runSingleAgent(
729 ctx.cwd,
730 agents,
731 t.agent,
732 t.task,
733 t.cwd,
734 undefined,
735 signal,
736 // Per-task update callback
737 (partial) => {
738 if (partial.details?.results[0]) {
739 allResults[index] = partial.details.results[0];
740 emitParallelUpdate();
741 }
742 },
743 makeDetails("parallel"),
744 ctx.modelRegistry,
745 ctx.model?.provider,
746 routing,
747 );
748 allResults[index] = result;
749 emitParallelUpdate();
750 return result;
751 });
752
753 const successCount = results.filter((r) => r.exitCode === 0).length;
754 const summaries = results.map((r) => {
755 const output = getFinalOutput(r.messages);
756 const preview = output.slice(0, 100) + (output.length > 100 ? "..." : "");
757 return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`;
758 });
759 return {
760 content: [
761 {
762 type: "text",
763 text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
764 },
765 ],
766 details: makeDetails("parallel")(results),
767 };
768 }
769
770 if (params.agent && params.task) {
771 const result = await runSingleAgent(
772 ctx.cwd,
773 agents,
774 params.agent,
775 params.task,
776 params.cwd,
777 undefined,
778 signal,
779 onUpdate,
780 makeDetails("single"),
781 ctx.modelRegistry,
782 ctx.model?.provider,
783 routing,
784 );
785 const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
786 if (isError) {
787 const errorMsg =
788 result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
789 return {
790 content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
791 details: makeDetails("single")([result]),
792 isError: true,
793 };
794 }
795 return {
796 content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
797 details: makeDetails("single")([result]),
798 };
799 }
800
801 const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
802 return {
803 content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
804 details: makeDetails("single")([]),
805 };
806 },
807
808 renderCall(args, theme) {
809 const scope: AgentScope = args.agentScope ?? "user";
810 if (args.chain && args.chain.length > 0) {
811 let text =
812 theme.fg("toolTitle", theme.bold("subagent ")) +
813 theme.fg("accent", `chain (${args.chain.length} steps)`) +
814 theme.fg("muted", ` [${scope}]`);
815 for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
816 const step = args.chain[i];
817 // Clean up {previous} placeholder for display
818 const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
819 const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
820 text +=
821 "\n " +
822 theme.fg("muted", `${i + 1}.`) +
823 " " +
824 theme.fg("accent", step.agent) +
825 theme.fg("dim", ` ${preview}`);
826 }
827 if (args.chain.length > 3) text += `\n ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`;
828 return new Text(text, 0, 0);
829 }
830 if (args.tasks && args.tasks.length > 0) {
831 let text =
832 theme.fg("toolTitle", theme.bold("subagent ")) +
833 theme.fg("accent", `parallel (${args.tasks.length} tasks)`) +
834 theme.fg("muted", ` [${scope}]`);
835 for (const t of args.tasks.slice(0, 3)) {
836 const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
837 text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", ` ${preview}`)}`;
838 }
839 if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`;
840 return new Text(text, 0, 0);
841 }
842 const agentName = args.agent || "...";
843 const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "...";
844 let text =
845 theme.fg("toolTitle", theme.bold("subagent ")) +
846 theme.fg("accent", agentName) +
847 theme.fg("muted", ` [${scope}]`);
848 text += `\n ${theme.fg("dim", preview)}`;
849 return new Text(text, 0, 0);
850 },
851
852 renderResult(result, { expanded }, theme) {
853 const details = result.details as SubagentDetails | undefined;
854 if (!details || details.results.length === 0) {
855 const text = result.content[0];
856 return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
857 }
858
859 const mdTheme = getMarkdownTheme();
860
861 const renderDisplayItems = (items: DisplayItem[], limit?: number) => {
862 const toShow = limit ? items.slice(-limit) : items;
863 const skipped = limit && items.length > limit ? items.length - limit : 0;
864 let text = "";
865 if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
866 for (const item of toShow) {
867 if (item.type === "text") {
868 const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
869 text += `${theme.fg("toolOutput", preview)}\n`;
870 } else {
871 text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
872 }
873 }
874 return text.trimEnd();
875 };
876
877 if (details.mode === "single" && details.results.length === 1) {
878 const r = details.results[0];
879 const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
880 const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
881 const displayItems = getDisplayItems(r.messages);
882 const finalOutput = getFinalOutput(r.messages);
883
884 if (expanded) {
885 const container = new Container();
886 let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
887 if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
888 container.addChild(new Text(header, 0, 0));
889 if (isError && r.errorMessage)
890 container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
891 container.addChild(new Spacer(1));
892 container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
893 container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
894 container.addChild(new Spacer(1));
895 container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
896 if (displayItems.length === 0 && !finalOutput) {
897 container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
898 } else {
899 for (const item of displayItems) {
900 if (item.type === "toolCall")
901 container.addChild(
902 new Text(
903 theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
904 0,
905 0,
906 ),
907 );
908 }
909 if (finalOutput) {
910 container.addChild(new Spacer(1));
911 container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
912 }
913 }
914 const usageStr = formatUsageStats(r.usage, r.model);
915 if (usageStr) {
916 container.addChild(new Spacer(1));
917 container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
918 }
919 return container;
920 }
921
922 let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
923 if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
924 if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
925 else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
926 else {
927 text += `\n${renderDisplayItems(displayItems, COLLAPSED_ITEM_COUNT)}`;
928 if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
929 }
930 const usageStr = formatUsageStats(r.usage, r.model);
931 if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
932 return new Text(text, 0, 0);
933 }
934
935 const aggregateUsage = (results: SingleResult[]) => {
936 const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
937 for (const r of results) {
938 total.input += r.usage.input;
939 total.output += r.usage.output;
940 total.cacheRead += r.usage.cacheRead;
941 total.cacheWrite += r.usage.cacheWrite;
942 total.cost += r.usage.cost;
943 total.turns += r.usage.turns;
944 }
945 return total;
946 };
947
948 if (details.mode === "chain") {
949 const successCount = details.results.filter((r) => r.exitCode === 0).length;
950 const icon = successCount === details.results.length ? theme.fg("success", "✓") : theme.fg("error", "✗");
951
952 if (expanded) {
953 const container = new Container();
954 container.addChild(
955 new Text(
956 icon +
957 " " +
958 theme.fg("toolTitle", theme.bold("chain ")) +
959 theme.fg("accent", `${successCount}/${details.results.length} steps`),
960 0,
961 0,
962 ),
963 );
964
965 for (const r of details.results) {
966 const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
967 const displayItems = getDisplayItems(r.messages);
968 const finalOutput = getFinalOutput(r.messages);
969
970 container.addChild(new Spacer(1));
971 container.addChild(
972 new Text(
973 `${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon}`,
974 0,
975 0,
976 ),
977 );
978 container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
979
980 // Show tool calls
981 for (const item of displayItems) {
982 if (item.type === "toolCall") {
983 container.addChild(
984 new Text(
985 theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
986 0,
987 0,
988 ),
989 );
990 }
991 }
992
993 // Show final output as markdown
994 if (finalOutput) {
995 container.addChild(new Spacer(1));
996 container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
997 }
998
999 const stepUsage = formatUsageStats(r.usage, r.model);
1000 if (stepUsage) container.addChild(new Text(theme.fg("dim", stepUsage), 0, 0));
1001 }
1002
1003 const usageStr = formatUsageStats(aggregateUsage(details.results));
1004 if (usageStr) {
1005 container.addChild(new Spacer(1));
1006 container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
1007 }
1008 return container;
1009 }
1010
1011 // Collapsed view
1012 let text =
1013 icon +
1014 " " +
1015 theme.fg("toolTitle", theme.bold("chain ")) +
1016 theme.fg("accent", `${successCount}/${details.results.length} steps`);
1017 for (const r of details.results) {
1018 const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
1019 const displayItems = getDisplayItems(r.messages);
1020 text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon}`;
1021 if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
1022 else text += `\n${renderDisplayItems(displayItems, 5)}`;
1023 }
1024 const usageStr = formatUsageStats(aggregateUsage(details.results));
1025 if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
1026 text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
1027 return new Text(text, 0, 0);
1028 }
1029
1030 if (details.mode === "parallel") {
1031 const running = details.results.filter((r) => r.exitCode === -1).length;
1032 const successCount = details.results.filter((r) => r.exitCode === 0).length;
1033 const failCount = details.results.filter((r) => r.exitCode > 0).length;
1034 const isRunning = running > 0;
1035 const icon = isRunning
1036 ? theme.fg("warning", "⏳")
1037 : failCount > 0
1038 ? theme.fg("warning", "◐")
1039 : theme.fg("success", "✓");
1040 const status = isRunning
1041 ? `${successCount + failCount}/${details.results.length} done, ${running} running`
1042 : `${successCount}/${details.results.length} tasks`;
1043
1044 if (expanded && !isRunning) {
1045 const container = new Container();
1046 container.addChild(
1047 new Text(
1048 `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`,
1049 0,
1050 0,
1051 ),
1052 );
1053
1054 for (const r of details.results) {
1055 const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
1056 const displayItems = getDisplayItems(r.messages);
1057 const finalOutput = getFinalOutput(r.messages);
1058
1059 container.addChild(new Spacer(1));
1060 container.addChild(
1061 new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0),
1062 );
1063 container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
1064
1065 // Show tool calls
1066 for (const item of displayItems) {
1067 if (item.type === "toolCall") {
1068 container.addChild(
1069 new Text(
1070 theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
1071 0,
1072 0,
1073 ),
1074 );
1075 }
1076 }
1077
1078 // Show final output as markdown
1079 if (finalOutput) {
1080 container.addChild(new Spacer(1));
1081 container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
1082 }
1083
1084 const taskUsage = formatUsageStats(r.usage, r.model);
1085 if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0));
1086 }
1087
1088 const usageStr = formatUsageStats(aggregateUsage(details.results));
1089 if (usageStr) {
1090 container.addChild(new Spacer(1));
1091 container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
1092 }
1093 return container;
1094 }
1095
1096 // Collapsed view (or still running)
1097 let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`;
1098 for (const r of details.results) {
1099 const rIcon =
1100 r.exitCode === -1
1101 ? theme.fg("warning", "⏳")
1102 : r.exitCode === 0
1103 ? theme.fg("success", "✓")
1104 : theme.fg("error", "✗");
1105 const displayItems = getDisplayItems(r.messages);
1106 text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`;
1107 if (displayItems.length === 0)
1108 text += `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`;
1109 else text += `\n${renderDisplayItems(displayItems, 5)}`;
1110 }
1111 if (!isRunning) {
1112 const usageStr = formatUsageStats(aggregateUsage(details.results));
1113 if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
1114 }
1115 if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
1116 return new Text(text, 0, 0);
1117 }
1118
1119 const text = result.content[0];
1120 return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
1121 },
1122 });
1123
1124 // Register flag for provider preference
1125 pi.registerFlag("subagent-providers", {
1126 description: "Comma-separated fallback providers for subagent model resolution after the active provider (e.g., 'anthropic-vertex,google,llama-cpp')",
1127 type: "string",
1128 });
1129
1130 // Helper command to show subagent configuration
1131 pi.registerCommand("subagent-config", {
1132 description: "Show subagent provider preference configuration",
1133 handler: async (_args, ctx) => {
1134 const flagValue = pi.getFlag("--subagent-providers") as string | undefined;
1135 const routing = loadRoutingSettings(flagValue);
1136 const source = flagValue ? "flag" : "settings.json";
1137
1138 // Build a formatted message
1139 const lines: string[] = [];
1140
1141 lines.push("Subagent Provider Configuration");
1142 lines.push("═".repeat(50));
1143 lines.push("");
1144
1145 // Model cache status
1146 lines.push("Model Cache:");
1147 if (modelCache === null) {
1148 lines.push(" Status: Not loaded (will load on first use)");
1149 } else if (modelCache.length === 0) {
1150 lines.push(" Status: Empty (pi --list-models failed)");
1151 } else {
1152 const providers = new Set(modelCache.map(m => m.provider));
1153 lines.push(` Status: Loaded (${modelCache.length} models across ${providers.size} providers)`);
1154 }
1155 lines.push("");
1156
1157 // Current setting
1158 lines.push("Provider Preference:");
1159 lines.push(` Source: ${source}`);
1160 lines.push(` Fallback order: ${routing.providerPreference.join(" → ") || "none"}`);
1161 lines.push(` Active provider: ${ctx.model?.provider ?? "none"} (tried first)`);
1162 lines.push("");
1163
1164 lines.push("Model Fallbacks:");
1165 for (const [model, fallbacks] of Object.entries(routing.modelFallbacks)) {
1166 lines.push(` ${model}: ${fallbacks.length ? fallbacks.join(" → ") : "none"}`);
1167 }
1168 lines.push("");
1169 lines.push("Provider/Model Masks:");
1170 for (const [provider, models] of Object.entries(routing.providerModelMasks)) {
1171 lines.push(` ${provider}: ${models.length ? models.join(", ") : "none"}`);
1172 }
1173 lines.push("");
1174
1175 lines.push("Configuration:");
1176 lines.push(" 1. Flag: pi --subagent-providers anthropic-vertex,google");
1177 lines.push(" 2. Settings: Add subagentProviderPreference, subagentModelFallbacks,");
1178 lines.push(" and subagentProviderModelMasks to ~/.pi/agent/settings.json.");
1179 lines.push("");
1180
1181 lines.push("Resolution Order:");
1182 lines.push(" 1. Try the active session provider first");
1183 lines.push(" 2. Try configured fallback providers in order");
1184 lines.push(" 3. Skip masked pairs and use only explicit same-tier fallbacks");
1185
1186 const output = lines.join("\n");
1187
1188 // Use sendMessage to inject as a system message (visible in conversation)
1189 pi.sendMessage({
1190 customType: "subagent-config",
1191 content: output,
1192 display: true,
1193 });
1194 },
1195 });
1196}