From ef7e70cef9ad56e966ecd310dbd4202704310e70 Mon Sep 17 00:00:00 2001 From: Seth Etter Date: Mon, 31 Aug 2026 12:15:36 -0500 Subject: [PATCH] feat(pi): add subagent orchestration Add Pi's bundled subagent extension, agent definitions, and workflow prompts. Remove model pins so child agents inherit the parent model and thinking level. Sync each resource into the Pi agent directory. This supports parallel ZIP golden-path authoring without a custom orchestrator. --- agent/agents/planner.md | 36 + agent/agents/reviewer.md | 34 + agent/agents/scout.md | 49 ++ agent/agents/worker.md | 23 + agent/extensions/subagent/README.md | 177 +++++ agent/extensions/subagent/agents.ts | 157 ++++ agent/extensions/subagent/index.ts | 1038 +++++++++++++++++++++++++ agent/prompts/implement-and-review.md | 10 + agent/prompts/implement.md | 10 + agent/prompts/scout-and-plan.md | 9 + sync.sh | 8 + 11 files changed, 1551 insertions(+) create mode 100644 agent/agents/planner.md create mode 100644 agent/agents/reviewer.md create mode 100644 agent/agents/scout.md create mode 100644 agent/agents/worker.md create mode 100644 agent/extensions/subagent/README.md create mode 100644 agent/extensions/subagent/agents.ts create mode 100644 agent/extensions/subagent/index.ts create mode 100644 agent/prompts/implement-and-review.md create mode 100644 agent/prompts/implement.md create mode 100644 agent/prompts/scout-and-plan.md diff --git a/agent/agents/planner.md b/agent/agents/planner.md new file mode 100644 index 0000000..109acaa --- /dev/null +++ b/agent/agents/planner.md @@ -0,0 +1,36 @@ +--- +name: planner +description: Creates implementation plans from context and requirements +tools: read, grep, find, ls +--- + +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/agent/agents/reviewer.md b/agent/agents/reviewer.md new file mode 100644 index 0000000..99450b5 --- /dev/null +++ b/agent/agents/reviewer.md @@ -0,0 +1,34 @@ +--- +name: reviewer +description: Code review specialist for quality and security analysis +tools: read, grep, find, ls, bash +--- + +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/agent/agents/scout.md b/agent/agents/scout.md new file mode 100644 index 0000000..c2f8922 --- /dev/null +++ b/agent/agents/scout.md @@ -0,0 +1,49 @@ +--- +name: scout +description: Fast codebase recon that returns compressed context for handoff to other agents +tools: read, grep, find, ls, bash +--- + +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. diff --git a/agent/agents/worker.md b/agent/agents/worker.md new file mode 100644 index 0000000..1b4d17d --- /dev/null +++ b/agent/agents/worker.md @@ -0,0 +1,23 @@ +--- +name: worker +description: General-purpose subagent with full capabilities, isolated context +--- + +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/agent/extensions/subagent/README.md b/agent/extensions/subagent/README.md new file mode 100644 index 0000000..f0ef06f --- /dev/null +++ b/agent/extensions/subagent/README.md @@ -0,0 +1,177 @@ +# Subagent Example + +Delegate tasks to specialized subagents with isolated context windows. + +## Features + +- **Isolated context**: Each subagent runs in a separate `pi` process +- **Streaming output**: See tool calls and progress as they happen +- **Parallel streaming**: All parallel tasks stream updates simultaneously +- **Markdown rendering**: Final output rendered with proper formatting (expanded view) +- **Usage tracking**: Shows turns, tokens, cost, and context usage per agent +- **Abort support**: Ctrl+C propagates to kill subagent processes + +## Structure + +``` +subagent/ +├── README.md # This file +├── index.ts # The extension (entry point) +├── agents.ts # Agent discovery logic +├── 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) +└── prompts/ # Workflow presets (prompt templates) + ├── implement.md # scout -> planner -> worker + ├── scout-and-plan.md # scout -> planner (no implementation) + └── implement-and-review.md # worker -> reviewer -> worker +``` + +## Installation + +From the repository root, symlink the files: + +```bash +# Symlink the extension (must be in a subdirectory with index.ts) +mkdir -p ~/.pi/agent/extensions/subagent +ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/index.ts" ~/.pi/agent/extensions/subagent/index.ts +ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/agents.ts" ~/.pi/agent/extensions/subagent/agents.ts + +# Symlink agents +mkdir -p ~/.pi/agent/agents +for f in packages/coding-agent/examples/extensions/subagent/agents/*.md; do + ln -sf "$(pwd)/$f" ~/.pi/agent/agents/$(basename "$f") +done + +# Symlink workflow prompts +mkdir -p ~/.pi/agent/prompts +for f in packages/coding-agent/examples/extensions/subagent/prompts/*.md; do + ln -sf "$(pwd)/$f" ~/.pi/agent/prompts/$(basename "$f") +done +``` + +## Security Model + +This tool executes a separate `pi` subprocess with a delegated system prompt and tool/model configuration. + +**Project-local agents** (`.pi/agents/*.md`) are repo-controlled prompts that can instruct the model to read files, run bash commands, etc. + +**Default behavior:** Only loads **user-level agents** from `~/.pi/agent/agents`. + +To enable project-local agents, pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust. + +When running interactively, the tool prompts for confirmation before running project-local agents in untrusted projects. Trusted projects skip the additional prompt. Set `confirmProjectAgents: false` to disable confirmation. + +## Usage + +### Single agent +``` +Use scout to find all authentication code +``` + +### Parallel execution +``` +Run 2 scouts in parallel: one to find models, one to find providers +``` + +### Chained workflow +``` +Use a chain: first have scout find the read tool, then have planner suggest improvements +``` + +### Workflow prompts +``` +/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 (max 8, 4 concurrent) | +| Chain | `{ chain: [...] }` | Sequential with `{previous}` placeholder | + +## Output Display + +**Collapsed view** (default): +- Status icon (✓/✗/⏳) and agent name +- Last 5-10 items (tool calls and text) +- Usage stats: `3 turns ↑input ↓output RcacheRead WcacheWrite $cost ctx:contextTokens model` + +**Expanded view** (Ctrl+O): +- Full task text +- All tool calls with formatted arguments +- Final output rendered as Markdown +- Per-task usage (for chain/parallel) + +**Parallel mode streaming**: +- Shows all tasks with live status (⏳ running, ✓ done, ✗ failed) +- Updates as each task makes progress +- Shows "2/3 done, 1 running" status +- Returns each completed task's final output to the parent model, capped at 50 KB per task +- Returns failure diagnostics from stderr/error messages when a child exits before producing output + +**Tool call formatting** (mimics built-in tools): +- `$ command` for bash +- `read ~/path:1-10` for read +- `grep /pattern/ in ~/path` for grep +- etc. + +## 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. +``` + +When `model` is omitted, the subagent inherits the dispatching session's active model and thinking level. + +**Locations:** +- `~/.pi/agent/agents/*.md` - User-level (always loaded) +- `.pi/agents/*.md` - Project-level (only with `agentScope: "project"` or `"both"`) + +Project agents override user agents with the same name when `agentScope: "both"`. + +## Sample Agents + +| Agent | Purpose | Model | Tools | +|-------|---------|-------|-------| +| `scout` | Fast codebase recon | Parent model | read, grep, find, ls, bash | +| `planner` | Implementation plans | Parent model | read, grep, find, ls | +| `reviewer` | Code review | Parent model | read, grep, find, ls, bash | +| `worker` | General-purpose | Parent model | (all default) | + +## Workflow Prompts + +| Prompt | Flow | +|--------|------| +| `/implement ` | scout → planner → worker | +| `/scout-and-plan ` | scout → planner | +| `/implement-and-review ` | worker → reviewer → worker | + +## Error Handling + +- **Exit code != 0**: Tool returns error with stderr/output +- **stopReason "error"**: LLM error propagated with error message +- **stopReason "aborted"**: User abort (Ctrl+C) kills subprocess, throws error +- **Chain mode**: Stops at first failing step, reports which step failed + +## Limitations + +- Output truncated to last 10 items in collapsed view (expand to see all) +- Parallel model-visible output is capped at 50 KB per task; full results remain in tool details +- Agents discovered fresh on each invocation (allows editing mid-session) +- Parallel mode limited to 8 tasks, 4 concurrent diff --git a/agent/extensions/subagent/agents.ts b/agent/extensions/subagent/agents.ts new file mode 100644 index 0000000..b8a3659 --- /dev/null +++ b/agent/extensions/subagent/agents.ts @@ -0,0 +1,157 @@ +/** + * Agent discovery and configuration + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/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; +} + +/** + * Raw agent frontmatter. Values are `unknown` because `parseFrontmatter` runs a + * real YAML parser, so any scalar or collection can appear here. + * + * A type alias rather than an interface: `parseFrontmatter` constrains its + * parameter to `Record`, and only an alias picks up the + * implicit index signature that satisfies it. + */ +type AgentFrontmatter = { + name?: unknown; + description?: unknown; + tools?: unknown; + model?: unknown; +}; + +/** + * Normalize a frontmatter `tools` value to a list of tool names. + * + * Both spellings are valid YAML and both are in use: + * + * tools: read, bash # string + * tools: [read, bash] # array + * + * so accept either. Anything else (a number, a map, a nested list) yields no + * tools rather than throwing: this runs inside agent discovery, where a single + * bad file must not take down every other agent in the same directory. + */ +function parseToolList(value: unknown): string[] | undefined { + const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : []; + const tools = raw + .filter((t): t is string => typeof t === "string") + .map((t) => t.trim()) + .filter(Boolean); + return tools.length > 0 ? tools : undefined; +} + +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(content); + + if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") { + continue; + } + + agents.push({ + name: frontmatter.name, + description: frontmatter.description, + tools: parseToolList(frontmatter.tools), + model: typeof frontmatter.model === "string" ? frontmatter.model : undefined, + 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, CONFIG_DIR_NAME, "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(getAgentDir(), "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") { + 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, + }; +} diff --git a/agent/extensions/subagent/index.ts b/agent/extensions/subagent/index.ts new file mode 100644 index 0000000..71b1a33 --- /dev/null +++ b/agent/extensions/subagent/index.ts @@ -0,0 +1,1038 @@ +/** + * 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, ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { Message } from "@earendil-works/pi-ai"; +import { StringEnum } from "@earendil-works/pi-ai"; +import { + CONFIG_DIR_NAME, + type ExtensionAPI, + getAgentDir, + getMarkdownTheme, + withFileMutationQueue, +} from "@earendil-works/pi-coding-agent"; +import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui"; +import { Type } from "typebox"; +import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.ts"; + +const MAX_PARALLEL_TASKS = 8; +const MAX_CONCURRENCY = 4; +const COLLAPSED_ITEM_COUNT = 10; +const PER_TASK_OUTPUT_CAP = 50 * 1024; + +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, + 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 ""; +} + +function isFailedResult(result: SingleResult): boolean { + return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; +} + +function getResultOutput(result: SingleResult): string { + if (isFailedResult(result)) { + return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; + } + return getFinalOutput(result.messages) || "(no output)"; +} + +function truncateParallelOutput(output: string): string { + const byteLength = Buffer.byteLength(output, "utf8"); + if (byteLength <= PER_TASK_OUTPUT_CAP) return output; + + let truncated = output.slice(0, PER_TASK_OUTPUT_CAP); + while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) { + truncated = truncated.slice(0, -1); + } + return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted. Full output preserved in tool details.]`; +} + +type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record }; + +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( + 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; +} + +async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> { + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-subagent-")); + const safeName = agentName.replace(/[^\w.-]+/g, "_"); + const filePath = path.join(tmpDir, `prompt-${safeName}.md`); + await withFileMutationQueue(filePath, async () => { + await fs.promises.writeFile(filePath, prompt, { encoding: "utf-8", mode: 0o600 }); + }); + return { dir: tmpDir, filePath }; +} + +function getPiInvocation(args: string[]): { command: string; args: string[] } { + const currentScript = process.argv[1]; + const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/"); + if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) { + return { command: process.execPath, args: [currentScript, ...args] }; + } + + const execName = path.basename(process.execPath).toLowerCase(); + const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName); + if (!isGenericRuntime) { + return { command: process.execPath, args }; + } + + return { command: "pi", args }; +} + +type OnUpdateCallback = (partial: AgentToolResult) => void; + +interface DispatchDefaults { + model?: string; + thinkingLevel?: ThinkingLevel; +} + +async function runSingleAgent( + defaultCwd: string, + dispatchDefaults: DispatchDefaults, + agents: AgentConfig[], + agentName: string, + task: string, + cwd: string | undefined, + step: number | undefined, + signal: AbortSignal | undefined, + onUpdate: OnUpdateCallback | undefined, + makeDetails: (results: SingleResult[]) => SubagentDetails, +): Promise { + const agent = agents.find((a) => a.name === agentName); + + if (!agent) { + const available = agents.map((a) => `"${a.name}"`).join(", ") || "none"; + return { + agent: agentName, + agentSource: "unknown", + task, + exitCode: 1, + messages: [], + stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`, + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, + step, + }; + } + + const args: string[] = ["--mode", "json", "-p", "--no-session"]; + const inheritsDispatchConfig = !agent.model; + const model = agent.model ?? dispatchDefaults.model; + if (model) args.push("--model", model); + if (inheritsDispatchConfig && dispatchDefaults.thinkingLevel) { + args.push("--thinking", dispatchDefaults.thinkingLevel); + } + 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, + step, + }; + + const emitUpdate = () => { + if (onUpdate) { + onUpdate({ + content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }], + details: makeDetails([currentResult]), + }); + } + }; + + try { + if (agent.systemPrompt.trim()) { + const tmp = await 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((resolve) => { + const invocation = getPiInvocation(args); + const proc = spawn(invocation.command, invocation.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 ${path.join(getAgentDir(), "agents")}).`, + `To enable project-local agents in ${CONFIG_DIR_NAME}/agents, set agentScope: "both" (or "project").`, + ].join(" "), + parameters: SubagentParams, + + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const agentScope: AgentScope = params.agentScope ?? "user"; + const dispatchDefaults: DispatchDefaults = { + model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined, + thinkingLevel: ctx.thinkingLevel, + }; + 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 && + !ctx.isProjectTrusted() + ) { + 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)"; + 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, + dispatchDefaults, + agents, + step.agent, + taskWithContext, + step.cwd, + i + 1, + signal, + chainUpdate, + makeDetails("chain"), + ); + results.push(result); + + const isError = isFailedResult(result); + if (isError) { + const errorMsg = getResultOutput(result); + 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, + dispatchDefaults, + 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) => !isFailedResult(r)).length; + const summaries = results.map((r) => { + const output = truncateParallelOutput(getResultOutput(r)); + const status = isFailedResult(r) + ? `failed${r.stopReason && r.stopReason !== "end" ? ` (${r.stopReason})` : ""}` + : "completed"; + return `### [${r.agent}] ${status}\n\n${output}`; + }); + return { + content: [ + { + type: "text", + text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`, + }, + ], + details: makeDetails("parallel")(results), + }; + } + + if (params.agent && params.task) { + const result = await runSingleAgent( + ctx.cwd, + dispatchDefaults, + agents, + params.agent, + params.task, + params.cwd, + undefined, + signal, + onUpdate, + makeDetails("single"), + ); + const isError = isFailedResult(result); + if (isError) { + const errorMsg = getResultOutput(result); + 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, _context) { + 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, _context) { + 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 = isFailedResult(r); + 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 !== -1 && !isFailedResult(r)).length; + const failCount = details.results.filter((r) => r.exitCode !== -1 && isFailedResult(r)).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 = isFailedResult(r) ? theme.fg("error", "✗") : theme.fg("success", "✓"); + 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", "⏳") + : isFailedResult(r) + ? theme.fg("error", "✗") + : theme.fg("success", "✓"); + 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); + }, + }); +} diff --git a/agent/prompts/implement-and-review.md b/agent/prompts/implement-and-review.md new file mode 100644 index 0000000..6493b3d --- /dev/null +++ b/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}. diff --git a/agent/prompts/implement.md b/agent/prompts/implement.md new file mode 100644 index 0000000..559da4d --- /dev/null +++ b/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}. diff --git a/agent/prompts/scout-and-plan.md b/agent/prompts/scout-and-plan.md new file mode 100644 index 0000000..093b633 --- /dev/null +++ b/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. diff --git a/sync.sh b/sync.sh index a725a19..08ce435 100755 --- a/sync.sh +++ b/sync.sh @@ -26,6 +26,14 @@ function link_configs { mappings["agent/AGENTS.md"]="$HOME/.pi/agent/AGENTS.md" mappings["agent/extensions/notify.ts"]="$HOME/.pi/agent/extensions/notify.ts" mappings["agent/extensions/notify-focus.sh"]="$HOME/.pi/agent/extensions/notify-focus.sh" + mappings["agent/extensions/subagent"]="$HOME/.pi/agent/extensions/subagent" + mappings["agent/agents/planner.md"]="$HOME/.pi/agent/agents/planner.md" + mappings["agent/agents/reviewer.md"]="$HOME/.pi/agent/agents/reviewer.md" + mappings["agent/agents/scout.md"]="$HOME/.pi/agent/agents/scout.md" + mappings["agent/agents/worker.md"]="$HOME/.pi/agent/agents/worker.md" + mappings["agent/prompts/implement-and-review.md"]="$HOME/.pi/agent/prompts/implement-and-review.md" + mappings["agent/prompts/implement.md"]="$HOME/.pi/agent/prompts/implement.md" + mappings["agent/prompts/scout-and-plan.md"]="$HOME/.pi/agent/prompts/scout-and-plan.md" mappings["agent/skills/web-access"]="$HOME/.pi/agent/skills/web-access" for key in "${!mappings[@]}"; do -- 2.51.2