diff --git a/modules/home/devtools/ai-tools.nix b/modules/home/devtools/ai-tools.nix index 4aeb974..cc6a494 100644 --- a/modules/home/devtools/ai-tools.nix +++ b/modules/home/devtools/ai-tools.nix @@ -11,6 +11,7 @@ let mkOption types mkIf + mkMerge escapeShellArg ; cfg = config.rsydn.aiTools; @@ -172,6 +173,78 @@ in description = "Configuration for the Claude wrapper that targets the Kimi Code API."; }; + piExtensions = mkOption { + type = types.submodule { + options = { + enable = mkEnableOption "Install the global pi extensions directory."; + source = mkOption { + type = types.path; + default = ../../../pi/extensions; + description = "Source directory for the global pi extensions tree."; + }; + }; + }; + default = { + enable = true; + source = ../../../pi/extensions; + }; + description = "Configuration for the global pi extensions directory deployment."; + }; + + piSkills = mkOption { + type = types.submodule { + options = { + enable = mkEnableOption "Install the global pi skills directory."; + source = mkOption { + type = types.path; + default = ../../../pi/skills; + description = "Source directory for the global pi skills tree."; + }; + }; + }; + default = { + enable = true; + source = ../../../pi/skills; + }; + description = "Configuration for the global pi skills directory deployment."; + }; + + piPrompts = mkOption { + type = types.submodule { + options = { + enable = mkEnableOption "Install the global pi prompt templates directory."; + source = mkOption { + type = types.path; + default = ../../../pi/prompts; + description = "Source directory for the global pi prompt templates tree."; + }; + }; + }; + default = { + enable = true; + source = ../../../pi/prompts; + }; + description = "Configuration for the global pi prompt templates directory deployment."; + }; + + piThemes = mkOption { + type = types.submodule { + options = { + enable = mkEnableOption "Install the global pi themes directory."; + source = mkOption { + type = types.path; + default = ../../../pi/themes; + description = "Source directory for the global pi themes tree."; + }; + }; + }; + default = { + enable = true; + source = ../../../pi/themes; + }; + description = "Configuration for the global pi themes directory deployment."; + }; + extraPackages = mkOption { type = types.listOf types.package; default = [ ]; @@ -192,5 +265,32 @@ in ++ zaiWrapperPackages ++ kimiWrapperPackages ++ cfg.extraPackages; + + home.file = mkMerge [ + (mkIf cfg.piExtensions.enable { + ".pi/agent/extensions" = { + source = cfg.piExtensions.source; + recursive = true; + }; + }) + (mkIf cfg.piSkills.enable { + ".pi/agent/skills" = { + source = cfg.piSkills.source; + recursive = true; + }; + }) + (mkIf cfg.piPrompts.enable { + ".pi/agent/prompts" = { + source = cfg.piPrompts.source; + recursive = true; + }; + }) + (mkIf cfg.piThemes.enable { + ".pi/agent/themes" = { + source = cfg.piThemes.source; + recursive = true; + }; + }) + ]; }; } diff --git a/pi/extensions/exa-tools/README.md b/pi/extensions/exa-tools/README.md new file mode 100644 index 0000000..29efdeb --- /dev/null +++ b/pi/extensions/exa-tools/README.md @@ -0,0 +1,55 @@ +# pi exa-tools scaffold + +Global pi extension that adds Exa-backed retrieval tools. + +## Tools + +- `exa_search` — general web/docs/reference search +- `exa_code` — coding context and open-source implementation examples + +## Mental model + +- use `exa_search` for official docs, release notes, blog posts, issues, comparisons, and general web research +- use `exa_code` for library usage, framework patterns, API examples, and code-specific prior art + +## Credentials + +The extension looks for credentials in this order: + +1. `EXA_API_KEY` +2. `~/.config/secrets/exa-api-key` +3. `~/.config/secrets/exa_api_key` + +If none are found, the tools throw a helpful error. + +Note: the Exa account also needs available credits. A valid key with no remaining credits will still fail at request time. + +## Command + +- `/exa-tools` — show credential status and available tools + +## Installation + +This repo deploys the extension declaratively through Home Manager to: + +```text +~/.pi/agent/extensions/exa-tools +``` + +## Example usage + +General web research: + +```text +Use exa_search to find the official docs for Exa context search +``` + +Coding context: + +```text +Use exa_code to find examples of React hook state management patterns +``` + +Subagent usage: + +The `librarian` subagent can use these tools once the extension is globally installed. diff --git a/pi/extensions/exa-tools/index.ts b/pi/extensions/exa-tools/index.ts new file mode 100644 index 0000000..900c69e --- /dev/null +++ b/pi/extensions/exa-tools/index.ts @@ -0,0 +1,354 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + type ExtensionAPI, + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + truncateHead, +} from "@mariozechner/pi-coding-agent"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { Type } from "@sinclair/typebox"; + +const EXA_API_BASE = "https://api.exa.ai"; +const DEFAULT_SEARCH_RESULTS = 5; +const MAX_SEARCH_RESULTS = 10; +const DEFAULT_HIGHLIGHT_CHARS = 1200; +const DEFAULT_CODE_TOKENS = 5000; +const MAX_CODE_TOKENS = 12000; + +function clip(text: string, max = 400): string { + const normalized = text.replace(/\s+/g, " ").trim(); + return normalized.length > max ? `${normalized.slice(0, max)}…` : normalized; +} + +function maybeString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function stringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string" && item.trim().length > 0); +} + +function readSecretFile(secretPath: string): string | undefined { + try { + if (!fs.existsSync(secretPath)) return undefined; + const value = fs.readFileSync(secretPath, "utf8").trim(); + return value || undefined; + } catch { + return undefined; + } +} + +function resolveExaApiKey(): { key?: string; source: string } { + const envKey = process.env.EXA_API_KEY?.trim(); + if (envKey) return { key: envKey, source: "EXA_API_KEY" }; + + const secretCandidates = [ + path.join(os.homedir(), ".config", "secrets", "exa-api-key"), + path.join(os.homedir(), ".config", "secrets", "exa_api_key"), + ]; + + for (const secretPath of secretCandidates) { + const value = readSecretFile(secretPath); + if (value) return { key: value, source: secretPath }; + } + + return { source: "missing" }; +} + +async function postExa(pathname: string, payload: Record, signal?: AbortSignal): Promise { + const auth = resolveExaApiKey(); + if (!auth.key) { + throw new Error( + "Exa API key not found. Set EXA_API_KEY or create ~/.config/secrets/exa-api-key.", + ); + } + + const response = await fetch(`${EXA_API_BASE}${pathname}`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": auth.key, + }, + body: JSON.stringify(payload), + signal, + }); + + const raw = await response.text(); + let data: any; + try { + data = raw ? JSON.parse(raw) : {}; + } catch { + data = { raw }; + } + + if (!response.ok) { + const detail = typeof data === "object" && data && maybeString(data.error) ? data.error : clip(raw, 600); + throw new Error(`Exa request failed (${response.status} ${response.statusText}): ${detail}`); + } + + if (typeof data === "object" && data && maybeString(data.error)) { + const tag = maybeString(data.tag); + throw new Error(`Exa error${tag ? ` [${tag}]` : ""}: ${data.error}`); + } + + return data; +} + +async function writeTempOutput(prefix: string, content: string): Promise { + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-exa-tools-")); + const filePath = path.join(dir, `${prefix}.txt`); + await fs.promises.writeFile(filePath, content, "utf8"); + return filePath; +} + +async function finalizeText(prefix: string, fullText: string): Promise<{ text: string; fullOutputPath?: string }> { + const truncation = truncateHead(fullText, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + if (!truncation.truncated) { + return { text: truncation.content }; + } + + const fullOutputPath = await writeTempOutput(prefix, fullText); + const notice = [ + "", + `[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full output saved to: ${fullOutputPath}]`, + ].join("\n"); + + return { + text: `${truncation.content}${notice}`, + fullOutputPath, + }; +} + +function formatSearchResult(result: any, index: number, includeText: boolean): string { + const title = maybeString(result?.title) ?? maybeString(result?.url) ?? `Result ${index + 1}`; + const lines = [`${index + 1}. ${title}`]; + + const url = maybeString(result?.url); + if (url) lines.push(` URL: ${url}`); + + const published = maybeString(result?.publishedDate) ?? maybeString(result?.published_date); + if (published) lines.push(` Published: ${published}`); + + const author = maybeString(result?.author); + if (author) lines.push(` Author: ${author}`); + + const summary = maybeString(result?.summary); + if (summary) lines.push(` Summary: ${clip(summary, 500)}`); + + const highlights = stringArray(result?.highlights); + if (highlights.length > 0) { + lines.push(" Highlights:"); + for (const highlight of highlights.slice(0, 3)) { + lines.push(` - ${clip(highlight, 350)}`); + } + } + + if (includeText) { + const text = maybeString(result?.text); + if (text) lines.push(` Text: ${clip(text, 700)}`); + } + + return lines.join("\n"); +} + +async function buildSearchOutput(query: string, payload: Record, data: any): Promise<{ text: string; fullOutputPath?: string }> { + const results = Array.isArray(data?.results) ? data.results : []; + const includeText = Boolean((payload.contents as any)?.text); + const lines: string[] = [ + `Exa search results for: ${query}`, + `Results returned: ${results.length}`, + ]; + + const requestId = maybeString(data?.requestId); + if (requestId) lines.push(`Request ID: ${requestId}`); + + const costTotal = data?.costDollars?.total; + if (typeof costTotal === "number") lines.push(`Cost: $${costTotal}`); + + lines.push(""); + + if (results.length === 0) { + lines.push("No results returned."); + } else { + for (const [index, result] of results.entries()) { + lines.push(formatSearchResult(result, index, includeText)); + if (index < results.length - 1) lines.push(""); + } + } + + return finalizeText("exa-search", lines.join("\n")); +} + +async function buildCodeOutput(query: string, data: any): Promise<{ text: string; fullOutputPath?: string }> { + const context = + maybeString(data?.context) ?? + maybeString(data?.text) ?? + maybeString(data?.output) ?? + maybeString(data?.result); + + const lines: string[] = [`Exa code context for: ${query}`]; + + const requestId = maybeString(data?.requestId); + if (requestId) lines.push(`Request ID: ${requestId}`); + + if (typeof data?.resultsCount === "number") lines.push(`Results count: ${data.resultsCount}`); + if (typeof data?.outputTokens === "number") lines.push(`Output tokens: ${data.outputTokens}`); + if (typeof data?.searchTime === "number") lines.push(`Search time: ${Math.round(data.searchTime)}ms`); + if (typeof data?.costDollars?.total === "number") lines.push(`Cost: $${data.costDollars.total}`); + + lines.push(""); + + if (context) { + lines.push(context.trim()); + } else if (Array.isArray(data?.results)) { + lines.push("No combined context field returned. Raw result excerpts:"); + lines.push(""); + for (const [index, result] of data.results.entries()) { + const title = maybeString(result?.title) ?? maybeString(result?.url) ?? `Result ${index + 1}`; + lines.push(`${index + 1}. ${title}`); + const url = maybeString(result?.url); + if (url) lines.push(` URL: ${url}`); + const text = maybeString(result?.text) ?? maybeString(result?.snippet); + if (text) lines.push(` ${clip(text, 900)}`); + lines.push(""); + } + } else { + lines.push("No code context returned."); + } + + return finalizeText("exa-code", lines.join("\n")); +} + +const SearchType = StringEnum(["auto", "neural", "keyword", "deep", "deep-lite", "deep-reasoning"] as const); + +const ExaSearchParams = Type.Object({ + query: Type.String({ description: "What to search for on the web" }), + type: Type.Optional(SearchType), + numResults: Type.Optional(Type.Integer({ description: `Number of results to return (max ${MAX_SEARCH_RESULTS})`, default: DEFAULT_SEARCH_RESULTS })), + category: Type.Optional(Type.String({ description: "Optional Exa category, e.g. research paper, company, news" })), + includeDomains: Type.Optional(Type.Array(Type.String(), { description: "Restrict results to these domains" })), + excludeDomains: Type.Optional(Type.Array(Type.String(), { description: "Exclude these domains" })), + includeText: Type.Optional(Type.Boolean({ description: "Include raw page text excerpts", default: false })), + includeSummary: Type.Optional(Type.Boolean({ description: "Ask Exa for result summaries", default: true })), + summaryQuery: Type.Optional(Type.String({ description: "Optional summary focus prompt" })), + includeHighlights: Type.Optional(Type.Boolean({ description: "Ask Exa for result highlights", default: true })), + highlightMaxCharacters: Type.Optional(Type.Integer({ description: "Maximum characters of highlights to request", default: DEFAULT_HIGHLIGHT_CHARS })), +}); + +const ExaCodeParams = Type.Object({ + query: Type.String({ description: "Coding question, library usage pattern, or implementation topic to retrieve code context for" }), + tokensNum: Type.Optional(Type.Integer({ description: `Approximate token budget for returned context (max ${MAX_CODE_TOKENS})`, default: DEFAULT_CODE_TOKENS })), +}); + +export default function (pi: ExtensionAPI) { + pi.registerCommand("exa-tools", { + description: "Show Exa tools status and credential source", + handler: async (_args, ctx) => { + const auth = resolveExaApiKey(); + const lines = [ + "Exa tools status", + `API key: ${auth.key ? "configured" : "missing"}`, + `Source: ${auth.source}`, + "", + "Available tools:", + "- exa_search -> general web/docs/reference retrieval", + "- exa_code -> coding examples and library usage context", + ]; + + if (ctx.hasUI) { + ctx.ui.setEditorText(lines.join("\n")); + ctx.ui.notify(`Exa tools ${auth.key ? "ready" : "missing API key"}`, auth.key ? "info" : "warning"); + } else { + console.log(lines.join("\n")); + } + }, + }); + + pi.registerTool({ + name: "exa_search", + label: "Exa Search", + description: "Search the web with Exa for official docs, articles, release notes, issues, and general references. Requires EXA_API_KEY.", + promptSnippet: "Search the web for docs, references, release notes, articles, and current external information.", + promptGuidelines: [ + "Use this for external web knowledge, not for files already in the current repository.", + "Prefer this tool when the user asks for official docs, comparisons, release notes, or broader web research.", + ], + parameters: ExaSearchParams, + async execute(_toolCallId, params, signal) { + const numResults = Math.max(1, Math.min(params.numResults ?? DEFAULT_SEARCH_RESULTS, MAX_SEARCH_RESULTS)); + const payload: Record = { + query: params.query, + type: params.type ?? "auto", + numResults, + }; + + if (params.category) payload.category = params.category; + if (params.includeDomains && params.includeDomains.length > 0) payload.includeDomains = params.includeDomains; + if (params.excludeDomains && params.excludeDomains.length > 0) payload.excludeDomains = params.excludeDomains; + + const contents: Record = {}; + if (params.includeText ?? false) contents.text = true; + if (params.includeSummary ?? true) contents.summary = { query: params.summaryQuery ?? params.query }; + if (params.includeHighlights ?? true) { + contents.highlights = { maxCharacters: params.highlightMaxCharacters ?? DEFAULT_HIGHLIGHT_CHARS }; + } + if (Object.keys(contents).length > 0) payload.contents = contents; + + const data = await postExa("/search", payload, signal); + const rendered = await buildSearchOutput(params.query, payload, data); + + return { + content: [{ type: "text", text: rendered.text }], + details: { + endpoint: "/search", + query: params.query, + requestId: data?.requestId, + resultsCount: Array.isArray(data?.results) ? data.results.length : undefined, + fullOutputPath: rendered.fullOutputPath, + }, + }; + }, + }); + + pi.registerTool({ + name: "exa_code", + label: "Exa Code", + description: "Retrieve coding-specific context and open-source implementation examples with Exa Code. Requires EXA_API_KEY.", + promptSnippet: "Find coding examples, library usage patterns, and implementation context from public code sources.", + promptGuidelines: [ + "Use this when the user wants examples of how libraries, frameworks, or APIs are used in code.", + "Prefer this over general web search when the task asks for implementation patterns or open-source examples.", + ], + parameters: ExaCodeParams, + async execute(_toolCallId, params, signal) { + const tokensNum = Math.max(500, Math.min(params.tokensNum ?? DEFAULT_CODE_TOKENS, MAX_CODE_TOKENS)); + const payload = { + query: params.query, + tokensNum, + }; + + const data = await postExa("/context", payload, signal); + const rendered = await buildCodeOutput(params.query, data); + + return { + content: [{ type: "text", text: rendered.text }], + details: { + endpoint: "/context", + query: params.query, + requestId: data?.requestId, + resultsCount: data?.resultsCount, + outputTokens: data?.outputTokens, + fullOutputPath: rendered.fullOutputPath, + }, + }; + }, + }); +} diff --git a/pi/extensions/subagent/README.md b/pi/extensions/subagent/README.md new file mode 100644 index 0000000..f2cd1ec --- /dev/null +++ b/pi/extensions/subagent/README.md @@ -0,0 +1,131 @@ +# pi subagent scaffold + +Foundation for a global pi subagent extension with parallel and chained delegation. + +## What this includes + +- `index.ts` — main `subagent` tool and `/subagents` command +- `agents.ts` — agent discovery and manifest parsing +- `agents/` — starter bundled agents: + - `scout` → `zai/glm-5.1` + - `planner` → `openai-codex/gpt-5.4:xhigh` + - `implementer` → `openai-codex/gpt-5.3-codex` + - `reviewer` → `openai-codex/gpt-5.4:xhigh` + - `librarian` → `zai/glm-5.1` + `exa_search` / `exa_code` +- `prompts/` — starter prompt templates: + - `/implement` + - `/scout-and-plan` + - `/implement-and-review` + - `/parallel-scout` + - `/research` + +## Discovery model + +This extension supports three agent sources: + +- **bundled** — agents shipped in this directory (`./agents`) +- **user** — `~/.pi/agent/agents` +- **project** — nearest `.pi/agents` from the current working directory upward + +Scope values: + +- `global` — bundled + user +- `project` — project only +- `both` — bundled + user + project + +Precedence when names collide: + +1. bundled +2. user +3. project + +So project-local agents override user/global ones, and user/global agents override bundled defaults. + +## Parallel safety + +Parallel mode is intentionally conservative. + +- agents with `parallelSafe: true` can run in parallel +- agents with `parallelSafe: false` are blocked in parallel mode + +The bundled `implementer` is marked serial-only. + +## Agent manifest format + +Each agent is a markdown file with YAML frontmatter: + +```md +--- +name: planner +description: Creates implementation plans +model: anthropic/claude-sonnet-4-5 +tools: read, grep, find, ls +parallelSafe: true +role: planner +tags: planning, design +--- + +System prompt goes here. +``` + +Notes: + +- `model` accepts normal pi `--model` values, including `provider/id` and optional `:thinking` +- the current scaffold intentionally uses built-in pi providers (`openai-codex`, `zai`), so no custom provider extension is required yet +- `tools` should usually be explicit to avoid recursive delegation +- `parallelSafe` defaults to `false` if omitted + +## Install globally from this repo + +Recommended runtime target: + +```bash +mkdir -p ~/.pi/agent/extensions +ln -sfn /Users/rasyidanakbar/Development/dotfiles/pi/extensions/subagent ~/.pi/agent/extensions/subagent +``` + +Then start pi and run: + +```text +/reload +``` + +Because prompts and bundled agents live inside the extension directory, a single directory symlink is enough. + +## Usage examples + +Single agent: + +```text +Use subagent planner to propose a plan for refactoring auth +``` + +Parallel: + +```text +Use subagent with two tasks in parallel: scout auth flow, librarian find similar patterns +``` + +Chain: + +```text +Use subagent chain: scout -> planner -> implementer +``` + +Prompt templates: + +```text +/implement add request validation to the API +/scout-and-plan refactor auth to support oauth +/implement-and-review add pagination to the endpoint +/parallel-scout investigate session handling +/research evaluate the best pattern for background job retries in this codebase +``` + +## Good next steps + +1. add `parallel_then_reduce` +2. add budget / timeout controls +3. add a custom `index_search` tool for librarian workflows +4. wire the whole `pi/extensions/` directory into Home Manager so `~/.pi/agent/extensions/` is managed automatically +5. pair librarian with the separate `pi/extensions/exa-tools` extension for web/docs/code retrieval diff --git a/pi/extensions/subagent/agents.ts b/pi/extensions/subagent/agents.ts new file mode 100644 index 0000000..7d2ea86 --- /dev/null +++ b/pi/extensions/subagent/agents.ts @@ -0,0 +1,186 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent"; + +export type AgentScope = "global" | "project" | "both"; +export type AgentSource = "bundled" | "user" | "project" | "unknown"; + +export interface AgentConfig { + name: string; + description: string; + tools?: string[]; + model?: string; + systemPrompt: string; + source: AgentSource; + filePath: string; + parallelSafe: boolean; + role?: string; + tags?: string[]; +} + +export interface AgentDiscoveryResult { + agents: AgentConfig[]; + bundledAgentsDir: string; + userAgentsDir: string; + projectAgentsDir: string | null; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value !== "string") return undefined; + + const normalized = value.trim().toLowerCase(); + if (["true", "yes", "on", "1"].includes(normalized)) return true; + if (["false", "no", "off", "0"].includes(normalized)) return false; + return undefined; +} + +function readStringList(value: unknown): string[] | undefined { + if (Array.isArray(value)) { + const items = value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter(Boolean); + return items.length > 0 ? items : undefined; + } + + if (typeof value === "string") { + const items = value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + return items.length > 0 ? items : undefined; + } + + return undefined; +} + +function isDirectory(pathname: string): boolean { + try { + return fs.statSync(pathname).isDirectory(); + } catch { + return false; + } +} + +function getBundledAgentsDir(): string { + return fileURLToPath(new URL("./agents", import.meta.url)); +} + +function getUserAgentsDir(): string { + return path.join(getAgentDir(), "agents"); +} + +function findNearestProjectAgentsDir(cwd: string): string | null { + let currentDir = cwd; + while (true) { + const candidate = path.join(currentDir, ".pi", "agents"); + if (isDirectory(candidate)) return candidate; + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) return null; + currentDir = parentDir; + } +} + +function loadAgentsFromDir(dir: string, source: Exclude): AgentConfig[] { + const agents: AgentConfig[] = []; + + if (!isDirectory(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); + const name = readString(frontmatter.name); + const description = readString(frontmatter.description); + + if (!name || !description) continue; + + agents.push({ + name, + description, + tools: readStringList(frontmatter.tools), + model: readString(frontmatter.model), + systemPrompt: body.trim(), + source, + filePath, + parallelSafe: readBoolean(frontmatter.parallelSafe) ?? false, + role: readString(frontmatter.role), + tags: readStringList(frontmatter.tags), + }); + } + + return agents; +} + +export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult { + const bundledAgentsDir = getBundledAgentsDir(); + const userAgentsDir = getUserAgentsDir(); + const projectAgentsDir = findNearestProjectAgentsDir(cwd); + + const bundledAgents = loadAgentsFromDir(bundledAgentsDir, "bundled"); + const userAgents = scope === "project" ? [] : loadAgentsFromDir(userAgentsDir, "user"); + const projectAgents = scope === "global" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project"); + + const agentMap = new Map(); + + if (scope === "global") { + for (const agent of bundledAgents) agentMap.set(agent.name, agent); + for (const agent of userAgents) agentMap.set(agent.name, agent); + } + + if (scope === "project") { + for (const agent of projectAgents) agentMap.set(agent.name, agent); + } + + if (scope === "both") { + for (const agent of bundledAgents) agentMap.set(agent.name, agent); + for (const agent of userAgents) agentMap.set(agent.name, agent); + for (const agent of projectAgents) agentMap.set(agent.name, agent); + } + + return { + agents: Array.from(agentMap.values()), + bundledAgentsDir, + userAgentsDir, + 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 = Math.max(0, agents.length - listed.length); + const text = listed + .map((agent) => { + const mode = agent.parallelSafe ? "parallel" : "serial"; + const role = agent.role ? ` ${agent.role}` : ""; + return `${agent.name} (${agent.source}, ${mode}${role ? `, ${role}` : ""})`; + }) + .join(", "); + + return { text, remaining }; +} diff --git a/pi/extensions/subagent/agents/implementer.md b/pi/extensions/subagent/agents/implementer.md new file mode 100644 index 0000000..4567bc1 --- /dev/null +++ b/pi/extensions/subagent/agents/implementer.md @@ -0,0 +1,34 @@ +--- +name: implementer +description: Executes approved plans and makes code changes carefully +tools: read, grep, find, ls, bash, edit, write +model: openai-codex/gpt-5.3-codex +parallelSafe: false +role: implementer +tags: implementation, coding, execution +--- + +You are an implementation specialist operating in an isolated subagent context. + +Your job is to make the requested change safely and completely. + +Rules: +- Follow the provided plan if one exists. +- Prefer minimal, targeted edits. +- Do not make unrelated refactors. +- When using bash, keep commands focused on verification and repository-aware workflows. +- If the task is ambiguous, make the smallest reasonable assumption and document it. + +Output format: + +## Completed +What you changed. + +## Files Changed +- `path/to/file.ts` - summary of change + +## Validation +Commands run, checks performed, or why validation was not run. + +## Notes +Any assumptions, follow-up items, or risks for a reviewer. diff --git a/pi/extensions/subagent/agents/librarian.md b/pi/extensions/subagent/agents/librarian.md new file mode 100644 index 0000000..2a94897 --- /dev/null +++ b/pi/extensions/subagent/agents/librarian.md @@ -0,0 +1,39 @@ +--- +name: librarian +description: Retrieves references, patterns, prior art, and supporting context +tools: read, grep, find, ls, exa_search, exa_code +model: zai/glm-5.1 +parallelSafe: true +role: librarian +tags: search, references, indexing, docs +--- + +You are a librarian. + +Your job is to gather supporting references from the repository so other agents can reason faster. + +Focus on: +- similar implementations elsewhere in the repo +- naming conventions and file patterns +- related tests, docs, or configs +- existing abstractions worth reusing +- official docs, external references, and code examples when local context is not enough + +Tool preference: +- use `exa_search` for official docs, web references, release notes, or broader external research +- use `exa_code` for library usage patterns, OSS examples, and coding-specific prior art +- use local repo tools first when the answer is already likely in the current codebase + +Output format: + +## Relevant References +- `path/to/file.ts` - why it is relevant + +## Reusable Patterns +Summarize useful conventions or implementation patterns. + +## Related Tests / Docs +Point to tests, fixtures, docs, or configs that should be consulted. + +## Recommendation +What other agent should do with these references. diff --git a/pi/extensions/subagent/agents/planner.md b/pi/extensions/subagent/agents/planner.md new file mode 100644 index 0000000..bc36ff4 --- /dev/null +++ b/pi/extensions/subagent/agents/planner.md @@ -0,0 +1,36 @@ +--- +name: planner +description: Turns requirements and findings into a concrete execution plan +tools: read, grep, find, ls +model: openai-codex/gpt-5.4:xhigh +parallelSafe: true +role: planner +tags: planning, design, execution +--- + +You are a planning specialist. + +You receive requirements, codebase findings, or both. Produce a concrete implementation plan that a separate implementation agent can follow. + +Rules: +- Do not modify code. +- Do not invent files or architecture without stating that they are proposals. +- Keep steps small, ordered, and testable. +- Prefer minimal, low-risk changes over broad rewrites. + +Output format: + +## Goal +One concise sentence. + +## Plan +Numbered, execution-ready steps. + +## Files to Touch +List likely files and the intended change in each. + +## Validation +List the checks, commands, or behavioral verification the implementer should run. + +## Risks +Call out breakage risks, hidden dependencies, or edge cases. diff --git a/pi/extensions/subagent/agents/reviewer.md b/pi/extensions/subagent/agents/reviewer.md new file mode 100644 index 0000000..64b7dce --- /dev/null +++ b/pi/extensions/subagent/agents/reviewer.md @@ -0,0 +1,37 @@ +--- +name: reviewer +description: Reviews code and plans for correctness, regressions, and maintainability +tools: read, grep, find, ls +model: openai-codex/gpt-5.4:xhigh +parallelSafe: true +role: reviewer +tags: review, quality, safety +--- + +You are a reviewer. + +Your job is to evaluate the delegated work for correctness, regression risk, maintainability, and missing validation. + +Focus on: +- logic bugs +- edge cases +- mismatches between plan and implementation +- missing tests or validation +- unnecessary complexity + +Output format: + +## Summary +Two or three sentences on overall quality. + +## Must Fix +Critical issues that block merging. + +## Should Fix +Important but non-blocking issues. + +## Nice to Improve +Optional cleanups or simplifications. + +## Validation Gaps +What should still be checked. diff --git a/pi/extensions/subagent/agents/scout.md b/pi/extensions/subagent/agents/scout.md new file mode 100644 index 0000000..700b0d3 --- /dev/null +++ b/pi/extensions/subagent/agents/scout.md @@ -0,0 +1,40 @@ +--- +name: scout +description: Fast codebase recon for locating relevant files, symbols, and flows +tools: read, grep, find, ls +model: zai/glm-5.1 +parallelSafe: true +role: scout +tags: search, recon, context +--- + +You are a scout. + +Your job is to quickly investigate a codebase and return compressed, high-signal findings that another agent can use without re-reading everything from scratch. + +You are optimized for: +- locating the right files +- tracing imports and call paths +- identifying key types, interfaces, and entrypoints +- narrowing the search space for planner/reviewer/implementer agents + +Do not propose broad speculative rewrites. Stay concrete. + +Output format: + +## Goal +Restate the delegated task in one or two lines. + +## Key Files +List exact files and why they matter. +- `path/to/file.ts` - purpose +- `path/to/other.ts` - purpose + +## Important Findings +Bullet the most relevant facts, APIs, constraints, and relationships. + +## Suggested Next Read +Name the 1-3 files another agent should inspect first, and why. + +## Open Questions +Any ambiguity or missing context that another agent should verify. diff --git a/pi/extensions/subagent/index.ts b/pi/extensions/subagent/index.ts new file mode 100644 index 0000000..611b020 --- /dev/null +++ b/pi/extensions/subagent/index.ts @@ -0,0 +1,1009 @@ +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 { fileURLToPath } from "node:url"; +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; +import type { Message } from "@mariozechner/pi-ai"; +import { StringEnum } from "@mariozechner/pi-ai"; +import { type ExtensionAPI, getMarkdownTheme, withFileMutationQueue } from "@mariozechner/pi-coding-agent"; +import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { type AgentConfig, type AgentScope, type AgentSource, discoverAgents, formatAgentList } from "./agents.js"; + +const MAX_PARALLEL_TASKS = 8; +const MAX_CONCURRENCY = 4; +const COLLAPSED_ITEM_COUNT = 10; +const BUNDLED_PROMPTS_DIR = fileURLToPath(new URL("./prompts", import.meta.url)); + +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 = (pathname: string) => { + const home = os.homedir(); + return pathname.startsWith(home) ? `~${pathname.slice(home.length)}` : pathname; + }; + + 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: AgentSource; + 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; + bundledAgentsDir: string; + userAgentsDir: string; + projectAgentsDir: string | null; + results: SingleResult[]; +} + +function getFinalOutput(messages: Message[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role !== "assistant") continue; + + for (const part of message.content) { + if (part.type === "text") return part.text; + } + } + return ""; +} + +type DisplayItem = + | { type: "text"; text: string } + | { type: "toolCall"; name: string; args: Record }; + +function getDisplayItems(messages: Message[]): DisplayItem[] { + const items: DisplayItem[] = []; + + for (const message of messages) { + if (message.role !== "assistant") continue; + + for (const part of message.content) { + if (part.type === "text") items.push({ type: "text", text: part.text }); + 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]; + if (currentScript && 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; + +async function runSingleAgent( + defaultCwd: string, + agents: AgentConfig[], + agentName: string, + task: string, + cwd: string | undefined, + step: number | undefined, + signal: AbortSignal | undefined, + onUpdate: OnUpdateCallback | undefined, + makeDetails: (results: SingleResult[]) => SubagentDetails, +): Promise { + const agent = agents.find((entry) => entry.name === agentName); + + if (!agent) { + const available = agents.map((entry) => `"${entry.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"]; + if (agent.model) args.push("--model", agent.model); + if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(",")); + + let tmpPromptDir: string | null = null; + let tmpPromptPath: string | null = null; + + const currentResult: SingleResult = { + agent: agentName, + agentSource: agent.source, + task, + exitCode: 0, + messages: [], + stderr: "", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, + model: agent.model, + step, + }; + + const emitUpdate = () => { + if (!onUpdate) return; + 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 message = event.message as Message; + currentResult.messages.push(message); + + if (message.role === "assistant") { + currentResult.usage.turns++; + const usage = message.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 && message.model) currentResult.model = message.model; + if (message.stopReason) currentResult.stopReason = message.stopReason; + if (message.errorMessage) currentResult.errorMessage = message.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 cleanup errors + } + } + + if (tmpPromptDir) { + try { + fs.rmdirSync(tmpPromptDir); + } catch { + // ignore cleanup errors + } + } + } +} + +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(["global", "project", "both"] as const, { + description: + 'Which agent directories to use. Default: "global". Global includes bundled agents from this extension plus ~/.pi/agent/agents. Use "both" to include project-local .pi/agents.', + default: "global", +}); + +const SubagentParams = Type.Object({ + agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })), + task: Type.Optional(Type.String({ description: "Task to delegate (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)" })), +}); + +function buildDiscoveryNotes(discovery: ReturnType): string[] { + const lines = [ + `Bundled: ${discovery.bundledAgentsDir}`, + `User: ${discovery.userAgentsDir}`, + `Project: ${discovery.projectAgentsDir ?? "(not found)"}`, + ]; + return lines; +} + +export default function (pi: ExtensionAPI) { + pi.on("resources_discover", async () => { + if (!fs.existsSync(BUNDLED_PROMPTS_DIR)) return; + return { promptPaths: [BUNDLED_PROMPTS_DIR] }; + }); + + pi.registerCommand("subagents", { + description: "List available subagents and their sources", + handler: async (args, ctx) => { + const requestedScope = args?.trim(); + const agentScope: AgentScope = + requestedScope === "project" || requestedScope === "both" || requestedScope === "global" + ? requestedScope + : "global"; + const discovery = discoverAgents(ctx.cwd, agentScope); + const lines = [ + `Subagents [${agentScope}]`, + ...buildDiscoveryNotes(discovery), + "", + ]; + + if (discovery.agents.length === 0) { + lines.push("No agents found."); + } else { + for (const agent of discovery.agents) { + const parallelMode = agent.parallelSafe ? "parallel-safe" : "serial-only"; + const model = agent.model ?? "(default model)"; + const tools = agent.tools?.join(", ") ?? "(default tools)"; + lines.push(`- ${agent.name} [${agent.source}]`); + lines.push(` role: ${agent.role ?? "(unspecified)"}`); + lines.push(` mode: ${parallelMode}`); + lines.push(` model: ${model}`); + lines.push(` tools: ${tools}`); + lines.push(` file: ${agent.filePath}`); + lines.push(` description: ${agent.description}`); + if (agent.tags && agent.tags.length > 0) lines.push(` tags: ${agent.tags.join(", ")}`); + lines.push(""); + } + } + + if (ctx.hasUI) { + ctx.ui.setEditorText(lines.join("\n").trim()); + ctx.ui.notify(`Loaded ${discovery.agents.length} subagent(s)`, "info"); + } else { + console.log(lines.join("\n")); + } + }, + }); + + pi.registerTool({ + name: "subagent", + label: "Subagent", + description: [ + "Delegate tasks to specialized subagents with isolated context windows.", + "Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).", + 'Default agent scope is "global" (bundled extension agents plus ~/.pi/agent/agents).', + 'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").', + "Parallel mode is intended for read-only or explicitly parallel-safe agents.", + ].join(" "), + promptSnippet: "Delegate recon, planning, implementation, review, or librarian tasks to specialized subagents.", + promptGuidelines: [ + "Use parallel subagents for independent read/search/review tasks.", + "Do not run implementation agents in parallel unless the agent is explicitly marked safe for parallel use.", + "Use chain mode for scout -> planner -> implementer or implementer -> reviewer handoffs.", + ], + parameters: SubagentParams, + + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const agentScope: AgentScope = params.agentScope ?? "global"; + 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, + bundledAgentsDir: discovery.bundledAgentsDir, + userAgentsDir: discovery.userAgentsDir, + projectAgentsDir: discovery.projectAgentsDir, + results, + }); + + if (modeCount !== 1) { + const { text, remaining } = formatAgentList(agents, 8); + const suffix = remaining > 0 ? ` (+${remaining} more)` : ""; + return { + content: [{ type: "text", text: `Invalid parameters. Provide exactly one mode. Available agents: ${text}${suffix}` }], + details: makeDetails("single")([]), + }; + } + + if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) { + const requestedAgentNames = new Set(); + if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent); + if (params.tasks) for (const task of params.tasks) requestedAgentNames.add(task.agent); + if (params.agent) requestedAgentNames.add(params.agent); + + const projectAgentsRequested = Array.from(requestedAgentNames) + .map((name) => agents.find((entry) => entry.name === name)) + .filter((entry): entry is AgentConfig => entry?.source === "project"); + + if (projectAgentsRequested.length > 0) { + const names = projectAgentsRequested.map((entry) => entry.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); + + const chainUpdate: OnUpdateCallback | undefined = onUpdate + ? (partial) => { + const currentResult = partial.details?.results[0]; + if (!currentResult) return; + onUpdate({ + content: partial.content, + details: makeDetails("chain")([...results, currentResult]), + }); + } + : undefined; + + const result = await runSingleAgent( + ctx.cwd, + agents, + step.agent, + taskWithContext, + step.cwd, + i + 1, + signal, + chainUpdate, + makeDetails("chain"), + ); + results.push(result); + + const isError = + result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; + if (isError) { + const errorMessage = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; + return { + content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMessage}` }], + details: makeDetails("chain")(results), + }; + } + + 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")([]), + }; + } + + const unsafeAgents = Array.from(new Set(params.tasks.map((task) => task.agent))) + .map((name) => agents.find((entry) => entry.name === name)) + .filter((entry): entry is AgentConfig => Boolean(entry && !entry.parallelSafe)); + + if (unsafeAgents.length > 0) { + const names = unsafeAgents.map((entry) => `${entry.name} [${entry.source}]`).join(", "); + return { + content: [{ type: "text", text: `Parallel mode blocked. These agents are serial-only: ${names}.` }], + details: makeDetails("parallel")([]), + }; + } + + const allResults: SingleResult[] = new Array(params.tasks.length); + 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, + messages: [], + stderr: "", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 }, + }; + } + + const emitParallelUpdate = () => { + if (!onUpdate) return; + const running = allResults.filter((result) => result.exitCode === -1).length; + const done = allResults.filter((result) => result.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 (task, index) => { + const result = await runSingleAgent( + ctx.cwd, + agents, + task.agent, + task.task, + task.cwd, + undefined, + signal, + (partial) => { + if (!partial.details?.results[0]) return; + allResults[index] = partial.details.results[0]; + emitParallelUpdate(); + }, + makeDetails("parallel"), + ); + allResults[index] = result; + emitParallelUpdate(); + return result; + }); + + const successCount = results.filter((result) => result.exitCode === 0).length; + const summaries = results.map((result) => { + const output = getFinalOutput(result.messages); + const preview = output.slice(0, 100) + (output.length > 100 ? "..." : ""); + return `[${result.agent}] ${result.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`; + }); + + return { + content: [{ type: "text", text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}` }], + details: makeDetails("parallel")(results), + }; + } + + if (params.agent && params.task) { + const result = await runSingleAgent( + ctx.cwd, + agents, + params.agent, + params.task, + params.cwd, + undefined, + signal, + onUpdate, + makeDetails("single"), + ); + + const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; + if (isError) { + const errorMessage = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; + return { + content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMessage}` }], + details: makeDetails("single")([result]), + }; + } + + return { + content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }], + details: makeDetails("single")([result]), + }; + } + + const { text, remaining } = formatAgentList(agents, 8); + const suffix = remaining > 0 ? ` (+${remaining} more)` : ""; + return { + content: [{ type: "text", text: `Invalid parameters. Available agents: ${text}${suffix}` }], + details: makeDetails("single")([]), + }; + }, + + renderCall(args, theme) { + const scope: AgentScope = args.agentScope ?? "global"; + + 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]; + 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 task of args.tasks.slice(0, 3)) { + const preview = task.task.length > 40 ? `${task.task.slice(0, 40)}...` : task.task; + text += `\n ${theme.fg("accent", task.agent)}${theme.fg("dim", ` ${preview}`)}`; + } + if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`; + return new Text(text, 0, 0); + } + + const agentName = args.agent || "..."; + const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "..."; + let text = + theme.fg("toolTitle", theme.bold("subagent ")) + + theme.fg("accent", agentName) + + theme.fg("muted", ` [${scope}]`); + text += `\n ${theme.fg("dim", preview)}`; + return new Text(text, 0, 0); + }, + + renderResult(result, { expanded }, theme) { + const details = result.details as SubagentDetails | undefined; + if (!details || details.results.length === 0) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0); + } + + const mdTheme = getMarkdownTheme(); + + const renderDisplayItems = (items: DisplayItem[], limit?: number) => { + const toShow = limit ? items.slice(-limit) : items; + const skipped = limit && items.length > limit ? items.length - limit : 0; + let text = ""; + if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`); + for (const item of toShow) { + if (item.type === "text") { + const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n"); + text += `${theme.fg("toolOutput", preview)}\n`; + } else { + text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`; + } + } + return text.trimEnd(); + }; + + if (details.mode === "single" && details.results.length === 1) { + const entry = details.results[0]; + const isError = entry.exitCode !== 0 || entry.stopReason === "error" || entry.stopReason === "aborted"; + const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓"); + const displayItems = getDisplayItems(entry.messages); + const finalOutput = getFinalOutput(entry.messages); + + if (expanded) { + const container = new Container(); + let header = `${icon} ${theme.fg("toolTitle", theme.bold(entry.agent))}${theme.fg("muted", ` (${entry.agentSource})`)}`; + if (isError && entry.stopReason) header += ` ${theme.fg("error", `[${entry.stopReason}]`)}`; + container.addChild(new Text(header, 0, 0)); + if (isError && entry.errorMessage) container.addChild(new Text(theme.fg("error", `Error: ${entry.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", entry.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 usageText = formatUsageStats(entry.usage, entry.model); + if (usageText) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("dim", usageText), 0, 0)); + } + return container; + } + + let text = `${icon} ${theme.fg("toolTitle", theme.bold(entry.agent))}${theme.fg("muted", ` (${entry.agentSource})`)}`; + if (isError && entry.stopReason) text += ` ${theme.fg("error", `[${entry.stopReason}]`)}`; + if (isError && entry.errorMessage) { + text += `\n${theme.fg("error", `Error: ${entry.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 usageText = formatUsageStats(entry.usage, entry.model); + if (usageText) text += `\n${theme.fg("dim", usageText)}`; + return new Text(text, 0, 0); + } + + const aggregateUsage = (entries: SingleResult[]) => { + const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }; + for (const entry of entries) { + total.input += entry.usage.input; + total.output += entry.usage.output; + total.cacheRead += entry.usage.cacheRead; + total.cacheWrite += entry.usage.cacheWrite; + total.cost += entry.usage.cost; + total.turns += entry.usage.turns; + } + return total; + }; + + if (details.mode === "chain") { + const successCount = details.results.filter((entry) => entry.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 entry of details.results) { + const entryIcon = entry.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const displayItems = getDisplayItems(entry.messages); + const finalOutput = getFinalOutput(entry.messages); + + container.addChild(new Spacer(1)); + container.addChild( + new Text(`${theme.fg("muted", `─── Step ${entry.step}: `)}${theme.fg("accent", entry.agent)} ${entryIcon}`, 0, 0), + ); + container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", entry.task), 0, 0)); + + 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 usageText = formatUsageStats(entry.usage, entry.model); + if (usageText) container.addChild(new Text(theme.fg("dim", usageText), 0, 0)); + } + + const usageText = formatUsageStats(aggregateUsage(details.results)); + if (usageText) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("dim", `Total: ${usageText}`), 0, 0)); + } + return container; + } + + let text = `${icon} ${theme.fg("toolTitle", theme.bold("chain "))}${theme.fg("accent", `${successCount}/${details.results.length} steps`)}`; + for (const entry of details.results) { + const entryIcon = entry.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const displayItems = getDisplayItems(entry.messages); + text += `\n\n${theme.fg("muted", `─── Step ${entry.step}: `)}${theme.fg("accent", entry.agent)} ${entryIcon}`; + if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`; + else text += `\n${renderDisplayItems(displayItems, 5)}`; + } + const usageText = formatUsageStats(aggregateUsage(details.results)); + if (usageText) text += `\n\n${theme.fg("dim", `Total: ${usageText}`)}`; + text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`; + return new Text(text, 0, 0); + } + + if (details.mode === "parallel") { + const running = details.results.filter((entry) => entry.exitCode === -1).length; + const successCount = details.results.filter((entry) => entry.exitCode === 0).length; + const failCount = details.results.filter((entry) => entry.exitCode > 0).length; + const isRunning = running > 0; + const icon = isRunning + ? theme.fg("warning", "⏳") + : failCount > 0 + ? theme.fg("warning", "◐") + : theme.fg("success", "✓"); + const status = isRunning + ? `${successCount + failCount}/${details.results.length} done, ${running} running` + : `${successCount}/${details.results.length} tasks`; + + if (expanded && !isRunning) { + const container = new Container(); + container.addChild( + new Text(`${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`, 0, 0), + ); + + for (const entry of details.results) { + const entryIcon = entry.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗"); + const displayItems = getDisplayItems(entry.messages); + const finalOutput = getFinalOutput(entry.messages); + + container.addChild(new Spacer(1)); + container.addChild(new Text(`${theme.fg("muted", "─── ")}${theme.fg("accent", entry.agent)} ${entryIcon}`, 0, 0)); + container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", entry.task), 0, 0)); + + 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 usageText = formatUsageStats(entry.usage, entry.model); + if (usageText) container.addChild(new Text(theme.fg("dim", usageText), 0, 0)); + } + + const usageText = formatUsageStats(aggregateUsage(details.results)); + if (usageText) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("dim", `Total: ${usageText}`), 0, 0)); + } + return container; + } + + let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`; + for (const entry of details.results) { + const entryIcon = + entry.exitCode === -1 + ? theme.fg("warning", "⏳") + : entry.exitCode === 0 + ? theme.fg("success", "✓") + : theme.fg("error", "✗"); + const displayItems = getDisplayItems(entry.messages); + text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", entry.agent)} ${entryIcon}`; + if (displayItems.length === 0) { + text += `\n${theme.fg("muted", entry.exitCode === -1 ? "(running...)" : "(no output)")}`; + } else { + text += `\n${renderDisplayItems(displayItems, 5)}`; + } + } + if (!isRunning) { + const usageText = formatUsageStats(aggregateUsage(details.results)); + if (usageText) text += `\n\n${theme.fg("dim", `Total: ${usageText}`)}`; + } + 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/pi/extensions/subagent/prompts/implement-and-review.md b/pi/extensions/subagent/prompts/implement-and-review.md new file mode 100644 index 0000000..b9c64a4 --- /dev/null +++ b/pi/extensions/subagent/prompts/implement-and-review.md @@ -0,0 +1,10 @@ +--- +description: Implementer makes the change, reviewer critiques it, implementer applies the review +--- +Use the `subagent` tool with `chain` mode for this workflow: + +1. Run `implementer` to implement: $@ +2. Run `reviewer` to review the implementation output using `{previous}` +3. Run `implementer` again to apply the review feedback using `{previous}` + +Use a chain so each step receives the prior step's output via `{previous}`. diff --git a/pi/extensions/subagent/prompts/implement.md b/pi/extensions/subagent/prompts/implement.md new file mode 100644 index 0000000..43b9720 --- /dev/null +++ b/pi/extensions/subagent/prompts/implement.md @@ -0,0 +1,10 @@ +--- +description: Scout gathers context, planner creates a plan, implementer applies it +--- +Use the `subagent` tool with `chain` mode for this workflow: + +1. Run `scout` to gather the most relevant code paths for: $@ +2. Run `planner` to turn the findings into an implementation plan for: $@ +3. Run `implementer` to execute the plan from the previous step using `{previous}` + +Use a chain so each step receives the prior step's output via `{previous}`. diff --git a/pi/extensions/subagent/prompts/parallel-scout.md b/pi/extensions/subagent/prompts/parallel-scout.md new file mode 100644 index 0000000..f8d06bc --- /dev/null +++ b/pi/extensions/subagent/prompts/parallel-scout.md @@ -0,0 +1,9 @@ +--- +description: Run scout and librarian in parallel, then summarize their findings +--- +Use the `subagent` tool with `tasks` mode to run these in parallel for: $@ + +- `scout`: identify the most relevant files, flows, and entrypoints +- `librarian`: find related patterns, prior art, tests, or docs + +After both results return, synthesize them into one concise summary with recommended next steps. diff --git a/pi/extensions/subagent/prompts/research.md b/pi/extensions/subagent/prompts/research.md new file mode 100644 index 0000000..f2b47be --- /dev/null +++ b/pi/extensions/subagent/prompts/research.md @@ -0,0 +1,28 @@ +--- +description: Run local scout and external librarian research in parallel, then synthesize grounded guidance +--- +Use the `subagent` tool with `tasks` mode to run these in parallel for: $@ + +- `scout`: inspect the current repository and identify the most relevant local files, entrypoints, constraints, and likely change surface +- `librarian`: gather external references and coding prior art, using `exa_search` for docs/web research and `exa_code` for implementation examples when useful + +After both results return: + +1. Synthesize them into one grounded response. +2. Clearly separate: + - local repository findings + - external references and docs + - recommended implementation patterns +3. Prefer official docs and high-signal sources over generic summaries. +4. Call out where external advice may not fit this repository's existing architecture. +5. Do **not** implement changes unless the user explicitly asks. + +Return the final answer in this structure: + +## Goal +## Local Findings +## External References +## Recommended Pattern +## Risks / Caveats +## Next Steps +## Sources diff --git a/pi/extensions/subagent/prompts/scout-and-plan.md b/pi/extensions/subagent/prompts/scout-and-plan.md new file mode 100644 index 0000000..cb2d84b --- /dev/null +++ b/pi/extensions/subagent/prompts/scout-and-plan.md @@ -0,0 +1,9 @@ +--- +description: Scout gathers context, planner turns it into a concrete plan without implementation +--- +Use the `subagent` tool with `chain` mode for this workflow: + +1. Run `scout` to gather the most relevant code paths for: $@ +2. Run `planner` to create a concrete implementation plan for: $@ using `{previous}` as context + +Return the plan only. Do not implement changes. diff --git a/pi/prompts/.keep b/pi/prompts/.keep new file mode 100644 index 0000000..e69de29 diff --git a/pi/skills/.keep b/pi/skills/.keep new file mode 100644 index 0000000..e69de29 diff --git a/pi/themes/.keep b/pi/themes/.keep new file mode 100644 index 0000000..e69de29