/** * 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 * * Usage: pi -e extensions/agent-chain.ts */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { Text, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { spawn } from "child_process"; import { readFileSync, existsSync, readdirSync, mkdirSync, unlinkSync } from "fs"; import { join, resolve } from "path"; import { applyExtensionDefaults } from "./themeMap.ts"; // ── Types ──────────────────────────────────────── interface ChainStep { agent: string; prompt: string; } interface ChainDef { name: string; description: string; steps: ChainStep[]; } interface AgentDef { name: string; description: string; tools: string; systemPrompt: string; } interface StepState { agent: 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(" "); } // ── Chain YAML Parser ──────────────────────────── function parseChainYaml(raw: string): ChainDef[] { const chains: ChainDef[] = []; let current: ChainDef | null = null; let currentStep: ChainStep | null = null; for (const line of raw.split("\n")) { // Chain name: top-level key const chainMatch = line.match(/^(\S[^:]*):$/); if (chainMatch) { if (current && currentStep) { current.steps.push(currentStep); currentStep = null; } current = { name: chainMatch[1].trim(), description: "", steps: [] }; chains.push(current); continue; } // Chain description const descMatch = line.match(/^\s+description:\s+(.+)$/); if (descMatch && current && !currentStep) { let desc = descMatch[1].trim(); if ((desc.startsWith('"') && desc.endsWith('"')) || (desc.startsWith("'") && desc.endsWith("'"))) { desc = desc.slice(1, -1); } current.description = desc; continue; } // "steps:" label — skip if (line.match(/^\s+steps:\s*$/) && current) { continue; } // Step agent line const agentMatch = line.match(/^\s+-\s+agent:\s+(.+)$/); if (agentMatch && current) { if (currentStep) { current.steps.push(currentStep); } currentStep = { agent: agentMatch[1].trim(), prompt: "" }; continue; } // Step prompt line const promptMatch = line.match(/^\s+prompt:\s+(.+)$/); if (promptMatch && currentStep) { let prompt = promptMatch[1].trim(); if ((prompt.startsWith('"') && prompt.endsWith('"')) || (prompt.startsWith("'") && prompt.endsWith("'"))) { prompt = prompt.slice(1, -1); } prompt = prompt.replace(/\\n/g, "\n"); currentStep.prompt = prompt; continue; } } if (current && currentStep) { current.steps.push(currentStep); } return chains; } // ── Frontmatter Parser ─────────────────────────── function parseAgentFile(filePath: string): 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; return { name: frontmatter.name, description: frontmatter.description || "", tools: frontmatter.tools || "read,grep,find,ls", systemPrompt: match[2].trim(), }; } catch { return null; } } function scanAgentDirs(cwd: string): Map { const dirs = [ join(cwd, "agents"), join(cwd, ".claude", "agents"), join(cwd, ".pi", "agents"), ]; const agents = new Map(); for (const dir of dirs) { if (!existsSync(dir)) continue; try { for (const file of readdirSync(dir)) { if (!file.endsWith(".md")) continue; const fullPath = resolve(dir, file); const def = parseAgentFile(fullPath); if (def && !agents.has(def.name.toLowerCase())) { agents.set(def.name.toLowerCase(), def); } } } 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; function loadChains(cwd: string) { sessionDir = join(cwd, ".pi", "agent-sessions"); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } allAgents = scanAgentDirs(cwd); agentSessions.clear(); for (const [key] of allAgents) { const sessionFile = join(sessionDir, `chain-${key}.json`); agentSessions.set(key, existsSync(sessionFile) ? sessionFile : null); } const chainPath = join(cwd, ".pi", "agents", "agent-chain.yaml"); if (existsSync(chainPath)) { try { chains = parseChainYaml(readFileSync(chainPath, "utf-8")); } catch { chains = []; } } else { chains = []; } } function activateChain(chain: ChainDef) { activeChain = chain; stepStates = chain.steps.map(s => ({ agent: s.agent, 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 renderCard(state: StepState, colWidth: number, theme: any): string[] { const w = colWidth - 2; const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s; const statusColor = state.status === "pending" ? "dim" : state.status === "running" ? "accent" : state.status === "done" ? "success" : "error"; const statusIcon = state.status === "pending" ? "○" : state.status === "running" ? "●" : state.status === "done" ? "✓" : "✗"; const name = displayName(state.agent); const nameStr = theme.fg("accent", theme.bold(truncate(name, w))); const nameVisible = Math.min(name.length, w); const statusStr = `${statusIcon} ${state.status}`; const timeStr = state.status !== "pending" ? ` ${Math.round(state.elapsed / 1000)}s` : ""; const statusLine = theme.fg(statusColor, statusStr + timeStr); const statusVisible = statusStr.length + timeStr.length; const workRaw = state.lastWork || ""; const workText = workRaw ? truncate(workRaw, Math.min(50, w - 1)) : ""; const workLine = workText ? theme.fg("muted", workText) : theme.fg("dim", "—"); const workVisible = workText ? workText.length : 1; const top = "┌" + "─".repeat(w) + "┐"; const bot = "└" + "─".repeat(w) + "┘"; const border = (content: string, visLen: number) => theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - visLen)) + theme.fg("dim", "│"); return [ theme.fg("dim", top), border(" " + nameStr, 1 + nameVisible), border(" " + statusLine, 1 + statusVisible), border(" " + workLine, 1 + workVisible), theme.fg("dim", bot), ]; } function updateWidget() { if (!widgetCtx) 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 arrowWidth = 5; // " ──▶ " const cols = stepStates.length; const totalArrowWidth = arrowWidth * (cols - 1); const colWidth = Math.max(12, Math.floor((width - totalArrowWidth) / cols)); const arrowRow = 2; // middle of 5-line card (0-indexed) const cards = stepStates.map(s => renderCard(s, colWidth, theme)); const cardHeight = cards[0].length; const outputLines: string[] = []; for (let line = 0; line < cardHeight; line++) { let row = cards[0][line]; for (let c = 1; c < cols; c++) { if (line === arrowRow) { row += theme.fg("dim", " ──▶ "); } else { row += " ".repeat(arrowWidth); } row += cards[c][line]; } outputLines.push(row); } 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 }> { const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "openrouter/google/gemini-3-flash-preview"; const agentKey = agentDef.name.toLowerCase().replace(/\s+/g, "-"); const agentSessionFile = join(sessionDir, `chain-${agentKey}.json`); const hasSession = agentSessions.get(agentKey); const args = [ "--mode", "json", "-p", "--no-extensions", "--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 }, }); 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) => { 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) => { clearInterval(timer); resolve({ output: `Error spawning agent: ${err.message}`, exitCode: 1, elapsed: Date.now() - startTime, }); }); }); } // ── 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 stepStates = activeChain.steps.map(s => ({ agent: s.agent, status: "pending" as const, elapsed: 0, lastWork: "", })); updateWidget(); let input = task; const originalPrompt = task; for (let i = 0; i < activeChain.steps.length; i++) { const step = activeChain.steps[i]; stepStates[i].status = "running"; updateWidget(); const resolvedPrompt = step.prompt .replace(/\$INPUT/g, input) .replace(/\$ORIGINAL/g, originalPrompt); 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(); 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; return new Text( theme.fg("toolTitle", theme.bold("run_chain ")) + theme.fg("accent", activeChain?.name || "?") + theme.fg("dim", " — ") + theme.fg("muted", preview), 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") { return new Text( theme.fg("accent", `● ${details.chain || "chain"}`) + theme.fg("dim", " running..."), 0, 0, ); } const icon = details.status === "done" ? "✓" : "✗"; const color = details.status === "done" ? "success" : "error"; const elapsed = typeof details.elapsed === "number" ? Math.round(details.elapsed / 1000) : 0; const header = theme.fg(color, `${icon} ${details.chain}`) + 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; return new Text(header + "\n" + theme.fg("muted", output), 0, 0); } return new Text(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-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"); }, }); // ── 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 => ({ agent: s.agent, status: "pending" as const, elapsed: 0, lastWork: "", })); updateWidget(); } 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"); 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`, }; }); // ── 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; 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]); // run_chain is registered as a tool — available alongside all default tools const flow = activeChain!.steps.map(s => displayName(s.agent)).join(" → "); _ctx.ui.setStatus("agent-chain", `Chain: ${activeChain!.name} (${activeChain!.steps.length} steps)`); _ctx.ui.notify( `Chain: ${activeChain!.name}\n${activeChain!.description}\n${flow}\n\n` + `/chain Switch chain\n` + `/chain-list List all chains`, "info", ); // Footer: model | chain name | context bar _ctx.ui.setFooter((_tui, theme, _footerData) => ({ dispose: () => {}, invalidate() {}, render(width: number): string[] { const model = _ctx.model?.id || "no-model"; const usage = _ctx.getContextUsage(); const pct = usage ? usage.percent : 0; const filled = Math.round(pct / 10); const bar = "#".repeat(filled) + "-".repeat(10 - filled); const chainLabel = activeChain ? theme.fg("accent", activeChain.name) : theme.fg("dim", "no chain"); const left = theme.fg("dim", ` ${model}`) + theme.fg("muted", " · ") + chainLabel; const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `); const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); return [truncateToWidth(left + pad + right, width)]; }, })); }); }