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

1415 lines
49 KiB
TypeScript

// ABOUTME: Multi-agent team dispatcher with specialist agents and grid dashboard.
// ABOUTME: Primary agent delegates via dispatch_agent tool; teams defined in .pi/agents/teams.yaml.
/**
* Agent Team — Dispatcher-only orchestrator with grid dashboard
*
* The primary Pi agent has NO codebase tools. It can ONLY delegate work
* to specialist agents via the `dispatch_agent` tool. Each specialist
* maintains its own Pi session for cross-invocation memory.
*
* Loads agent definitions from agents/*.md, .claude/agents/*.md, .pi/agents/*.md.
* Teams are defined in .pi/agents/teams.yaml — on boot a select dialog lets
* you pick which team to work with. Only team members are available for dispatch.
*
* Commands:
* /agents-team — switch active team
* /agents-list — list loaded agents
* /agents-grid N — set column count (default 2)
* /agents-clear — clear agent team widget from screen
* Alt+G — toggle compact/expanded widget view
*
* Usage: pi -e extensions/agent-team.ts -e extensions/footer.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { Text, type AutocompleteItem, visibleWidth, truncateToWidth, Container, Spacer, Box, Markdown, matchesKey, Key, type Component } from "@mariozechner/pi-tui";
import { DynamicBorder, getMarkdownTheme as getPiMdTheme } from "@mariozechner/pi-coding-agent";
import { spawn } from "child_process";
import { readdirSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
import { dirname, join, resolve } from "path";
import { fileURLToPath } from "url";
import { applyExtensionDefaults } from "./lib/themeMap.ts";
import { statusButton } from "./lib/pipeline-render.ts";
import { DEFAULT_SUBAGENT_MODEL } from "./lib/defaults.ts";
import { loadAgentModelsConfig, loadToolkitModelsConfig, resolveAgentModelString, scanToolkitAgentDefs, type AgentModelsConfig } from "./lib/agent-defs.ts";
import { resolveToolkitWorkerModel, isToolkitCliAgent, spawnToolkitWorker } from "./lib/toolkit-cli.ts";
import { padRight, wordWrap, sideBySide } from "./lib/ui-helpers.ts";
import { contextBudgetLevel, isContextLossError } from "./lib/context-budget.ts";
import { buildCommanderPrompt } from "./lib/commander-prompt.ts";
import { preClaimTask, postCompleteTask, postFailTask } from "./lib/commander-lifecycle.ts";
import { renderTaskList, navDown, navUp, navExit, navEnter, type TaskListInfo, type TaskListState } from "./lib/task-list-render.ts";
import { renderSubagentWidget } from "./lib/subagent-render.ts";
// ── Types ────────────────────────────────────────
interface AgentDef {
name: string;
description: string;
tools: string;
model: string; // full provider/model ID, empty = inherit parent
systemPrompt: string;
file: string;
}
interface AgentState {
def: AgentDef;
status: "idle" | "running" | "done" | "error";
task: string;
toolCount: number;
elapsed: number;
lastWork: string;
contextPct: number;
sessionFile: string | null;
runCount: number;
resolvedModel: string;
timer?: ReturnType<typeof setInterval>;
_warnSent?: boolean;
_criticalWarned?: boolean;
widgetId: number; // unique ID for subagent-style widget
textChunks: string[]; // streaming text for widget summary
summary?: string; // short summary shown in widget
summaryLines?: string[]; // up to 2 recent CLI/output lines for richer widget preview
proc?: any; // ChildProcess ref for escape-cancel
}
// ── Display Name Helper ──────────────────────────
function displayName(name: string): string {
return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
}
function abbreviateAgentName(name: string): string {
const parts = name.split("-");
if (parts.length > 1) {
// Multi-word: take first letter of each word and uppercase
return parts.map(w => w.charAt(0).toUpperCase()).join("");
} else {
// Single-word: uppercase the entire name
return name.toUpperCase();
}
}
// ── Teams YAML Parser ────────────────────────────
function parseTeamsYaml(raw: string): Record<string, string[]> {
const teams: Record<string, string[]> = {};
let current: string | null = null;
for (const line of raw.split("\n")) {
const teamMatch = line.match(/^(\S[^:]*):$/);
if (teamMatch) {
current = teamMatch[1].trim();
teams[current] = [];
continue;
}
const itemMatch = line.match(/^\s+-\s+(.+)$/);
if (itemMatch && current) {
teams[current].push(itemMatch[1].trim());
}
}
return teams;
}
// ── Frontmatter Parser ───────────────────────────
function parseAgentFile(filePath: string, modelsConfig?: AgentModelsConfig): AgentDef | null {
try {
const raw = readFileSync(filePath, "utf-8");
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return null;
const frontmatter: Record<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(),
file: filePath,
};
} catch {
return null;
}
}
function scanAgentDirs(cwd: string, extProjectDir?: string, modelsConfig?: AgentModelsConfig): AgentDef[] {
const dirs = [
join(cwd, "agents"),
join(cwd, ".claude", "agents"),
join(cwd, ".pi", "agents"),
...(extProjectDir ? [join(extProjectDir, ".pi", "agents"), join(extProjectDir, "agents")] : []),
];
const agents: AgentDef[] = [];
const seen = new Set<string>();
for (const dir of dirs) {
if (!existsSync(dir)) continue;
try {
const scan = (d: string) => {
for (const file of readdirSync(d, { withFileTypes: true })) {
const fullPath = resolve(d, file.name);
if (file.isDirectory()) {
scan(fullPath);
} else if (file.name.endsWith(".md")) {
const def = parseAgentFile(fullPath, modelsConfig);
if (def && !seen.has(def.name.toLowerCase())) {
seen.add(def.name.toLowerCase());
agents.push(def);
}
}
}
};
scan(dir);
} catch {}
}
return agents;
}
// ── Extension ────────────────────────────────────
export default function (pi: ExtensionAPI) {
const agentStates: Map<string, AgentState> = new Map();
let allAgentDefs: AgentDef[] = [];
let teams: Record<string, string[]> = {};
let activeTeamName = "";
let gridCols = 2;
let widgetCtx: any;
let sessionDir = "";
let contextWindow = 0;
let widgetCompact = true;
let selectedAgentIndex = -1; // -1 = no selection
let taskListState: TaskListState = { selectedIndex: -1, scrollOffset: 0 };
let nextWidgetId = 1;
const agentWidgetBoxes = new Map<number, { invalidate: () => void }>();
// ── Dark background colors for agent status (matches subagent-widget) ────
const STATUS_BG: Record<string, string> = {
running: "\x1b[48;2;26;58;92m", // dark steel blue
done: "\x1b[48;2;35;50;55m", // dark teal-gray
error: "\x1b[48;2;70;35;35m", // dark muted red
};
const RESET_BG = "\x1b[49m";
const WHITE_BOLD = "\x1b[1;97m"; // bold bright white text
const RESET_ALL = "\x1b[0m";
function loadAgents(cwd: string) {
const extDir = dirname(fileURLToPath(import.meta.url));
const extProjectDir = resolve(extDir, "..");
// Create session storage dir
sessionDir = join(cwd, ".pi", "agent-sessions");
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
// Load standard + toolkit model config, then scan agent .md files
const modelsConfig = loadAgentModelsConfig(cwd, extProjectDir);
const toolkitModelsConfig = loadToolkitModelsConfig(cwd, extProjectDir);
const standardAgentDefs = scanAgentDirs(cwd, extProjectDir, modelsConfig);
const toolkitAgentDefs = Array.from(scanToolkitAgentDefs(cwd, extProjectDir, toolkitModelsConfig).values());
const merged = new Map<string, AgentDef>();
for (const def of [...standardAgentDefs, ...toolkitAgentDefs]) {
if (!merged.has(def.name.toLowerCase())) merged.set(def.name.toLowerCase(), def);
}
allAgentDefs = Array.from(merged.values());
// Load teams from .pi/agents/teams.yaml (fallback to extension project dir)
let teamsPath = join(cwd, ".pi", "agents", "teams.yaml");
if (!existsSync(teamsPath)) {
teamsPath = join(extProjectDir, ".pi", "agents", "teams.yaml");
}
if (!existsSync(teamsPath)) {
teamsPath = join(extProjectDir, "agents", "teams.yaml");
}
if (existsSync(teamsPath)) {
try {
teams = parseTeamsYaml(readFileSync(teamsPath, "utf-8"));
} catch {
teams = {};
}
} else {
teams = {};
}
// If no teams defined, create a default "all" team
if (Object.keys(teams).length === 0) {
teams = { all: allAgentDefs.map(d => d.name) };
}
}
function activateTeam(teamName: string) {
activeTeamName = teamName;
const members = teams[teamName] || [];
const defsByName = new Map(allAgentDefs.map(d => [d.name.toLowerCase(), d]));
agentStates.clear();
selectedAgentIndex = -1; // Reset selection when team changes
for (const member of members) {
const def = defsByName.get(member.toLowerCase());
if (!def) continue;
const key = def.name.toLowerCase().replace(/\s+/g, "-");
const sessionFile = join(sessionDir, `${key}.json`);
agentStates.set(def.name.toLowerCase(), {
def,
status: "idle",
task: "",
toolCount: 0,
elapsed: 0,
lastWork: "",
contextPct: 0,
sessionFile: existsSync(sessionFile) ? sessionFile : null,
runCount: 0,
resolvedModel: "",
widgetId: nextWidgetId++,
textChunks: [],
summary: undefined,
summaryLines: undefined,
});
}
// Auto-size grid columns based on team size
const size = agentStates.size;
gridCols = size <= 3 ? size : size === 4 ? 2 : 3;
}
// ── Per-Agent Widget Rendering (subagent-style) ──────────────────
function registerAgentWidget(state: AgentState) {
if (!widgetCtx) return;
const key = `agent-${state.widgetId}`;
widgetCtx.ui.setWidget(key, (_tui: any, theme: any) => {
const bgFn = (text: string): string => {
const bg = STATUS_BG[state.status] || STATUS_BG.running;
return `${bg}${WHITE_BOLD}${text}${RESET_ALL}${RESET_BG}`;
};
const box = new Box(1, 1, bgFn);
const content = new Text("", 0, 0);
box.addChild(content);
agentWidgetBoxes.set(state.widgetId, { invalidate: () => box.invalidate() });
return {
render(width: number): string[] {
box.setBgFn((text: string): string => {
const bg = STATUS_BG[state.status] || STATUS_BG.running;
return `${bg}${WHITE_BOLD}${text}${RESET_ALL}${RESET_BG}`;
});
const renderState = {
id: state.widgetId,
status: state.status as "running" | "done" | "error",
name: state.def.name.toUpperCase(),
task: state.task,
toolCount: state.toolCount,
elapsed: state.elapsed,
turnCount: state.runCount,
summary: state.summary,
summaryLines: state.summaryLines,
model: state.resolvedModel || state.def.model || undefined,
};
const result = renderSubagentWidget(renderState, width, theme);
content.setText(result.lines.join("\n"));
return box.render(width);
},
invalidate() {
box.invalidate();
},
};
});
}
function invalidateAgentWidget(state: AgentState) {
agentWidgetBoxes.get(state.widgetId)?.invalidate();
}
function removeAgentWidget(state: AgentState) {
if (!widgetCtx) return;
widgetCtx.ui.setWidget(`agent-${state.widgetId}`, undefined);
agentWidgetBoxes.delete(state.widgetId);
}
function removeAllAgentWidgets() {
if (!widgetCtx) return;
for (const state of agentStates.values()) {
widgetCtx.ui.setWidget(`agent-${state.widgetId}`, undefined);
}
agentWidgetBoxes.clear();
}
// ── Combined Widget (task list only) ──────────────────────────────
function updateWidget() {
if (!widgetCtx) return;
// Task list widget (above editor)
const taskList = (globalThis as any).__piTaskList as TaskListInfo | null;
if (taskList && taskList.tasks.length > 0) {
widgetCtx.ui.setWidget("agent-team", (_tui: any, theme: any) => {
const text = new Text("", 0, 0);
return {
render(width: number): string[] {
const tl = (globalThis as any).__piTaskList as TaskListInfo | null;
if (!tl || tl.tasks.length === 0) {
text.setText("");
return [];
}
const termHeight = process.stdout.rows || 24;
const availableHeight = Math.max(3, Math.min(termHeight - 10, 14));
const taskLines = renderTaskList(
tl, taskListState, width, availableHeight,
{ truncateToWidth, fg: (c: string, t: string) => theme.fg(c, t) },
);
const taskBg = "\x1b[48;5;236m";
const taskReset = "\x1b[0m";
const emptyPad = taskBg + padRight("", width) + taskReset;
const allLines: string[] = [];
allLines.push(emptyPad);
allLines.push(...taskLines.map(l => taskBg + padRight(l, width) + taskReset));
allLines.push(emptyPad);
text.setText(allLines.join("\n"));
return allLines;
},
invalidate() {
text.invalidate();
},
};
}, { placement: "aboveEditor" });
} else {
// No task list — remove the combined widget
widgetCtx.ui.setWidget("agent-team", undefined);
}
// Individual agent widgets are managed separately via registerAgentWidget/invalidateAgentWidget
// Re-pin mode bar as the last aboveEditor widget so it stays directly above the editor input.
// Without this, the agent-team widget (tasks) would render between the mode bar and the editor.
(globalThis as any).__piRefreshModeBlock?.();
}
// ── Dispatch Agent (returns Promise) ─────────
function dispatchAgent(
agentName: string,
task: string,
ctx: any,
): Promise<{ output: string; exitCode: number; elapsed: number; model: string }> {
const key = agentName.toLowerCase();
const state = agentStates.get(key);
if (!state) {
return Promise.resolve({
output: `Agent "${agentName}" not found. Available: ${Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ")}`,
exitCode: 1,
elapsed: 0,
model: "",
});
}
if (state.status === "running") {
return Promise.resolve({
output: `Agent "${displayName(state.def.name)}" is already running. Wait for it to finish.`,
exitCode: 1,
elapsed: 0,
model: "",
});
}
state.status = "running";
state.task = task;
state.toolCount = 0;
state.elapsed = 0;
state.lastWork = "";
state.textChunks = [];
state.summary = undefined;
state.summaryLines = undefined;
state.runCount++;
registerAgentWidget(state);
updateWidget();
const startTime = Date.now();
state.timer = setInterval(() => {
state.elapsed = Date.now() - startTime;
invalidateAgentWidget(state);
}, 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(state.def.name, state.def.model || DEFAULT_SUBAGENT_MODEL);
state.resolvedModel = model;
// Session file for this agent
const agentKey = state.def.name.toLowerCase().replace(/\s+/g, "-");
const agentSessionFile = join(sessionDir, `${agentKey}.json`);
// Build args — first run creates session, subsequent runs resume
const extDir = dirname(fileURLToPath(import.meta.url));
const tasksExtPath = join(extDir, "tasks.ts");
const commanderExtPath = join(extDir, "commander-mcp.ts");
const footerExtPath = join(extDir, "footer.ts");
const memoryCycleExtPath = join(extDir, "memory-cycle.ts");
// Resolve tools — append commander tools when Commander is available
const g = globalThis as any;
const commanderAvailable = g.__piCommanderGate?.state === "available" && !!g.__piCommanderClient;
// Commander lifecycle: gate-aware fire-and-forget helper
function commanderSync(fn: (client: any) => Promise<void>): void {
const gate = g.__piCommanderGate;
if (!gate || gate.state !== "available" || !g.__piCommanderClient) return;
fn(g.__piCommanderClient).catch(() => {});
}
// Hoist for use in pre-dispatch claim + post-dispatch reconciliation
const canonicalName = state.def.name;
const taskId = commanderAvailable ? g.__piCurrentTask?.commanderTaskId as number | undefined : undefined;
let tools = state.def.tools;
// Commander tools are extension-registered (not built-in), so they must NOT
// go in --tools (which only accepts built-in names and warns on unknowns).
// Loading the commander-mcp extension via -e is sufficient — pi auto-activates
// all extension tools via includeAllExtensionTools.
// Build system prompt — append Commander discipline when available
let systemPrompt = state.def.systemPrompt;
if (commanderAvailable) {
// Gather peer names for inter-agent mailbox communication
const peerNames: string[] = [];
for (const [name] of agentStates) {
if (name !== canonicalName.toLowerCase()) {
peerNames.push(name);
}
}
systemPrompt += buildCommanderPrompt({
agentName: canonicalName,
taskId,
enableMailboxChat: true,
peerNames,
});
}
const args = [
"--mode", "json",
"-p",
"--no-extensions",
"-e", tasksExtPath,
"-e", footerExtPath,
"-e", memoryCycleExtPath,
...(commanderAvailable ? ["-e", commanderExtPath] : []),
"--model", model,
"--tools", tools,
"--thinking", "off",
"--append-system-prompt", systemPrompt,
"--session", agentSessionFile,
];
// Continue existing session if we have one
if (state.sessionFile) {
args.push("-c");
}
args.push(task);
const textChunks: string[] = [];
return new Promise((resolve) => {
// Build env — include Commander task ID when available
const spawnEnv: Record<string, string | undefined> = { ...process.env, PI_SUBAGENT: "1" };
if (commanderAvailable) {
const currentTask = g.__piCurrentTask as { commanderTaskId?: number } | null;
if (currentTask?.commanderTaskId !== undefined) {
spawnEnv.PI_COMMANDER_TASK_ID = String(currentTask.commanderTaskId);
}
}
// Pre-dispatch: claim task in Commander before spawning
if (commanderAvailable && taskId !== undefined) {
commanderSync((client) => preClaimTask(client, taskId, canonicalName));
}
const finish = (code: number | null, stderrBuf: string) => {
state.proc = null;
clearInterval(state.timer);
state.elapsed = Date.now() - startTime;
state.status = code === 0 ? "done" : "error";
if (code === 0) {
state.sessionFile = agentSessionFile;
}
let full = textChunks.join("");
if ((code !== 0 && code !== null) && stderrBuf.trim()) {
if (isContextLossError(stderrBuf)) {
full = "Context overflow: agent session broke tool_use/tool_result pairing. Clear session and re-dispatch.";
state.sessionFile = null;
} else {
full = full.trim() ? `${full}\n\n--- stderr ---\n${stderrBuf.trim()}` : stderrBuf.trim();
}
}
state.lastWork = full.split("\n").filter((l: string) => l.trim()).pop() || "";
state.summary = state.lastWork;
state.summaryLines = full.split("\n").map((l: string) => l.trim()).filter(Boolean).slice(-3);
invalidateAgentWidget(state);
setTimeout(() => {
if (state.status !== "running") removeAgentWidget(state);
}, 30_000);
if (commanderAvailable && taskId !== undefined) {
const summary = textChunks.join("").trim().split("\n").pop() || canonicalName;
if (state.status === "done") {
commanderSync((client) => postCompleteTask(client, taskId, canonicalName, summary));
} else {
const errMsg = stderrBuf.trim() || summary || "Agent exited with error";
commanderSync((client) => postFailTask(client, taskId, errMsg));
}
}
ctx.ui.notify(
`${displayName(state.def.name)} ${state.status} in ${Math.round(state.elapsed / 1000)}s`,
state.status === "done" ? "success" : "error"
);
resolve({ output: full, exitCode: code ?? 1, elapsed: state.elapsed, model });
};
const handleStdoutLine = (line: string) => {
try {
const event = JSON.parse(line);
if (event.type === "message_update") {
const delta = event.assistantMessageEvent;
if (delta?.type === "text_delta") {
textChunks.push(delta.delta || "");
state.textChunks.push(delta.delta || "");
const full = textChunks.join("");
const last = full.split("\n").filter((l: string) => l.trim()).pop() || "";
state.lastWork = last;
state.summary = last;
state.summaryLines = full.split("\n").map((l: string) => l.trim()).filter(Boolean).slice(-3);
invalidateAgentWidget(state);
}
} else if (event.type === "tool_execution_start") {
state.toolCount++;
invalidateAgentWidget(state);
} else if (event.type === "message_end") {
const msg = event.message;
if (msg?.usage && contextWindow > 0) {
state.contextPct = ((msg.usage.input || 0) / contextWindow) * 100;
const level = contextBudgetLevel(state.contextPct);
if (level === "warn" && !state._warnSent) {
state._warnSent = true;
ctx.ui.notify(`${displayName(state.def.name)} Context: ${Math.round(state.contextPct)}%`, "info");
} else if (level === "critical" && !state._criticalWarned) {
state._criticalWarned = true;
ctx.ui.notify(`${displayName(state.def.name)} Context: ${Math.round(state.contextPct)}% — Agent will Cycle-Memory soon`, "info");
}
invalidateAgentWidget(state);
}
} else if (event.type === "agent_end") {
const msgs = event.messages || [];
const last = [...msgs].reverse().find((m: any) => m.role === "assistant");
if (last?.usage && contextWindow > 0) {
state.contextPct = ((last.usage.input || 0) / contextWindow) * 100;
invalidateAgentWidget(state);
}
}
} catch {}
};
let stderrBuf = "";
if (isToolkitCliAgent(state.def.name)) {
spawnToolkitWorker(state.def, {
task,
sessionFile: agentSessionFile,
cwd: ctx.cwd,
env: spawnEnv,
onStdoutLine: handleStdoutLine,
onStderr: (chunk: string) => { stderrBuf += chunk; },
}).then(({ exitCode }) => finish(exitCode, stderrBuf));
return;
}
const proc = spawn("pi", args, {
stdio: ["ignore", "pipe", "pipe"],
env: spawnEnv,
cwd: ctx.cwd,
});
state.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;
handleStdoutLine(line);
}
});
proc.stderr!.setEncoding("utf-8");
proc.stderr!.on("data", (chunk: string) => { stderrBuf += chunk; });
proc.on("close", (code) => {
if (buffer.trim()) handleStdoutLine(buffer);
finish(code, stderrBuf);
});
proc.on("error", (err) => finish(1, `Error spawning agent: ${err.message}`));
proc.on("exit", () => { clearInterval(state.timer); });
});
}
// ── dispatch_agent Tool (registered at top level) ──
pi.registerTool({
name: "dispatch_agent",
label: "Dispatch Agent",
description: "Dispatch a task to a specialist agent. The agent will execute the task and return the result. Use the system prompt to see available agent names.",
parameters: Type.Object({
agent: Type.String({ description: "Agent name (case-insensitive)" }),
task: Type.String({ description: "Task description for the agent to execute" }),
}),
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
const { agent, task } = params as { agent: string; task: string };
const defModel = agentStates.get(agent.toLowerCase())?.def.model || "";
try {
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: `Dispatching to ${agent}...` }],
details: { agent, task, status: "dispatching", model: defModel },
});
}
const result = await dispatchAgent(agent, task, ctx);
const truncated = result.output.length > 8000
? result.output.slice(0, 8000) + "\n\n... [truncated]"
: result.output;
const status = result.exitCode === 0 ? "done" : "error";
const summary = `[${agent}] ${status} in ${Math.round(result.elapsed / 1000)}s`;
return {
content: [{ type: "text", text: `${summary}\n\n${truncated}` }],
details: {
agent,
task,
status,
elapsed: result.elapsed,
exitCode: result.exitCode,
fullOutput: result.output,
model: result.model,
},
};
} catch (err: any) {
return {
content: [{ type: "text", text: `Error dispatching to ${agent}: ${err?.message || err}` }],
details: { agent, task, status: "error", elapsed: 0, exitCode: 1, fullOutput: "", model: defModel },
};
}
},
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);
}
const agent = (details.agent || "AGENT").toUpperCase();
const model = details.model || "";
const elapsed = typeof details.elapsed === "number" ? details.elapsed : 0;
const rawStatus = details.status || "done";
const status: "running" | "done" | "error" = rawStatus === "dispatching"
? "running"
: rawStatus === "error"
? "error"
: "done";
const renderState = {
id: 0,
status,
name: agent,
task: details.task || "",
toolCount: 0,
elapsed,
turnCount: 1,
summary: `dispatching: ${agent.toLowerCase()}${model ? ` @ ${model}` : ""}`,
model: model || undefined,
};
const rendered = renderSubagentWidget(renderState, options.width || 80, theme);
const bg = STATUS_BG[status] || STATUS_BG.running;
const bgFn = (text: string): string => `${bg}${WHITE_BOLD}${text}${RESET_ALL}${RESET_BG}`;
const box = new Box(1, 1, bgFn);
box.addChild(new Text(rendered.lines.join("\n"), 0, 0));
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(box);
container.addChild(new Markdown(output, 2, 0, mdTheme));
return container;
}
return box;
},
});
// ── Commands ─────────────────────────────────
pi.registerCommand("agents-team", {
description: "Select a team to work with",
handler: async (_args, ctx) => {
widgetCtx = ctx;
const teamNames = Object.keys(teams);
if (teamNames.length === 0) {
ctx.ui.notify("No teams defined in .pi/agents/teams.yaml", "warning");
return;
}
const options = teamNames.map(name => {
const members = teams[name].map(m => displayName(m));
return `${name}${members.join(", ")}`;
});
const choice = await ctx.ui.select("Select Team", options);
if (choice === undefined) return;
const idx = options.indexOf(choice);
const name = teamNames[idx];
activateTeam(name);
updateWidget();
ctx.ui.setStatus("agent-team", `Team: ${name} (${agentStates.size})`);
ctx.ui.notify(`Team: ${name}${Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ")}`, "info");
},
});
pi.registerCommand("agents-list", {
description: "List all loaded agents",
handler: async (_args, _ctx) => {
widgetCtx = _ctx;
const names = Array.from(agentStates.values())
.map(s => {
const session = s.sessionFile ? "resumed" : "new";
return `${displayName(s.def.name)} (${s.status}, ${session}, runs: ${s.runCount}): ${s.def.description}`;
})
.join("\n");
_ctx.ui.notify(names || "No agents loaded", "info");
},
});
pi.registerCommand("agents-grid", {
description: "Set grid columns: /agents-grid <1-6>",
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
const items = ["1", "2", "3", "4", "5", "6"].map(n => ({
value: n,
label: `${n} columns`,
}));
const filtered = items.filter(i => i.value.startsWith(prefix));
return filtered.length > 0 ? filtered : items;
},
handler: async (args, _ctx) => {
widgetCtx = _ctx;
const n = parseInt(args?.trim() || "", 10);
if (n >= 1 && n <= 6) {
gridCols = n;
_ctx.ui.notify(`Grid set to ${gridCols} columns`, "info");
updateWidget();
} else {
_ctx.ui.notify("Usage: /agents-grid <1-6>", "error");
}
},
});
pi.registerCommand("agents-clear", {
description: "Clear agent team widget from screen",
handler: async (_args, ctx) => {
widgetCtx = ctx;
ctx.ui.setWidget("agent-team", undefined);
// Remove all individual agent widgets
removeAllAgentWidgets();
// Reset all agent states to idle so the widget can reappear on next dispatch
for (const state of agentStates.values()) {
if (state.status === "done" || state.status === "error") {
state.status = "idle";
state.task = "";
state.toolCount = 0;
state.elapsed = 0;
state.lastWork = "";
state.contextPct = 0;
state.resolvedModel = "";
state.textChunks = [];
state.summary = undefined;
}
}
selectedAgentIndex = -1;
ctx.ui.notify("Agent team widget cleared.", "info");
},
});
// ── Agent Detail Overlay ──────────────────────
class AgentDetailOverlay {
private scrollOffset = 0;
private totalContentLines = 0;
constructor(
private agent: AgentState,
private onDone: () => void,
) {}
handleInput(data: string, tui: any): void {
// Calculate max scroll based on current content
const height = process.stdout.rows || 24;
const contentHeight = height - 1; // Reserve 1 line for footer
const maxScroll = Math.max(0, this.totalContentLines - contentHeight);
if (matchesKey(data, Key.up)) {
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
} else if (matchesKey(data, Key.down)) {
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
} else if (matchesKey(data, Key.pageUp)) {
this.scrollOffset = Math.max(0, this.scrollOffset - Math.max(1, contentHeight - 1));
} else if (matchesKey(data, Key.pageDown)) {
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + Math.max(1, contentHeight - 1));
} else if (matchesKey(data, Key.home)) {
this.scrollOffset = 0;
} else if (matchesKey(data, Key.end)) {
this.scrollOffset = maxScroll;
} else if (matchesKey(data, Key.escape)) {
this.onDone();
return;
}
tui.requestRender();
}
render(width: number, height: number, theme: any): string[] {
const container = new Container();
const mdTheme = getPiMdTheme();
// Full width with minimal padding
const panelW = width - 4; // 2 chars padding each side
const innerWidth = panelW - 2; // Account for border
// Header with agent name pill and status
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
const name = displayName(this.agent.def.name);
const statusBtn = statusButton(this.agent.status, name, theme, false);
const timeStr = this.agent.status !== "idle" ? ` ${Math.round(this.agent.elapsed / 1000)}s` : "";
container.addChild(new Text(
`${statusBtn}${timeStr}`,
1, 0,
));
container.addChild(new Spacer(1));
// Section header helper - fills width with line characters
const sectionHeader = (title: string) => {
const label = ` ─── ${title} `;
const remaining = Math.max(0, innerWidth - visibleWidth(label));
return theme.fg("accent", theme.bold(label + "─".repeat(remaining)));
};
// Metadata section (full width, vertical list)
container.addChild(new Text(sectionHeader("METADATA"), 1, 0));
const formatRow = (label: string, value: string, valueColor: string = "muted") => {
const labelStr = theme.fg("accent", theme.bold(padRight(label + ":", 14)));
const valueStr = theme.fg(valueColor, value);
return labelStr + " " + valueStr;
};
// Helper to add wrapped metadata rows
const addWrappedRow = (label: string, value: string, valueColor: string = "muted") => {
const labelWidth = 14;
const valueWidth = innerWidth - labelWidth - 1;
const wrapped = wordWrap(value, valueWidth);
for (let i = 0; i < wrapped.length; i++) {
const displayLabel = i === 0 ? label : "";
container.addChild(new Text(formatRow(displayLabel, wrapped[i], valueColor), 1, 0));
}
};
// STATUS - color based on state
const statusColorMap: Record<string, string> = { running: "accent", done: "success", error: "error", idle: "dim" };
const statusColor = statusColorMap[this.agent.status] || "muted";
container.addChild(new Text(formatRow("STATUS", this.agent.status.toUpperCase(), statusColor), 1, 0));
// DESCRIPTION - if present
if (this.agent.def.description) {
addWrappedRow("DESCRIPTION", this.agent.def.description, "muted");
}
// MODEL - accent color
addWrappedRow("MODEL", this.agent.resolvedModel || this.agent.def.model || "(unknown)", "accent");
// TOOLS - success color
addWrappedRow("TOOLS", this.agent.def.tools, "success");
// CONTEXT - conditional color based on percentage
const pct = Math.ceil(this.agent.contextPct);
const ctxColor = pct > 80 ? "error" : pct > 50 ? "warning" : "success";
container.addChild(new Text(formatRow("CONTEXT", `${pct}%`, ctxColor), 1, 0));
// RUNS - accent color
container.addChild(new Text(formatRow("RUNS", this.agent.runCount.toString(), "accent"), 1, 0));
// TOOLS USED - accent color
container.addChild(new Text(formatRow("TOOLS USED", this.agent.toolCount.toString(), "accent"), 1, 0));
// FILE - dim color (path)
addWrappedRow("FILE", this.agent.def.file, "dim");
// SESSION - dim color (path)
if (this.agent.sessionFile) {
addWrappedRow("SESSION", this.agent.sessionFile, "dim");
}
container.addChild(new Spacer(1));
// System prompt section (full width)
container.addChild(new Text(sectionHeader("SYSTEM PROMPT"), 1, 0));
container.addChild(new Spacer(1));
// Render system prompt as markdown - it will handle its own wrapping
const sysPromptMd = new Markdown(this.agent.def.systemPrompt, 1, 0, mdTheme);
container.addChild(sysPromptMd);
container.addChild(new Spacer(1));
// Task section (if present) - render as markdown
if (this.agent.task) {
container.addChild(new Text(sectionHeader("CURRENT TASK"), 1, 0));
container.addChild(new Spacer(1));
const taskMd = new Markdown(this.agent.task, 1, 0, mdTheme);
container.addChild(taskMd);
container.addChild(new Spacer(1));
}
// Last work section (if present) - render as markdown
if (this.agent.lastWork) {
container.addChild(new Text(sectionHeader("LAST WORK"), 1, 0));
container.addChild(new Spacer(1));
const workMd = new Markdown(this.agent.lastWork, 1, 0, mdTheme);
container.addChild(workMd);
container.addChild(new Spacer(1));
}
// Render all content (without footer)
const allLines = container.render(panelW);
this.totalContentLines = allLines.length; // Store for handleInput
const contentHeight = height - 1; // Reserve 1 line for footer
const maxScroll = Math.max(0, allLines.length - contentHeight);
// Clamp scroll offset to valid range
this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, maxScroll));
// Apply scrolling - show content lines, footer always at bottom
const visibleContentLines = allLines.slice(this.scrollOffset, this.scrollOffset + contentHeight);
// Footer (always visible at bottom, separate from scrollable content)
const scrollInfo = maxScroll > 0
? ` ↑/↓/PgUp/PgDn/Home/End Scroll (${this.scrollOffset + 1}-${Math.min(this.scrollOffset + contentHeight, allLines.length)}/${allLines.length}) • Esc Close`
: " Esc Close";
const footer = theme.fg("dim", scrollInfo);
const footerLine = padRight(footer, panelW);
// Dark backdrop: full screen from top to bottom
const dimBg = "\x1b[48;2;10;10;15m";
const reset = "\x1b[0m";
const result: string[] = [];
// Render visible content lines from top
// Pad each line to panelW before wrapping with background to ensure full coverage
for (const line of visibleContentLines) {
result.push(dimBg + " " + padRight(line, panelW) + " " + reset);
}
// Add footer at bottom (already padded to panelW)
result.push(dimBg + " " + footerLine + " " + reset);
// Fill remaining height with dark background
while (result.length < height) {
result.push(dimBg + " ".repeat(width) + reset);
}
return result;
}
}
async function showAgentDetail(ctx: any, agent: AgentState) {
await ctx.ui.custom((tui, theme, _kb, done) => {
const overlay = new AgentDetailOverlay(agent, () => done(undefined));
return {
render: (w) => overlay.render(w, process.stdout.rows || 24, theme),
handleInput: (data) => overlay.handleInput(data, tui),
invalidate: () => {},
};
}, {
overlay: true,
overlayOptions: { width: "100%" },
});
}
pi.registerShortcut("alt+g", {
description: "Toggle agent team compact/expanded view",
handler: async (ctx) => {
widgetCtx = ctx;
widgetCompact = !widgetCompact;
updateWidget();
},
});
const selectNext = async (ctx: any) => {
if (!ctx.hasUI) return;
widgetCtx = ctx;
// Filter out only idle agents - include completed ones
const active = Array.from(agentStates.values()).filter(
(a) => a.status !== "idle",
);
const count = active.length;
if (count === 0) {
selectedAgentIndex = -1;
return;
}
// Auto-expand to expanded view if in compact mode so selection is visible
if (widgetCompact) {
widgetCompact = false;
}
if (selectedAgentIndex < 0) selectedAgentIndex = 0;
selectedAgentIndex = (selectedAgentIndex + 1) % count;
updateWidget();
};
const selectPrev = async (ctx: any) => {
if (!ctx.hasUI) return;
widgetCtx = ctx;
// Filter out only idle agents - include completed ones
const active = Array.from(agentStates.values()).filter(
(a) => a.status !== "idle",
);
const count = active.length;
if (count === 0) {
selectedAgentIndex = -1;
return;
}
// Auto-expand to expanded view if in compact mode so selection is visible
if (widgetCompact) {
widgetCompact = false;
}
if (selectedAgentIndex < 0) selectedAgentIndex = count - 1;
selectedAgentIndex = (selectedAgentIndex - 1 + count) % count;
updateWidget();
};
const exitSelection = async (ctx: any) => {
if (!ctx.hasUI) return;
widgetCtx = ctx;
selectedAgentIndex = -1;
updateWidget();
};
// ── System Prompt Override ───────────────────
pi.on("before_agent_start", async (_event, _ctx) => {
// Mode gate: only fire when mode is TEAM (or unset for backward compat)
const mode = (globalThis as any).__piCurrentMode;
if (mode && mode !== "TEAM") return {};
// Build dynamic agent catalog from active team only
const agentCatalog = Array.from(agentStates.values())
.map(s => `### ${displayName(s.def.name)}\n**Dispatch as:** \`${s.def.name}\`\n${s.def.description}\n**Tools:** ${s.def.tools}` + (s.def.model ? `\n**Model:** ${s.def.model}` : ""))
.join("\n\n");
const teamMembers = Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ");
const commanderAvailable = (globalThis as any).__piCommanderGate?.state === "available";
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 dispatched agent results or task lists` : "";
// Check if scout is on the team for delegation instructions
const hasScout = agentStates.has("scout");
const scoutSection = hasScout ? `
## Scout Agent (ALWAYS use for context gathering)
A scout agent is on your team. **ALWAYS dispatch to the scout** for any context-gathering work instead of doing it yourself.
### What to dispatch to the scout:
- Reading files, exploring directory structures
- Searching for patterns, symbols, or text in the codebase (grep, find)
- Understanding architecture, tracing code paths, mapping dependencies
- Any investigation or information-gathering task
### How to use the scout:
\`\`\`
dispatch_agent { agent: "scout", task: "Read the file at src/index.ts and summarize its exports" }
\`\`\`
The scout runs in the background. When it finishes, its findings are returned. Then you synthesize and respond to the user.
### What YOU still do directly:
- Respond to the user (synthesize scout findings, answer questions)
- Write/edit files, run commands, make code changes
- Plan, create tasks, manage workflow
- Any action that modifies the codebase
### Important:
- Do NOT use Read, grep, find, or ls yourself — dispatch those to the scout
- You CAN still use Bash for running tests, builds, or commands that modify things
- If the scout errors, fall back to doing the work directly` : "";
return {
systemPrompt: `You coordinate specialist agents and delegate context-gathering to them.
You dispatch specialist agents for investigation and can work directly for responses and edits.
## Active Team: ${activeTeamName}
Members: ${teamMembers}
You can ONLY dispatch to agents listed below. Do not attempt to dispatch to agents outside this team.
${scoutSection}
## When to Work Directly
- Responding to the user with information gathered by agents
- Writing or editing files, running builds/tests
- Small edits, answering questions you already know the answer to
- Task management, planning, workflow decisions
## When to Dispatch Agents
- ${hasScout ? "ANY context-gathering: reading files, searching code, exploring structure — ALWAYS dispatch scout" : "Simple lookups: reading a file, checking status, listing contents"}
- Significant work: new features, refactors, multi-file changes
- Tasks that benefit from specialist knowledge
- When you want structured, multi-agent collaboration
## Guidelines
- ${hasScout ? "ALWAYS dispatch scout for reads/searches — do NOT read files yourself" : "Use your judgment — if it's quick, just do it; if it's real work, dispatch"}
- You can mix direct work and agent dispatches in the same conversation
- You can chain agents: use scout to explore, then builder to implement
- You can dispatch the same agent multiple times with different tasks
- Keep tasks focused — one clear objective per dispatch
## Asking the User
- You have the ask_user tool to ask the user questions directly
- Use it when you need clarification, decisions, or preferences before dispatching agents
- Three modes: "select" (pick from options with markdown preview), "input" (free text), "confirm" (yes/no)
- If a sub-agent needs user input, it should describe what it needs — then YOU ask the user and relay the answer
## Task Management
- You have direct access to the \`tasks\` tool — use it yourself, do NOT dispatch agents for task management
- Use \`tasks new-list\` to start a themed list, \`tasks add\` to add items, \`tasks toggle\` to cycle status
- Define your plan as tasks BEFORE dispatching agents
## Agents
${agentCatalog}${commanderSection}`,
};
});
// ── Reset helpers ─────────────────────────────────────────────────
function resetAgentState(state: AgentState) {
state.status = "idle";
state.task = "";
state.toolCount = 0;
state.elapsed = 0;
state.lastWork = "";
state.contextPct = 0;
state.resolvedModel = "";
state.textChunks = [];
state.summary = undefined;
}
// ── Reset agent boxes on new message ───────────────────────────────
pi.on("input", () => {
// When user sends a new message, reset completed/error agents to idle
// and remove their individual widgets so boxes display cleanly for the new task
for (const state of agentStates.values()) {
if (state.status === "done" || state.status === "error") {
removeAgentWidget(state);
resetAgentState(state);
}
}
updateWidget();
});
// ── Reset agent boxes on /new ─────────────────────────────────────
pi.on("session_switch", async (_event, _ctx) => {
// /new fires session_switch — clear all agent boxes from previous session
if (widgetCtx) {
widgetCtx.ui.setWidget("agent-team", undefined);
}
removeAllAgentWidgets();
widgetCtx = _ctx;
for (const state of agentStates.values()) {
resetAgentState(state);
}
updateWidget();
});
// ── Session Start ────────────────────────────
pi.on("session_start", async (_event, _ctx) => {
applyExtensionDefaults(import.meta.url, _ctx);
// Clear widgets from previous session
if (widgetCtx) {
widgetCtx.ui.setWidget("agent-team", undefined);
}
removeAllAgentWidgets();
widgetCtx = _ctx;
contextWindow = _ctx.model?.contextWindow || 0;
// Wipe old agent session files so subagents start fresh
const sessDir = join(_ctx.cwd, ".pi", "agent-sessions");
if (existsSync(sessDir)) {
for (const f of readdirSync(sessDir)) {
if (f.endsWith(".json")) {
try { unlinkSync(join(sessDir, f)); } catch {}
}
}
}
loadAgents(_ctx.cwd);
// Default to first team — use /agents-team to switch
const teamNames = Object.keys(teams);
if (teamNames.length > 0) {
activateTeam(teamNames[0]);
}
// All tools remain visible — dispatcher can use any registered tool directly
_ctx.ui.setStatus("agent-team", `Team: ${activeTeamName} (${agentStates.size})`);
updateWidget();
// ── Expose global hooks for escape-cancel integration ────────────
// Team agents also have running subprocesses that should be killed
// on double-ESC. We reuse __piKillAllSubagents-style hook naming but
// scoped to team procs. The escape-cancel extension checks these.
(globalThis as any).__piKillTeamProcs = (): number => {
let killed = 0;
for (const [, state] of agentStates) {
if (state.proc && state.status === "running") {
try { state.proc.kill("SIGTERM"); } catch {}
killed++;
}
}
return killed;
};
(globalThis as any).__piHasRunningTeam = (): boolean => {
for (const [, state] of agentStates) {
if (state.status === "running") return true;
}
return false;
};
// Use footer.ts for footer — do not overwrite; widget uses placement: belowEditor
// Register nav providers for F-key navigation
const providers = ((globalThis as any).__piNavProviders = (globalThis as any).__piNavProviders || []);
// Task list nav provider (first priority when tasks exist)
providers.push({
isActive: () => {
const tl = (globalThis as any).__piTaskList as TaskListInfo | null;
return !!(tl && tl.tasks.length > 0);
},
selectPrev: (ctx: any) => {
if (!ctx.hasUI) return;
widgetCtx = ctx;
const tl = (globalThis as any).__piTaskList as TaskListInfo | null;
if (!tl || tl.tasks.length === 0) return;
if (taskListState.selectedIndex < 0) {
taskListState = navEnter(taskListState, tl.tasks.length);
} else {
taskListState = navUp(taskListState);
}
updateWidget();
},
selectNext: (ctx: any) => {
if (!ctx.hasUI) return;
widgetCtx = ctx;
const tl = (globalThis as any).__piTaskList as TaskListInfo | null;
if (!tl || tl.tasks.length === 0) return;
if (taskListState.selectedIndex < 0) {
taskListState = navEnter(taskListState, tl.tasks.length);
} else {
taskListState = navDown(taskListState, tl.tasks.length);
}
updateWidget();
},
showDetail: async (_ctx: any) => {
// Could open /tasks overlay in the future
},
exitSelection: (ctx: any) => {
if (!ctx.hasUI) return;
widgetCtx = ctx;
taskListState = navExit(taskListState);
updateWidget();
},
});
// Agent pills nav provider
providers.push({
isActive: () => {
const active = Array.from(agentStates.values()).filter(a => a.status !== "idle");
return active.length > 0;
},
selectPrev: selectPrev,
selectNext: selectNext,
showDetail: async (ctx: any) => {
if (!ctx.hasUI) return;
const active = Array.from(agentStates.values()).filter(
(a) => a.status !== "idle",
);
const count = active.length;
if (count === 0 || selectedAgentIndex < 0 || selectedAgentIndex >= count) return;
const agent = active[selectedAgentIndex];
if (!agent) return;
await showAgentDetail(ctx, agent);
},
exitSelection: exitSelection,
});
});
}