Commit ac9fac499623
Changed files (9)
dots
pi
agent
agents
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.