// 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; _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 { const teams: Record = {}; 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 = {}; 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(); 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 = new Map(); let allAgentDefs: AgentDef[] = []; let teams: Record = {}; 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 void }>(); // ── Dark background colors for agent status (matches subagent-widget) ──── const STATUS_BG: Record = { 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(); 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 { 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 = { ...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 = { 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: }\` — 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, }); }); }