1216 lines
40 KiB
TypeScript
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;
|
|
};
|
|
});
|
|
}
|