From 7879b5aada5d11304893e297a4debc476a8ba898 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Fri, 19 Jun 2026 18:47:43 +0700 Subject: [PATCH] patch pi codex websocket usage-limit error mapping remove deprecated subagent extension (to be remade later) --- flake.lock | 24 +- modules/home/devtools/ai-tools.nix | 25 +- patches/pi/fix-codex-ws-usage-limit.mjs | 123 ++ pi/extensions/exa-tools/README.md | 3 - pi/extensions/subagent/README.md | 131 --- pi/extensions/subagent/agents.ts | 186 --- pi/extensions/subagent/agents/implementer.md | 34 - pi/extensions/subagent/agents/librarian.md | 39 - pi/extensions/subagent/agents/planner.md | 36 - pi/extensions/subagent/agents/reviewer.md | 37 - pi/extensions/subagent/agents/scout.md | 40 - pi/extensions/subagent/index.ts | 1009 ----------------- .../subagent/prompts/implement-and-review.md | 10 - pi/extensions/subagent/prompts/implement.md | 10 - .../subagent/prompts/parallel-scout.md | 9 - pi/extensions/subagent/prompts/research.md | 28 - .../subagent/prompts/scout-and-plan.md | 9 - 17 files changed, 154 insertions(+), 1599 deletions(-) create mode 100644 patches/pi/fix-codex-ws-usage-limit.mjs delete mode 100644 pi/extensions/subagent/README.md delete mode 100644 pi/extensions/subagent/agents.ts delete mode 100644 pi/extensions/subagent/agents/implementer.md delete mode 100644 pi/extensions/subagent/agents/librarian.md delete mode 100644 pi/extensions/subagent/agents/planner.md delete mode 100644 pi/extensions/subagent/agents/reviewer.md delete mode 100644 pi/extensions/subagent/agents/scout.md delete mode 100644 pi/extensions/subagent/index.ts delete mode 100644 pi/extensions/subagent/prompts/implement-and-review.md delete mode 100644 pi/extensions/subagent/prompts/implement.md delete mode 100644 pi/extensions/subagent/prompts/parallel-scout.md delete mode 100644 pi/extensions/subagent/prompts/research.md delete mode 100644 pi/extensions/subagent/prompts/scout-and-plan.md diff --git a/flake.lock b/flake.lock index 7f0c7e4..a69798d 100644 --- a/flake.lock +++ b/flake.lock @@ -67,11 +67,11 @@ ] }, "locked": { - "lastModified": 1778446047, - "narHash": "sha256-oQvcadh2BCkrog+SGrG6YffKJrveYpjj3TdQJWaKhaM=", + "lastModified": 1782772816, + "narHash": "sha256-s9BuFv0mRuZx9C1MF8qPHRdcAK14ONi0A5m6E2wqOoM=", "owner": "nix-community", "repo": "bun2nix", - "rev": "f2bc12af1a6369648aac41041ceeaa0b866599c6", + "rev": "5a39d717029e94163ac223aee8d5c9946cafed1c", "type": "github" }, "original": { @@ -131,11 +131,11 @@ ] }, "locked": { - "lastModified": 1778716662, - "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", + "lastModified": 1782949081, + "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", + "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", "type": "github" }, "original": { @@ -249,11 +249,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1781849779, - "narHash": "sha256-Ihdla0v+oCO6dNN6iExgdGc9+JmKLmAnbiTneiJgZbE=", + "lastModified": 1783669679, + "narHash": "sha256-WDGDMsPXxceC0k1ktqcobmwE4GCTrzzULUKlp1CzSKQ=", "owner": "numtide", "repo": "llm-agents.nix", - "rev": "a1e67cc315b6bd924614d4232e09bab54df3ae39", + "rev": "6e9f6664e72433966557ab83a7c3e8c0bbf64a44", "type": "github" }, "original": { @@ -355,11 +355,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1781607440, - "narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=", + "lastModified": 1783279667, + "narHash": "sha256-/NAkDSsve+GNM0Bt6tleJdCGfsTlK89nPjkVOzZMo0s=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3e41b24abd260e8f71dbe2f5737d24122f972158", + "rev": "f205b5574fd0cb7da5b702a2da51507b7f4fdd1b", "type": "github" }, "original": { diff --git a/modules/home/devtools/ai-tools.nix b/modules/home/devtools/ai-tools.nix index 4ccac68..6cd10cf 100644 --- a/modules/home/devtools/ai-tools.nix +++ b/modules/home/devtools/ai-tools.nix @@ -23,6 +23,18 @@ let # Pinned llm-agents for Claude Code 2.0.64 llmPkgsPinned = inputs.llm-agents-pinned.packages.${system}; + # pi with a local patch applied: maps Codex usage-limit errors that arrive + # over the (default) WebSocket transport to the same friendly + # "You have hit your ChatGPT usage limit" message the SSE path already emits. + # Without it, a normal ChatGPT-subscription quota trip surfaces as the raw, + # misleading "exceeded your current quota / check your billing" API text. + # See patches/pi/fix-codex-ws-usage-limit.mjs. + piPatched = llmPkgs.pi.overrideAttrs (o: { + postInstall = (o.postInstall or "") + '' + ${pkgs.nodejs}/bin/node ${./../../../patches/pi/fix-codex-ws-usage-limit.mjs} $out/lib/node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/api/openai-codex-responses.js + ''; + }); + # Z.AI Gateway wrapper for Claude Code zaiWrapperPackages = let @@ -275,18 +287,19 @@ in home.packages = [ llmPkgs.claude-code # latest llmPkgs.opencode # latest - llmPkgs.pi + piPatched llmPkgs.ccstatusline # latest llmPkgs.ccusage # latest llmPkgs.codex llmPkgs.rtk + llmPkgs.cursor-agent ] ++ zaiWrapperPackages ++ kimiWrapperPackages ++ cfg.extraPackages; home.activation.installPiFff = lib.hm.dag.entryAfter [ "writeBoundary" ] '' - if [ -x ${lib.getExe llmPkgs.pi} ]; then + if [ -x ${lib.getExe piPatched} ]; then export PI_SKIP_VERSION_CHECK=1 export PI_TELEMETRY=0 export PATH=${pkgs.nodejs}/bin:${pkgs.git}/bin:$PATH @@ -295,10 +308,10 @@ in mkdir -p "$(dirname "$settings_file")" ${pkgs.nodejs}/bin/node -e "const fs=require('fs'); const path=process.env.HOME+'/.pi/agent/settings.json'; let settings={}; if (fs.existsSync(path)) settings=JSON.parse(fs.readFileSync(path,'utf8')); settings.theme='dark'; fs.writeFileSync(path, JSON.stringify(settings, null, 2)+String.fromCharCode(10));" - ${lib.getExe llmPkgs.pi} list | ${pkgs.gnugrep}/bin/grep -q '@ff-labs/pi-fff' || \ - ${lib.getExe llmPkgs.pi} install npm:@ff-labs/pi-fff@0.9.4 - ${lib.getExe llmPkgs.pi} list | ${pkgs.gnugrep}/bin/grep -q '@juicesharp/rpiv-ask-user-question' || \ - ${lib.getExe llmPkgs.pi} install npm:@juicesharp/rpiv-ask-user-question@1.20.0 + ${lib.getExe piPatched} list | ${pkgs.gnugrep}/bin/grep -q '@ff-labs/pi-fff' || \ + ${lib.getExe piPatched} install npm:@ff-labs/pi-fff@0.9.4 + ${lib.getExe piPatched} list | ${pkgs.gnugrep}/bin/grep -q '@juicesharp/rpiv-ask-user-question' || \ + ${lib.getExe piPatched} install npm:@juicesharp/rpiv-ask-user-question@1.20.0 fi ''; diff --git a/patches/pi/fix-codex-ws-usage-limit.mjs b/patches/pi/fix-codex-ws-usage-limit.mjs new file mode 100644 index 0000000..a8c63ce --- /dev/null +++ b/patches/pi/fix-codex-ws-usage-limit.mjs @@ -0,0 +1,123 @@ +// Patches @earendil-works/pi-ai's openai-codex-responses provider so that +// usage-limit errors arriving over the WebSocket transport are mapped to the +// same friendly "You have hit your ChatGPT usage limit" message that the +// SSE/fetch path already produces via parseErrorResponse(). +// +// Without this, the default `transport: auto` (WebSocket-first) path throws +// the raw upstream string verbatim, e.g.: +// "You exceeded your current quota, please check your plan and billing +// details. For more information on this error, read the docs: +// https://platform.openai.com/docs/guides/error-codes/api-errors." +// which is misleading: the account is a ChatGPT subscription (prolite plan), +// not API billing, and the real limit is a per-window ChatGPT/Codex quota. +// +// Usage: node fix-codex-ws-usage-limit.mjs +import { readFileSync, writeFileSync } from "node:fs"; + +const file = process.argv[2]; +if (!file) { + console.error("fix-codex-ws-usage-limit: missing target file argument"); + process.exit(1); +} + +let src = readFileSync(file, "utf8"); + +// 1. Insert a friendly-message helper just before mapCodexEvents. +const helperAnchor = "async function* mapCodexEvents(events) {"; +if (!src.includes(helperAnchor)) { + console.error( + "fix-codex-ws-usage-limit: anchor 'async function* mapCodexEvents(events) {' not found; refusing to patch an unfamiliar file.", + ); + process.exit(2); +} + +const helper = `function friendlyCodexUsageMessage(code, message, payload) { + const codeStr = typeof code === "string" ? code : ""; + const msgStr = typeof message === "string" ? message : ""; + const isUsageLimit = + /usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(codeStr) || + /insufficient_quota|exceeded your current quota|quota exceeded|out of budget|usage limit|billing/i.test(msgStr); + if (!isUsageLimit) + return undefined; + const src = (payload && typeof payload === "object") + ? (payload.response?.error ?? payload.error ?? payload) + : {}; + const plan = src.plan_type ? \` (\${String(src.plan_type).toLowerCase()} plan)\` : ""; + const resetsAt = src.resets_at ?? payload?.resets_at; + const mins = typeof resetsAt === "number" + ? Math.max(0, Math.round((resetsAt * 1000 - Date.now()) / 60000)) + : undefined; + const when = mins !== undefined ? \` Try again in ~\${mins} min.\` : ""; + return \`You have hit your ChatGPT usage limit\${plan}.\${when}\`.trim(); +} +`; + +if (!src.includes("function friendlyCodexUsageMessage(")) { + src = src.replace(helperAnchor, helper + helperAnchor); +} + +// 2. Map usage-limit errors in the `error` event branch. +const errorBranchOld = ` if (type === "error") { + const code = event.code || ""; + const message = event.message || ""; + throw new CodexApiError(\`Codex error: \${message || code || JSON.stringify(event)}\`, { + code: code || undefined, + payload: event, + }); + }`; +const errorBranchWithExtractorOld = ` if (type === "error") { + const { code, message } = extractCodexEventError(event); + throw new CodexApiError(\`Codex error: \${message || code || JSON.stringify(event)}\`, { + code, + payload: event, + }); + }`; +const errorBranchNew = ` if (type === "error") { + const code = event.code || ""; + const message = event.message || ""; + const friendly = friendlyCodexUsageMessage(code, message, event); + throw new CodexApiError(friendly || \`Codex error: \${message || code || JSON.stringify(event)}\`, { + code: code || undefined, + payload: event, + }); + }`; +const errorBranchWithExtractorNew = ` if (type === "error") { + const { code, message } = extractCodexEventError(event); + const friendly = friendlyCodexUsageMessage(code, message, event); + throw new CodexApiError(friendly || \`Codex error: \${message || code || JSON.stringify(event)}\`, { + code, + payload: event, + }); + }`; +if (src.includes(errorBranchOld)) { + src = src.replace(errorBranchOld, errorBranchNew); +} else if (src.includes(errorBranchWithExtractorOld)) { + src = src.replace(errorBranchWithExtractorOld, errorBranchWithExtractorNew); +} else if (!src.includes("const friendly = friendlyCodexUsageMessage(code, message, event);")) { + console.error("fix-codex-ws-usage-limit: could not locate the 'error' event throw branch; refusing to patch."); + process.exit(3); +} + +// 3. Map usage-limit errors in the `response.failed` event branch. +const failedBranchOld = ` if (type === "response.failed") { + const response = event.response; + const code = response?.error?.code; + const message = response?.error?.message; + throw new CodexApiError(message || "Codex response failed", { code, payload: event }); + }`; +const failedBranchNew = ` if (type === "response.failed") { + const response = event.response; + const code = response?.error?.code; + const message = response?.error?.message; + const friendly = friendlyCodexUsageMessage(code || "", message || "", event); + throw new CodexApiError(friendly || message || "Codex response failed", { code, payload: event }); + }`; +if (src.includes(failedBranchOld)) { + src = src.replace(failedBranchOld, failedBranchNew); +} else if (!src.includes('const friendly = friendlyCodexUsageMessage(code || "", message || "", event);')) { + console.error("fix-codex-ws-usage-limit: could not locate the 'response.failed' throw branch; refusing to patch."); + process.exit(4); +} + +writeFileSync(file, src); +console.error("fix-codex-ws-usage-limit: patched " + file); diff --git a/pi/extensions/exa-tools/README.md b/pi/extensions/exa-tools/README.md index 29efdeb..6ef5cd1 100644 --- a/pi/extensions/exa-tools/README.md +++ b/pi/extensions/exa-tools/README.md @@ -50,6 +50,3 @@ Coding context: 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/subagent/README.md b/pi/extensions/subagent/README.md deleted file mode 100644 index f2cd1ec..0000000 --- a/pi/extensions/subagent/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# 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 deleted file mode 100644 index 7d2ea86..0000000 --- a/pi/extensions/subagent/agents.ts +++ /dev/null @@ -1,186 +0,0 @@ -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 deleted file mode 100644 index 4567bc1..0000000 --- a/pi/extensions/subagent/agents/implementer.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -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 deleted file mode 100644 index 2a94897..0000000 --- a/pi/extensions/subagent/agents/librarian.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -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 deleted file mode 100644 index bc36ff4..0000000 --- a/pi/extensions/subagent/agents/planner.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -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 deleted file mode 100644 index 64b7dce..0000000 --- a/pi/extensions/subagent/agents/reviewer.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -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 deleted file mode 100644 index 700b0d3..0000000 --- a/pi/extensions/subagent/agents/scout.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -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 deleted file mode 100644 index 611b020..0000000 --- a/pi/extensions/subagent/index.ts +++ /dev/null @@ -1,1009 +0,0 @@ -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 deleted file mode 100644 index b9c64a4..0000000 --- a/pi/extensions/subagent/prompts/implement-and-review.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -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 deleted file mode 100644 index 43b9720..0000000 --- a/pi/extensions/subagent/prompts/implement.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -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 deleted file mode 100644 index f8d06bc..0000000 --- a/pi/extensions/subagent/prompts/parallel-scout.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -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 deleted file mode 100644 index f2b47be..0000000 --- a/pi/extensions/subagent/prompts/research.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -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 deleted file mode 100644 index cb2d84b..0000000 --- a/pi/extensions/subagent/prompts/scout-and-plan.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -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.