// ABOUTME: Sequential pipeline orchestrator that chains agent steps with prompt templates. // ABOUTME: Each step's output feeds into the next via $INPUT; provides run_chain tool and /chain command. /** * Agent Chain — Sequential pipeline orchestrator * * Runs opinionated, repeatable agent workflows. Chains are defined in * .pi/agents/agent-chain.yaml — each chain is a sequence of agent steps * with prompt templates. The user's original prompt flows into step 1, * the output becomes $INPUT for step 2's prompt template, and so on. * $ORIGINAL is always the user's original prompt. * * The primary Pi agent has NO codebase tools — it can ONLY kick off the * pipeline via the `run_chain` tool. On boot you select a chain; the * agent decides when to run it based on the user's prompt. * * Agents maintain session context within a Pi session — re-running the * chain lets each agent resume where it left off. * * Commands: * /chain — switch active chain * /chain-list — list all available chains * /chain-clear — clear chain widget from screen * * Usage: pi -e extensions/agent-chain.ts */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { Text, visibleWidth, truncateToWidth, Container, Spacer, Markdown, matchesKey, Key } from "@mariozechner/pi-tui"; import { DynamicBorder, getMarkdownTheme as getPiMdTheme } from "@mariozechner/pi-coding-agent"; import { spawn } from "child_process"; import { readFileSync, existsSync, readdirSync, mkdirSync, unlinkSync, writeFileSync } from "fs"; import { dirname, join, resolve } from "path"; import { fileURLToPath } from "url"; import { applyExtensionDefaults } from "./lib/themeMap.ts"; import { outputLine } from "./lib/output-box.ts"; import { statusButton } from "./lib/pipeline-render.ts"; import { DEFAULT_SUBAGENT_MODEL } from "./lib/defaults.ts"; import { resolveToolkitWorkerModel } from "./lib/toolkit-cli.ts"; import { loadAgentModelsConfig, resolveAgentModelString, type AgentModelsConfig } from "./lib/agent-defs.ts"; import { parseChainYaml, type ChainStep, type ChainDef } from "./lib/parse-chain-yaml.ts"; // ── Types ──────────────────────────────────────── interface AgentDef { name: string; description: string; tools: string; model: string; // full provider/model ID, empty = use default systemPrompt: string; } interface StepState { agent: string; description: string; status: "pending" | "running" | "done" | "error"; elapsed: number; lastWork: string; } // ── Display Name Helper ────────────────────────── function displayName(name: string): string { return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); } // ── Frontmatter Parser ─────────────────────────── function parseAgentFile(filePath: string, modelsConfig?: AgentModelsConfig): AgentDef | null { try { const raw = readFileSync(filePath, "utf-8"); const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!match) return null; const frontmatter: Record = {}; for (const line of match[1].split("\n")) { const idx = line.indexOf(":"); if (idx > 0) { frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim(); } } if (!frontmatter.name) return null; // Model resolution: models.json > frontmatter fallback > empty let model = ""; if (modelsConfig) { const key = frontmatter.name.toLowerCase(); const entry = modelsConfig.agents[key]; if (entry) { model = resolveAgentModelString(frontmatter.name, modelsConfig); } } if (!model && frontmatter.model) { model = frontmatter.model; } return { name: frontmatter.name, description: frontmatter.description || "", tools: frontmatter.tools || "read,grep,find,ls", model, systemPrompt: match[2].trim(), }; } catch { return null; } } function scanAgentDirs(cwd: string, extProjectDir?: string, modelsConfig?: AgentModelsConfig): Map { const dirs = [ join(cwd, "agents"), join(cwd, ".claude", "agents"), join(cwd, ".pi", "agents"), ...(extProjectDir ? [join(extProjectDir, ".pi", "agents"), join(extProjectDir, "agents")] : []), ]; const agents = new Map(); for (const dir of dirs) { if (!existsSync(dir)) continue; try { const scan = (d: string) => { for (const file of readdirSync(d, { withFileTypes: true })) { const fullPath = resolve(d, file.name); if (file.isDirectory()) { scan(fullPath); } else if (file.name.endsWith(".md")) { const def = parseAgentFile(fullPath, modelsConfig); if (def && !agents.has(def.name.toLowerCase())) { agents.set(def.name.toLowerCase(), def); } } } }; scan(dir); } catch {} } return agents; } // ── Extension ──────────────────────────────────── export default function (pi: ExtensionAPI) { let allAgents: Map = new Map(); let chains: ChainDef[] = []; let activeChain: ChainDef | null = null; let widgetCtx: any; let sessionDir = ""; const agentSessions: Map = new Map(); // Per-step state for the active chain let stepStates: StepState[] = []; let pendingReset = false; let selectedStepIndex = -1; // Track the currently running chain subprocess for cancellation let currentChainProc: any = null; function loadChains(cwd: string) { const extDir = dirname(fileURLToPath(import.meta.url)); const extProjectDir = resolve(extDir, ".."); sessionDir = join(cwd, ".pi", "agent-sessions"); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } // Load model config from .pi/agents/models.json, then scan agent .md files const modelsConfig = loadAgentModelsConfig(cwd, extProjectDir); allAgents = scanAgentDirs(cwd, extProjectDir, modelsConfig); agentSessions.clear(); for (const [key] of allAgents) { const sessionFile = join(sessionDir, `chain-${key}.json`); agentSessions.set(key, existsSync(sessionFile) ? sessionFile : null); } let chainPath = join(cwd, ".pi", "agents", "agent-chain.yaml"); if (!existsSync(chainPath)) { chainPath = join(extProjectDir, ".pi", "agents", "agent-chain.yaml"); } if (!existsSync(chainPath)) { chainPath = join(extProjectDir, "agents", "agent-chain.yaml"); } if (existsSync(chainPath)) { try { chains = parseChainYaml(readFileSync(chainPath, "utf-8")); } catch { chains = []; } } else { chains = []; } } function activateChain(chain: ChainDef) { activeChain = chain; (globalThis as any).__piActiveChain = chain.name; selectedStepIndex = -1; stepStates = chain.steps.map(s => { const agentDef = allAgents.get(s.agent.toLowerCase()); return { agent: s.agent, description: agentDef?.description || "", status: "pending" as const, elapsed: 0, lastWork: "", }; }); // Skip widget re-registration if reset is pending — let before_agent_start handle it if (!pendingReset) { updateWidget(); } } // ── Card Rendering ────────────────────────── function renderStepLines(state: StepState, index: number, width: number, theme: any): string[] { const name = displayName(state.agent); const statusForButton = state.status === "pending" ? "idle" : state.status; const btn = statusButton(statusForButton, name, theme); const timeStr = state.status !== "pending" && state.elapsed > 0 ? " " + theme.fg("dim", `${Math.round(state.elapsed / 1000)}s`) : ""; const lines: string[] = []; let pillLine = ` ${btn}${timeStr}`; if (index === selectedStepIndex) { pillLine = ` ${theme.fg("accent", "[")}${btn}${theme.fg("accent", "]")}${timeStr}`; } lines.push(pillLine); if (state.lastWork && state.status !== "pending") { const prefix = " \u2502 "; const maxWork = width - prefix.length - 1; const work = state.lastWork.length > maxWork ? state.lastWork.slice(0, maxWork - 3) + "..." : state.lastWork; lines.push(theme.fg("dim", " \u2502") + " " + theme.fg("muted", work)); } if (state.status === "pending" && state.description) { const prefix = " "; const maxDesc = width - prefix.length - 1; const desc = state.description.length > maxDesc ? state.description.slice(0, maxDesc - 3) + "..." : state.description; lines.push(" " + theme.fg("dim", desc)); } return lines; } function updateWidget() { if (!widgetCtx) return; // Only show widget when pipeline is actually running (at least one non-pending step) const hasActiveStep = stepStates.some(s => s.status !== "pending"); if (!hasActiveStep) return; widgetCtx.ui.setWidget("agent-chain", (_tui: any, theme: any) => { const text = new Text("", 0, 1); return { render(width: number): string[] { if (!activeChain || stepStates.length === 0) { text.setText(theme.fg("dim", "No chain active. Use /chain to select one.")); return text.render(width); } const outputLines: string[] = []; const chainName = activeChain.name; const rule = "─".repeat(Math.max(0, width - chainName.length - 6)); outputLines.push(theme.fg("dim", ` ── `) + theme.fg("accent", chainName) + theme.fg("dim", ` ${rule}`)); for (let i = 0; i < stepStates.length; i++) { outputLines.push(...renderStepLines(stepStates[i], i, width, theme)); if (i < stepStates.length - 1) { outputLines.push(theme.fg("dim", " \u2502")); } } text.setText(outputLines.join("\n")); return text.render(width); }, invalidate() { text.invalidate(); }, }; }); } // ── Run Agent (subprocess) ────────────────── function runAgent( agentDef: AgentDef, task: string, stepIndex: number, ctx: any, ): Promise<{ output: string; exitCode: number; elapsed: number }> { // Use agent's defined model or fall back to default subagent model. // NOTE: We intentionally do NOT inherit the parent model. Each agent // should use its explicitly defined model or the lightweight default. const model = resolveToolkitWorkerModel(agentDef.name, agentDef.model || DEFAULT_SUBAGENT_MODEL); const agentKey = agentDef.name.toLowerCase().replace(/\s+/g, "-"); const agentSessionFile = join(sessionDir, `chain-${agentKey}.json`); const hasSession = agentSessions.get(agentKey); const extDir = dirname(fileURLToPath(import.meta.url)); const tasksExtPath = join(extDir, "tasks.ts"); const footerExtPath = join(extDir, "footer.ts"); const memoryCycleExtPath = join(extDir, "memory-cycle.ts"); const args = [ "--mode", "json", "-p", "--no-extensions", "-e", tasksExtPath, "-e", footerExtPath, "-e", memoryCycleExtPath, "--model", model, "--tools", agentDef.tools, "--thinking", "off", "--append-system-prompt", agentDef.systemPrompt, "--session", agentSessionFile, ]; if (hasSession) { args.push("-c"); } args.push(task); const textChunks: string[] = []; const startTime = Date.now(); const state = stepStates[stepIndex]; return new Promise((resolve) => { const proc = spawn("pi", args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, PI_SUBAGENT: "1" }, cwd: ctx.cwd, }); // Track for escape-cancel integration currentChainProc = proc; const timer = setInterval(() => { state.elapsed = Date.now() - startTime; updateWidget(); }, 1000); let buffer = ""; proc.stdout!.setEncoding("utf-8"); proc.stdout!.on("data", (chunk: string) => { buffer += chunk; const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.trim()) continue; try { const event = JSON.parse(line); if (event.type === "message_update") { const delta = event.assistantMessageEvent; if (delta?.type === "text_delta") { textChunks.push(delta.delta || ""); const full = textChunks.join(""); const last = full.split("\n").filter((l: string) => l.trim()).pop() || ""; state.lastWork = last; updateWidget(); } } } catch {} } }); proc.stderr!.setEncoding("utf-8"); proc.stderr!.on("data", () => {}); proc.on("close", (code) => { currentChainProc = null; if (buffer.trim()) { try { const event = JSON.parse(buffer); if (event.type === "message_update") { const delta = event.assistantMessageEvent; if (delta?.type === "text_delta") textChunks.push(delta.delta || ""); } } catch {} } clearInterval(timer); const elapsed = Date.now() - startTime; state.elapsed = elapsed; const output = textChunks.join(""); state.lastWork = output.split("\n").filter((l: string) => l.trim()).pop() || ""; if (code === 0) { agentSessions.set(agentKey, agentSessionFile); } resolve({ output, exitCode: code ?? 1, elapsed }); }); proc.on("error", (err) => { currentChainProc = null; clearInterval(timer); resolve({ output: `Error spawning agent: ${err.message}`, exitCode: 1, elapsed: Date.now() - startTime, }); }); proc.on("exit", () => { clearInterval(timer); }); }); } // ── Run Chain (sequential pipeline) ───────── async function runChain( task: string, ctx: any, ): Promise<{ output: string; success: boolean; elapsed: number }> { if (!activeChain) { return { output: "No chain active", success: false, elapsed: 0 }; } const chainStart = Date.now(); // Reset all steps to pending selectedStepIndex = -1; stepStates = activeChain.steps.map(s => { const agentDef = allAgents.get(s.agent.toLowerCase()); return { agent: s.agent, description: agentDef?.description || "", status: "pending" as const, elapsed: 0, lastWork: "", }; }); updateWidget(); let input = task; const originalPrompt = task; const stepOutputs: string[] = []; for (let i = 0; i < activeChain.steps.length; i++) { const step = activeChain.steps[i]; stepStates[i].status = "running"; updateWidget(); // Resolve prompt: $INPUT (previous step), $ORIGINAL (original task), $INPUT_N (step N output, 1-indexed) let resolvedPrompt = step.prompt .replace(/\$ORIGINAL/g, originalPrompt) .replace(/\$INPUT/g, input); // Replace $INPUT_N with stepOutputs[N-1] (1-indexed) resolvedPrompt = resolvedPrompt.replace(/\$INPUT_(\d+)/g, (_, n) => { const stepIndex = parseInt(n, 10) - 1; return stepIndex >= 0 && stepIndex < stepOutputs.length ? stepOutputs[stepIndex] : ""; }); const agentDef = allAgents.get(step.agent.toLowerCase()); if (!agentDef) { stepStates[i].status = "error"; stepStates[i].lastWork = `Agent "${step.agent}" not found`; updateWidget(); return { output: `Error at step ${i + 1}: Agent "${step.agent}" not found. Available: ${Array.from(allAgents.keys()).join(", ")}`, success: false, elapsed: Date.now() - chainStart, }; } const result = await runAgent(agentDef, resolvedPrompt, i, ctx); if (result.exitCode !== 0) { stepStates[i].status = "error"; updateWidget(); return { output: `Error at step ${i + 1} (${step.agent}): ${result.output}`, success: false, elapsed: Date.now() - chainStart, }; } stepStates[i].status = "done"; updateWidget(); stepOutputs.push(result.output); input = result.output; } return { output: input, success: true, elapsed: Date.now() - chainStart }; } // ── run_chain Tool ────────────────────────── pi.registerTool({ name: "run_chain", label: "Run Chain", description: "Execute the active agent chain pipeline. Each step runs sequentially — output from one step feeds into the next. Agents maintain session context across runs.", parameters: Type.Object({ task: Type.String({ description: "The task/prompt for the chain to process" }), }), async execute(_toolCallId, params, _signal, onUpdate, ctx) { const { task } = params as { task: string }; if (onUpdate) { onUpdate({ content: [{ type: "text", text: `Starting chain: ${activeChain?.name}...` }], details: { chain: activeChain?.name, task, status: "running" }, }); } const result = await runChain(task, ctx); const truncated = result.output.length > 8000 ? result.output.slice(0, 8000) + "\n\n... [truncated]" : result.output; const status = result.success ? "done" : "error"; const summary = `[chain:${activeChain?.name}] ${status} in ${Math.round(result.elapsed / 1000)}s`; return { content: [{ type: "text", text: `${summary}\n\n${truncated}` }], details: { chain: activeChain?.name, task, status, elapsed: result.elapsed, fullOutput: result.output, }, }; }, renderCall(args, theme) { const task = (args as any).task || ""; const preview = task.length > 60 ? task.slice(0, 57) + "..." : task; const text = theme.fg("toolTitle", theme.bold("run_chain ")) + theme.fg("accent", activeChain?.name || "?") + theme.fg("dim", " — ") + theme.fg("muted", preview); return new Text(outputLine(theme, "accent", text), 0, 0); }, renderResult(result, options, theme) { const details = result.details as any; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); } if (options.isPartial || details.status === "running") { const runningBtn = statusButton("running", details.chain || "chain", theme); return new Text(outputLine(theme, "accent", runningBtn), 0, 0); } const status = details.status === "done" ? "done" : "error"; const bar = status === "done" ? "success" : "error"; const statusBtn = statusButton(status, details.chain, theme); const elapsed = typeof details.elapsed === "number" ? Math.round(details.elapsed / 1000) : 0; const header = statusBtn + theme.fg("dim", ` ${elapsed}s`); if (options.expanded && details.fullOutput) { const output = details.fullOutput.length > 4000 ? details.fullOutput.slice(0, 4000) + "\n... [truncated]" : details.fullOutput; const mdTheme = getPiMdTheme(); const container = new Container(); container.addChild(new Text(outputLine(theme, bar, header), 0, 0)); container.addChild(new Markdown(output, 2, 0, mdTheme)); return container; } return new Text(outputLine(theme, bar, header), 0, 0); }, }); // ── Commands ───────────────────────────────── pi.registerCommand("chain", { description: "Switch active chain", handler: async (_args, ctx) => { widgetCtx = ctx; if (chains.length === 0) { ctx.ui.notify("No chains defined in .pi/agents/agent-chain.yaml", "warning"); return; } const options = chains.map(c => { const steps = c.steps.map(s => displayName(s.agent)).join(" → "); const desc = c.description ? ` — ${c.description}` : ""; return `${c.name}${desc} (${steps})`; }); const choice = await ctx.ui.select("Select Chain", options); if (choice === undefined) return; const idx = options.indexOf(choice); activateChain(chains[idx]); const flow = chains[idx].steps.map(s => displayName(s.agent)).join(" → "); ctx.ui.setStatus("agent-chain", `Chain: ${chains[idx].name} (${chains[idx].steps.length} steps)`); ctx.ui.notify( `Chain: ${chains[idx].name}\n${chains[idx].description}\n${flow}`, "info", ); }, }); pi.registerCommand("chain-clear", { description: "Clear chain widget from screen", handler: async (_args, ctx) => { widgetCtx = ctx; ctx.ui.setWidget("agent-chain", undefined); // Reset step states to pending so the widget can reappear on next run for (const s of stepStates) { s.status = "pending"; s.elapsed = 0; s.lastWork = ""; } selectedStepIndex = -1; ctx.ui.notify("Chain widget cleared.", "info"); }, }); pi.registerCommand("chain-list", { description: "List all available chains", handler: async (_args, ctx) => { widgetCtx = ctx; if (chains.length === 0) { ctx.ui.notify("No chains defined in .pi/agents/agent-chain.yaml", "warning"); return; } const list = chains.map(c => { const desc = c.description ? ` ${c.description}` : ""; const steps = c.steps.map((s, i) => ` ${i + 1}. ${displayName(s.agent)}` ).join("\n"); return `${c.name}:${desc ? "\n" + desc : ""}\n${steps}`; }).join("\n\n"); ctx.ui.notify(list, "info"); }, }); pi.registerCommand("audit", { description: "Run comprehensive code audit — scans project, finds issues, generates report and hardening plan", handler: async (args, ctx) => { widgetCtx = ctx; const scope = (args || "").trim(); const scopeHint = scope ? ` (scope: ${scope})` : ""; ctx.ui.notify(`Starting code audit${scopeHint}...`, "info"); // Find audit chain const auditChain = chains.find(c => c.name === "audit"); if (!auditChain) { ctx.ui.notify("Audit chain not found in .pi/agents/agent-chain.yaml", "error"); return; } // Activate the audit chain activateChain(auditChain); // Build task string with optional scope const task = scope ? `Audit this codebase. Scope: ${scope}` : "Audit this codebase"; // Run the chain const result = await runChain(task, ctx); // Hide chain widget — audit is done, result goes to file widgetCtx.ui.setWidget("agent-chain", undefined); if (!result.success) { ctx.ui.notify(`Audit failed: ${result.output.slice(0, 200)}`, "error"); return; } // Write report file const reportPath = join(ctx.cwd, ".pi", "audit-report.md"); const reportDir = dirname(reportPath); if (!existsSync(reportDir)) { mkdirSync(reportDir, { recursive: true }); } writeFileSync(reportPath, result.output, "utf-8"); ctx.ui.notify(`Audit complete! Report saved to ${reportPath}`, "success"); }, }); pi.registerCommand("performance", { description: "Optimize this codebase for performance — profile bottlenecks, stress-test, and build an optimization plan", handler: async (args, ctx) => { widgetCtx = ctx; const scope = (args || "").trim(); const scopeHint = scope ? ` (scope: ${scope})` : ""; ctx.ui.notify(`Starting performance analysis${scopeHint}...`, "info"); // Find performance chain const perfChain = chains.find(c => c.name === "performance"); if (!perfChain) { ctx.ui.notify("Performance chain not found in .pi/agents/agent-chain.yaml", "error"); return; } // Activate the performance chain activateChain(perfChain); // Build task string with optional scope const task = scope ? `Optimize this codebase for performance. Scope: ${scope}` : "Optimize this codebase for performance"; // Run the chain const result = await runChain(task, ctx); // Hide chain widget — performance analysis is done, result goes to file widgetCtx.ui.setWidget("agent-chain", undefined); if (!result.success) { ctx.ui.notify(`Performance analysis failed: ${result.output.slice(0, 200)}`, "error"); return; } // Write report file const reportPath = join(ctx.cwd, ".pi", "performance-report.md"); const reportDir = dirname(reportPath); if (!existsSync(reportDir)) { mkdirSync(reportDir, { recursive: true }); } writeFileSync(reportPath, result.output, "utf-8"); ctx.ui.notify(`Performance analysis complete! Report saved to ${reportPath}`, "success"); }, }); pi.registerCommand("sentry-setup", { description: "Verify Sentry CLI setup — check auth, project linking, SDK integration, and DSN configuration", handler: async (args, ctx) => { widgetCtx = ctx; const scope = (args || "").trim(); const scopeHint = scope ? ` (scope: ${scope})` : ""; ctx.ui.notify(`Starting Sentry setup check${scopeHint}...`, "info"); // Find sentry-setup chain const sentrySetupChain = chains.find(c => c.name === "sentry-setup"); if (!sentrySetupChain) { ctx.ui.notify("sentry-setup chain not found in .pi/agents/agent-chain.yaml", "error"); return; } // Activate the sentry-setup chain activateChain(sentrySetupChain); // Build task string with optional scope const task = scope ? `Verify Sentry setup for this project. Scope: ${scope}` : "Verify Sentry setup for this project"; // Run the chain const result = await runChain(task, ctx); // Hide chain widget widgetCtx.ui.setWidget("agent-chain", undefined); if (!result.success) { ctx.ui.notify(`Sentry setup check failed: ${result.output.slice(0, 200)}`, "error"); return; } // Write report file const reportPath = join(ctx.cwd, ".pi", "sentry-setup-report.md"); const reportDir = dirname(reportPath); if (!existsSync(reportDir)) { mkdirSync(reportDir, { recursive: true }); } writeFileSync(reportPath, result.output, "utf-8"); ctx.ui.notify(`Sentry setup check complete! Report saved to ${reportPath}`, "success"); }, }); pi.registerCommand("sentry-logs", { description: "Fetch Sentry issues and crashes, analyze root causes, and create a prioritized fix plan", handler: async (args, ctx) => { widgetCtx = ctx; const scope = (args || "").trim(); const scopeHint = scope ? ` (scope: ${scope})` : ""; ctx.ui.notify(`Starting Sentry issue analysis${scopeHint}...`, "info"); // Find sentry-logs chain const sentryLogsChain = chains.find(c => c.name === "sentry-logs"); if (!sentryLogsChain) { ctx.ui.notify("sentry-logs chain not found in .pi/agents/agent-chain.yaml", "error"); return; } // Activate the sentry-logs chain activateChain(sentryLogsChain); // Build task string with optional scope const task = scope ? `Get Sentry issues and crashes for this project and create a fix plan. Scope: ${scope}` : "Get Sentry issues and crashes for this project and create a fix plan"; // Run the chain const result = await runChain(task, ctx); // Hide chain widget widgetCtx.ui.setWidget("agent-chain", undefined); if (!result.success) { ctx.ui.notify(`Sentry issue analysis failed: ${result.output.slice(0, 200)}`, "error"); return; } // Write report file const reportPath = join(ctx.cwd, ".pi", "sentry-report.md"); const reportDir = dirname(reportPath); if (!existsSync(reportDir)) { mkdirSync(reportDir, { recursive: true }); } writeFileSync(reportPath, result.output, "utf-8"); ctx.ui.notify(`Sentry issue analysis complete! Report saved to ${reportPath}`, "success"); }, }); pi.registerCommand("code-review", { description: "Multi-pass code review — parallel context gathering, split review, remediation, validation, test verification, and final report", handler: async (args, ctx) => { widgetCtx = ctx; const inlineScope = (args || "").trim(); // Find code-review chain const codeReviewChain = chains.find(c => c.name === "code-review"); if (!codeReviewChain) { ctx.ui.notify("code-review chain not found in .pi/agents/agent-chain.yaml", "error"); return; } let task: string; if (inlineScope) { // User passed scope inline: /code-review src/auth/ task = `Review the code. Scope: ${inlineScope}`; } else { // Interactive scope picker const scopeOptions = [ "Last commit (git diff HEAD~1)", "Unstaged changes (git diff)", "Staged changes (git diff --cached)", "Last 3 commits (git diff HEAD~3)", "Specific file or directory", "Full codebase", ]; const choice = await ctx.ui.select("What do you want to review?", scopeOptions); if (choice === undefined) return; switch (choice) { case scopeOptions[0]: task = "Review the changes in the last commit. Use `git diff HEAD~1` to identify changed files."; break; case scopeOptions[1]: task = "Review the current unstaged changes. Use `git diff` to identify changed files."; break; case scopeOptions[2]: task = "Review the staged changes. Use `git diff --cached` to identify changed files."; break; case scopeOptions[3]: task = "Review the changes in the last 3 commits. Use `git diff HEAD~3` to identify changed files."; break; case scopeOptions[4]: { const path = await ctx.ui.input("Enter file or directory path:", "src/"); if (!path) return; task = `Review the code at: ${path}`; break; } case scopeOptions[5]: task = "Review the full codebase. Scan all source files."; break; default: task = "Review the current unstaged changes. Use `git diff` to identify changed files."; } } ctx.ui.notify(`Starting code review...`, "info"); // Activate the code-review chain activateChain(codeReviewChain); // Run the chain const result = await runChain(task, ctx); // Hide chain widget widgetCtx.ui.setWidget("agent-chain", undefined); if (!result.success) { ctx.ui.notify(`Code review failed: ${result.output.slice(0, 200)}`, "error"); return; } // Write report file const reportPath = join(ctx.cwd, ".pi", "code-review-report.md"); const reportDir = dirname(reportPath); if (!existsSync(reportDir)) { mkdirSync(reportDir, { recursive: true }); } writeFileSync(reportPath, result.output, "utf-8"); ctx.ui.notify(`Code review complete! Report saved to ${reportPath}`, "success"); }, }); // ── System Prompt Override ─────────────────── pi.on("before_agent_start", async (_event, _ctx) => { // Force widget reset on first turn after /new if (pendingReset && activeChain) { pendingReset = false; widgetCtx = _ctx; stepStates = activeChain.steps.map(s => { const agentDef = allAgents.get(s.agent.toLowerCase()); return { agent: s.agent, description: agentDef?.description || "", status: "pending" as const, elapsed: 0, lastWork: "", }; }); updateWidget(); } // Mode gate: only fire when mode is CHAIN (or unset for backward compat) const mode = (globalThis as any).__piCurrentMode; if (mode && mode !== "CHAIN") return {}; if (!activeChain) return {}; const flow = activeChain.steps.map(s => displayName(s.agent)).join(" → "); const desc = activeChain.description ? `\n${activeChain.description}` : ""; // Build pipeline steps summary const steps = activeChain.steps.map((s, i) => { const agentDef = allAgents.get(s.agent.toLowerCase()); const agentDesc = agentDef?.description || ""; return `${i + 1}. **${displayName(s.agent)}** — ${agentDesc}`; }).join("\n"); // Build full agent catalog (like agent-team.ts) const seen = new Set(); const agentCatalog = activeChain.steps .filter(s => { const key = s.agent.toLowerCase(); if (seen.has(key)) return false; seen.add(key); return true; }) .map(s => { const agentDef = allAgents.get(s.agent.toLowerCase()); if (!agentDef) return `### ${displayName(s.agent)}\nAgent not found.`; return `### ${displayName(agentDef.name)}\n${agentDef.description}\n**Tools:** ${agentDef.tools}\n**Role:** ${agentDef.systemPrompt}`; }) .join("\n\n"); const commanderAvailable = !!(globalThis as any).__piCommanderAvailable; const commanderSection = commanderAvailable ? ` ## Commander Integration (REQUIRED) Commander is connected. ALWAYS use these tools for dashboard visibility: - \`commander_session { operation: "file:open", file_path: }\` — display key files in Commander's floating viewer - \`commander_task\` — track tasks in the Commander dashboard - \`commander_mailbox\` — send status updates to the dashboard ### Mailbox Protocol - Check your inbox periodically: \`commander_mailbox { operation: "inbox", agent_name: "coordinator" }\` - Send status at start, milestones, and completion - Warm, professional, collaborative tone — no emojis anywhere` : ""; return { systemPrompt: `You are an agent with a sequential pipeline called "${activeChain.name}" at your disposal.${desc} You have full access to your own tools AND the run_chain tool to delegate to your team. ## Active Chain: ${activeChain.name} Flow: ${flow} ${steps} ## Agent Details ${agentCatalog} ## When to Use run_chain - Significant work: new features, refactors, multi-file changes, anything non-trivial - Tasks that benefit from the full pipeline: planning, building, reviewing - When you want structured, multi-agent collaboration on a problem ## When to Work Directly - Simple one-off commands: reading a file, checking status, listing contents - Quick lookups, small edits, answering questions about the codebase - Anything you can handle in a single step without needing the pipeline ## How run_chain Works - Pass a clear task description to run_chain - Each step's output feeds into the next step as $INPUT - Agents maintain session context — they remember previous work within this session - You can run the chain multiple times with different tasks if needed - After the chain completes, review the result and summarize for the user ## Guidelines - Use your judgment — if it's quick, just do it; if it's real work, run the chain - Keep chain tasks focused and clearly described - You can mix direct work and chain runs in the same conversation${commanderSection}`, }; }); // ── Step Detail Overlay ────────────────────── function padRight(s: string, width: number): string { const vis = visibleWidth(s); if (vis >= width) return truncateToWidth(s, width, ""); return s + " ".repeat(width - vis); } function wordWrap(text: string, width: number): string[] { if (visibleWidth(text) <= width) return [text]; const words = text.split(/(\s+)/); const lines: string[] = []; let cur = ""; for (const w of words) { if (visibleWidth(cur + w) > width && cur.length > 0) { lines.push(cur); cur = w.trimStart(); } else { cur += w; } } if (cur.length > 0) lines.push(cur); return lines; } class StepDetailOverlay { private scrollOffset = 0; private totalContentLines = 0; constructor( private step: StepState, private agentDef: AgentDef | null, private onDone: () => void, ) {} handleInput(data: string, tui: any): void { const height = process.stdout.rows || 24; const contentHeight = height - 1; const maxScroll = Math.max(0, this.totalContentLines - contentHeight); if (matchesKey(data, Key.up)) { this.scrollOffset = Math.max(0, this.scrollOffset - 1); } else if (matchesKey(data, Key.down)) { this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1); } else if (matchesKey(data, Key.pageUp)) { this.scrollOffset = Math.max(0, this.scrollOffset - Math.max(1, contentHeight - 1)); } else if (matchesKey(data, Key.pageDown)) { this.scrollOffset = Math.min(maxScroll, this.scrollOffset + Math.max(1, contentHeight - 1)); } else if (matchesKey(data, Key.home)) { this.scrollOffset = 0; } else if (matchesKey(data, Key.end)) { this.scrollOffset = maxScroll; } else if (matchesKey(data, Key.escape)) { this.onDone(); return; } tui.requestRender(); } render(width: number, height: number, theme: any): string[] { const container = new Container(); const mdTheme = getPiMdTheme(); const panelW = width - 4; const innerWidth = panelW - 2; // Header with step name pill and status container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); const name = displayName(this.step.agent); const statusForButton = this.step.status === "pending" ? "idle" : this.step.status; const statusBtn = statusButton(statusForButton, name, theme); const timeStr = this.step.status !== "pending" && this.step.elapsed > 0 ? ` ${Math.round(this.step.elapsed / 1000)}s` : ""; container.addChild(new Text(`${statusBtn}${timeStr}`, 1, 0)); container.addChild(new Spacer(1)); // Section header helper const sectionHeader = (title: string) => { const label = ` ─── ${title} `; const remaining = Math.max(0, innerWidth - visibleWidth(label)); return theme.fg("accent", theme.bold(label + "─".repeat(remaining))); }; // Metadata section container.addChild(new Text(sectionHeader("METADATA"), 1, 0)); const formatRow = (label: string, value: string, valueColor: string = "muted") => { const labelStr = theme.fg("accent", theme.bold(padRight(label + ":", 14))); const valueStr = theme.fg(valueColor, value); return labelStr + " " + valueStr; }; const addWrappedRow = (label: string, value: string, valueColor: string = "muted") => { const labelWidth = 14; const valueWidth = innerWidth - labelWidth - 1; const wrapped = wordWrap(value, valueWidth); for (let i = 0; i < wrapped.length; i++) { const displayLabel = i === 0 ? label : ""; container.addChild(new Text(formatRow(displayLabel, wrapped[i], valueColor), 1, 0)); } }; const statusColorMap: Record = { running: "accent", done: "success", error: "error", pending: "dim" }; const statusColor = statusColorMap[this.step.status] || "muted"; container.addChild(new Text(formatRow("STATUS", this.step.status.toUpperCase(), statusColor), 1, 0)); if (this.step.description) { addWrappedRow("DESCRIPTION", this.step.description, "muted"); } if (this.agentDef?.tools) { addWrappedRow("TOOLS", this.agentDef.tools, "success"); } container.addChild(new Spacer(1)); // System prompt section if (this.agentDef?.systemPrompt) { container.addChild(new Text(sectionHeader("SYSTEM PROMPT"), 1, 0)); container.addChild(new Spacer(1)); const sysPromptMd = new Markdown(this.agentDef.systemPrompt, 1, 0, mdTheme); container.addChild(sysPromptMd); container.addChild(new Spacer(1)); } // Last work section if (this.step.lastWork) { container.addChild(new Text(sectionHeader("LAST WORK"), 1, 0)); container.addChild(new Spacer(1)); const workMd = new Markdown(this.step.lastWork, 1, 0, mdTheme); container.addChild(workMd); container.addChild(new Spacer(1)); } // Render all content const allLines = container.render(panelW); this.totalContentLines = allLines.length; const contentHeight = height - 1; const maxScroll = Math.max(0, allLines.length - contentHeight); this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll)); const visibleContentLines = allLines.slice(this.scrollOffset, this.scrollOffset + contentHeight); const scrollInfo = maxScroll > 0 ? ` \u2191/\u2193/PgUp/PgDn/Home/End Scroll (${this.scrollOffset + 1}-${Math.min(this.scrollOffset + contentHeight, allLines.length)}/${allLines.length}) \u2022 Esc Close` : " Esc Close"; const footer = theme.fg("dim", scrollInfo); const footerLine = padRight(footer, panelW); const dimBg = "\x1b[48;2;10;10;15m"; const reset = "\x1b[0m"; const result: string[] = []; for (const line of visibleContentLines) { result.push(dimBg + " " + padRight(line, panelW) + " " + reset); } result.push(dimBg + " " + footerLine + " " + reset); while (result.length < height) { result.push(dimBg + " ".repeat(width) + reset); } return result; } } async function showStepDetail(ctx: any, step: StepState, agentDef: AgentDef | null) { await ctx.ui.custom((tui: any, theme: any, _kb: any, done: any) => { const overlay = new StepDetailOverlay(step, agentDef, () => done(undefined)); return { render: (w: number) => overlay.render(w, process.stdout.rows || 24, theme), handleInput: (data: string) => overlay.handleInput(data, tui), invalidate: () => {}, }; }, { overlay: true, overlayOptions: { width: "100%" }, }); } // ── Session Start ─────────────────────────── pi.on("session_start", async (_event, _ctx) => { applyExtensionDefaults(import.meta.url, _ctx); // Clear widget with both old and new ctx — one of them will be valid if (widgetCtx) { widgetCtx.ui.setWidget("agent-chain", undefined); } _ctx.ui.setWidget("agent-chain", undefined); widgetCtx = _ctx; // Reset execution state — widget re-registration deferred to before_agent_start stepStates = []; activeChain = null; (globalThis as any).__piActiveChain = null; selectedStepIndex = -1; pendingReset = true; // Wipe chain session files — reset agent context on /new and launch const sessDir = join(_ctx.cwd, ".pi", "agent-sessions"); if (existsSync(sessDir)) { for (const f of readdirSync(sessDir)) { if (f.startsWith("chain-") && f.endsWith(".json")) { try { unlinkSync(join(sessDir, f)); } catch {} } } } // Reload chains + clear agentSessions map (all agents start fresh) loadChains(_ctx.cwd); if (chains.length === 0) { _ctx.ui.notify("No chains found in .pi/agents/agent-chain.yaml", "warning"); return; } // Default to first chain — use /chain to switch activateChain(chains[0]); // ── Expose global hooks for escape-cancel integration ──────────── (globalThis as any).__piKillChainProc = (): boolean => { if (currentChainProc) { try { currentChainProc.kill("SIGTERM"); } catch {} currentChainProc = null; return true; } return false; }; (globalThis as any).__piHasRunningChain = (): boolean => { return currentChainProc !== null; }; // run_chain is registered as a tool — available alongside all default tools _ctx.ui.setStatus("agent-chain", `Chain: ${activeChain!.name} (${activeChain!.steps.length} steps)`); // Footer: use footer.ts only — do not overwrite // Register nav provider for F-key navigation const providers = ((globalThis as any).__piNavProviders = (globalThis as any).__piNavProviders || []); providers.push({ isActive: () => activeChain !== null && stepStates.length > 0, selectPrev: (_ctx2: any) => { const count = stepStates.length; if (count === 0) { selectedStepIndex = -1; return; } if (selectedStepIndex < 0) selectedStepIndex = count - 1; else selectedStepIndex = (selectedStepIndex - 1 + count) % count; updateWidget(); }, selectNext: (_ctx2: any) => { const count = stepStates.length; if (count === 0) { selectedStepIndex = -1; return; } if (selectedStepIndex < 0) selectedStepIndex = 0; else selectedStepIndex = (selectedStepIndex + 1) % count; updateWidget(); }, showDetail: async (ctx: any) => { if (selectedStepIndex < 0 || selectedStepIndex >= stepStates.length) return; const step = stepStates[selectedStepIndex]; const agentDef = allAgents.get(step.agent.toLowerCase()) || null; await showStepDetail(ctx, step, agentDef); }, exitSelection: (_ctx2: any) => { selectedStepIndex = -1; updateWidget(); }, }); }); }