// ABOUTME: Spawns and manages background subagent processes with live status widgets. // ABOUTME: Provides /sub, /subcont, /subrm, /subclear commands and subagent_* tools. /** * Subagent Widget — /sub, /subclear, /subrm, /subcont commands with stacking live widgets * * Each /sub spawns a background Pi subagent with its own persistent session, * enabling conversation continuations via /subcont. * * Usage: pi -e extensions/subagent-widget.ts * Then: * /sub list files and summarize — spawn a new subagent * /subcont 1 now write tests for it — continue subagent #1's conversation * /subrm 2 — remove subagent #2 widget * /subclear — clear all subagent widgets */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Box, Text } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; const { spawn } = require("child_process") as any; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { fileURLToPath } from "url"; import { applyExtensionDefaults } from "./lib/themeMap.ts"; import { renderSubagentWidget, parseSubName } from "./lib/subagent-render.ts"; import { DEFAULT_SUBAGENT_MODEL } from "./lib/defaults.ts"; import { cleanOldSessionFiles } from "./lib/subagent-cleanup.ts"; import { buildCommanderPrompt } from "./lib/commander-prompt.ts"; import { preClaimTask, postCompleteTask, postFailTask } from "./lib/commander-lifecycle.ts"; import { parseGroupCreateResult, buildGroupCreatePayload } from "./lib/commander-sync.ts"; import { scanAgentDefs, scanToolkitAgentDefs, resolveAgentByName, loadAgentModelsConfig, loadToolkitModelsConfig, resolveAgentModelString, type AgentDef, type AgentModelsConfig } from "./lib/agent-defs.ts"; import { resolveToolkitWorkerModel, isToolkitCliAgent, spawnToolkitWorker } from "./lib/toolkit-cli.ts"; // ── Commander availability ─────────────────────────────────────────────────── function isCommanderAvailable(): boolean { const g = globalThis as any; return g.__piCommanderGate?.state === "available" && !!g.__piCommanderClient; } function getCommanderClient(): any | undefined { const g = globalThis as any; if (!isCommanderAvailable()) return undefined; return g.__piCommanderClient; } // ── Graceful kill helper ───────────────────────────────────────────────────── /** Send SIGTERM and wait up to `timeoutMs` for exit; escalate to SIGKILL. */ function killGracefully(proc: any, timeoutMs = 3000): Promise { return new Promise((resolve) => { if (!proc || proc.exitCode !== null) { resolve(); return; } let settled = false; const onExit = () => { if (settled) return; settled = true; clearTimeout(timer); resolve(); }; proc.once("exit", onExit); proc.kill("SIGTERM"); const timer = setTimeout(() => { if (settled) return; settled = true; proc.removeListener("exit", onExit); try { proc.kill("SIGKILL"); } catch {} resolve(); }, timeoutMs); }); } /** Default timeout per agent role (ms). Prevents zombie subagents. */ const ROLE_TIMEOUT_MS: Record = { SCOUT: 10 * 60 * 1000, // 10 minutes BUILDER: 30 * 60 * 1000, // 30 minutes REVIEWER: 15 * 60 * 1000, // 15 minutes TESTER: 20 * 60 * 1000, // 20 minutes PLANNER: 15 * 60 * 1000, // 15 minutes }; const DEFAULT_TIMEOUT_MS = 20 * 60 * 1000; // 20 minutes /** Grace period after SIGTERM before escalating to SIGKILL. */ const TIMEOUT_KILL_GRACE_MS = 30_000; /** Resolve the timeout for a subagent based on role name or explicit override. */ function resolveTimeout(name: string, explicitTimeout?: number): number { if (explicitTimeout !== undefined && explicitTimeout > 0) return explicitTimeout; return ROLE_TIMEOUT_MS[name.toUpperCase()] || DEFAULT_TIMEOUT_MS; } interface SubState { id: number; status: "running" | "done" | "error"; name: string; // short role label, e.g. "SCOUT", "REVIEWER" task: string; textChunks: string[]; toolCount: number; elapsed: number; sessionFile: string; // persistent JSONL session path — used by /subcont to resume turnCount: number; // increments each time /subcont continues this agent summary?: string; // pre-written summary shown in widget (no markdown) proc?: any; // active ChildProcess ref (for kill on /subrm) commanderTaskId?: number; // pre-assigned Commander task ID autoRemove?: boolean; // auto-remove widget ~30s after done (default: true) model?: string; // resolved model string for display standby?: boolean; // true = warmup spawn, suppress follow-up message maxDurationMs: number; // watchdog timeout — kills agent after this duration watchdogTimer?: ReturnType; // reference to clear on normal exit } export default function (pi: ExtensionAPI) { const agents: Map = new Map(); let nextId = 1; let widgetCtx: any; const widgetBoxes = new Map void }>(); // ── Agent definition registry (loaded from .md files + models.json) ─────── // Maps lowercase agent names to their definitions. Model assignments come from // .pi/agents/models.json — not from .md frontmatter. When subagent_create is // called with a name matching a known agent, we auto-apply that agent's // configured model, tools, and system prompt. let knownAgents: Map = new Map(); let modelsConfig: AgentModelsConfig | null = null; // ── Session file helpers ────────────────────────────────────────────────── function makeSessionFile(id: number): string { const dir = path.join(os.homedir(), ".pi", "agent", "sessions", "subagents"); fs.mkdirSync(dir, { recursive: true }); return path.join(dir, `subagent-${id}-${Date.now()}.jsonl`); } // ── Widget rendering ────────────────────────────────────────────────────── // ── Dark background colors for subagent status ─────────────────────────── // Standard dark shades that keep white text readable on any terminal. 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 registerWidget(state: SubState) { if (!widgetCtx) return; const key = `sub-${state.id}`; 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); widgetBoxes.set(state.id, { 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 result = renderSubagentWidget(state, width, theme); content.setText(result.lines.join("\n")); return box.render(width); }, invalidate() { box.invalidate(); }, }; }); } function invalidateWidget(id: number) { widgetBoxes.get(id)?.invalidate(); } // ── Streaming helpers ───────────────────────────────────────────────────── function processLine(state: SubState, line: string) { if (!line.trim()) return; try { const event = JSON.parse(line); const type = event.type; if (type === "message_update") { const delta = event.assistantMessageEvent; if (delta?.type === "text_delta") { state.textChunks.push(delta.delta || ""); invalidateWidget(state.id); } } else if (type === "tool_execution_start") { state.toolCount++; invalidateWidget(state.id); } } catch {} } function spawnAgent( state: SubState, prompt: string, ctx: any, peerNames?: string[], ): Promise { // Model resolution priority: // 1) Caller-specified override (state.model set by tool call) // 2) Agent definition model (from .md file, resolved via models.json) // 3) models.json agent entry (even without .md file) // 4) models.json default entry const agentDef = resolveAgentByName(state.name, knownAgents); const configModel = modelsConfig ? resolveAgentModelString(state.name, modelsConfig) : undefined; const model = resolveToolkitWorkerModel( state.name, state.model || agentDef?.model || configModel || DEFAULT_SUBAGENT_MODEL, ); state.model = model; const extDir = path.dirname(fileURLToPath(import.meta.url)); const tasksExtPath = path.join(extDir, "tasks.ts"); const commanderExtPath = path.join(extDir, "commander-mcp.ts"); const footerExtPath = path.join(extDir, "footer.ts"); const memoryCycleExtPath = path.join(extDir, "memory-cycle.ts"); // Commander integration const commanderAvail = isCommanderAvailable(); const cmdTaskId = state.commanderTaskId; // Tools: use agent definition tools if available, else default set let tools = agentDef?.tools || "read,bash,grep,find,ls"; const extensions = ["-e", tasksExtPath, "-e", footerExtPath, "-e", memoryCycleExtPath]; if (commanderAvail) { // 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 extension is sufficient — pi auto-activates all extension tools. extensions.push("-e", commanderExtPath); } // Build system prompt: agent definition prompt + Commander discipline const systemPromptArgs: string[] = []; if (agentDef?.systemPrompt) { systemPromptArgs.push("--append-system-prompt", agentDef.systemPrompt); } if (commanderAvail) { const cmdPrompt = buildCommanderPrompt({ agentName: `SA-${state.id}-${state.name}`, taskId: cmdTaskId, enableMailboxChat: true, peerNames, }); systemPromptArgs.push("--append-system-prompt", cmdPrompt); } // Pre-claim: parent claims Commander task on behalf of subagent if (commanderAvail && cmdTaskId !== undefined) { const client = getCommanderClient(); if (client) { preClaimTask(client, cmdTaskId, `SA-${state.id}-${state.name}`).catch(() => {}); } } const spawnEnv: Record = { ...process.env, PI_SUBAGENT: "1" }; if (commanderAvail && cmdTaskId !== undefined) { spawnEnv.PI_COMMANDER_TASK_ID = String(cmdTaskId); } return new Promise((resolve) => { const startTime = Date.now(); const isScout = (globalThis as any).__piScoutId === state.id; const timer = setInterval(() => { state.elapsed = Date.now() - startTime; invalidateWidget(state.id); if (isScout) publishScoutStatus(state); }, 1000); // ── Watchdog: kill agent if it exceeds maxDurationMs ────────── // Standby (warmup) spawns are exempt — they're short-lived by design. if (!state.standby && state.maxDurationMs > 0) { state.watchdogTimer = setTimeout(() => { if (state.status !== "running") return; // already finished const mins = Math.round(state.maxDurationMs / 60_000); state.textChunks.push(`\n[TIMEOUT] Agent timed out after ${mins} minutes.`); ctx.ui.notify(`SA${state.id} (${state.name}) timed out after ${mins}m`, "warning"); if (state.proc) { killGracefully(state.proc, TIMEOUT_KILL_GRACE_MS).catch(() => {}); } }, state.maxDurationMs); } const finish = (code: number | null) => { clearInterval(timer); // Clear watchdog — agent exited normally before timeout if (state.watchdogTimer) { clearTimeout(state.watchdogTimer); state.watchdogTimer = undefined; } state.elapsed = Date.now() - startTime; state.status = code === 0 ? "done" : "error"; state.proc = undefined; invalidateWidget(state.id); // If this is the pre-spawned scout, publish status for the footer pill if ((globalThis as any).__piScoutId === state.id) { publishScoutStatus(state); // If errored, clear the global so the main agent falls back if (state.status === "error") { (globalThis as any).__piScoutId = undefined; (globalThis as any).__piScoutStatus = undefined; } } // Post-dispatch: reconcile Commander task to terminal state if (commanderAvail && cmdTaskId !== undefined) { const client = getCommanderClient(); if (client) { const agentLabel = `SA-${state.id}-${state.name}`; const summary = state.textChunks.join("").trim().split("\n").pop() || agentLabel; if (state.status === "done") { postCompleteTask(client, cmdTaskId, agentLabel, summary).catch(() => {}); } else { const errMsg = summary || "Agent exited with error"; postFailTask(client, cmdTaskId, errMsg).catch(() => {}); } } } const result = state.textChunks.join(""); // Standby spawns (warmup) suppress notification and follow-up message if (!state.standby) { ctx.ui.notify( `SA${state.id} (${state.name}) ${state.status} in ${Math.round(state.elapsed / 1000)}s`, state.status === "done" ? "success" : "error" ); pi.sendMessage({ customType: "subagent-result", content: `SA${state.id} (${state.name})${state.turnCount > 1 ? ` (Turn ${state.turnCount})` : ""} finished "${prompt}" in ${Math.round(state.elapsed / 1000)}s.\n\nResult:\n${result.slice(0, 8000)}${result.length > 8000 ? "\n\n... [truncated]" : ""}`, display: true, }, { deliverAs: "followUp", triggerTurn: true }); } else { // Clear standby flag after warmup completes — next use is real work state.standby = false; } // Auto-remove widget after 30s (default behavior) if (state.autoRemove !== false) { setTimeout(() => { if (agents.has(state.id) && state.status !== "running") { ctx.ui.setWidget(`sub-${state.id}`, undefined); widgetBoxes.delete(state.id); agents.delete(state.id); } }, 30_000); } resolve(); }; if (isToolkitCliAgent(state.name)) { spawnToolkitWorker({ name: state.name, tools, systemPrompt: [agentDef?.systemPrompt, ...systemPromptArgs.filter((_, i) => i % 2 === 1)].filter(Boolean).join("\n\n"), }, { task: prompt, sessionFile: state.sessionFile, env: spawnEnv, onStdoutLine: (line: string) => processLine(state, line), onStderr: (chunk: string) => { if (chunk.trim()) { state.textChunks.push(chunk); invalidateWidget(state.id); } }, }).then(({ exitCode }) => { finish(exitCode); }); return; } const proc = spawn("pi", [ "--mode", "json", "-p", "--session", state.sessionFile, "--no-extensions", ...extensions, "--model", model, "--tools", tools, "--thinking", "off", ...systemPromptArgs, prompt, ], { stdio: ["ignore", "pipe", "pipe"], env: spawnEnv, }); 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) processLine(state, line); }); proc.stderr!.setEncoding("utf-8"); proc.stderr!.on("data", (chunk: string) => { if (chunk.trim()) { state.textChunks.push(chunk); invalidateWidget(state.id); } }); proc.on("close", (code) => { if (buffer.trim()) processLine(state, buffer); finish(code); }); proc.on("error", (err) => { state.textChunks.push(`Error: ${err.message}`); finish(1); }); proc.on("exit", () => { clearInterval(timer); }); }); } // ── Tools for the Main Agent ────────────────────────────────────────────── pi.registerTool({ name: "subagent_create", description: "Spawn a background subagent to perform a task. Returns the subagent ID immediately while it runs in the background. Results will be delivered as a follow-up message when finished.\n\nWhen `name` matches a known agent definition (scout, builder, reviewer, planner, tester, red-team), that agent's configured model, tools, and system prompt are automatically applied. Only set `model` to override the agent's default.", parameters: Type.Object({ task: Type.String({ description: "The complete task description for the subagent to perform" }), name: Type.Optional(Type.String({ description: "Short role label (e.g. REVIEWER, SCOUT). If this matches a known agent definition, that agent's model/tools/prompt are auto-applied." })), summary: Type.Optional(Type.String({ description: "Short summary shown in widget (no markdown)" })), model: Type.Optional(Type.String({ description: "Model override. Only set this to override the agent's default model. If omitted, uses the agent definition's model or the system default." })), commanderTaskId: Type.Optional(Type.Number({ description: "Pre-assigned Commander task ID (avoids race conditions)" })), autoRemove: Type.Optional(Type.Boolean({ description: "Auto-remove widget ~30s after done (default: true)" })), timeout: Type.Optional(Type.Number({ description: "Max runtime in milliseconds. Defaults by role: scout=10min, builder=30min, reviewer=15min, default=20min. Set 0 to disable." })), }), execute: async (callId, args, _signal, _onUpdate, ctx) => { widgetCtx = ctx; const id = nextId++; const agentName = (args.name || "AGENT").toUpperCase(); const state: SubState = { id, status: "running", name: agentName, task: args.task, textChunks: [], toolCount: 0, elapsed: 0, sessionFile: makeSessionFile(id), turnCount: 1, summary: args.summary, commanderTaskId: args.commanderTaskId, autoRemove: args.autoRemove, model: args.model, // caller-specified model override maxDurationMs: resolveTimeout(agentName, args.timeout), }; agents.set(id, state); registerWidget(state); // Fire-and-forget spawnAgent(state, args.task, ctx); return { content: [{ type: "text", text: `SA${id} (${state.name}) spawned and running in background.` }], }; }, }); pi.registerTool({ name: "subagent_create_batch", description: "Spawn multiple subagents at once with optional Commander task group. Pre-creates Commander tasks to avoid race conditions where multiple agents try to claim the same task.\n\nWhen an agent's `name` matches a known agent definition, that agent's configured model, tools, and system prompt are automatically applied.", parameters: Type.Object({ agents: Type.Array(Type.Object({ task: Type.String({ description: "The complete task description for the subagent" }), name: Type.Optional(Type.String({ description: "Short role label (e.g. REVIEWER, SCOUT). If this matches a known agent definition, that agent's model/tools/prompt are auto-applied." })), summary: Type.Optional(Type.String({ description: "Short summary shown in widget (no markdown)" })), model: Type.Optional(Type.String({ description: "Model override. Only set to override the agent definition's default model." })), }), { description: "Array of agent definitions to spawn" }), groupName: Type.Optional(Type.String({ description: "Commander task group name (used when Commander is available)" })), autoRemove: Type.Optional(Type.Boolean({ description: "Auto-remove widgets ~30s after done (default: true)" })), timeout: Type.Optional(Type.Number({ description: "Max runtime in ms for all agents in this batch. Defaults by role." })), force: Type.Optional(Type.Boolean({ description: "Force spawn even if agents are already running (default: false)" })), }), execute: async (callId, args, _signal, _onUpdate, ctx) => { widgetCtx = ctx; const defs = args.agents; if (!defs || defs.length === 0) { return { content: [{ type: "text", text: "Error: No agents specified." }] }; } // ── Guard: prevent duplicate batch spawns while agents are running ── if (!args.force) { const running = Array.from(agents.values()).filter(a => a.status === "running"); if (running.length > 0) { const names = running.map(a => `SA${a.id} (${a.name})`).join(", "); return { content: [{ type: "text", text: `Warning: ${running.length} agent(s) still running: ${names}. Wait for them to finish, use subagent_cleanup to clear stale agents, or pass force: true to override.` }], }; } } // ── Auto-cleanup: remove done/error agents before spawning new batch ── for (const [id, a] of Array.from(agents.entries())) { if (a.status === "done" || a.status === "error") { if (widgetCtx) widgetCtx.ui.setWidget(`sub-${id}`, undefined); widgetBoxes.delete(id); agents.delete(id); } } // Build states for all agents const states: SubState[] = defs.map((def: any) => { const id = nextId++; const agentName = (def.name || "AGENT").toUpperCase(); return { id, status: "running" as const, name: agentName, task: def.task, textChunks: [], toolCount: 0, elapsed: 0, sessionFile: makeSessionFile(id), turnCount: 1, summary: def.summary, autoRemove: args.autoRemove, model: def.model, // per-agent model override maxDurationMs: resolveTimeout(agentName, args.timeout), }; }); // Try to create Commander task group for all agents at once const client = getCommanderClient(); if (client && isCommanderAvailable()) { const groupName = args.groupName || `subagent-batch-${Date.now()}`; const taskTexts = defs.map((def: any) => def.task); const payload = buildGroupCreatePayload( groupName, `Batch subagent group: ${groupName}`, taskTexts, process.cwd(), ); try { const result = await client.callTool("commander_task", payload); const parsed = parseGroupCreateResult(result); if (parsed && parsed.taskIds.length >= states.length) { for (let i = 0; i < states.length; i++) { states[i].commanderTaskId = parsed.taskIds[i]; } } } catch { // Commander group creation failed — proceed without task IDs } } // Collect peer names for mailbox banter const peerNames = states.map(s => `SA-${s.id}-${s.name}`); // Register and spawn all agents for (const state of states) { agents.set(state.id, state); registerWidget(state); } for (const state of states) { const peers = peerNames.filter(n => n !== `SA-${state.id}-${state.name}`); spawnAgent(state, state.task, ctx, peers); } const ids = states.map(s => `SA${s.id} (${s.name})`).join(", "); return { content: [{ type: "text", text: `Batch spawned ${states.length} subagents: ${ids}` }], }; }, }); pi.registerTool({ name: "subagent_continue", description: "Continue an existing subagent's conversation. Use this to give further instructions to a finished subagent. Returns immediately while it runs in the background.", parameters: Type.Object({ id: Type.Number({ description: "The ID of the subagent to continue" }), prompt: Type.String({ description: "The follow-up prompt or new instructions" }), }), execute: async (callId, args, _signal, _onUpdate, ctx) => { widgetCtx = ctx; const state = agents.get(args.id); if (!state) { return { content: [{ type: "text", text: `Error: No SA${args.id} found.` }] }; } if (state.status === "running") { return { content: [{ type: "text", text: `Error: SA${args.id} is still running.` }] }; } state.status = "running"; state.task = args.prompt; state.textChunks = []; state.elapsed = 0; state.turnCount++; // Re-register widget if it was removed (e.g. after standby warmup auto-remove) if (!widgetBoxes.has(state.id)) { registerWidget(state); } invalidateWidget(state.id); ctx.ui.notify(`Continuing SA${args.id} (${state.name}) Turn ${state.turnCount}…`, "info"); spawnAgent(state, args.prompt, ctx); return { content: [{ type: "text", text: `SA${args.id} (${state.name}) continuing conversation in background.` }], }; }, }); pi.registerTool({ name: "subagent_remove", description: "Remove a specific subagent. Kills it if it's currently running.", parameters: Type.Object({ id: Type.Number({ description: "The ID of the subagent to remove" }), }), execute: async (callId, args, _signal, _onUpdate, ctx) => { widgetCtx = ctx; const state = agents.get(args.id); if (!state) { return { content: [{ type: "text", text: `Error: No SA${args.id} found.` }] }; } if (state.proc && state.status === "running") { await killGracefully(state.proc); } ctx.ui.setWidget(`sub-${args.id}`, undefined); widgetBoxes.delete(args.id); agents.delete(args.id); return { content: [{ type: "text", text: `SA${args.id} removed.` }], }; }, }); pi.registerTool({ name: "subagent_list", description: "List all active and finished subagents, showing their IDs, tasks, and status.", parameters: Type.Object({}), execute: async () => { if (agents.size === 0) { return { content: [{ type: "text", text: "No active subagents." }] }; } const list = Array.from(agents.values()).map(s => `SA${s.id} [${s.status.toUpperCase()}] ${s.name} - ${s.task}` ).join("\n"); return { content: [{ type: "text", text: `Subagents:\n${list}` }], }; }, }); pi.registerTool({ name: "subagent_cleanup", description: "Clean up finished and stale subagents. Removes done/error agents and kills agents running longer than max_age_seconds. Use before spawning new batches or when the screen is cluttered.", parameters: Type.Object({ max_age_seconds: Type.Optional(Type.Number({ description: "Kill agents running longer than this (default: 600s = 10 min). Set 0 to only remove done/error agents." })), }), execute: async (callId, args, _signal, _onUpdate, ctx) => { widgetCtx = ctx; const maxAge = (args.max_age_seconds ?? 600) * 1000; let removedDone = 0; let killedStale = 0; const killPromises: Promise[] = []; for (const [id, state] of Array.from(agents.entries())) { // Skip the pre-spawned scout — it's managed separately if ((globalThis as any).__piScoutId === id) continue; if (state.status === "done" || state.status === "error") { ctx.ui.setWidget(`sub-${id}`, undefined); widgetBoxes.delete(id); agents.delete(id); removedDone++; } else if (state.status === "running" && maxAge > 0 && state.elapsed > maxAge) { if (state.proc) { killPromises.push(killGracefully(state.proc)); } state.status = "error"; state.textChunks.push(`\n[CLEANUP] Killed after ${Math.round(state.elapsed / 1000)}s (stale).`); ctx.ui.setWidget(`sub-${id}`, undefined); widgetBoxes.delete(id); agents.delete(id); killedStale++; } } await Promise.all(killPromises); const remaining = Array.from(agents.values()).filter(a => a.status === "running").length; const summary = `Cleanup: removed ${removedDone} done/error, killed ${killedStale} stale. ${remaining} active remain.`; return { content: [{ type: "text", text: summary }], }; }, }); // ── /sub ─────────────────────────────────────────────────────────── pi.registerCommand("sub", { description: "Spawn a subagent with live widget: /sub ", handler: async (args, ctx) => { widgetCtx = ctx; const raw = args?.trim(); if (!raw) { ctx.ui.notify("Usage: /sub [NAME] ", "error"); return; } const parsed = parseSubName(raw); if (!parsed.task) { ctx.ui.notify("Usage: /sub [NAME] ", "error"); return; } const id = nextId++; const state: SubState = { id, status: "running", name: parsed.name, task: parsed.task, textChunks: [], toolCount: 0, elapsed: 0, sessionFile: makeSessionFile(id), turnCount: 1, maxDurationMs: resolveTimeout(parsed.name), }; agents.set(id, state); registerWidget(state); // Fire-and-forget spawnAgent(state, parsed.task, ctx); }, }); // ── /subcont ──────────────────────────────────────────── pi.registerCommand("subcont", { description: "Continue an existing subagent's conversation: /subcont ", handler: async (args, ctx) => { widgetCtx = ctx; const trimmed = args?.trim() ?? ""; const spaceIdx = trimmed.indexOf(" "); if (spaceIdx === -1) { ctx.ui.notify("Usage: /subcont ", "error"); return; } const num = parseInt(trimmed.slice(0, spaceIdx), 10); const prompt = trimmed.slice(spaceIdx + 1).trim(); if (isNaN(num) || !prompt) { ctx.ui.notify("Usage: /subcont ", "error"); return; } const state = agents.get(num); if (!state) { ctx.ui.notify(`No SA${num} found. Use /sub to create one.`, "error"); return; } if (state.status === "running") { ctx.ui.notify(`SA${num} is still running — wait for it to finish first.`, "warning"); return; } // Resume: update state for a new turn state.status = "running"; state.task = prompt; state.textChunks = []; state.elapsed = 0; state.turnCount++; // Re-register widget if it was removed (e.g. after auto-remove) if (!widgetBoxes.has(state.id)) { registerWidget(state); } invalidateWidget(state.id); ctx.ui.notify(`Continuing SA${num} (${state.name}) Turn ${state.turnCount}…`, "info"); // Fire-and-forget — reuses the same sessionFile for conversation history spawnAgent(state, prompt, ctx); }, }); // ── /subrm ─────────────────────────────────────────────────────── pi.registerCommand("subrm", { description: "Remove a specific subagent widget: /subrm ", handler: async (args, ctx) => { widgetCtx = ctx; const num = parseInt(args?.trim() ?? "", 10); if (isNaN(num)) { ctx.ui.notify("Usage: /subrm ", "error"); return; } const state = agents.get(num); if (!state) { ctx.ui.notify(`No SA${num} found.`, "error"); return; } // Kill the process if still running if (state.proc && state.status === "running") { await killGracefully(state.proc); ctx.ui.notify(`SA${num} killed and removed.`, "warning"); } else { ctx.ui.notify(`SA${num} removed.`, "info"); } ctx.ui.setWidget(`sub-${num}`, undefined); widgetBoxes.delete(num); agents.delete(num); }, }); // ── /subclear ───────────────────────────────────────────────────────────── pi.registerCommand("subclear", { description: "Clear all subagent widgets", handler: async (_args, ctx) => { widgetCtx = ctx; let killed = 0; const killPromises: Promise[] = []; for (const [id, state] of Array.from(agents.entries())) { if (state.proc && state.status === "running") { killPromises.push(killGracefully(state.proc)); killed++; } ctx.ui.setWidget(`sub-${id}`, undefined); } await Promise.all(killPromises); const total = agents.size; agents.clear(); widgetBoxes.clear(); nextId = 1; const msg = total === 0 ? "No subagents to clear." : `Cleared ${total} subagent${total !== 1 ? "s" : ""}${killed > 0 ? ` (${killed} killed)` : ""}.`; ctx.ui.notify(msg, total === 0 ? "info" : "success"); }, }); // ── Session lifecycle ───────────────────────────────────────────────────── // ── Pre-spawn scout helper ──────────────────────────────────────────────── /** Publish scout status to globalThis so the footer can render a pill. */ function publishScoutStatus(state: SubState) { (globalThis as any).__piScoutStatus = { status: state.status, model: state.model || "", elapsed: state.elapsed, }; } function preSpawnScout(ctx: any) { // Only pre-spawn if scout agent definition exists const scoutDef = resolveAgentByName("scout", knownAgents); if (!scoutDef) return; const id = nextId++; const state: SubState = { id, status: "running", name: "SCOUT", task: "Warming up — standing by for recon tasks.", textChunks: [], toolCount: 0, elapsed: 0, sessionFile: makeSessionFile(id), turnCount: 1, summary: "Standing by...", autoRemove: false, // keep widget alive — scout persists across tasks standby: true, // suppress follow-up message on warmup completion maxDurationMs: 0, // no timeout for pre-spawned scout (warmup is exempt) }; agents.set(id, state); // No registerWidget — scout shows as a footer pill, not a stacking widget // Store scout ID globally so mode prompts can reference it (globalThis as any).__piScoutId = id; publishScoutStatus(state); // Spawn with a minimal warmup prompt — establishes the session file spawnAgent(state, "You are now on standby. Respond with exactly: Ready.", ctx); } pi.on("session_start", async (_event, ctx) => { applyExtensionDefaults(import.meta.url, ctx); const sessDir = path.join(os.homedir(), ".pi", "agent", "sessions", "subagents"); cleanOldSessionFiles(sessDir, 7); const killPromises: Promise[] = []; for (const [id, state] of Array.from(agents.entries())) { if (state.proc && state.status === "running") { killPromises.push(killGracefully(state.proc)); } ctx.ui.setWidget(`sub-${id}`, undefined); } await Promise.all(killPromises); agents.clear(); widgetBoxes.clear(); nextId = 1; widgetCtx = ctx; // Clear stale scout state from previous session (globalThis as any).__piScoutId = undefined; (globalThis as any).__piScoutStatus = undefined; // Load model config from .pi/agents/models.json, then scan agent .md files. // Models come from the JSON config; .md files provide tools + system prompts. const extDir = path.dirname(fileURLToPath(import.meta.url)); const extProjectDir = path.resolve(extDir, ".."); modelsConfig = loadAgentModelsConfig(ctx.cwd || process.cwd(), extProjectDir); const standardAgents = scanAgentDefs(ctx.cwd || process.cwd(), extProjectDir, modelsConfig); const toolkitModelsConfig = loadToolkitModelsConfig(ctx.cwd || process.cwd(), extProjectDir); const toolkitAgents = scanToolkitAgentDefs(ctx.cwd || process.cwd(), extProjectDir, toolkitModelsConfig); knownAgents = new Map([...standardAgents, ...toolkitAgents]); // Pre-spawn scout subagent so it's always ready for recon tasks preSpawnScout(ctx); // ── Expose global hooks for escape-cancel integration ──────────── (globalThis as any).__piKillAllSubagents = (): number => { let killed = 0; for (const [, state] of agents) { if (state.proc && state.status === "running") { try { state.proc.kill("SIGTERM"); } catch {} killed++; } } return killed; }; (globalThis as any).__piHasRunningSubagents = (): boolean => { for (const [, state] of agents) { if (state.status === "running") return true; } return false; }; }); // ── /new resets — re-spawn scout for the new session ────────────────────── pi.on("session_switch", async (_event, ctx) => { // Kill running subagents and clear all widgets const killPromises: Promise[] = []; for (const [id, state] of Array.from(agents.entries())) { if (state.proc && state.status === "running") { killPromises.push(killGracefully(state.proc)); } ctx.ui.setWidget(`sub-${id}`, undefined); } await Promise.all(killPromises); agents.clear(); widgetBoxes.clear(); nextId = 1; widgetCtx = ctx; // Clear stale scout state (globalThis as any).__piScoutId = undefined; (globalThis as any).__piScoutStatus = undefined; // Re-spawn scout for the new session preSpawnScout(ctx); }); }