// ABOUTME: Pipeline-Team — Hybrid sequential pipeline with parallel agent dispatch // ABOUTME: Combines agent-chain (sequential phases) with agent-team (parallel dispatch) plus Alt+P overlay /** * * Pipeline: UNDERSTAND → GATHER → PLAN → EXECUTE → REVIEW * * Phase 1 (UNDERSTAND): Interactive — primary agent converses with user * Phase 2 (GATHER): Parallel scouts explore codebase concurrently * Phase 3 (PLAN): Sequential planner creates implementation plan * Phase 4 (EXECUTE): Parallel builders implement the plan * Phase 5 (REVIEW): Agent-driven loop — reviewer audits, primary decides approve/re-dispatch * * Commands: * /pipeline — select pipeline config from YAML (opt-in activation) * /pipeline-status — full pipeline state notification * /pipeline-reset — reset pipeline to phase 1 * /pipeline-clear — clear pipeline widget from screen (keeps pipeline active) * /pipeline-off — deactivate pipeline and hide UI * * Usage: pi -e extensions/pipeline-team.ts */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { Box, Text, Container, Spacer, Markdown, matchesKey, Key, truncateToWidth, visibleWidth, } 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 } from "fs"; import { join, resolve, basename, dirname } from "path"; import { fileURLToPath } from "url"; import { applyExtensionDefaults } from "./lib/themeMap.ts"; import { outputLine, outputBox, type BarColor } from "./lib/output-box.ts"; import { renderVerticalTimeline, renderCollapsedTimeline, 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 { parsePipelineYaml, type PhaseAgentDef, type PhaseDef, type PipelineConfig } from "./lib/parse-pipeline-yaml.ts"; // ── Types ──────────────────────────────────────── interface AgentDef { name: string; description: string; tools: string; model: string; // full provider/model ID, empty = use default systemPrompt: string; } interface AgentState { role: string; index: number; status: "idle" | "running" | "done" | "error"; task: string; elapsed: number; lastWork: string; output: string; timer?: ReturnType; proc?: any; // ChildProcess ref for escape-cancel } type PhaseStatus = "pending" | "active" | "done" | "error"; interface PhaseState { def: PhaseDef; status: PhaseStatus; summary: string; agents: AgentState[]; } // ── Display Name Helper ────────────────────────── function displayName(name: string): string { return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); } // ── Frontmatter Parser (reused from agent-team) ── 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 { for (const file of readdirSync(dir)) { if (!file.endsWith(".md")) continue; const fullPath = resolve(dir, file); const def = parseAgentFile(fullPath, modelsConfig); if (def && !agents.has(def.name.toLowerCase())) { agents.set(def.name.toLowerCase(), def); } } } catch {} } return agents; } // ── Context Helpers ────────────────────────────── const CONTEXT_MAX = 30000; function truncateContext(text: string): string { if (text.length <= CONTEXT_MAX) return text; return text.slice(0, CONTEXT_MAX) + "\n\n... [context truncated at 30000 chars]"; } function resolveTemplate( template: string, vars: { task: string; context: string; plan: string; input: string; review: string }, ): string { return template .replace(/\$TASK/g, vars.task) .replace(/\$CONTEXT/g, truncateContext(vars.context)) .replace(/\$PLAN/g, vars.plan) .replace(/\$INPUT/g, vars.input) .replace(/\$REVIEW/g, vars.review); } // ── Extension ──────────────────────────────────── export default function (pi: ExtensionAPI) { let allAgents: Map = new Map(); let pipelineConfigs: PipelineConfig[] = []; let activeConfig: PipelineConfig | null = null; let phaseStates: PhaseState[] = []; let currentPhaseIndex = 0; let widgetCtx: any; let widgetCollapsed = true; let sessionDir = ""; let contextWindow = 0; // Accumulated context across phases let taskSummary = ""; // $TASK — from phase 1 let accContext = ""; // $CONTEXT — accumulated from all phases let planOutput = ""; // $PLAN — from phase 3 let reviewOutput = ""; // $REVIEW — from phase 5 (when looping) let reviewLoopCount = 0; // ── Load Config ────────────────────────────── function loadConfig(cwd: string) { sessionDir = join(cwd, ".pi", "agent-sessions"); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } const extDir = dirname(fileURLToPath(import.meta.url)); const extProjectDir = resolve(extDir, ".."); // Load model config from .pi/agents/models.json, then scan agent .md files const modelsConfig = loadAgentModelsConfig(cwd, extProjectDir); allAgents = scanAgentDirs(cwd, extProjectDir, modelsConfig); // Look for config in cwd first, fall back to extension's own project dir let configPath = join(cwd, ".pi", "agents", "pipeline-team.yaml"); if (!existsSync(configPath)) { configPath = join(extProjectDir, ".pi", "agents", "pipeline-team.yaml"); } if (!existsSync(configPath)) { configPath = join(extProjectDir, "agents", "pipeline-team.yaml"); } if (existsSync(configPath)) { try { pipelineConfigs = parsePipelineYaml(readFileSync(configPath, "utf-8")); } catch { pipelineConfigs = []; } } else { pipelineConfigs = []; } } function activatePipeline(config: PipelineConfig) { activeConfig = config; (globalThis as any).__piActivePipeline = config.name; currentPhaseIndex = 0; taskSummary = ""; accContext = ""; planOutput = ""; reviewOutput = ""; reviewLoopCount = 0; phaseStates = config.phases.map(p => ({ def: p, status: "pending" as PhaseStatus, summary: "", agents: [], })); if (phaseStates.length > 0) { phaseStates[0].status = "active"; } updateWidget(); } function resetPipeline() { if (activeConfig) activatePipeline(activeConfig); } // ── Widget ─────────────────────────────────── function clearPipelineUI() { if (!widgetCtx) return; widgetCtx.ui.setWidget("pipeline-team", undefined); widgetCtx.ui.setStatus("pipeline-team", undefined); } function updateStatus() { if (!widgetCtx) return; if (!activeConfig) { widgetCtx.ui.setStatus("pipeline-team", undefined); return; } const phase = phaseStates[currentPhaseIndex]; if (phase) { widgetCtx.ui.setStatus("pipeline-team", phase.def.name.toUpperCase()); } } function updateWidget() { if (!widgetCtx) return; if (!activeConfig || phaseStates.length === 0) { clearPipelineUI(); return; } // Only show when agents are actively running const hasActiveWork = phaseStates.some((ps) => ps.agents.some((a) => a.status === "running"), ); if (!hasActiveWork) { clearPipelineUI(); return; } updateStatus(); widgetCtx.ui.setWidget("pipeline-team", (_tui: any, theme: any) => { const text = new Text("", 0, 1); return { render(width: number): string[] { if (!activeConfig || phaseStates.length === 0) return []; const renderPhases = phaseStates.map(s => ({ name: s.def.name, status: s.status, summary: s.summary, agents: s.agents.map(a => ({ role: a.role, index: a.index, status: a.status, lastWork: a.lastWork, task: a.task, elapsed: a.elapsed, })), })); const rawLines = widgetCollapsed ? renderCollapsedTimeline(renderPhases, currentPhaseIndex, activeConfig!.name, width, theme) : renderVerticalTimeline(renderPhases, currentPhaseIndex, width, theme); const allDone = phaseStates.every(p => p.status === "done"); const hasError = phaseStates.some(p => p.status === "error"); const barColor: BarColor = hasError ? "error" : allDone ? "success" : "accent"; const outputLines = outputBox(theme, barColor, rawLines); text.setText(outputLines.join("\n")); return text.render(width); }, invalidate() { text.invalidate(); }, }; }, { placement: "belowEditor" }); } // ── Subprocess Spawning ────────────────────── function spawnAgent( agentDef: AgentDef, task: string, agentState: AgentState, ctx: any, ): Promise<{ output: string; exitCode: number; elapsed: number }> { agentState.status = "running"; agentState.task = task; agentState.elapsed = 0; agentState.lastWork = ""; agentState.output = ""; updateWidget(); const startTime = Date.now(); agentState.timer = setInterval(() => { agentState.elapsed = Date.now() - startTime; updateWidget(); }, 1000); // 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 = `pipeline-${agentDef.name.toLowerCase().replace(/\s+/g, "-")}-${agentState.index}`; const agentSessionFile = join(sessionDir, `${agentKey}.json`); 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, task, ]; const textChunks: string[] = []; return new Promise((resolvePromise) => { const proc = spawn("pi", args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, PI_SUBAGENT: "1" }, }); // Track for escape-cancel integration agentState.proc = proc; 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() || ""; agentState.lastWork = last; updateWidget(); } } } catch {} } }); proc.stderr!.setEncoding("utf-8"); proc.stderr!.on("data", () => {}); proc.on("close", (code) => { agentState.proc = 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(agentState.timer); agentState.elapsed = Date.now() - startTime; const output = textChunks.join(""); agentState.output = output; agentState.status = code === 0 ? "done" : "error"; agentState.lastWork = output.split("\n").filter((l: string) => l.trim()).pop() || ""; updateWidget(); ctx.ui.notify( `${displayName(agentState.role)} #${agentState.index + 1} ${agentState.status} in ${Math.round(agentState.elapsed / 1000)}s`, agentState.status === "done" ? "success" : "error", ); resolvePromise({ output, exitCode: code ?? 1, elapsed: agentState.elapsed }); }); proc.on("error", (err) => { agentState.proc = null; clearInterval(agentState.timer); agentState.status = "error"; agentState.lastWork = `Error: ${err.message}`; agentState.output = `Error spawning agent: ${err.message}`; updateWidget(); resolvePromise({ output: `Error spawning agent: ${err.message}`, exitCode: 1, elapsed: Date.now() - startTime, }); }); proc.on("exit", () => { clearInterval(agentState.timer); }); }); } // ── Dispatch Agents for a Phase ────────────── async function dispatchPhaseAgents( agentDefs: { role: string; task: string }[], mode: "parallel" | "sequential", ctx: any, ): Promise<{ outputs: string[]; success: boolean }> { const phaseState = phaseStates[currentPhaseIndex]; phaseState.agents = agentDefs.map((d, i) => ({ role: d.role, index: i, status: "idle" as const, task: d.task, elapsed: 0, lastWork: "", output: "", })); updateWidget(); const outputs: string[] = []; let allSuccess = true; if (mode === "parallel") { const promises = agentDefs.map((d, i) => { const def = allAgents.get(d.role.toLowerCase()); if (!def) { phaseState.agents[i].status = "error"; phaseState.agents[i].lastWork = `Agent "${d.role}" not found`; updateWidget(); return Promise.resolve({ output: `Agent "${d.role}" not found`, exitCode: 1, elapsed: 0 }); } return spawnAgent(def, d.task, phaseState.agents[i], ctx); }); const results = await Promise.all(promises); for (const r of results) { outputs.push(r.output); if (r.exitCode !== 0) allSuccess = false; } } else { // Sequential — each agent's output becomes $INPUT for next let input = ""; for (let i = 0; i < agentDefs.length; i++) { const d = agentDefs[i]; const def = allAgents.get(d.role.toLowerCase()); if (!def) { phaseState.agents[i].status = "error"; phaseState.agents[i].lastWork = `Agent "${d.role}" not found`; updateWidget(); outputs.push(`Agent "${d.role}" not found`); allSuccess = false; break; } const task = d.task.replace(/\$INPUT/g, input); const result = await spawnAgent(def, task, phaseState.agents[i], ctx); outputs.push(result.output); input = result.output; if (result.exitCode !== 0) { allSuccess = false; break; } } } return { outputs, success: allSuccess }; } // ── Ctrl+J Overlay ─────────────────────────── class AgentGridOverlay { private selectedIndex = 0; private expandedIndex: number | null = null; private scrollOffset = 0; constructor( private items: AgentState[], private onDone: () => void, ) { this.selectedIndex = 0; } handleInput(data: string, tui: any): void { if (matchesKey(data, Key.up)) { this.selectedIndex = Math.max(0, this.selectedIndex - 1); } else if (matchesKey(data, Key.down)) { this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1); } else if (matchesKey(data, Key.enter)) { this.expandedIndex = this.expandedIndex === this.selectedIndex ? null : this.selectedIndex; } else if (matchesKey(data, Key.escape)) { this.onDone(); return; } tui.requestRender(); } private ensureVisible(height: number) { const pageSize = Math.floor(height / 4); if (this.selectedIndex < this.scrollOffset) { this.scrollOffset = this.selectedIndex; } else if (this.selectedIndex >= this.scrollOffset + pageSize) { this.scrollOffset = this.selectedIndex - pageSize + 1; } } render(width: number, height: number, theme: any): string[] { this.ensureVisible(height); const container = new Container(); // Header container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); const phaseName = phaseStates[currentPhaseIndex]?.def.name.toUpperCase() || "PIPELINE"; container.addChild(new Text( `${theme.fg("accent", theme.bold(` AGENTS — ${phaseName}`))} ${theme.fg("dim", "|")} ${theme.fg("success", this.items.length.toString())} agents`, 1, 0, )); container.addChild(new Spacer(1)); const visibleItems = this.items.slice(this.scrollOffset); visibleItems.forEach((item, idx) => { const absoluteIndex = idx + this.scrollOffset; const isSelected = absoluteIndex === this.selectedIndex; const isExpanded = absoluteIndex === this.expandedIndex; const cardBox = new Box(1, 0, (s) => isSelected ? theme.bg("selectedBg", s) : s); const agentLabel = displayName(item.role) + " #" + (item.index + 1); const statusBtn = statusButton(item.status, agentLabel, theme); const timeStr = item.elapsed > 0 ? ` ${Math.round(item.elapsed / 1000)}s` : ""; const titleLine = `${statusBtn} ${theme.fg("dim", timeStr)}`; cardBox.addChild(new Text(titleLine, 0, 0)); if (isExpanded && item.output) { cardBox.addChild(new Spacer(1)); const output = item.output.length > 4000 ? item.output.slice(0, 4000) + "\n... [truncated]" : item.output; cardBox.addChild(new Text(theme.fg("muted", output), 0, 0)); } else { const preview = (item.lastWork || item.task || "—").replace(/\n/g, " "); const truncated = preview.length > width - 10 ? preview.slice(0, width - 13) + "..." : preview; cardBox.addChild(new Text(theme.fg("dim", " " + truncated), 0, 0)); } container.addChild(cardBox); }); // Footer container.addChild(new Spacer(1)); container.addChild(new Text(theme.fg("dim", " ↑/↓ Navigate • Enter Expand • Esc Close"), 1, 0)); container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s))); return container.render(width); } } // ── Collect All Agents for Overlay ─────────── function collectOverlayAgents(): AgentState[] { // Current phase agents first, then all others const current = phaseStates[currentPhaseIndex]?.agents || []; if (current.length > 0) return current; // If no current phase agents, show all from all phases const all: AgentState[] = []; for (const ps of phaseStates) { all.push(...ps.agents); } return all; } // ── Tools ──────────────────────────────────── pi.registerTool({ name: "advance_phase", label: "Advance Phase", description: "Move the pipeline to the next phase. Call this when the current phase is complete. In Phase 1 (UNDERSTAND), call this once the task is fully clarified.", parameters: Type.Object({ summary: Type.String({ description: "Summary of what was accomplished in this phase / the clarified task" }), skip_to: Type.Optional(Type.String({ description: "Optional: skip to a specific phase name (e.g. 'plan' to skip gather)" })), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { const { summary, skip_to } = params as { summary: string; skip_to?: string }; if (!activeConfig || phaseStates.length === 0) { return { content: [{ type: "text", text: "No pipeline active." }], details: {} }; } // Mark current phase done phaseStates[currentPhaseIndex].status = "done"; phaseStates[currentPhaseIndex].summary = summary; // Accumulate context if (currentPhaseIndex === 0) { taskSummary = summary; } accContext += `\n\n## Phase ${currentPhaseIndex + 1}: ${phaseStates[currentPhaseIndex].def.name}\n${summary}`; // Determine next phase let nextIndex = currentPhaseIndex + 1; if (skip_to) { const target = phaseStates.findIndex(p => p.def.name.toLowerCase() === skip_to.toLowerCase()); if (target > currentPhaseIndex) nextIndex = target; } if (nextIndex >= phaseStates.length) { return { content: [{ type: "text", text: "Pipeline complete! All phases finished." }], details: { phase: "complete", summary }, }; } currentPhaseIndex = nextIndex; phaseStates[currentPhaseIndex].status = "active"; updateWidget(); const phase = phaseStates[currentPhaseIndex].def; return { content: [{ type: "text", text: `Advanced to phase: ${phase.name.toUpperCase()} — ${phase.description}\nMode: ${phase.mode}\nAgents: ${phase.agents.length}` }], details: { phase: phase.name, mode: phase.mode }, }; }, renderCall(args, theme) { const summary = (args as any).summary || ""; const preview = summary.length > 60 ? summary.slice(0, 57) + "..." : summary; const text = theme.fg("toolTitle", theme.bold("advance_phase ")) + theme.fg("muted", preview); return new Text(outputLine(theme, "accent", text), 0, 0); }, renderResult(result, _options, theme) { const text = result.content[0]; const msg = text?.type === "text" ? text.text : ""; return new Text(outputLine(theme, "success", msg), 0, 0); }, }); pi.registerTool({ name: "dispatch_agents", label: "Dispatch Agents", description: "Dispatch one or more agents for the current pipeline phase. Agents run in parallel or sequential mode depending on the phase configuration. Use this in phases 2-5 to do the actual work.", parameters: Type.Object({ agents: Type.Array(Type.Object({ role: Type.String({ description: "Agent role name (e.g. 'scout', 'builder', 'reviewer')" }), task: Type.String({ description: "Task description for this agent" }), }), { description: "Array of agents to dispatch" }), }), async execute(_toolCallId, params, _signal, onUpdate, ctx) { const { agents } = params as { agents: { role: string; task: string }[] }; const phase = phaseStates[currentPhaseIndex]; if (!phase) { return { content: [{ type: "text", text: "No active phase." }], details: {} }; } if (onUpdate) { onUpdate({ content: [{ type: "text", text: `Dispatching ${agents.length} agent(s) in ${phase.def.mode} mode...` }], details: { agents: agents.map(a => a.role), mode: phase.def.mode, status: "dispatching" }, }); } // Resolve template variables in task strings const resolved = agents.map(a => ({ role: a.role, task: resolveTemplate(a.task, { task: taskSummary, context: accContext, plan: planOutput, input: "", review: reviewOutput, }), })); const mode = phase.def.mode === "interactive" ? "sequential" : phase.def.mode; const result = await dispatchPhaseAgents(resolved, mode as "parallel" | "sequential", ctx); // Merge outputs into accumulated context const mergedOutput = result.outputs.join("\n\n---\n\n"); const outputSummary = mergedOutput.length > 3000 ? mergedOutput.slice(0, 3000) + "\n\n... [output truncated, full output was " + mergedOutput.length + " chars]" : mergedOutput; accContext += `\n\n## Phase ${currentPhaseIndex + 1} Agent Output:\n${outputSummary}`; // Store plan output if this is the plan phase if (phase.def.name.toLowerCase() === "plan") { planOutput = mergedOutput; } // Store review output if this is the review phase if (phase.def.name.toLowerCase() === "review") { reviewOutput = mergedOutput; reviewLoopCount++; } const truncated = mergedOutput.length > 8000 ? mergedOutput.slice(0, 8000) + "\n\n... [truncated]" : mergedOutput; const status = result.success ? "done" : "error"; return { content: [{ type: "text", text: `[${phase.def.name}] ${status} — ${agents.length} agent(s)\n\n${truncated}` }], details: { phase: phase.def.name, agents: agents.map(a => a.role), status, fullOutput: mergedOutput, reviewLoop: reviewLoopCount, }, }; }, renderCall(args, theme) { const agents = (args as any).agents || []; const roles = agents.map((a: any) => a.role).join(", "); const text = theme.fg("toolTitle", theme.bold("dispatch_agents ")) + theme.fg("accent", `${agents.length} agent(s)`) + theme.fg("dim", " — ") + theme.fg("muted", roles); 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 === "dispatching") { const runningBtn = statusButton("active", details.phase || "?", theme); const content = runningBtn + theme.fg("dim", ` dispatching ${(details.agents || []).length} agents...`); return new Text(outputLine(theme, "accent", content), 0, 0); } const status = details.status === "done" ? "done" : "error"; const bar = status === "done" ? "success" : "error"; const statusBtn = statusButton(status, details.phase, theme); const header = statusBtn + theme.fg("dim", ` ${(details.agents || []).length} agents`); 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); }, }); pi.registerTool({ name: "pipeline_status", label: "Pipeline Status", description: "Returns the current pipeline state — phases, current phase, accumulated context summary. No parameters needed.", parameters: Type.Object({}), async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { if (!activeConfig) { return { content: [{ type: "text", text: "No pipeline active." }], details: {} }; } const phases = phaseStates.map((ps, i) => { const marker = i === currentPhaseIndex ? "→ " : " "; return `${marker}${ps.def.name.toUpperCase()} [${ps.status}]${ps.summary ? ": " + ps.summary.slice(0, 100) : ""}`; }).join("\n"); const status = [ `Pipeline: ${activeConfig.name}`, `Current Phase: ${phaseStates[currentPhaseIndex]?.def.name.toUpperCase() || "none"} (${currentPhaseIndex + 1}/${phaseStates.length})`, `Review Loops: ${reviewLoopCount}/${activeConfig.review_max_loops}`, ``, `Phases:`, phases, ``, `Task: ${taskSummary || "(not yet clarified)"}`, `Context Length: ${accContext.length} chars`, `Plan: ${planOutput ? planOutput.slice(0, 200) + "..." : "(none yet)"}`, ].join("\n"); return { content: [{ type: "text", text: status }], details: { phase: currentPhaseIndex, total: phaseStates.length, reviewLoops: reviewLoopCount }, }; }, renderCall(_args, theme) { return new Text(outputLine(theme, "accent", theme.bold("pipeline_status")), 0, 0); }, renderResult(result, _options, theme) { const text = result.content[0]; const msg = text?.type === "text" ? text.text : ""; return new Text(outputLine(theme, "accent", msg), 0, 0); }, }); // ── Commands ────────────────────────────────── pi.registerCommand("pipeline", { description: "Select a pipeline configuration", handler: async (_args, ctx) => { widgetCtx = ctx; if (pipelineConfigs.length === 0) { ctx.ui.notify("No pipelines in .pi/agents/pipeline-team.yaml", "warning"); return; } /** Shows pipeline name with its first phase (starting point) */ const options = pipelineConfigs.map(c => { const firstPhase = c.phases[0] ? displayName(c.phases[0].name) : "No Phases"; return `${c.name} — ${firstPhase}`; }); const choice = await ctx.ui.select("Select Pipeline", options); if (choice === undefined) return; const idx = options.indexOf(choice); activatePipeline(pipelineConfigs[idx]); updateStatus(); ctx.ui.notify(`Pipeline: ${activeConfig!.name}\n${activeConfig!.description}`, "info"); }, }); pi.registerCommand("pipeline-status", { description: "Show full pipeline state", handler: async (_args, ctx) => { if (!activeConfig) { ctx.ui.notify("No pipeline active", "warning"); return; } const phases = phaseStates.map((ps, i) => { const marker = i === currentPhaseIndex ? "→ " : " "; const agents = ps.agents.length > 0 ? ` (${ps.agents.filter(a => a.status === "done").length}/${ps.agents.length} agents done)` : ""; return `${marker}${ps.def.name.toUpperCase()} [${ps.status}]${agents}`; }).join("\n"); ctx.ui.notify( `Pipeline: ${activeConfig.name}\n\n${phases}\n\nReview loops: ${reviewLoopCount}/${activeConfig.review_max_loops}`, "info", ); }, }); pi.registerCommand("pipeline-reset", { description: "Reset pipeline to phase 1", handler: async (_args, ctx) => { widgetCtx = ctx; resetPipeline(); ctx.ui.notify("Pipeline reset to phase 1", "info"); updateStatus(); }, }); pi.registerCommand("pipeline-off", { description: "Deactivate pipeline and hide UI", handler: async (_args, ctx) => { widgetCtx = ctx; activeConfig = null; (globalThis as any).__piActivePipeline = null; phaseStates = []; clearPipelineUI(); ctx.ui.notify("Pipeline deactivated. Use /pipeline to select one.", "info"); }, }); pi.registerCommand("pipeline-clear", { description: "Clear pipeline widget from screen (keeps pipeline active)", handler: async (_args, ctx) => { widgetCtx = ctx; clearPipelineUI(); // Reset agent states within each phase so the widget can reappear on next dispatch for (const ps of phaseStates) { for (const agent of ps.agents) { if (agent.status === "done" || agent.status === "error") { agent.status = "idle"; agent.lastWork = ""; agent.output = ""; agent.elapsed = 0; } } } ctx.ui.notify("Pipeline widget cleared. Pipeline remains active.", "info"); }, }); // ── Ctrl+J Shortcut ────────────────────────── pi.registerShortcut("ctrl+j", { description: "Open agent grid overlay", handler: async (ctx) => { const agents = collectOverlayAgents(); if (agents.length === 0) { ctx.ui.notify("No agents to inspect", "info"); return; } await ctx.ui.custom((tui, theme, _kb, done) => { const overlay = new AgentGridOverlay(agents, () => done(undefined)); return { render: (w) => overlay.render(w, 30, theme), handleInput: (data) => overlay.handleInput(data, tui), invalidate: () => {}, }; }, { overlay: true, overlayOptions: { width: "80%", anchor: "center" }, }); }, }); // ── Alt+P Shortcut ────────────────────────── pi.registerShortcut("alt+p", { description: "Toggle pipeline widget collapse/expand", handler: async (ctx) => { widgetCtx = ctx; if (!activeConfig) { ctx.ui.notify("No pipeline active. Use /pipeline to select one.", "info"); return; } widgetCollapsed = !widgetCollapsed; updateWidget(); }, }); // ── System Prompt (dynamic per-phase) ──────── pi.on("before_agent_start", async (_event, _ctx) => { // Mode gate: only fire when mode is PIPELINE (or unset for backward compat) const mode = (globalThis as any).__piCurrentMode; if (mode && mode !== "PIPELINE") return {}; if (!activeConfig || phaseStates.length === 0) return {}; const phase = phaseStates[currentPhaseIndex]; const phaseName = phase.def.name.toUpperCase(); // Build agent catalog for dispatch const agentCatalog = Array.from(allAgents.values()) .map(a => `- **${displayName(a.name)}** (dispatch as \`${a.name}\`): ${a.description}`) .join("\n"); // Pipeline status summary const phasesSummary = phaseStates.map((ps, i) => { const marker = i === currentPhaseIndex ? "→ " : " "; return `${marker}${ps.def.name.toUpperCase()} [${ps.status}]`; }).join("\n"); // Context summary const contextSummary = accContext ? `\n## Accumulated Context\n${truncateContext(accContext)}` : ""; const planSection = planOutput ? `\n## Implementation Plan\n${truncateContext(planOutput)}` : ""; const reviewSection = reviewOutput ? `\n## Last Review (loop ${reviewLoopCount}/${activeConfig.review_max_loops})\n${truncateContext(reviewOutput)}` : ""; // Phase-specific instructions let phaseInstructions = ""; if (phase.def.name === "understand") { phaseInstructions = `## Phase Instructions: UNDERSTAND You are in the UNDERSTAND phase. Your job is to: 1. Analyze the task and classify its complexity 2. Use your codebase tools to verify assumptions 3. When the task is fully clarified, call \`advance_phase\` with a detailed summary ## Task Complexity Routing Before proceeding, classify the task: **SIMPLE** — Do it yourself. No pipeline needed. - Reading files, checking status, listing contents - Quick lookups, answering questions, single small edits → Use your own tools directly. Do NOT call advance_phase. **MEDIUM** — Shortened pipeline. Skip GATHER. - Focused 1-2 file changes where scope is clear - Bug fixes where location is known → Call advance_phase with skip_to: "plan" (or skip_to: "execute" if obvious) **COMPLEX** — Full pipeline. - Multi-file features, refactors, architectural changes - Tasks needing codebase exploration first → Call advance_phase normally (all phases) Do NOT dispatch agents in this phase. Converse directly with the user. Call \`advance_phase\` with a comprehensive task summary when ready to proceed.`; } else if (phase.def.name === "gather") { phaseInstructions = `## Phase Instructions: GATHER You are in the GATHER phase. Dispatch scout agents to explore the codebase in parallel. Use \`dispatch_agents\` to send multiple scouts concurrently. Review their findings, then call \`advance_phase\` with a summary. Default agents from config: ${phase.def.agents.map((a, i) => `${i + 1}. ${a.role}: ${a.task_template.slice(0, 100)}`).join("\n")}`; } else if (phase.def.name === "plan") { phaseInstructions = `## Phase Instructions: PLAN You are in the PLAN phase. Dispatch a planner agent to create an implementation plan. Use \`dispatch_agents\` with a planner. The plan will be stored as $PLAN for later phases. Call \`advance_phase\` with the plan summary when done.`; } else if (phase.def.name === "execute") { phaseInstructions = `## Phase Instructions: EXECUTE You are in the EXECUTE phase. Dispatch builder agents to implement the plan. You can dispatch multiple builders for independent tasks. Use \`dispatch_agents\` then call \`advance_phase\` when implementation is complete.`; } else if (phase.def.name === "review") { phaseInstructions = `## Phase Instructions: REVIEW You are in the REVIEW phase (loop ${reviewLoopCount + 1}/${activeConfig.review_max_loops}). Dispatch a reviewer agent to audit the implementation. After reviewing the output: - If the reviewer says APPROVED → call \`advance_phase\` to complete the pipeline - If issues found and loops remaining → use \`dispatch_agents\` to fix issues, then review again - Max review loops: ${activeConfig.review_max_loops}`; } 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 - Use file:open to show pipeline plans, phase results, or review reports` : ""; return { systemPrompt: `You are orchestrating a pipeline called "${activeConfig.name}". You have full codebase tools AND pipeline tools (advance_phase, dispatch_agents, pipeline_status). ## When to Work Directly (Skip the Pipeline) - 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 Use your judgment — if it's quick, just do it; if it's real work, use the pipeline. ## Current Phase: ${phaseName} ${phase.def.description} ## Pipeline Progress ${phasesSummary} ${phaseInstructions} ## Available Agents for Dispatch ${agentCatalog} ## Task ${taskSummary || "(Phase 1: Ask the user what they want to accomplish)"} ${contextSummary}${planSection}${reviewSection} ## Tools - \`advance_phase\`: Move to next phase (required summary of what was done) - \`dispatch_agents\`: Send agents to work (array of {role, task}) - \`pipeline_status\`: Check current pipeline state - Plus all standard codebase tools (read, write, edit, bash, etc.)${commanderSection}`, }; }); // ── Session Start ──────────────────────────── pi.on("session_start", async (_event, _ctx) => { applyExtensionDefaults(import.meta.url, _ctx); // Clear widgets from previous session widgetCtx = _ctx; clearPipelineUI(); contextWindow = _ctx.model?.contextWindow || 0; // Wipe pipeline session files const sessDir = join(_ctx.cwd, ".pi", "agent-sessions"); if (existsSync(sessDir)) { for (const f of readdirSync(sessDir)) { if (f.startsWith("pipeline-") && f.endsWith(".json")) { try { unlinkSync(join(sessDir, f)); } catch {} } } } loadConfig(_ctx.cwd); if (pipelineConfigs.length === 0) { activeConfig = null; phaseStates = []; clearPipelineUI(); _ctx.ui.notify("No pipelines found in .pi/agents/pipeline-team.yaml", "warning"); return; } // Opt-in: do NOT auto-activate. User must run /pipeline to start. // Ensure no pipeline UI is shown until user explicitly activates one. activeConfig = null; (globalThis as any).__piActivePipeline = null; phaseStates = []; clearPipelineUI(); // ── Expose global hooks for escape-cancel integration ──────────── (globalThis as any).__piKillPipelineProc = (): boolean => { let killed = false; for (const phase of phaseStates) { for (const agent of phase.agents) { if (agent.proc && agent.status === "running") { try { agent.proc.kill("SIGTERM"); } catch {} killed = true; } } } return killed; }; (globalThis as any).__piHasRunningPipeline = (): boolean => { for (const phase of phaseStates) { for (const agent of phase.agents) { if (agent.status === "running") return true; } } return false; }; }); }