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}