Files
pi-skill/extensions/pipeline-team.ts
2026-05-25 16:41:08 +07:00

1216 lines
40 KiB
TypeScript

// 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<typeof setInterval>;
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<string, string> = {};
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<string, AgentDef> {
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<string, AgentDef>();
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<string, AgentDef> = 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: <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;
};
});
}