Commit ac9fac499623

Vincent Demeester <vincent@sbr.pm>
2026-02-06 20:06:39
feat(pi): add subagent extension for delegating tasks to specialized agents
Added subagent extension for spawning isolated pi processes with specialized prompts and capabilities. Features: Three execution modes: - Single: One specialized agent for one task - Parallel: Multiple agents running concurrently (max 8, 4 at once) - Chain: Sequential workflow where output feeds into next agent Extension Components: - index.ts: Main extension with tool registration and streaming UI - agents.ts: Agent discovery from ~/.pi/agent/agents/ and .pi/agents/ Sample Agents (in dots/pi/agent/agents/): - scout.md: Fast recon with Haiku (read, grep, find, ls, bash) - Returns compressed context with file locations and key code - Output format designed for handoff to other agents - planner.md: Creates implementation plans with Sonnet (read-only) - Produces numbered steps, files to modify, risks - No modifications, analysis only - reviewer.md: Code review with Sonnet (read + bash for tests) - Reviews for correctness, security, performance - worker.md: General purpose with Sonnet (full capabilities) - Implements plans, makes actual changes Workflow Prompts (in dots/pi/agent/prompts/): - implement.md: scout → planner → worker (full workflow) - scout-and-plan.md: scout → planner (planning only) - implement-and-review.md: worker → reviewer → worker (with review) Usage Examples: Single agent: Use scout to find all authentication code Parallel execution: Run 3 scouts: find DB schemas, API endpoints, test files Chained workflow: Chain: scout finds auth, planner suggests OAuth, worker implements Workflow prompts: /implement add Redis caching to session store /scout-and-plan refactor auth module /implement-and-review add input validation Benefits: - Isolation: Each agent has own context window (no pollution) - Specialization: Different models/tools for different tasks - Efficiency: Fast agents (Haiku) for recon, powerful (Sonnet) for implementation - Cost control: Use cheaper models where appropriate - Streaming: Real-time progress updates - Parallel work: Multiple tasks simultaneously - Abort support: Ctrl+C propagates to kill subprocesses Agent Organization: User-level agents (always loaded): ~/.pi/agent/agents/*.md Project-level agents (opt-in, for trusted repos): .pi/agents/*.md Agent Definition Format: --- name: agent-name description: What this agent does tools: read, grep, find, ls model: claude-haiku-4-5 --- System prompt goes here... Security: - Default: Only loads user-level agents from ~/.pi/agent/agents/ - Project agents require agentScope: "both" or "project" - Interactive confirmation for project-local agents - Each agent runs in isolated subprocess Source: https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent/examples/extensions/subagent Future: Consider pi-shadow-git for orchestration logging and Mission Control
1 parent 93f3671
dots/pi/agent/agents/planner.md
@@ -0,0 +1,37 @@
+---
+name: planner
+description: Creates implementation plans from context and requirements
+tools: read, grep, find, ls
+model: claude-sonnet-4-5
+---
+
+You are a planning specialist. You receive context (from a scout) and requirements, then produce a clear implementation plan.
+
+You must NOT make any changes. Only read, analyze, and plan.
+
+Input format you'll receive:
+- Context/findings from a scout agent
+- Original query or requirements
+
+Output format:
+
+## Goal
+One sentence summary of what needs to be done.
+
+## Plan
+Numbered steps, each small and actionable:
+1. Step one - specific file/function to modify
+2. Step two - what to add/change
+3. ...
+
+## Files to Modify
+- `path/to/file.ts` - what changes
+- `path/to/other.ts` - what changes
+
+## New Files (if any)
+- `path/to/new.ts` - purpose
+
+## Risks
+Anything to watch out for.
+
+Keep the plan concrete. The worker agent will execute it verbatim.
dots/pi/agent/agents/reviewer.md
@@ -0,0 +1,35 @@
+---
+name: reviewer
+description: Code review specialist for quality and security analysis
+tools: read, grep, find, ls, bash
+model: claude-sonnet-4-5
+---
+
+You are a senior code reviewer. Analyze code for quality, security, and maintainability.
+
+Bash is for read-only commands only: `git diff`, `git log`, `git show`. Do NOT modify files or run builds.
+Assume tool permissions are not perfectly enforceable; keep all bash usage strictly read-only.
+
+Strategy:
+1. Run `git diff` to see recent changes (if applicable)
+2. Read the modified files
+3. Check for bugs, security issues, code smells
+
+Output format:
+
+## Files Reviewed
+- `path/to/file.ts` (lines X-Y)
+
+## Critical (must fix)
+- `file.ts:42` - Issue description
+
+## Warnings (should fix)
+- `file.ts:100` - Issue description
+
+## Suggestions (consider)
+- `file.ts:150` - Improvement idea
+
+## Summary
+Overall assessment in 2-3 sentences.
+
+Be specific with file paths and line numbers.
dots/pi/agent/agents/scout.md
@@ -0,0 +1,50 @@
+---
+name: scout
+description: Fast codebase recon that returns compressed context for handoff to other agents
+tools: read, grep, find, ls, bash
+model: claude-haiku-4-5
+---
+
+You are a scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything.
+
+Your output will be passed to an agent who has NOT seen the files you explored.
+
+Thoroughness (infer from task, default medium):
+- Quick: Targeted lookups, key files only
+- Medium: Follow imports, read critical sections
+- Thorough: Trace all dependencies, check tests/types
+
+Strategy:
+1. grep/find to locate relevant code
+2. Read key sections (not entire files)
+3. Identify types, interfaces, key functions
+4. Note dependencies between files
+
+Output format:
+
+## Files Retrieved
+List with exact line ranges:
+1. `path/to/file.ts` (lines 10-50) - Description of what's here
+2. `path/to/other.ts` (lines 100-150) - Description
+3. ...
+
+## Key Code
+Critical types, interfaces, or functions:
+
+```typescript
+interface Example {
+  // actual code from the files
+}
+```
+
+```typescript
+function keyFunction() {
+  // actual implementation
+}
+```
+
+## Architecture
+Brief explanation of how the pieces connect.
+
+## Start Here
+Which file to look at first and why.
dots/pi/agent/agents/worker.md
@@ -0,0 +1,24 @@
+---
+name: worker
+description: General-purpose subagent with full capabilities, isolated context
+model: claude-sonnet-4-5
+---
+
+You are a worker agent with full capabilities. You operate in an isolated context window to handle delegated tasks without polluting the main conversation.
+
+Work autonomously to complete the assigned task. Use all available tools as needed.
+
+Output format when finished:
+
+## Completed
+What was done.
+
+## Files Changed
+- `path/to/file.ts` - what changed
+
+## Notes (if any)
+Anything the main agent should know.
+
+If handing off to another agent (e.g. reviewer), include:
+- Exact file paths changed
+- Key functions/types touched (short list)
dots/pi/agent/extensions/subagent/agents.ts
@@ -0,0 +1,127 @@
+/**
+ * Agent discovery and configuration
+ */
+
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { parseFrontmatter } from "@mariozechner/pi-coding-agent";
+
+export type AgentScope = "user" | "project" | "both";
+
+export interface AgentConfig {
+	name: string;
+	description: string;
+	tools?: string[];
+	model?: string;
+	systemPrompt: string;
+	source: "user" | "project";
+	filePath: string;
+}
+
+export interface AgentDiscoveryResult {
+	agents: AgentConfig[];
+	projectAgentsDir: string | null;
+}
+
+function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
+	const agents: AgentConfig[] = [];
+
+	if (!fs.existsSync(dir)) {
+		return agents;
+	}
+
+	let entries: fs.Dirent[];
+	try {
+		entries = fs.readdirSync(dir, { withFileTypes: true });
+	} catch {
+		return agents;
+	}
+
+	for (const entry of entries) {
+		if (!entry.name.endsWith(".md")) continue;
+		if (!entry.isFile() && !entry.isSymbolicLink()) continue;
+
+		const filePath = path.join(dir, entry.name);
+		let content: string;
+		try {
+			content = fs.readFileSync(filePath, "utf-8");
+		} catch {
+			continue;
+		}
+
+		const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
+
+		if (!frontmatter.name || !frontmatter.description) {
+			continue;
+		}
+
+		const tools = frontmatter.tools
+			?.split(",")
+			.map((t: string) => t.trim())
+			.filter(Boolean);
+
+		agents.push({
+			name: frontmatter.name,
+			description: frontmatter.description,
+			tools: tools && tools.length > 0 ? tools : undefined,
+			model: frontmatter.model,
+			systemPrompt: body,
+			source,
+			filePath,
+		});
+	}
+
+	return agents;
+}
+
+function isDirectory(p: string): boolean {
+	try {
+		return fs.statSync(p).isDirectory();
+	} catch {
+		return false;
+	}
+}
+
+function findNearestProjectAgentsDir(cwd: string): string | null {
+	let currentDir = cwd;
+	while (true) {
+		const candidate = path.join(currentDir, ".pi", "agents");
+		if (isDirectory(candidate)) return candidate;
+
+		const parentDir = path.dirname(currentDir);
+		if (parentDir === currentDir) return null;
+		currentDir = parentDir;
+	}
+}
+
+export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
+	const userDir = path.join(os.homedir(), ".pi", "agent", "agents");
+	const projectAgentsDir = findNearestProjectAgentsDir(cwd);
+
+	const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
+	const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
+
+	const agentMap = new Map<string, AgentConfig>();
+
+	if (scope === "both") {
+		for (const agent of userAgents) agentMap.set(agent.name, agent);
+		for (const agent of projectAgents) agentMap.set(agent.name, agent);
+	} else if (scope === "user") {
+		for (const agent of userAgents) agentMap.set(agent.name, agent);
+	} else {
+		for (const agent of projectAgents) agentMap.set(agent.name, agent);
+	}
+
+	return { agents: Array.from(agentMap.values()), projectAgentsDir };
+}
+
+export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } {
+	if (agents.length === 0) return { text: "none", remaining: 0 };
+	const listed = agents.slice(0, maxItems);
+	const remaining = agents.length - listed.length;
+	return {
+		text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "),
+		remaining,
+	};
+}
dots/pi/agent/extensions/subagent/index.ts
@@ -0,0 +1,963 @@
+/**
+ * Subagent Tool - Delegate tasks to specialized agents
+ *
+ * Spawns a separate `pi` process for each subagent invocation,
+ * giving it an isolated context window.
+ *
+ * Supports three modes:
+ *   - Single: { agent: "name", task: "..." }
+ *   - Parallel: { tasks: [{ agent: "name", task: "..." }, ...] }
+ *   - Chain: { chain: [{ agent: "name", task: "... {previous} ..." }, ...] }
+ *
+ * Uses JSON mode to capture structured output from subagents.
+ */
+
+import { spawn } from "node:child_process";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import type { AgentToolResult } from "@mariozechner/pi-agent-core";
+import type { Message } from "@mariozechner/pi-ai";
+import { StringEnum } from "@mariozechner/pi-ai";
+import { type ExtensionAPI, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
+import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
+import { Type } from "@sinclair/typebox";
+import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
+
+const MAX_PARALLEL_TASKS = 8;
+const MAX_CONCURRENCY = 4;
+const COLLAPSED_ITEM_COUNT = 10;
+
+function formatTokens(count: number): string {
+	if (count < 1000) return count.toString();
+	if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
+	if (count < 1000000) return `${Math.round(count / 1000)}k`;
+	return `${(count / 1000000).toFixed(1)}M`;
+}
+
+function formatUsageStats(
+	usage: {
+		input: number;
+		output: number;
+		cacheRead: number;
+		cacheWrite: number;
+		cost: number;
+		contextTokens?: number;
+		turns?: number;
+	},
+	model?: string,
+): string {
+	const parts: string[] = [];
+	if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
+	if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
+	if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
+	if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
+	if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
+	if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
+	if (usage.contextTokens && usage.contextTokens > 0) {
+		parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
+	}
+	if (model) parts.push(model);
+	return parts.join(" ");
+}
+
+function formatToolCall(
+	toolName: string,
+	args: Record<string, unknown>,
+	themeFg: (color: any, text: string) => string,
+): string {
+	const shortenPath = (p: string) => {
+		const home = os.homedir();
+		return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
+	};
+
+	switch (toolName) {
+		case "bash": {
+			const command = (args.command as string) || "...";
+			const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
+			return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
+		}
+		case "read": {
+			const rawPath = (args.file_path || args.path || "...") as string;
+			const filePath = shortenPath(rawPath);
+			const offset = args.offset as number | undefined;
+			const limit = args.limit as number | undefined;
+			let text = themeFg("accent", filePath);
+			if (offset !== undefined || limit !== undefined) {
+				const startLine = offset ?? 1;
+				const endLine = limit !== undefined ? startLine + limit - 1 : "";
+				text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
+			}
+			return themeFg("muted", "read ") + text;
+		}
+		case "write": {
+			const rawPath = (args.file_path || args.path || "...") as string;
+			const filePath = shortenPath(rawPath);
+			const content = (args.content || "") as string;
+			const lines = content.split("\n").length;
+			let text = themeFg("muted", "write ") + themeFg("accent", filePath);
+			if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
+			return text;
+		}
+		case "edit": {
+			const rawPath = (args.file_path || args.path || "...") as string;
+			return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
+		}
+		case "ls": {
+			const rawPath = (args.path || ".") as string;
+			return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
+		}
+		case "find": {
+			const pattern = (args.pattern || "*") as string;
+			const rawPath = (args.path || ".") as string;
+			return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`);
+		}
+		case "grep": {
+			const pattern = (args.pattern || "") as string;
+			const rawPath = (args.path || ".") as string;
+			return (
+				themeFg("muted", "grep ") +
+				themeFg("accent", `/${pattern}/`) +
+				themeFg("dim", ` in ${shortenPath(rawPath)}`)
+			);
+		}
+		default: {
+			const argsStr = JSON.stringify(args);
+			const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
+			return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
+		}
+	}
+}
+
+interface UsageStats {
+	input: number;
+	output: number;
+	cacheRead: number;
+	cacheWrite: number;
+	cost: number;
+	contextTokens: number;
+	turns: number;
+}
+
+interface SingleResult {
+	agent: string;
+	agentSource: "user" | "project" | "unknown";
+	task: string;
+	exitCode: number;
+	messages: Message[];
+	stderr: string;
+	usage: UsageStats;
+	model?: string;
+	stopReason?: string;
+	errorMessage?: string;
+	step?: number;
+}
+
+interface SubagentDetails {
+	mode: "single" | "parallel" | "chain";
+	agentScope: AgentScope;
+	projectAgentsDir: string | null;
+	results: SingleResult[];
+}
+
+function getFinalOutput(messages: Message[]): string {
+	for (let i = messages.length - 1; i >= 0; i--) {
+		const msg = messages[i];
+		if (msg.role === "assistant") {
+			for (const part of msg.content) {
+				if (part.type === "text") return part.text;
+			}
+		}
+	}
+	return "";
+}
+
+type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
+
+function getDisplayItems(messages: Message[]): DisplayItem[] {
+	const items: DisplayItem[] = [];
+	for (const msg of messages) {
+		if (msg.role === "assistant") {
+			for (const part of msg.content) {
+				if (part.type === "text") items.push({ type: "text", text: part.text });
+				else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments });
+			}
+		}
+	}
+	return items;
+}
+
+async function mapWithConcurrencyLimit<TIn, TOut>(
+	items: TIn[],
+	concurrency: number,
+	fn: (item: TIn, index: number) => Promise<TOut>,
+): Promise<TOut[]> {
+	if (items.length === 0) return [];
+	const limit = Math.max(1, Math.min(concurrency, items.length));
+	const results: TOut[] = new Array(items.length);
+	let nextIndex = 0;
+	const workers = new Array(limit).fill(null).map(async () => {
+		while (true) {
+			const current = nextIndex++;
+			if (current >= items.length) return;
+			results[current] = await fn(items[current], current);
+		}
+	});
+	await Promise.all(workers);
+	return results;
+}
+
+function writePromptToTempFile(agentName: string, prompt: string): { dir: string; filePath: string } {
+	const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
+	const safeName = agentName.replace(/[^\w.-]+/g, "_");
+	const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
+	fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
+	return { dir: tmpDir, filePath };
+}
+
+type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
+
+async function runSingleAgent(
+	defaultCwd: string,
+	agents: AgentConfig[],
+	agentName: string,
+	task: string,
+	cwd: string | undefined,
+	step: number | undefined,
+	signal: AbortSignal | undefined,
+	onUpdate: OnUpdateCallback | undefined,
+	makeDetails: (results: SingleResult[]) => SubagentDetails,
+): Promise<SingleResult> {
+	const agent = agents.find((a) => a.name === agentName);
+
+	if (!agent) {
+		return {
+			agent: agentName,
+			agentSource: "unknown",
+			task,
+			exitCode: 1,
+			messages: [],
+			stderr: `Unknown agent: ${agentName}`,
+			usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
+			step,
+		};
+	}
+
+	const args: string[] = ["--mode", "json", "-p", "--no-session"];
+	if (agent.model) args.push("--model", agent.model);
+	if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
+
+	let tmpPromptDir: string | null = null;
+	let tmpPromptPath: string | null = null;
+
+	const currentResult: SingleResult = {
+		agent: agentName,
+		agentSource: agent.source,
+		task,
+		exitCode: 0,
+		messages: [],
+		stderr: "",
+		usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
+		model: agent.model,
+		step,
+	};
+
+	const emitUpdate = () => {
+		if (onUpdate) {
+			onUpdate({
+				content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
+				details: makeDetails([currentResult]),
+			});
+		}
+	};
+
+	try {
+		if (agent.systemPrompt.trim()) {
+			const tmp = writePromptToTempFile(agent.name, agent.systemPrompt);
+			tmpPromptDir = tmp.dir;
+			tmpPromptPath = tmp.filePath;
+			args.push("--append-system-prompt", tmpPromptPath);
+		}
+
+		args.push(`Task: ${task}`);
+		let wasAborted = false;
+
+		const exitCode = await new Promise<number>((resolve) => {
+			const proc = spawn("pi", args, { cwd: cwd ?? defaultCwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
+			let buffer = "";
+
+			const processLine = (line: string) => {
+				if (!line.trim()) return;
+				let event: any;
+				try {
+					event = JSON.parse(line);
+				} catch {
+					return;
+				}
+
+				if (event.type === "message_end" && event.message) {
+					const msg = event.message as Message;
+					currentResult.messages.push(msg);
+
+					if (msg.role === "assistant") {
+						currentResult.usage.turns++;
+						const usage = msg.usage;
+						if (usage) {
+							currentResult.usage.input += usage.input || 0;
+							currentResult.usage.output += usage.output || 0;
+							currentResult.usage.cacheRead += usage.cacheRead || 0;
+							currentResult.usage.cacheWrite += usage.cacheWrite || 0;
+							currentResult.usage.cost += usage.cost?.total || 0;
+							currentResult.usage.contextTokens = usage.totalTokens || 0;
+						}
+						if (!currentResult.model && msg.model) currentResult.model = msg.model;
+						if (msg.stopReason) currentResult.stopReason = msg.stopReason;
+						if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
+					}
+					emitUpdate();
+				}
+
+				if (event.type === "tool_result_end" && event.message) {
+					currentResult.messages.push(event.message as Message);
+					emitUpdate();
+				}
+			};
+
+			proc.stdout.on("data", (data) => {
+				buffer += data.toString();
+				const lines = buffer.split("\n");
+				buffer = lines.pop() || "";
+				for (const line of lines) processLine(line);
+			});
+
+			proc.stderr.on("data", (data) => {
+				currentResult.stderr += data.toString();
+			});
+
+			proc.on("close", (code) => {
+				if (buffer.trim()) processLine(buffer);
+				resolve(code ?? 0);
+			});
+
+			proc.on("error", () => {
+				resolve(1);
+			});
+
+			if (signal) {
+				const killProc = () => {
+					wasAborted = true;
+					proc.kill("SIGTERM");
+					setTimeout(() => {
+						if (!proc.killed) proc.kill("SIGKILL");
+					}, 5000);
+				};
+				if (signal.aborted) killProc();
+				else signal.addEventListener("abort", killProc, { once: true });
+			}
+		});
+
+		currentResult.exitCode = exitCode;
+		if (wasAborted) throw new Error("Subagent was aborted");
+		return currentResult;
+	} finally {
+		if (tmpPromptPath)
+			try {
+				fs.unlinkSync(tmpPromptPath);
+			} catch {
+				/* ignore */
+			}
+		if (tmpPromptDir)
+			try {
+				fs.rmdirSync(tmpPromptDir);
+			} catch {
+				/* ignore */
+			}
+	}
+}
+
+const TaskItem = Type.Object({
+	agent: Type.String({ description: "Name of the agent to invoke" }),
+	task: Type.String({ description: "Task to delegate to the agent" }),
+	cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
+});
+
+const ChainItem = Type.Object({
+	agent: Type.String({ description: "Name of the agent to invoke" }),
+	task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
+	cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
+});
+
+const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
+	description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
+	default: "user",
+});
+
+const SubagentParams = Type.Object({
+	agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })),
+	task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })),
+	tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
+	chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })),
+	agentScope: Type.Optional(AgentScopeSchema),
+	confirmProjectAgents: Type.Optional(
+		Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }),
+	),
+	cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
+});
+
+export default function (pi: ExtensionAPI) {
+	pi.registerTool({
+		name: "subagent",
+		label: "Subagent",
+		description: [
+			"Delegate tasks to specialized subagents with isolated context.",
+			"Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
+			'Default agent scope is "user" (from ~/.pi/agent/agents).',
+			'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").',
+		].join(" "),
+		parameters: SubagentParams,
+
+		async execute(_toolCallId, params, signal, onUpdate, ctx) {
+			const agentScope: AgentScope = params.agentScope ?? "user";
+			const discovery = discoverAgents(ctx.cwd, agentScope);
+			const agents = discovery.agents;
+			const confirmProjectAgents = params.confirmProjectAgents ?? true;
+
+			const hasChain = (params.chain?.length ?? 0) > 0;
+			const hasTasks = (params.tasks?.length ?? 0) > 0;
+			const hasSingle = Boolean(params.agent && params.task);
+			const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
+
+			const makeDetails =
+				(mode: "single" | "parallel" | "chain") =>
+				(results: SingleResult[]): SubagentDetails => ({
+					mode,
+					agentScope,
+					projectAgentsDir: discovery.projectAgentsDir,
+					results,
+				});
+
+			if (modeCount !== 1) {
+				const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
+				return {
+					content: [
+						{
+							type: "text",
+							text: `Invalid parameters. Provide exactly one mode.\nAvailable agents: ${available}`,
+						},
+					],
+					details: makeDetails("single")([]),
+				};
+			}
+
+			if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) {
+				const requestedAgentNames = new Set<string>();
+				if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
+				if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
+				if (params.agent) requestedAgentNames.add(params.agent);
+
+				const projectAgentsRequested = Array.from(requestedAgentNames)
+					.map((name) => agents.find((a) => a.name === name))
+					.filter((a): a is AgentConfig => a?.source === "project");
+
+				if (projectAgentsRequested.length > 0) {
+					const names = projectAgentsRequested.map((a) => a.name).join(", ");
+					const dir = discovery.projectAgentsDir ?? "(unknown)";
+					const ok = await ctx.ui.confirm(
+						"Run project-local agents?",
+						`Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
+					);
+					if (!ok)
+						return {
+							content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
+							details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
+						};
+				}
+			}
+
+			if (params.chain && params.chain.length > 0) {
+				const results: SingleResult[] = [];
+				let previousOutput = "";
+
+				for (let i = 0; i < params.chain.length; i++) {
+					const step = params.chain[i];
+					const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
+
+					// Create update callback that includes all previous results
+					const chainUpdate: OnUpdateCallback | undefined = onUpdate
+						? (partial) => {
+								// Combine completed results with current streaming result
+								const currentResult = partial.details?.results[0];
+								if (currentResult) {
+									const allResults = [...results, currentResult];
+									onUpdate({
+										content: partial.content,
+										details: makeDetails("chain")(allResults),
+									});
+								}
+							}
+						: undefined;
+
+					const result = await runSingleAgent(
+						ctx.cwd,
+						agents,
+						step.agent,
+						taskWithContext,
+						step.cwd,
+						i + 1,
+						signal,
+						chainUpdate,
+						makeDetails("chain"),
+					);
+					results.push(result);
+
+					const isError =
+						result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
+					if (isError) {
+						const errorMsg =
+							result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
+						return {
+							content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
+							details: makeDetails("chain")(results),
+							isError: true,
+						};
+					}
+					previousOutput = getFinalOutput(result.messages);
+				}
+				return {
+					content: [{ type: "text", text: getFinalOutput(results[results.length - 1].messages) || "(no output)" }],
+					details: makeDetails("chain")(results),
+				};
+			}
+
+			if (params.tasks && params.tasks.length > 0) {
+				if (params.tasks.length > MAX_PARALLEL_TASKS)
+					return {
+						content: [
+							{
+								type: "text",
+								text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
+							},
+						],
+						details: makeDetails("parallel")([]),
+					};
+
+				// Track all results for streaming updates
+				const allResults: SingleResult[] = new Array(params.tasks.length);
+
+				// Initialize placeholder results
+				for (let i = 0; i < params.tasks.length; i++) {
+					allResults[i] = {
+						agent: params.tasks[i].agent,
+						agentSource: "unknown",
+						task: params.tasks[i].task,
+						exitCode: -1, // -1 = still running
+						messages: [],
+						stderr: "",
+						usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
+					};
+				}
+
+				const emitParallelUpdate = () => {
+					if (onUpdate) {
+						const running = allResults.filter((r) => r.exitCode === -1).length;
+						const done = allResults.filter((r) => r.exitCode !== -1).length;
+						onUpdate({
+							content: [
+								{ type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` },
+							],
+							details: makeDetails("parallel")([...allResults]),
+						});
+					}
+				};
+
+				const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
+					const result = await runSingleAgent(
+						ctx.cwd,
+						agents,
+						t.agent,
+						t.task,
+						t.cwd,
+						undefined,
+						signal,
+						// Per-task update callback
+						(partial) => {
+							if (partial.details?.results[0]) {
+								allResults[index] = partial.details.results[0];
+								emitParallelUpdate();
+							}
+						},
+						makeDetails("parallel"),
+					);
+					allResults[index] = result;
+					emitParallelUpdate();
+					return result;
+				});
+
+				const successCount = results.filter((r) => r.exitCode === 0).length;
+				const summaries = results.map((r) => {
+					const output = getFinalOutput(r.messages);
+					const preview = output.slice(0, 100) + (output.length > 100 ? "..." : "");
+					return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`;
+				});
+				return {
+					content: [
+						{
+							type: "text",
+							text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
+						},
+					],
+					details: makeDetails("parallel")(results),
+				};
+			}
+
+			if (params.agent && params.task) {
+				const result = await runSingleAgent(
+					ctx.cwd,
+					agents,
+					params.agent,
+					params.task,
+					params.cwd,
+					undefined,
+					signal,
+					onUpdate,
+					makeDetails("single"),
+				);
+				const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
+				if (isError) {
+					const errorMsg =
+						result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
+					return {
+						content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
+						details: makeDetails("single")([result]),
+						isError: true,
+					};
+				}
+				return {
+					content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
+					details: makeDetails("single")([result]),
+				};
+			}
+
+			const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
+			return {
+				content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
+				details: makeDetails("single")([]),
+			};
+		},
+
+		renderCall(args, theme) {
+			const scope: AgentScope = args.agentScope ?? "user";
+			if (args.chain && args.chain.length > 0) {
+				let text =
+					theme.fg("toolTitle", theme.bold("subagent ")) +
+					theme.fg("accent", `chain (${args.chain.length} steps)`) +
+					theme.fg("muted", ` [${scope}]`);
+				for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
+					const step = args.chain[i];
+					// Clean up {previous} placeholder for display
+					const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
+					const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
+					text +=
+						"\n  " +
+						theme.fg("muted", `${i + 1}.`) +
+						" " +
+						theme.fg("accent", step.agent) +
+						theme.fg("dim", ` ${preview}`);
+				}
+				if (args.chain.length > 3) text += `\n  ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`;
+				return new Text(text, 0, 0);
+			}
+			if (args.tasks && args.tasks.length > 0) {
+				let text =
+					theme.fg("toolTitle", theme.bold("subagent ")) +
+					theme.fg("accent", `parallel (${args.tasks.length} tasks)`) +
+					theme.fg("muted", ` [${scope}]`);
+				for (const t of args.tasks.slice(0, 3)) {
+					const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
+					text += `\n  ${theme.fg("accent", t.agent)}${theme.fg("dim", ` ${preview}`)}`;
+				}
+				if (args.tasks.length > 3) text += `\n  ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`;
+				return new Text(text, 0, 0);
+			}
+			const agentName = args.agent || "...";
+			const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "...";
+			let text =
+				theme.fg("toolTitle", theme.bold("subagent ")) +
+				theme.fg("accent", agentName) +
+				theme.fg("muted", ` [${scope}]`);
+			text += `\n  ${theme.fg("dim", preview)}`;
+			return new Text(text, 0, 0);
+		},
+
+		renderResult(result, { expanded }, theme) {
+			const details = result.details as SubagentDetails | undefined;
+			if (!details || details.results.length === 0) {
+				const text = result.content[0];
+				return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
+			}
+
+			const mdTheme = getMarkdownTheme();
+
+			const renderDisplayItems = (items: DisplayItem[], limit?: number) => {
+				const toShow = limit ? items.slice(-limit) : items;
+				const skipped = limit && items.length > limit ? items.length - limit : 0;
+				let text = "";
+				if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
+				for (const item of toShow) {
+					if (item.type === "text") {
+						const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
+						text += `${theme.fg("toolOutput", preview)}\n`;
+					} else {
+						text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
+					}
+				}
+				return text.trimEnd();
+			};
+
+			if (details.mode === "single" && details.results.length === 1) {
+				const r = details.results[0];
+				const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
+				const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
+				const displayItems = getDisplayItems(r.messages);
+				const finalOutput = getFinalOutput(r.messages);
+
+				if (expanded) {
+					const container = new Container();
+					let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
+					if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
+					container.addChild(new Text(header, 0, 0));
+					if (isError && r.errorMessage)
+						container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
+					container.addChild(new Spacer(1));
+					container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
+					container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
+					container.addChild(new Spacer(1));
+					container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
+					if (displayItems.length === 0 && !finalOutput) {
+						container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
+					} else {
+						for (const item of displayItems) {
+							if (item.type === "toolCall")
+								container.addChild(
+									new Text(
+										theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
+										0,
+										0,
+									),
+								);
+						}
+						if (finalOutput) {
+							container.addChild(new Spacer(1));
+							container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
+						}
+					}
+					const usageStr = formatUsageStats(r.usage, r.model);
+					if (usageStr) {
+						container.addChild(new Spacer(1));
+						container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
+					}
+					return container;
+				}
+
+				let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
+				if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
+				if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
+				else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
+				else {
+					text += `\n${renderDisplayItems(displayItems, COLLAPSED_ITEM_COUNT)}`;
+					if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
+				}
+				const usageStr = formatUsageStats(r.usage, r.model);
+				if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
+				return new Text(text, 0, 0);
+			}
+
+			const aggregateUsage = (results: SingleResult[]) => {
+				const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
+				for (const r of results) {
+					total.input += r.usage.input;
+					total.output += r.usage.output;
+					total.cacheRead += r.usage.cacheRead;
+					total.cacheWrite += r.usage.cacheWrite;
+					total.cost += r.usage.cost;
+					total.turns += r.usage.turns;
+				}
+				return total;
+			};
+
+			if (details.mode === "chain") {
+				const successCount = details.results.filter((r) => r.exitCode === 0).length;
+				const icon = successCount === details.results.length ? theme.fg("success", "✓") : theme.fg("error", "✗");
+
+				if (expanded) {
+					const container = new Container();
+					container.addChild(
+						new Text(
+							icon +
+								" " +
+								theme.fg("toolTitle", theme.bold("chain ")) +
+								theme.fg("accent", `${successCount}/${details.results.length} steps`),
+							0,
+							0,
+						),
+					);
+
+					for (const r of details.results) {
+						const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
+						const displayItems = getDisplayItems(r.messages);
+						const finalOutput = getFinalOutput(r.messages);
+
+						container.addChild(new Spacer(1));
+						container.addChild(
+							new Text(
+								`${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon}`,
+								0,
+								0,
+							),
+						);
+						container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
+
+						// Show tool calls
+						for (const item of displayItems) {
+							if (item.type === "toolCall") {
+								container.addChild(
+									new Text(
+										theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
+										0,
+										0,
+									),
+								);
+							}
+						}
+
+						// Show final output as markdown
+						if (finalOutput) {
+							container.addChild(new Spacer(1));
+							container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
+						}
+
+						const stepUsage = formatUsageStats(r.usage, r.model);
+						if (stepUsage) container.addChild(new Text(theme.fg("dim", stepUsage), 0, 0));
+					}
+
+					const usageStr = formatUsageStats(aggregateUsage(details.results));
+					if (usageStr) {
+						container.addChild(new Spacer(1));
+						container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
+					}
+					return container;
+				}
+
+				// Collapsed view
+				let text =
+					icon +
+					" " +
+					theme.fg("toolTitle", theme.bold("chain ")) +
+					theme.fg("accent", `${successCount}/${details.results.length} steps`);
+				for (const r of details.results) {
+					const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
+					const displayItems = getDisplayItems(r.messages);
+					text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon}`;
+					if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
+					else text += `\n${renderDisplayItems(displayItems, 5)}`;
+				}
+				const usageStr = formatUsageStats(aggregateUsage(details.results));
+				if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
+				text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
+				return new Text(text, 0, 0);
+			}
+
+			if (details.mode === "parallel") {
+				const running = details.results.filter((r) => r.exitCode === -1).length;
+				const successCount = details.results.filter((r) => r.exitCode === 0).length;
+				const failCount = details.results.filter((r) => r.exitCode > 0).length;
+				const isRunning = running > 0;
+				const icon = isRunning
+					? theme.fg("warning", "⏳")
+					: failCount > 0
+						? theme.fg("warning", "◐")
+						: theme.fg("success", "✓");
+				const status = isRunning
+					? `${successCount + failCount}/${details.results.length} done, ${running} running`
+					: `${successCount}/${details.results.length} tasks`;
+
+				if (expanded && !isRunning) {
+					const container = new Container();
+					container.addChild(
+						new Text(
+							`${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`,
+							0,
+							0,
+						),
+					);
+
+					for (const r of details.results) {
+						const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
+						const displayItems = getDisplayItems(r.messages);
+						const finalOutput = getFinalOutput(r.messages);
+
+						container.addChild(new Spacer(1));
+						container.addChild(
+							new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0),
+						);
+						container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
+
+						// Show tool calls
+						for (const item of displayItems) {
+							if (item.type === "toolCall") {
+								container.addChild(
+									new Text(
+										theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
+										0,
+										0,
+									),
+								);
+							}
+						}
+
+						// Show final output as markdown
+						if (finalOutput) {
+							container.addChild(new Spacer(1));
+							container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
+						}
+
+						const taskUsage = formatUsageStats(r.usage, r.model);
+						if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0));
+					}
+
+					const usageStr = formatUsageStats(aggregateUsage(details.results));
+					if (usageStr) {
+						container.addChild(new Spacer(1));
+						container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
+					}
+					return container;
+				}
+
+				// Collapsed view (or still running)
+				let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`;
+				for (const r of details.results) {
+					const rIcon =
+						r.exitCode === -1
+							? theme.fg("warning", "⏳")
+							: r.exitCode === 0
+								? theme.fg("success", "✓")
+								: theme.fg("error", "✗");
+					const displayItems = getDisplayItems(r.messages);
+					text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`;
+					if (displayItems.length === 0)
+						text += `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`;
+					else text += `\n${renderDisplayItems(displayItems, 5)}`;
+				}
+				if (!isRunning) {
+					const usageStr = formatUsageStats(aggregateUsage(details.results));
+					if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
+				}
+				if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
+				return new Text(text, 0, 0);
+			}
+
+			const text = result.content[0];
+			return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
+		},
+	});
+}
dots/pi/agent/prompts/implement-and-review.md
@@ -0,0 +1,10 @@
+---
+description: Worker implements, reviewer reviews, worker applies feedback
+---
+Use the subagent tool with the chain parameter to execute this workflow:
+
+1. First, use the "worker" agent to implement: $@
+2. Then, use the "reviewer" agent to review the implementation from the previous step (use {previous} placeholder)
+3. Finally, use the "worker" agent to apply the feedback from the review (use {previous} placeholder)
+
+Execute this as a chain, passing output between steps via {previous}.
dots/pi/agent/prompts/implement.md
@@ -0,0 +1,10 @@
+---
+description: Full implementation workflow - scout gathers context, planner creates plan, worker implements
+---
+Use the subagent tool with the chain parameter to execute this workflow:
+
+1. First, use the "scout" agent to find all code relevant to: $@
+2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder)
+3. Finally, use the "worker" agent to implement the plan from the previous step (use {previous} placeholder)
+
+Execute this as a chain, passing output between steps via {previous}.
dots/pi/agent/prompts/scout-and-plan.md
@@ -0,0 +1,9 @@
+---
+description: Scout gathers context, planner creates implementation plan (no implementation)
+---
+Use the subagent tool with the chain parameter to execute this workflow:
+
+1. First, use the "scout" agent to find all code relevant to: $@
+2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder)
+
+Execute this as a chain, passing output between steps via {previous}. Do NOT implement - just return the plan.