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