diff --git a/packages/coding-agent/examples/custom-tools/README.md b/packages/coding-agent/examples/custom-tools/README.md
index 522f193e..ca4fb49f 100644
--- a/packages/coding-agent/examples/custom-tools/README.md
+++ b/packages/coding-agent/examples/custom-tools/README.md
@@ -17,6 +17,14 @@ Full-featured example demonstrating:
- Proper branching support via details storage
- State management without external files
+### subagent/
+Delegate tasks to specialized subagents with isolated context windows. Includes:
+- `subagent.ts` - The custom tool (single, parallel, and chain modes)
+- `agents/` - Sample agent definitions (scout, planner, reviewer, worker)
+- `commands/` - Workflow presets (/implement, /scout-and-plan, /implement-and-review)
+
+See [subagent/README.md](subagent/README.md) for full documentation.
+
## Usage
```bash
diff --git a/packages/coding-agent/examples/custom-tools/subagent/README.md b/packages/coding-agent/examples/custom-tools/subagent/README.md
new file mode 100644
index 00000000..740aa752
--- /dev/null
+++ b/packages/coding-agent/examples/custom-tools/subagent/README.md
@@ -0,0 +1,245 @@
+# Subagent Example
+
+Delegate tasks to specialized subagents with isolated context windows.
+
+## Structure
+
+```
+subagent/
+├── README.md # This file
+├── subagent.ts # The custom tool
+├── agents/ # Sample agent definitions
+│ ├── scout.md # Fast recon, returns compressed context
+│ ├── planner.md # Creates implementation plans
+│ ├── reviewer.md # Code review
+│ └── worker.md # General-purpose (full capabilities)
+└── commands/ # Workflow presets
+ ├── implement.md # scout -> planner -> worker
+ ├── scout-and-plan.md # scout -> planner (no implementation)
+ └── implement-and-review.md # worker -> reviewer -> worker
+```
+
+## Installation
+
+From the `examples/custom-tools/subagent/` directory:
+
+```bash
+# Copy the tool
+mkdir -p ~/.pi/agent/tools
+cp subagent.ts ~/.pi/agent/tools/
+
+# Copy agents
+mkdir -p ~/.pi/agent/agents
+cp agents/*.md ~/.pi/agent/agents/
+
+# Copy workflow commands
+mkdir -p ~/.pi/agent/commands
+cp commands/*.md ~/.pi/agent/commands/
+```
+
+## Security Model
+
+This example intentionally executes a separate `pi` subprocess with a delegated system prompt and tool/model configuration.
+
+Treat **project-local agent definitions as repo-controlled prompts**:
+- A project can define agents in `.pi/agents/*.md`.
+- Those prompts can instruct the model to read files, run bash commands, etc. (depending on the allowed tools).
+
+**Default behavior:** the tool only loads **user-level agents** from `~/.pi/agent/agents`.
+
+To enable project-local agents, pass `agentScope: "both"` (or `"project"`) explicitly. Only do this for repositories you trust.
+
+When running interactively, the tool will prompt for confirmation before running project-local agents. Set `confirmProjectAgents: false` to disable the prompt.
+
+## Usage
+
+### Single agent
+```
+> Use the subagent tool with agent "scout" and task "find all authentication code"
+```
+
+### Parallel execution
+```
+> Use subagent with tasks:
+> - scout: "analyze the auth module"
+> - scout: "analyze the api module"
+> - scout: "analyze the database module"
+```
+
+### Chained workflow
+```
+> Use subagent chain:
+> 1. scout: "find code related to caching"
+> 2. planner: "plan Redis integration using: {previous}"
+> 3. worker: "implement: {previous}"
+```
+
+### Workflow commands
+```
+/implement add Redis caching to the session store
+/scout-and-plan refactor auth to support OAuth
+/implement-and-review add input validation to API endpoints
+```
+
+## Tool Modes
+
+| Mode | Parameter | Description |
+|------|-----------|-------------|
+| Single | `{ agent, task }` | One agent, one task |
+| Parallel | `{ tasks: [...] }` | Multiple agents run concurrently |
+| Chain | `{ chain: [...] }` | Sequential with `{previous}` placeholder |
+
+
+Flow Diagrams
+
+### Single Mode
+
+```
+┌─────────────────┐
+│ Main Agent │
+└────────┬────────┘
+ │ "use scout to find auth code"
+ ▼
+┌─────────────────┐
+│ subagent tool │
+└────────┬────────┘
+ │ pi -p --model haiku ...
+ ▼
+┌─────────────────┐
+│ Scout │
+│ (subprocess) │
+└────────┬────────┘
+ │ stdout
+ ▼
+┌─────────────────┐
+│ Tool Result │
+└─────────────────┘
+```
+
+### Parallel Mode
+
+```
+┌──────────────────────┐
+│ Main Agent │
+└──────────┬───────────┘
+ │
+ ▼
+┌──────────────────────┐
+│ subagent tool │
+│ Promise.all() │
+└──────────┬───────────┘
+ │
+ ┌─────┼─────┐
+ ▼ ▼ ▼
+┌──────┐┌──────┐┌──────┐
+│Scout ││Scout ││Scout │
+│ auth ││ api ││ db │
+└──┬───┘└──┬───┘└──┬───┘
+ │ │ │
+ └───────┼───────┘
+ ▼
+┌──────────────────────┐
+│ Combined Result │
+└──────────────────────┘
+```
+
+### Chain Mode
+
+```
+┌─────────────────┐
+│ Main Agent │
+└────────┬────────┘
+ │
+ ▼
+┌─────────────────┐
+│ subagent tool │
+└────────┬────────┘
+ │
+ ▼
+┌─────────────────┐
+│ Step 1: Scout │
+└────────┬────────┘
+ │ {previous} = scout output
+ ▼
+┌─────────────────┐
+│ Step 2: Planner │
+└────────┬────────┘
+ │ {previous} = planner output
+ ▼
+┌─────────────────┐
+│ Step 3: Worker │
+└────────┬────────┘
+ │
+ ▼
+┌─────────────────┐
+│ Chain Result │
+└─────────────────┘
+```
+
+### Workflow Command Expansion
+
+```
+/implement add Redis
+ │
+ ▼
+┌─────────────────────────────────────────┐
+│ Expands to chain: │
+│ 1. scout: "find code for add Redis" │
+│ 2. planner: "plan using {previous}" │
+│ 3. worker: "implement {previous}" │
+└─────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────┐
+│ Chain Execution │
+│ │
+│ scout ──► planner ──► worker │
+│ (haiku) (sonnet) (sonnet) │
+└─────────────────────────────────────────┘
+```
+
+
+
+## Agent Definitions
+
+Agents are markdown files with YAML frontmatter:
+
+```markdown
+---
+name: my-agent
+description: What this agent does
+tools: read, grep, find, ls
+model: claude-haiku-4-5
+---
+
+System prompt for the agent goes here.
+```
+
+**Locations:**
+- `~/.pi/agent/agents/*.md` - User-level (global)
+- `.pi/agents/*.md` - Project-level (only loaded if `agentScope` includes `"project"`)
+
+## Sample Agents
+
+| Agent | Purpose | Model |
+|-------|---------|-------|
+| `scout` | Fast codebase recon, returns compressed context | Haiku |
+| `planner` | Creates implementation plans from context | Sonnet |
+| `reviewer` | Code review for quality/security | Sonnet |
+| `worker` | General-purpose with full capabilities | Sonnet |
+
+## Workflow Commands
+
+Commands are prompt templates that invoke the subagent tool:
+
+| Command | Flow |
+|---------|------|
+| `/implement ` | scout -> planner -> worker |
+| `/scout-and-plan ` | scout -> planner |
+| `/implement-and-review ` | worker -> reviewer -> worker |
+
+## Limitations
+
+- No timeout/cancellation (subprocess limitation)
+- Output truncated to 500 lines / 50KB per agent
+- Agents discovered fresh on each invocation
diff --git a/packages/coding-agent/examples/custom-tools/subagent/agents/planner.md b/packages/coding-agent/examples/custom-tools/subagent/agents/planner.md
new file mode 100644
index 00000000..7acc7187
--- /dev/null
+++ b/packages/coding-agent/examples/custom-tools/subagent/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.
diff --git a/packages/coding-agent/examples/custom-tools/subagent/agents/reviewer.md b/packages/coding-agent/examples/custom-tools/subagent/agents/reviewer.md
new file mode 100644
index 00000000..a6706993
--- /dev/null
+++ b/packages/coding-agent/examples/custom-tools/subagent/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.
diff --git a/packages/coding-agent/examples/custom-tools/subagent/agents/scout.md b/packages/coding-agent/examples/custom-tools/subagent/agents/scout.md
new file mode 100644
index 00000000..a67544c1
--- /dev/null
+++ b/packages/coding-agent/examples/custom-tools/subagent/agents/scout.md
@@ -0,0 +1,53 @@
+---
+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:
+
+## Query
+One line summary of what was searched.
+
+## 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.
diff --git a/packages/coding-agent/examples/custom-tools/subagent/agents/worker.md b/packages/coding-agent/examples/custom-tools/subagent/agents/worker.md
new file mode 100644
index 00000000..d9688355
--- /dev/null
+++ b/packages/coding-agent/examples/custom-tools/subagent/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)
diff --git a/packages/coding-agent/examples/custom-tools/subagent/commands/implement-and-review.md b/packages/coding-agent/examples/custom-tools/subagent/commands/implement-and-review.md
new file mode 100644
index 00000000..6493b3d6
--- /dev/null
+++ b/packages/coding-agent/examples/custom-tools/subagent/commands/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}.
diff --git a/packages/coding-agent/examples/custom-tools/subagent/commands/implement.md b/packages/coding-agent/examples/custom-tools/subagent/commands/implement.md
new file mode 100644
index 00000000..559da4d6
--- /dev/null
+++ b/packages/coding-agent/examples/custom-tools/subagent/commands/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}.
diff --git a/packages/coding-agent/examples/custom-tools/subagent/commands/scout-and-plan.md b/packages/coding-agent/examples/custom-tools/subagent/commands/scout-and-plan.md
new file mode 100644
index 00000000..093b6339
--- /dev/null
+++ b/packages/coding-agent/examples/custom-tools/subagent/commands/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.
diff --git a/packages/coding-agent/examples/custom-tools/subagent/subagent.ts b/packages/coding-agent/examples/custom-tools/subagent/subagent.ts
new file mode 100644
index 00000000..cd29e798
--- /dev/null
+++ b/packages/coding-agent/examples/custom-tools/subagent/subagent.ts
@@ -0,0 +1,771 @@
+/**
+ * Subagent Tool - Delegate tasks to specialized agents
+ *
+ * Discovers agent definitions from:
+ * - ~/.pi/agent/agents/*.md (user-level)
+ * - .pi/agents/*.md (project-level, opt-in via agentScope)
+ *
+ * Agent files use markdown with YAML frontmatter:
+ *
+ * ---
+ * name: scout
+ * description: Fast codebase recon
+ * tools: read, grep, find, ls, bash
+ * model: claude-haiku-4-5
+ * ---
+ *
+ * You are a scout. Quickly investigate and return findings.
+ *
+ * The tool spawns a separate `pi` process for each subagent invocation,
+ * giving it an isolated context window. Project agents can be enabled explicitly,
+ * and will override user agents with the same name when agentScope="both".
+ *
+ * Supports three modes:
+ * - Single: { agent: "name", task: "..." }
+ * - Parallel: { tasks: [{ agent: "name", task: "..." }, ...] }
+ * - Chain: { chain: [{ agent: "name", task: "... {previous} ..." }, ...] }
+ *
+ * Chain mode runs steps sequentially. Use {previous} in task to reference
+ * the previous step's output.
+ *
+ * Limitations:
+ * - No timeout/cancellation (pi.exec limitation)
+ * - Output is truncated for UI/context size (pi.exec still buffers full output today)
+ * - Agents reloaded on each invocation (edit agents mid-session)
+ */
+
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import { Type } from "@sinclair/typebox";
+import { StringEnum } from "@mariozechner/pi-ai";
+import { Text } from "@mariozechner/pi-tui";
+import type { CustomAgentTool, CustomToolFactory, ToolAPI } from "@mariozechner/pi-coding-agent";
+
+const MAX_OUTPUT_LINES = 500;
+const MAX_OUTPUT_BYTES = 50_000;
+const MAX_PARALLEL_TASKS = 8;
+const MAX_CONCURRENCY = 4;
+const MAX_AGENTS_IN_DESCRIPTION = 10;
+
+type AgentScope = "user" | "project" | "both";
+
+interface AgentConfig {
+ name: string;
+ description: string;
+ tools?: string[];
+ model?: string;
+ systemPrompt: string;
+ source: "user" | "project";
+ filePath: string;
+}
+
+interface SingleResult {
+ agent: string;
+ agentSource: "user" | "project" | "unknown";
+ task: string;
+ exitCode: number;
+ stdout: string;
+ stderr: string;
+ truncated: boolean;
+ step?: number;
+}
+
+interface SubagentDetails {
+ mode: "single" | "parallel" | "chain";
+ agentScope: AgentScope;
+ projectAgentsDir: string | null;
+ results: SingleResult[];
+}
+
+function parseFrontmatter(content: string): { frontmatter: Record; body: string } {
+ const frontmatter: Record = {};
+ const normalized = content.replace(/\r\n/g, "\n");
+
+ if (!normalized.startsWith("---")) {
+ return { frontmatter, body: normalized };
+ }
+
+ const endIndex = normalized.indexOf("\n---", 3);
+ if (endIndex === -1) {
+ return { frontmatter, body: normalized };
+ }
+
+ const frontmatterBlock = normalized.slice(4, endIndex);
+ const body = normalized.slice(endIndex + 4).trim();
+
+ for (const line of frontmatterBlock.split("\n")) {
+ const match = line.match(/^([\w-]+):\s*(.*)$/);
+ if (match) {
+ let value = match[2].trim();
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+ value = value.slice(1, -1);
+ }
+ frontmatter[match[1]] = value;
+ }
+ }
+
+ return { frontmatter, body };
+}
+
+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.isFile() || !entry.name.endsWith(".md")) continue;
+
+ const filePath = path.join(dir, entry.name);
+ let content: string;
+ try {
+ content = fs.readFileSync(filePath, "utf-8");
+ } catch {
+ continue;
+ }
+
+ const { frontmatter, body } = parseFrontmatter(content);
+
+ if (!frontmatter.name || !frontmatter.description) {
+ continue;
+ }
+
+ const tools = frontmatter.tools
+ ?.split(",")
+ .map((t) => 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;
+ }
+}
+
+function discoverAgents(cwd: string, scope: AgentScope): { agents: AgentConfig[]; projectAgentsDir: string | null } {
+ 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();
+
+ if (scope === "both") {
+ // Explicit opt-in: project agents override user agents with the same name.
+ 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 };
+}
+
+function truncateOutput(output: string): { text: string; truncated: boolean } {
+ let truncated = false;
+ let byteBudget = MAX_OUTPUT_BYTES;
+ let lineBudget = MAX_OUTPUT_LINES;
+
+ // Note: This truncation is for UI/context size. The underlying pi.exec() currently buffers
+ // full stdout/stderr in memory before we see it here.
+
+ let i = 0;
+ let lastNewlineIndex = -1;
+ while (i < output.length && byteBudget > 0) {
+ const ch = output.charCodeAt(i);
+
+ // Approximate bytes by UTF-16 code units; MAX_OUTPUT_BYTES is a practical guardrail, not exact bytes.
+ byteBudget--;
+
+ if (ch === 10 /* \n */) {
+ lineBudget--;
+ lastNewlineIndex = i;
+ if (lineBudget <= 0) {
+ truncated = true;
+ break;
+ }
+ }
+
+ i++;
+ }
+
+ if (i < output.length) {
+ truncated = true;
+ }
+
+ // Prefer cutting at a newline boundary if we hit the line cap, to keep previews readable.
+ if (truncated && lineBudget <= 0 && lastNewlineIndex >= 0) {
+ output = output.slice(0, lastNewlineIndex);
+ } else {
+ output = output.slice(0, i);
+ }
+
+ return { text: output, truncated };
+}
+
+function previewFirstLines(text: string, maxLines: number): string {
+ if (maxLines <= 0) return "";
+ let linesRemaining = maxLines;
+ let i = 0;
+ while (i < text.length) {
+ const nextNewline = text.indexOf("\n", i);
+ if (nextNewline === -1) return text;
+ linesRemaining--;
+ if (linesRemaining <= 0) return text.slice(0, nextNewline);
+ i = nextNewline + 1;
+ }
+ return text;
+}
+
+function firstLine(text: string): string {
+ const idx = text.indexOf("\n");
+ return idx === -1 ? text : text.slice(0, idx);
+}
+
+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,
+ };
+}
+
+async function mapWithConcurrencyLimit(
+ items: TIn[],
+ concurrency: number,
+ fn: (item: TIn, index: number) => Promise
+): Promise {
+ 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 };
+}
+
+async function runSingleAgent(
+ pi: ToolAPI,
+ agents: AgentConfig[],
+ agentName: string,
+ task: string,
+ step?: number
+): Promise {
+ const agent = agents.find((a) => a.name === agentName);
+
+ if (!agent) {
+ return {
+ agent: agentName,
+ agentSource: "unknown",
+ task,
+ exitCode: 1,
+ stdout: "",
+ stderr: `Unknown agent: ${agentName}`,
+ truncated: false,
+ step,
+ };
+ }
+
+ const args: string[] = ["-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;
+ try {
+ if (agent.systemPrompt.trim()) {
+ // IMPORTANT: Never pass raw prompt text to --append-system-prompt.
+ // pi treats this flag as "path or literal", and will read the file contents if the string
+ // happens to match an existing path. Writing to a temp file prevents unintended file exfiltration.
+ const tmp = writePromptToTempFile(agent.name, agent.systemPrompt);
+ tmpPromptDir = tmp.dir;
+ tmpPromptPath = tmp.filePath;
+ args.push("--append-system-prompt", tmpPromptPath);
+ }
+
+ // Prefixing prevents accidental CLI flag parsing if the task starts with '-'.
+ args.push(`Task: ${task}`);
+
+ const result = await pi.exec("pi", args);
+
+ const stdoutResult = truncateOutput(result.stdout);
+ const stderrResult = truncateOutput(result.stderr);
+
+ return {
+ agent: agentName,
+ agentSource: agent.source,
+ task,
+ exitCode: result.code,
+ stdout: stdoutResult.text,
+ stderr: stderrResult.text,
+ truncated: stdoutResult.truncated || stderrResult.truncated,
+ step,
+ };
+ } 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" }),
+});
+
+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" }),
+});
+
+const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
+ description:
+ 'Which agent directories are eligible. Default: "user". Use "both" to enable project-local agents from .pi/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. Use {previous} in task to reference prior output" })),
+ agentScope: Type.Optional(AgentScopeSchema),
+ confirmProjectAgents: Type.Optional(
+ Type.Boolean({
+ description:
+ "Interactive-only safety prompt when running project-local agents (.pi/agents). Ignored in headless modes. Default: true.",
+ default: true,
+ }),
+ ),
+});
+
+const factory: CustomToolFactory = (pi) => {
+ const tool: CustomAgentTool = {
+ name: "subagent",
+ label: "Subagent",
+ get description() {
+ const user = discoverAgents(pi.cwd, "user");
+ const project = discoverAgents(pi.cwd, "project");
+
+ const userList = formatAgentList(user.agents, MAX_AGENTS_IN_DESCRIPTION);
+ const projectList = formatAgentList(project.agents, MAX_AGENTS_IN_DESCRIPTION);
+
+ const userSuffix = userList.remaining > 0 ? `; ... and ${userList.remaining} more` : "";
+ const projectSuffix = projectList.remaining > 0 ? `; ... and ${projectList.remaining} more` : "";
+
+ const projectDirNote = project.projectAgentsDir ? ` (from ${project.projectAgentsDir})` : "";
+
+ return [
+ "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").',
+ `User agents: ${userList.text}${userSuffix}.`,
+ `Project agents${projectDirNote}: ${projectList.text}${projectSuffix}.`,
+ ].join(" ");
+ },
+ parameters: SubagentParams,
+
+ async execute(_toolCallId, params) {
+ const agentScope: AgentScope = params.agentScope ?? "user";
+ const discovery = discoverAgents(pi.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);
+
+ 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:\n" +
+ "- { agent, task } for single\n" +
+ "- { tasks: [...] } for parallel\n" +
+ "- { chain: [...] } for sequential\n\n" +
+ `agentScope: ${agentScope}\n` +
+ `Available agents: ${available}`,
+ },
+ ],
+ details: { mode: "single", agentScope, projectAgentsDir: discovery.projectAgentsDir, results: [] },
+ };
+ }
+
+ if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && pi.hasUI) {
+ const requestedAgentNames = new Set();
+ 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 .pi/agents)";
+ const ok = await pi.ui.confirm(
+ "Run project-local agents?",
+ `About to run project agent(s): ${names}\n\nSource directory:\n${dir}\n\nProject agents are repo-controlled prompts. Only continue for repositories you trust.\n\nContinue?`,
+ );
+ if (!ok) {
+ return {
+ content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
+ details: { mode: hasChain ? "chain" : hasTasks ? "parallel" : "single", agentScope, projectAgentsDir: discovery.projectAgentsDir, results: [] },
+ };
+ }
+ }
+ }
+
+ 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);
+
+ const result = await runSingleAgent(pi, agents, step.agent, taskWithContext, i + 1);
+ results.push(result);
+
+ if (result.exitCode !== 0) {
+ const output = result.stdout.trim() || result.stderr.trim() || "(no output)";
+ const preview = previewFirstLines(output, 15);
+ const summaries = results.map((r) => {
+ const status = r.exitCode === 0 ? "completed" : `failed (exit ${r.exitCode})`;
+ return `Step ${r.step}: [${r.agent}] ${status}`;
+ });
+ return {
+ content: [
+ {
+ type: "text",
+ text:
+ `Chain stopped at step ${i + 1} (${step.agent} failed)\n\n` +
+ `${summaries.join("\n")}\n\n` +
+ `Failed step output (preview):\n${preview}`,
+ },
+ ],
+ details: { mode: "chain", agentScope, projectAgentsDir: discovery.projectAgentsDir, results },
+ };
+ }
+
+ previousOutput = result.stdout.trim() || result.stderr.trim();
+ }
+
+ const finalResult = results[results.length - 1];
+ const output = finalResult.stdout.trim() || finalResult.stderr.trim() || "(no output)";
+ const summaries = results.map((r) => `Step ${r.step}: [${r.agent}] completed`);
+
+ return {
+ content: [{ type: "text", text: `Chain completed (${results.length} steps)\n\n${summaries.join("\n")}\n\nFinal output:\n${output}` }],
+ details: { mode: "chain", agentScope, projectAgentsDir: discovery.projectAgentsDir, 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}. Split into multiple calls or use chain mode.`,
+ },
+ ],
+ details: { mode: "parallel", agentScope, projectAgentsDir: discovery.projectAgentsDir, results: [] },
+ };
+ }
+
+ const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, (t) =>
+ runSingleAgent(pi, agents, t.agent, t.task)
+ );
+
+ const successCount = results.filter((r) => r.exitCode === 0).length;
+ const summaries = results.map((r) => {
+ const status = r.exitCode === 0 ? "completed" : `failed (exit ${r.exitCode})`;
+ const output = r.stdout.trim() || r.stderr.trim() || "(no output)";
+ const preview = previewFirstLines(output, 5);
+ return `[${r.agent}] ${status}\n${preview}`;
+ });
+
+ return {
+ content: [{ type: "text", text: `Parallel execution: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}` }],
+ details: { mode: "parallel", agentScope, projectAgentsDir: discovery.projectAgentsDir, results },
+ };
+ }
+
+ if (params.agent && params.task) {
+ const result = await runSingleAgent(pi, agents, params.agent, params.task);
+
+ const success = result.exitCode === 0;
+ const output = result.stdout.trim() || result.stderr.trim() || "(no output)";
+ const truncatedNote = result.truncated ? " [output truncated]" : "";
+
+ return {
+ content: [{ type: "text", text: success ? output + truncatedNote : `Agent failed (exit ${result.exitCode}): ${output}${truncatedNote}` }],
+ details: { mode: "single", agentScope, projectAgentsDir: discovery.projectAgentsDir, results: [result] },
+ };
+ }
+
+ const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
+ return {
+ content: [{ type: "text", text: `Invalid parameters. Use: {agent, task} for single, {tasks: [...]} for parallel, or {chain: [...]} for sequential. Available agents: ${available}` }],
+ details: { mode: "single", agentScope, projectAgentsDir: discovery.projectAgentsDir, results: [] },
+ };
+ },
+
+ renderCall(args, theme) {
+ const agents = discoverAgents(pi.cwd, "both").agents;
+ 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: ${scope}]`);
+ for (let i = 0; i < Math.min(args.chain.length, 5); i++) {
+ const step = args.chain[i];
+ const agent = agents.find((a) => a.name === step.agent);
+ const sourceTag = agent ? theme.fg("muted", ` (${agent.source})`) : "";
+ const taskPreview = step.task.length > 35 ? step.task.slice(0, 35) + "..." : step.task;
+ text += "\n" + theme.fg("dim", ` ${i + 1}. ${step.agent}${sourceTag}: ${taskPreview}`);
+ }
+ if (args.chain.length > 5) {
+ text += "\n" + theme.fg("muted", ` ... and ${args.chain.length - 5} more steps`);
+ }
+ 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: ${scope}]`);
+ for (const t of args.tasks.slice(0, 5)) {
+ const agent = agents.find((a) => a.name === t.agent);
+ const sourceTag = agent ? theme.fg("muted", ` (${agent.source})`) : "";
+ const taskPreview = t.task.length > 40 ? t.task.slice(0, 40) + "..." : t.task;
+ text += "\n" + theme.fg("dim", ` ${t.agent}${sourceTag}: ${taskPreview}`);
+ }
+ if (args.tasks.length > 5) {
+ text += "\n" + theme.fg("muted", ` ... and ${args.tasks.length - 5} more`);
+ }
+ return new Text(text, 0, 0);
+ }
+
+ if (args.agent && args.task) {
+ const agent = agents.find((a) => a.name === args.agent);
+ const sourceTag = agent ? theme.fg("muted", ` (${agent.source})`) : "";
+ const agentLabel = agent ? theme.fg("accent", args.agent) + sourceTag : theme.fg("error", args.agent);
+
+ let text = theme.fg("toolTitle", theme.bold("subagent ")) + agentLabel + theme.fg("muted", ` [scope: ${scope}]`);
+ const taskPreview = args.task.length > 60 ? args.task.slice(0, 60) + "..." : args.task;
+ text += "\n" + theme.fg("dim", ` ${taskPreview}`);
+ return new Text(text, 0, 0);
+ }
+
+ return new Text(theme.fg("error", "subagent: invalid parameters"), 0, 0);
+ },
+
+ renderResult(result, { expanded }, theme) {
+ const { details } = result;
+ if (!details || details.results.length === 0) {
+ const text = result.content[0];
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
+ }
+
+ if (details.mode === "chain") {
+ const successCount = details.results.filter((r) => r.exitCode === 0).length;
+ const totalCount = details.results.length;
+ const allSuccess = successCount === totalCount;
+ const icon = allSuccess ? theme.fg("success", "✓") : theme.fg("error", "✗");
+
+ let text =
+ icon +
+ " " +
+ theme.fg("accent", `chain ${successCount}/${totalCount}`) +
+ theme.fg("muted", " steps completed") +
+ theme.fg("muted", ` (scope: ${details.agentScope})`);
+
+ if (expanded) {
+ for (const r of details.results) {
+ const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
+ const truncTag = r.truncated ? theme.fg("warning", " [truncated]") : "";
+ const sourceTag = theme.fg("muted", ` (${r.agentSource})`);
+ text += "\n\n" + theme.fg("muted", `Step ${r.step}: `) + rIcon + " " + theme.fg("accent", r.agent) + sourceTag + truncTag;
+ const output = r.stdout.trim() || r.stderr.trim();
+ if (output) {
+ text += "\n" + theme.fg("dim", output);
+ }
+ }
+ } else {
+ for (const r of details.results) {
+ const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
+ const output = r.stdout.trim() || r.stderr.trim();
+ const preview = (firstLine(output) || "(no output)").slice(0, 50);
+ text +=
+ "\n" +
+ theme.fg("muted", `${r.step}. `) +
+ rIcon +
+ " " +
+ theme.fg("accent", r.agent) +
+ theme.fg("muted", ` (${r.agentSource})`) +
+ ": " +
+ theme.fg("dim", preview);
+ }
+ if (details.results.some((r) => (r.stdout + r.stderr).includes("\n"))) {
+ text += "\n" + theme.fg("muted", " (Ctrl+O to expand)");
+ }
+ }
+
+ return new Text(text, 0, 0);
+ }
+
+ if (details.mode === "parallel") {
+ const successCount = details.results.filter((r) => r.exitCode === 0).length;
+ const totalCount = details.results.length;
+ const allSuccess = successCount === totalCount;
+ const icon = allSuccess ? theme.fg("success", "✓") : theme.fg("warning", "◐");
+
+ let text =
+ icon +
+ " " +
+ theme.fg("accent", `${successCount}/${totalCount}`) +
+ theme.fg("muted", " tasks completed") +
+ theme.fg("muted", ` (scope: ${details.agentScope})`);
+
+ if (expanded) {
+ for (const r of details.results) {
+ const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
+ const truncTag = r.truncated ? theme.fg("warning", " [truncated]") : "";
+ text += "\n\n" + rIcon + " " + theme.fg("accent", r.agent) + theme.fg("muted", ` (${r.agentSource})`) + truncTag;
+ const output = r.stdout.trim() || r.stderr.trim();
+ if (output) {
+ text += "\n" + theme.fg("dim", output);
+ }
+ }
+ } else {
+ for (const r of details.results.slice(0, 3)) {
+ const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
+ const output = r.stdout.trim() || r.stderr.trim();
+ const preview = (firstLine(output) || "(no output)").slice(0, 60);
+ text += "\n" + rIcon + " " + theme.fg("accent", r.agent) + ": " + theme.fg("dim", preview);
+ }
+ if (details.results.length > 3) {
+ text += "\n" + theme.fg("muted", ` ... ${details.results.length - 3} more (Ctrl+O to expand)`);
+ }
+ }
+
+ return new Text(text, 0, 0);
+ }
+
+ const r = details.results[0];
+ const success = r.exitCode === 0;
+ const icon = success ? theme.fg("success", "✓") : theme.fg("error", "✗");
+ const status = success ? "completed" : `failed (exit ${r.exitCode})`;
+ const sourceTag = theme.fg("muted", ` (${r.agentSource})`);
+ const truncatedTag = r.truncated ? theme.fg("warning", " [truncated]") : "";
+
+ let text = icon + " " + theme.fg("accent", r.agent) + sourceTag + " " + theme.fg("muted", status) + truncatedTag;
+
+ const output = r.stdout.trim() || r.stderr.trim();
+ if (output) {
+ if (expanded) {
+ text += "\n" + theme.fg("dim", output);
+ } else {
+ const preview = previewFirstLines(output, 3);
+ const hasMore = preview.length < output.length;
+ text += "\n" + theme.fg("dim", preview);
+ if (hasMore) {
+ text += "\n" + theme.fg("muted", " ... (Ctrl+O to expand)");
+ }
+ }
+ }
+
+ return new Text(text, 0, 0);
+ },
+ };
+
+ return tool;
+};
+
+export default factory;