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}