/** * 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 (single) or * `dispatch_agents` tool (parallel batch). 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) * * Usage: pi -e extensions/agent-team.ts */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { Text, type AutocompleteItem, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { spawn, type ChildProcess } from "child_process"; import { readdirSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs"; import { join, resolve } from "path"; import { applyExtensionDefaults } from "./themeMap.ts"; // ── Constants ──────────────────────────────────── const AGENT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes per dispatch const WIDGET_THROTTLE_MS = 500; // max widget refresh rate // ── Types ──────────────────────────────────────── interface AgentDef { name: string; description: string; tools: string; 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; timer?: ReturnType; proc?: ChildProcess; } // ── Display Name Helper ────────────────────────── function displayName(name: string): string { return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" "); } // ── 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): 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; return { name: frontmatter.name, description: frontmatter.description || "", tools: frontmatter.tools || "read,grep,find,ls", systemPrompt: match[2].trim(), file: filePath, }; } catch { return null; } } function scanAgentDirs(cwd: string): AgentDef[] { const dirs = [ join(cwd, "agents"), join(cwd, ".claude", "agents"), join(cwd, ".pi", "agents"), ]; const agents: AgentDef[] = []; const seen = new Set(); 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); if (def && !seen.has(def.name.toLowerCase())) { seen.add(def.name.toLowerCase()); agents.push(def); } } } catch {} } return agents; } // ── Extension ──────────────────────────────────── export default function (pi: ExtensionAPI) { const agentStates: Map = new Map(); const activeProcesses: Set = new Set(); let allAgentDefs: AgentDef[] = []; let teams: Record = {}; let activeTeamName = ""; let gridCols = 2; let widgetCtx: any; let sessionDir = ""; let contextWindow = 0; // ── Throttled Widget Update ────────────────── let widgetDirty = false; let widgetTimer: ReturnType | null = null; function scheduleWidgetUpdate() { widgetDirty = true; if (widgetTimer) return; // already scheduled widgetTimer = setTimeout(() => { widgetTimer = null; if (widgetDirty) { widgetDirty = false; doUpdateWidget(); } }, WIDGET_THROTTLE_MS); } function flushWidgetUpdate() { if (widgetTimer) { clearTimeout(widgetTimer); widgetTimer = null; } widgetDirty = false; doUpdateWidget(); } function loadAgents(cwd: string) { sessionDir = join(cwd, ".pi", "agent-sessions"); if (!existsSync(sessionDir)) { mkdirSync(sessionDir, { recursive: true }); } allAgentDefs = scanAgentDirs(cwd); const teamsPath = join(cwd, ".pi", "agents", "teams.yaml"); if (existsSync(teamsPath)) { try { teams = parseTeamsYaml(readFileSync(teamsPath, "utf-8")); } catch { teams = {}; } } else { teams = {}; } 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(); 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, }); } const size = agentStates.size; gridCols = size <= 3 ? size : size === 4 ? 2 : 3; } // ── Kill all tracked child processes ───────── function killAllAgents() { for (const proc of activeProcesses) { try { proc.kill("SIGTERM"); } catch {} } // Force kill after 3s setTimeout(() => { for (const proc of activeProcesses) { try { proc.kill("SIGKILL"); } catch {} } }, 3000); } // ── Grid Rendering ─────────────────────────── function renderCard(state: AgentState, colWidth: number, theme: any): string[] { const w = colWidth - 2; const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s; const statusColor = state.status === "idle" ? "dim" : state.status === "running" ? "accent" : state.status === "done" ? "success" : "error"; const statusIcon = state.status === "idle" ? "○" : state.status === "running" ? "●" : state.status === "done" ? "✓" : "✗"; const name = displayName(state.def.name); const nameStr = theme.fg("accent", theme.bold(truncate(name, w))); const nameVisible = Math.min(name.length, w); const statusStr = `${statusIcon} ${state.status}`; const timeStr = state.status !== "idle" ? ` ${Math.round(state.elapsed / 1000)}s` : ""; const statusLine = theme.fg(statusColor, statusStr + timeStr); const statusVisible = statusStr.length + timeStr.length; const filled = Math.ceil(state.contextPct / 20); const bar = "#".repeat(filled) + "-".repeat(5 - filled); const ctxStr = `[${bar}] ${Math.ceil(state.contextPct)}%`; const ctxLine = theme.fg("dim", ctxStr); const ctxVisible = ctxStr.length; const workRaw = state.task ? (state.lastWork || state.task) : state.def.description; const workText = truncate(workRaw, Math.min(50, w - 1)); const workLine = theme.fg("muted", workText); const workVisible = workText.length; const top = "┌" + "─".repeat(w) + "┐"; const bot = "└" + "─".repeat(w) + "┘"; const border = (content: string, visLen: number) => theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - visLen)) + theme.fg("dim", "│"); return [ theme.fg("dim", top), border(" " + nameStr, 1 + nameVisible), border(" " + statusLine, 1 + statusVisible), border(" " + ctxLine, 1 + ctxVisible), border(" " + workLine, 1 + workVisible), theme.fg("dim", bot), ]; } function doUpdateWidget() { if (!widgetCtx) return; widgetCtx.ui.setWidget("agent-team", (_tui: any, theme: any) => { const text = new Text("", 0, 1); return { render(width: number): string[] { if (agentStates.size === 0) { text.setText(theme.fg("dim", "No agents found. Add .md files to agents/")); return text.render(width); } const cols = Math.min(gridCols, agentStates.size); const gap = 1; const colWidth = Math.floor((width - gap * (cols - 1)) / cols); const agents = Array.from(agentStates.values()); const rows: string[][] = []; for (let i = 0; i < agents.length; i += cols) { const rowAgents = agents.slice(i, i + cols); const cards = rowAgents.map(a => renderCard(a, colWidth, theme)); while (cards.length < cols) { cards.push(Array(6).fill(" ".repeat(colWidth))); } const cardHeight = cards[0].length; for (let line = 0; line < cardHeight; line++) { rows.push(cards.map(card => card[line] || "")); } } const output = rows.map(cols => cols.join(" ".repeat(gap))); text.setText(output.join("\n")); return text.render(width); }, invalidate() { text.invalidate(); }, }; }); } // ── Dispatch Agent (returns Promise) ───────── function dispatchAgent( agentName: string, task: string, ctx: any, signal?: AbortSignal, ): Promise<{ output: string; exitCode: number; elapsed: number }> { 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, }); } 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, }); } // Reset state for new run state.status = "running"; state.task = task; state.toolCount = 0; state.elapsed = 0; state.lastWork = ""; state.contextPct = 0; state.runCount++; scheduleWidgetUpdate(); const startTime = Date.now(); state.timer = setInterval(() => { state.elapsed = Date.now() - startTime; scheduleWidgetUpdate(); }, 1000); const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "openrouter/google/gemini-3-flash-preview"; const agentKey = state.def.name.toLowerCase().replace(/\s+/g, "-"); const agentSessionFile = join(sessionDir, `${agentKey}.json`); const args = [ "--mode", "json", "-p", "--no-extensions", "--model", model, "--tools", state.def.tools, "--thinking", "off", "--append-system-prompt", state.def.systemPrompt, "--session", agentSessionFile, ]; if (state.sessionFile) { args.push("-c"); } args.push(task); const textChunks: string[] = []; let resolved = false; return new Promise((promiseResolve) => { // Guard against double-resolve const safeResolve = (val: { output: string; exitCode: number; elapsed: number }) => { if (resolved) return; resolved = true; promiseResolve(val); }; const proc = spawn("pi", args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env }, }); state.proc = proc; activeProcesses.add(proc); // ── Timeout guard ── const timeout = setTimeout(() => { try { proc.kill("SIGTERM"); } catch {} // Force kill after 3s if still alive setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 3000); }, AGENT_TIMEOUT_MS); // ── AbortSignal support ── const onAbort = () => { try { proc.kill("SIGTERM"); } catch {} setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 3000); }; if (signal) { if (signal.aborted) { onAbort(); } else { signal.addEventListener("abort", onAbort, { once: true }); } } 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() || ""; state.lastWork = last; scheduleWidgetUpdate(); } } else if (event.type === "tool_execution_start") { state.toolCount++; scheduleWidgetUpdate(); } else if (event.type === "message_end") { const msg = event.message; if (msg?.usage && contextWindow > 0) { state.contextPct = ((msg.usage.input || 0) / contextWindow) * 100; scheduleWidgetUpdate(); } } 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; scheduleWidgetUpdate(); } } } catch {} } }); proc.stderr!.setEncoding("utf-8"); proc.stderr!.on("data", () => {}); proc.on("close", (code) => { clearTimeout(timeout); if (signal) signal.removeEventListener?.("abort", onAbort); activeProcesses.delete(proc); state.proc = undefined; // Process any remaining buffer 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(state.timer); state.elapsed = Date.now() - startTime; const timedOut = state.elapsed >= AGENT_TIMEOUT_MS; state.status = timedOut ? "error" : (code === 0 ? "done" : "error"); if (code === 0) { state.sessionFile = agentSessionFile; } const full = textChunks.join(""); state.lastWork = full.split("\n").filter((l: string) => l.trim()).pop() || ""; flushWidgetUpdate(); const statusMsg = timedOut ? `${displayName(state.def.name)} timed out after ${Math.round(AGENT_TIMEOUT_MS / 1000)}s` : `${displayName(state.def.name)} ${state.status} in ${Math.round(state.elapsed / 1000)}s`; ctx.ui.notify(statusMsg, state.status === "done" ? "success" : "error"); const output = timedOut ? full + "\n\n[TIMED OUT after " + Math.round(AGENT_TIMEOUT_MS / 1000) + "s]" : full; safeResolve({ output, exitCode: code ?? 1, elapsed: state.elapsed, }); }); proc.on("error", (err) => { clearTimeout(timeout); if (signal) signal.removeEventListener?.("abort", onAbort); activeProcesses.delete(proc); state.proc = undefined; clearInterval(state.timer); state.status = "error"; state.lastWork = `Error: ${err.message}`; flushWidgetUpdate(); safeResolve({ output: `Error spawning agent: ${err.message}`, exitCode: 1, elapsed: Date.now() - startTime, }); }); }); } // ── dispatch_agent Tool (single) ───────────── pi.registerTool({ name: "dispatch_agent", label: "Dispatch Agent", description: "Dispatch a task to a single specialist agent. The agent executes the task and returns the result. For dispatching multiple agents in parallel, use dispatch_agents instead.", 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 }; try { if (onUpdate) { onUpdate({ content: [{ type: "text", text: `Dispatching to ${agent}...` }], details: { agent, task, status: "dispatching" }, }); } const result = await dispatchAgent(agent, task, ctx, signal); 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, }, }; } 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: "" }, }; } }, renderCall(args, theme) { const agentName = (args as any).agent || "?"; const task = (args as any).task || ""; const preview = task.length > 60 ? task.slice(0, 57) + "..." : task; return new Text( theme.fg("toolTitle", theme.bold("dispatch_agent ")) + theme.fg("accent", agentName) + theme.fg("dim", " — ") + theme.fg("muted", preview), 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") { return new Text( theme.fg("accent", `● ${details.agent || "?"}`) + theme.fg("dim", " working..."), 0, 0, ); } const icon = details.status === "done" ? "✓" : "✗"; const color = details.status === "done" ? "success" : "error"; const elapsed = typeof details.elapsed === "number" ? Math.round(details.elapsed / 1000) : 0; const header = theme.fg(color, `${icon} ${details.agent}`) + theme.fg("dim", ` ${elapsed}s`); if (options.expanded && details.fullOutput) { const output = details.fullOutput.length > 4000 ? details.fullOutput.slice(0, 4000) + "\n... [truncated]" : details.fullOutput; return new Text(header + "\n" + theme.fg("muted", output), 0, 0); } return new Text(header, 0, 0); }, }); // ── dispatch_agents Tool (parallel batch) ──── pi.registerTool({ name: "dispatch_agents", label: "Dispatch Agents (Parallel)", description: "Dispatch tasks to multiple specialist agents in parallel. All agents run simultaneously and results are returned together. Much faster than sequential dispatch_agent calls when tasks are independent.", parameters: Type.Object({ dispatches: Type.Array( Type.Object({ agent: Type.String({ description: "Agent name (case-insensitive)" }), task: Type.String({ description: "Task description for the agent" }), }), { description: "Array of {agent, task} pairs to dispatch in parallel", minItems: 1 }, ), }), async execute(_toolCallId, params, signal, onUpdate, ctx) { const { dispatches } = params as { dispatches: { agent: string; task: string }[] }; const agentNames = dispatches.map(d => d.agent).join(", "); if (onUpdate) { onUpdate({ content: [{ type: "text", text: `Dispatching ${dispatches.length} agents in parallel: ${agentNames}` }], details: { dispatches, status: "dispatching", count: dispatches.length }, }); } // Launch all in parallel const promises = dispatches.map(({ agent, task }) => dispatchAgent(agent, task, ctx, signal).then(result => ({ agent, task, ...result, })) ); const results = await Promise.all(promises); const summaryParts: string[] = []; const allDetails: any[] = []; for (const r of results) { const status = r.exitCode === 0 ? "done" : "error"; const truncated = r.output.length > 4000 ? r.output.slice(0, 4000) + "\n... [truncated]" : r.output; summaryParts.push(`## [${r.agent}] ${status} in ${Math.round(r.elapsed / 1000)}s\n\n${truncated}`); allDetails.push({ agent: r.agent, task: r.task, status, elapsed: r.elapsed, exitCode: r.exitCode, fullOutput: r.output, }); } const doneCount = results.filter(r => r.exitCode === 0).length; const header = `Parallel dispatch complete: ${doneCount}/${results.length} succeeded`; return { content: [{ type: "text", text: `${header}\n\n${summaryParts.join("\n\n---\n\n")}` }], details: { dispatches: allDetails, status: "complete", count: results.length, succeeded: doneCount, }, }; }, renderCall(args, theme) { const dispatches = (args as any).dispatches || []; const names = dispatches.map((d: any) => d.agent || "?").join(", "); return new Text( theme.fg("toolTitle", theme.bold("dispatch_agents ")) + theme.fg("accent", `[${dispatches.length}] `) + theme.fg("muted", names), 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") { return new Text( theme.fg("accent", `● Parallel dispatch`) + theme.fg("dim", ` ${details.count || "?"} agents working...`), 0, 0, ); } const header = theme.fg("success", `✓ ${details.succeeded}`) + theme.fg("dim", `/${details.count} agents completed`); if (options.expanded && Array.isArray(details.dispatches)) { const lines = details.dispatches.map((d: any) => { const icon = d.status === "done" ? "✓" : "✗"; const color = d.status === "done" ? "success" : "error"; return theme.fg(color, ` ${icon} ${d.agent}`) + theme.fg("dim", ` ${Math.round(d.elapsed / 1000)}s`); }); return new Text(header + "\n" + lines.join("\n"), 0, 0); } return new Text(header, 0, 0); }, }); // ── 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); flushWidgetUpdate(); 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"); flushWidgetUpdate(); } else { _ctx.ui.notify("Usage: /agents-grid <1-6>", "error"); } }, }); // ── System Prompt Override ─────────────────── pi.on("before_agent_start", async (_event, _ctx) => { 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}`) .join("\n\n"); const teamMembers = Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", "); return { systemPrompt: `You are a dispatcher agent. You coordinate specialist agents to accomplish tasks. You do NOT have direct access to the codebase. You MUST delegate all work through agents using the dispatch_agent or dispatch_agents tools. ## Active Team: ${activeTeamName} Members: ${teamMembers} You can ONLY dispatch to agents listed below. Do not attempt to dispatch to agents outside this team. ## How to Work - Analyze the user's request and break it into clear sub-tasks - Choose the right agent(s) for each sub-task - **Use dispatch_agents for independent parallel tasks** — this is much faster - Use dispatch_agent for sequential tasks where order matters - Review results and dispatch follow-up agents if needed - If a task fails, try a different agent or adjust the task description - Summarize the outcome for the user ## Rules - NEVER try to read, write, or execute code directly — you have no such tools - ALWAYS use dispatch_agent or dispatch_agents to get work done - **Prefer dispatch_agents when tasks are independent** — parallelism saves time - 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 - Each agent has a ${Math.round(AGENT_TIMEOUT_MS / 1000)}s timeout — break large tasks into smaller ones ## Agents ${agentCatalog}`, }; }); // ── Session Start ──────────────────────────── pi.on("session_start", async (_event, _ctx) => { applyExtensionDefaults(import.meta.url, _ctx); if (widgetCtx) { widgetCtx.ui.setWidget("agent-team", undefined); } 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); const teamNames = Object.keys(teams); if (teamNames.length > 0) { activateTeam(teamNames[0]); } pi.setActiveTools(["dispatch_agent", "dispatch_agents"]); _ctx.ui.setStatus("agent-team", `Team: ${activeTeamName} (${agentStates.size})`); const members = Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", "); _ctx.ui.notify( `Team: ${activeTeamName} (${members})\n` + `Team sets loaded from: .pi/agents/teams.yaml\n\n` + `/agents-team Select a team\n` + `/agents-list List active agents and status\n` + `/agents-grid <1-6> Set grid column count`, "info", ); flushWidgetUpdate(); _ctx.ui.setFooter((_tui, theme, _footerData) => ({ dispose: () => {}, invalidate() {}, render(width: number): string[] { const model = _ctx.model?.id || "no-model"; const usage = _ctx.getContextUsage(); const pct = usage ? usage.percent : 0; const filled = Math.round(pct / 10); const bar = "#".repeat(filled) + "-".repeat(10 - filled); const running = Array.from(agentStates.values()).filter(s => s.status === "running").length; const runningStr = running > 0 ? theme.fg("accent", ` ● ${running} running`) : ""; const left = theme.fg("dim", ` ${model}`) + theme.fg("muted", " · ") + theme.fg("accent", activeTeamName) + runningStr; const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `); const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); return [truncateToWidth(left + pad + right, width)]; }, })); }); // ── Cleanup on exit ────────────────────────── process.on("exit", () => killAllAgents()); process.on("SIGINT", () => { killAllAgents(); process.exit(0); }); process.on("SIGTERM", () => { killAllAgents(); process.exit(0); }); }