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