import type { AgentToolResult, ExtensionAPI, ExtensionContext, Theme, ThemeColor, ToolRenderResultOptions, } from "@earendil-works/pi-coding-agent"; import { calculateContextTokens, getAgentDir, getMarkdownTheme, } from "@earendil-works/pi-coding-agent"; import { Box, Container, Markdown, Spacer, Text, truncateToWidth, type Component, } from "@earendil-works/pi-tui"; import { spawn, ChildProcess } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { openSubagentFleet, type FleetAgentDetail, type FleetAgentSummary, type FleetDataSource, } from "./fleet"; type ThinkingLevel = | "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; const THINKING_LEVELS = new Set([ "off", "minimal", "low", "medium", "high", "xhigh", "max", ]); interface SubAgent { id: string; process: ChildProcess; task: string; taskTitle: string; agentType?: string; model?: string; thinkingLevel: ThinkingLevel; extraContext?: string; status: "starting" | "running" | "completed" | "error" | "interrupted"; activity: string[]; currentResponsePreview: string; lastAssistantText?: string; lastAssistantStopReason?: string; lastAssistantError?: string; completionResult?: string; pendingCompletionResult?: string; pendingCompletionToolCallId?: string; partialResult?: string; failureReason?: | "missing_result" | "incomplete_result" | "assistant_error" | "process_error"; interruptionReason?: "reload"; startTime: number; endTime?: number; exitCode?: number; processExited?: boolean; currentTool?: string; lastAction?: string; progressPercent?: number; progressBuffer?: string; /** Total context tokens from the latest assistant `usage`, for monitoring sub-agent token use. */ contextTokens?: number; lastActivity: number; receivedEvent: boolean; timeoutSeconds?: number; timeoutAt?: number; isDefaultSubagent?: boolean; showDefaultSubagentFooter?: boolean; timeoutNotified?: boolean; timeoutWarningHandle?: NodeJS.Timeout; timeoutHandle?: NodeJS.Timeout; timeoutEscalationHandle?: NodeJS.Timeout; shutdownHandle?: NodeJS.Timeout; completionNotified?: boolean; lastStateChangeAt?: number; } const activeAgents = new Map(); const recentFleetAgents = new Map(); let currentCtx: ExtensionContext | null = null; let nextAgentId = 1; let configuredAgents: Record = {}; let hasAgentConfiguration = false; let hideDefaultSubagentFooter = false; let maxActiveSubagents: number | undefined = undefined; let defaultTimeoutSeconds: number | undefined = 180; let allowNestedSubagents = false; let sendCompletionMessage: | (( content: string, details?: Record, options?: { triggerTurn?: boolean }, ) => void) | null = null; const MAX_ACTIVE_SUBAGENTS_CAP = 100; const MAX_DEFAULT_TIMEOUT_SECONDS = 86400; const MAX_ACTIVITY_ENTRIES = 50; const MAX_ACTIVITY_ENTRY_CHARS = 1000; const MAX_RESPONSE_PREVIEW_CHARS = 4000; const MAX_RECENT_FLEET_AGENTS = 20; const PROCESS_SHUTDOWN_GRACE_MS = 2000; const TIMEOUT_WRAP_UP_WARNING_SECONDS = 60; let timeoutEscalationDelayMs = 30000; type SubagentProfile = { model: string; thinking_level?: ThinkingLevel; when_to_use?: string; extra_context?: string; fork?: string; }; type PiSubagentSettings = { agents?: Record; max_active_subagents?: number; default_timeout_seconds?: number; allow_nested_subagents?: boolean; hide_default_subagent_footer?: boolean; has_agent_configuration: boolean; }; const STALE_RUNNING_MS = 60_000; const EXTRA_SUBAGENT_INSTRUCTIONS = "Ensure that your work stays scoped to the assigned task."; const SUBAGENT_PARENT_GUIDANCE = "The sub-agent is now running in parallel. Its completion message will be delivered automatically as a new turn. Never poll for progress or completion by any means, including status checks, sleep commands, or wait loops. Continue with other work for the user, or end your turn."; let spawnProcess: typeof spawn = spawn; const SUBAGENT_COMPLETION_INSTRUCTIONS = `Do the requested task only; do not expand scope. If the task is too large, complete the highest-value slice and clearly state what remains. When finished or blocked, call the \`subagent_complete\` tool with the complete deliverable for the parent in its \`result\` field. A successful call should be your final action. If the tool returns an error, correct the result and call it again. The result may use normal Markdown and should include evidence, changed files, commands, or open questions only when relevant. Do not include planning or process narration. If the completion tool is unavailable, return the complete deliverable as your final response instead.`; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function readJsonFile(path: string): Record { if (!existsSync(path)) return {}; try { const raw = readFileSync(path, "utf-8"); const parsed = JSON.parse(raw); return isRecord(parsed) ? parsed : {}; } catch { return {}; } } /** Match Pi settings semantics: nested records merge, other values replace. */ function deepMergeRecords( base: Record, overrides: Record, ): Record { const merged = { ...base }; for (const [key, overrideValue] of Object.entries(overrides)) { if (overrideValue === undefined) continue; const baseValue = merged[key]; merged[key] = isRecord(baseValue) && isRecord(overrideValue) ? deepMergeRecords(baseValue, overrideValue) : overrideValue; } return merged; } function normalizeMaxActiveSubagents(raw: unknown): number | undefined { if (typeof raw !== "number" || !Number.isFinite(raw)) return undefined; const normalized = Math.trunc(raw); if (normalized < 1) return undefined; return Math.min(normalized, MAX_ACTIVE_SUBAGENTS_CAP); } function normalizeDefaultTimeoutSeconds(raw: unknown): number | undefined { if (typeof raw !== "number" || !Number.isFinite(raw)) return undefined; const normalized = Math.trunc(raw); if (normalized < 1) return undefined; return Math.min(normalized, MAX_DEFAULT_TIMEOUT_SECONDS); } function normalizeManualTimeoutSeconds(raw: unknown): number | undefined { if (raw === undefined || raw === null) return undefined; if (typeof raw !== "number" || !Number.isFinite(raw)) return undefined; const normalized = Math.trunc(raw); if (normalized < 1) return undefined; return Math.min(normalized, MAX_DEFAULT_TIMEOUT_SECONDS); } function normalizeAllowNestedSubagents(raw: unknown): boolean | undefined { if (typeof raw !== "boolean") return undefined; return raw; } function normalizeHideDefaultSubagentFooter(raw: unknown): boolean | undefined { if (typeof raw !== "boolean") return undefined; return raw; } function normalizeThinkingLevel(raw: unknown): ThinkingLevel | undefined { if (typeof raw !== "string") return undefined; const normalized = raw.trim().toLowerCase(); return THINKING_LEVELS.has(normalized as ThinkingLevel) ? (normalized as ThinkingLevel) : undefined; } function resolveThinkingLevel( profile: SubagentProfile, parentThinkingLevel: ThinkingLevel, ): ThinkingLevel { return profile.thinking_level ?? parentThinkingLevel; } function buildSubAgentArgs( model: string, thinkingLevel: ThinkingLevel, fork?: string, ): string[] { return [ "--mode", "rpc", ...(fork ? ["--fork", fork] : ["--no-session"]), "--model", model, "--thinking", thinkingLevel, ]; } function isTruthyEnv(value: string | undefined): boolean { if (!value) return false; const normalized = value.trim().toLowerCase(); return normalized === "1" || normalized === "true" || normalized === "yes"; } function getPiSubagentSettings( cwd: string, projectTrusted = true, ): PiSubagentSettings { const globalSettingsPath = join(getAgentDir(), "settings.json"); const projectSettingsPath = join(cwd, ".pi", "settings.json"); const globalSettings = readJsonFile(globalSettingsPath); const projectSettings = projectTrusted ? readJsonFile(projectSettingsPath) : {}; const globalSubagent = isRecord(globalSettings["pi-subagent"]) ? globalSettings["pi-subagent"] : {}; const projectSubagent = isRecord(projectSettings["pi-subagent"]) ? projectSettings["pi-subagent"] : {}; const mergedSubagent = deepMergeRecords(globalSubagent, projectSubagent); const mergedAgentsValue = mergedSubagent.agents; const mergedAgents = isRecord(mergedAgentsValue) ? mergedAgentsValue : {}; const normalizedAgents: Record = {}; for (const [agentName, agentConfig] of Object.entries(mergedAgents)) { if (!isRecord(agentConfig)) continue; const model = typeof agentConfig.model === "string" ? agentConfig.model.trim() : ""; if (!model) continue; const thinkingLevel = normalizeThinkingLevel(agentConfig.thinking_level); const whenToUse = typeof agentConfig.when_to_use === "string" ? agentConfig.when_to_use.trim() : undefined; const extraContext = typeof agentConfig.extra_context === "string" ? agentConfig.extra_context.trim() : undefined; const fork = typeof agentConfig.fork === "string" ? agentConfig.fork.trim() : undefined; normalizedAgents[agentName] = { model, ...(thinkingLevel ? { thinking_level: thinkingLevel } : {}), ...(whenToUse ? { when_to_use: whenToUse } : {}), ...(extraContext ? { extra_context: extraContext } : {}), ...(fork ? { fork } : {}), }; } return { agents: normalizedAgents, max_active_subagents: normalizeMaxActiveSubagents( mergedSubagent.max_active_subagents, ), default_timeout_seconds: normalizeDefaultTimeoutSeconds( mergedSubagent.default_timeout_seconds, ), allow_nested_subagents: normalizeAllowNestedSubagents(mergedSubagent.allow_nested_subagents) ?? false, hide_default_subagent_footer: normalizeHideDefaultSubagentFooter( mergedSubagent.hide_default_subagent_footer, ) ?? false, has_agent_configuration: mergedAgentsValue !== undefined && (!isRecord(mergedAgentsValue) || Object.keys(mergedAgents).length > 0), }; } function projectSettingsAreTrusted( ctx: ExtensionContext | null | undefined, ): boolean { const trustAwareContext = ctx as | (ExtensionContext & { isProjectTrusted?: () => boolean }) | null | undefined; return trustAwareContext?.isProjectTrusted?.() ?? true; } function refreshConfiguredAgents(cwd: string, projectTrusted = true): void { const settings = getPiSubagentSettings(cwd, projectTrusted); configuredAgents = settings.agents || {}; hasAgentConfiguration = settings.has_agent_configuration; hideDefaultSubagentFooter = settings.hide_default_subagent_footer ?? false; maxActiveSubagents = settings.max_active_subagents; defaultTimeoutSeconds = settings.default_timeout_seconds ?? 180; allowNestedSubagents = settings.allow_nested_subagents ?? false; } type ResolvedSubagentProfile = { profile: SubagentProfile; defaultSubagent: boolean; }; function getCurrentModelReference( ctx: ExtensionContext | null | undefined, ): string | undefined { const model = ctx?.model; return model ? `${model.provider}/${model.id}` : undefined; } function resolveSubagentProfile( agentName: string, ctx: ExtensionContext | null | undefined, ): ResolvedSubagentProfile { const normalizedAgentName = agentName.trim(); if (!normalizedAgentName) { throw new Error("Missing agent type"); } refreshConfiguredAgents( ctx?.cwd ?? process.cwd(), projectSettingsAreTrusted(ctx), ); const profile = configuredAgents[normalizedAgentName]; if (profile) return { profile, defaultSubagent: false }; if (normalizedAgentName === "default" && !hasAgentConfiguration) { const model = getCurrentModelReference(ctx); if (!model) { throw new Error( "The default sub-agent needs an active model. Select a model before spawning.", ); } return { profile: { model }, defaultSubagent: true }; } const availableAgents = hasAgentConfiguration ? Object.keys(configuredAgents) : ["default"]; const suffix = availableAgents.length > 0 ? ` Available agents: ${availableAgents.join(", ")}` : hasAgentConfiguration ? " No valid agents are configured in settings." : " No agents configured in settings."; throw new Error( `Unknown sub-agent type \`${normalizedAgentName}\`.${suffix}`, ); } function getConfiguredAgentEntries( ctx: ExtensionContext | null | undefined, ): Array<{ name: string; profile: SubagentProfile }> { refreshConfiguredAgents( ctx?.cwd ?? process.cwd(), projectSettingsAreTrusted(ctx), ); if (!hasAgentConfiguration) { return [ { name: "default", profile: { model: getCurrentModelReference(ctx) ?? "current model", when_to_use: "Uses the current model and thinking level", }, }, ]; } return Object.entries(configuredAgents) .sort(([left], [right]) => left.localeCompare(right)) .map(([name, profile]) => ({ name, profile })); } function getTaskTitle(task: string, maxLength = 80): string { const firstNonEmptyLine = task .split(/\r?\n/) .map((line) => line.trim()) .find((line) => line.length > 0); const title = firstNonEmptyLine ?? task.trim(); if (!title) return "(empty task)"; if (title.length <= maxLength) return title; return `${title.slice(0, Math.max(1, maxLength - 3))}...`; } function formatSubagentPrompt(task: string, extraContext?: string): string { const taskWithContext = !extraContext?.trim() ? task : `Additional context:\n${extraContext.trim()}\n\nTask:\n${task}`; return `${EXTRA_SUBAGENT_INSTRUCTIONS}\n\n${taskWithContext}\n\n${SUBAGENT_COMPLETION_INSTRUCTIONS}`; } /** Format context tokens as zero-padded millions (e.g. `0.072Mt`), or undefined when none reported yet. */ function formatContextTokens(tokens: number | undefined): string | undefined { if (tokens == null || !Number.isFinite(tokens) || tokens <= 0) return undefined; return `${(tokens / 1_000_000).toFixed(3)}Mt`; } function isAgentFinished(agent: SubAgent): boolean { return ( agent.status === "completed" || agent.status === "error" || agent.status === "interrupted" ); } function getSubagentStatusEmoji(status: SubAgent["status"]): string { if (status === "interrupted") return "🤖⚠️"; if (status === "error") return "🤖❌"; if (status === "completed") return "🤖✅"; return "🤖🚀"; } function transitionAgentStatus( agent: SubAgent, nextStatus: SubAgent["status"], nextAction: string, ): void { const rank: Record = { starting: 0, running: 1, completed: 2, error: 2, interrupted: 2, }; if (rank[nextStatus] < rank[agent.status]) { return; } if (isAgentFinished(agent)) { return; } agent.status = nextStatus; agent.lastAction = nextAction; agent.lastStateChangeAt = Date.now(); if ( nextStatus === "completed" || nextStatus === "error" || nextStatus === "interrupted" ) { agent.endTime = Date.now(); agent.currentTool = undefined; } } function clearSubAgentTimeout(agent: SubAgent): void { if (agent.timeoutWarningHandle) { clearTimeout(agent.timeoutWarningHandle); agent.timeoutWarningHandle = undefined; } if (agent.timeoutHandle) { clearTimeout(agent.timeoutHandle); agent.timeoutHandle = undefined; } if (agent.timeoutEscalationHandle) { clearTimeout(agent.timeoutEscalationHandle); agent.timeoutEscalationHandle = undefined; } } function scheduleSubAgentTimeout( agent: SubAgent, timeoutSecondsOverride?: number, ): void { const timeoutSeconds = timeoutSecondsOverride ?? defaultTimeoutSeconds; if (!timeoutSeconds) return; agent.timeoutSeconds = timeoutSeconds; agent.timeoutAt = agent.startTime + timeoutSeconds * 1000; if (timeoutSeconds > TIMEOUT_WRAP_UP_WARNING_SECONDS) { const warningDelayMs = (timeoutSeconds - TIMEOUT_WRAP_UP_WARNING_SECONDS) * 1000; agent.timeoutWarningHandle = setTimeout(() => { agent.timeoutWarningHandle = undefined; if (agent.status === "completed" || agent.status === "error") { return; } const elapsedSeconds = Math.max( 0, Math.floor((Date.now() - agent.startTime) / 1000), ); steerSubAgent( agent.id, `You have been running for ${elapsedSeconds} seconds. You have ${TIMEOUT_WRAP_UP_WARNING_SECONDS} seconds to finish your task.`, ); }, warningDelayMs); } agent.timeoutHandle = setTimeout(() => { agent.timeoutHandle = undefined; if (agent.status === "completed" || agent.status === "error") { return; } const timeoutText = `Time budget reached (${timeoutSeconds}s). ` + "Do not continue expanding scope. Submit what you completed and what remains through subagent_complete now."; const result = steerSubAgent(agent.id, timeoutText); if (result.ok) { agent.timeoutNotified = true; agent.lastAction = `⏰ timeout reached (${timeoutSeconds}s)`; agent.timeoutEscalationHandle = setTimeout(() => { agent.timeoutEscalationHandle = undefined; if (agent.status === "completed" || agent.status === "error") { return; } steerSubAgent( agent.id, "You are still running past the time budget. Stop now and call subagent_complete immediately with the best result available.", ); sendCompletionMessage?.( `⚠️ Sub-agent ${agent.id} is still running after timeout finalize request. Consider checking status and using an aggressive steering message to instruct the agent to finish immediately. If the agent fails to respond, you may want to use subagent_kill to forcefully end it.`, { agentId: agent.id, timeoutSeconds: timeoutSeconds, timeoutStage: "escalation", }, ); }, timeoutEscalationDelayMs); } }, timeoutSeconds * 1000); } function clearSubAgentShutdown(agent: SubAgent): void { if (!agent.shutdownHandle) return; clearTimeout(agent.shutdownHandle); agent.shutdownHandle = undefined; } function recordActivity(agent: SubAgent, entry: string): void { const compact = entry.trim().slice(0, MAX_ACTIVITY_ENTRY_CHARS); if (!compact) return; agent.activity.push(compact); if (agent.activity.length > MAX_ACTIVITY_ENTRIES) { agent.activity.splice(0, agent.activity.length - MAX_ACTIVITY_ENTRIES); } } function requestProcessShutdown(agent: SubAgent): void { clearSubAgentTimeout(agent); if (agent.processExited || agent.shutdownHandle) return; try { if (agent.process.stdin && !agent.process.stdin.destroyed) { agent.process.stdin.end(); } } catch {} agent.shutdownHandle = setTimeout(() => { agent.shutdownHandle = undefined; if (!agent.processExited) { agent.process.kill(); } }, PROCESS_SHUTDOWN_GRACE_MS); agent.shutdownHandle.unref?.(); } function rememberAgentForFleet(agent: SubAgent): void { if (!isAgentFinished(agent)) return; recentFleetAgents.delete(agent.id); recentFleetAgents.set(agent.id, { ...toFleetAgentDetail(agent), activity: [...agent.activity], currentResponsePreview: "", }); while (recentFleetAgents.size > MAX_RECENT_FLEET_AGENTS) { const oldestId = recentFleetAgents.keys().next().value; if (oldestId === undefined) break; recentFleetAgents.delete(oldestId); } } function removeAgentFromTracking(id: string): void { const agent = activeAgents.get(id); if (agent) rememberAgentForFleet(agent); activeAgents.delete(id); updateSubAgentStatus(); } function interruptSubAgentForReload(agent: SubAgent): void { agent.interruptionReason = "reload"; transitionAgentStatus( agent, "interrupted", "interrupted by extension reload", ); notifyAgentCompletion(agent, { triggerTurn: false }); } function terminateSubAgentWithoutNotification( agent: SubAgent, action: string, ): void { clearSubAgentTimeout(agent); clearSubAgentShutdown(agent); transitionAgentStatus(agent, "interrupted", action); agent.completionNotified = true; agent.process.kill(); removeAgentFromTracking(agent.id); } function getFailureMessage(agent: SubAgent): string { if (agent.failureReason === "incomplete_result") { return "The sub-agent response was truncated before completion."; } if (agent.failureReason === "assistant_error") { return agent.lastAssistantError || "The sub-agent failed while responding."; } if (agent.failureReason === "process_error") { return `The sub-agent process exited unexpectedly${agent.exitCode === undefined ? "." : ` with code ${agent.exitCode}.`}`; } return "The sub-agent did not return a usable result."; } function shouldSuggestNarrowerTask(agent: SubAgent): boolean { return ( agent.failureReason === "missing_result" || agent.failureReason === "incomplete_result" || agent.failureReason === "assistant_error" ); } function notifyAgentCompletion( agent: SubAgent, options?: { triggerTurn?: boolean }, ) { if (agent.completionNotified) return; if (!isAgentFinished(agent)) return; const durationSec = Math.max( 0, Math.round(((agent.endTime || Date.now()) - agent.startTime) / 1000), ); const statusEmoji = getSubagentStatusEmoji(agent.status); const statusText = agent.status === "completed" ? "completed" : agent.status === "interrupted" ? "was interrupted" : "errored"; const exitText = agent.exitCode !== undefined ? ` | exit=${agent.exitCode}` : ""; const resultText = agent.completionResult?.trim(); const partialResult = agent.partialResult?.trim(); const resultBlock = resultText ? `\n\n${resultText}` : partialResult ? `\n\n${partialResult}` : ""; const failureBlock = agent.status === "error" ? `\nreason: ${getFailureMessage(agent)}` + (shouldSuggestNarrowerTask(agent) ? "\nSuggestion: If retrying, use a simpler or more narrowly scoped task." : "") : agent.status === "interrupted" && agent.interruptionReason === "reload" ? "\nreason: Extension reload intentionally terminated the child process (SIGTERM, commonly reported as exit 143). Its work may be incomplete; retry if still needed." : ""; sendCompletionMessage?.( `${statusEmoji} Sub-agent ${agent.id} ${statusText} in ${durationSec}s` + ` | [${agent.agentType || "unknown"}] ${agent.taskTitle}${exitText}` + failureBlock + resultBlock, { agentId: agent.id, status: agent.status, taskTitle: agent.taskTitle, agentType: agent.agentType, model: agent.model, thinkingLevel: agent.thinkingLevel, durationSec, exitCode: agent.exitCode, timedOut: !!agent.timeoutNotified, timeoutSeconds: agent.timeoutSeconds, result: resultText, partialResult, failureReason: agent.failureReason, interruptionReason: agent.interruptionReason, finalReportText: resultText || partialResult || "", }, options, ); agent.completionNotified = true; requestProcessShutdown(agent); removeAgentFromTracking(agent.id); } function settleSubAgent(agent: SubAgent, completionAction: string): void { if (isAgentFinished(agent)) return; const toolResult = agent.completionResult?.trim(); if (toolResult) { agent.completionResult = toolResult; transitionAgentStatus(agent, "completed", completionAction); notifyAgentCompletion(agent); return; } const fallbackResult = agent.lastAssistantText?.trim(); const stopReason = agent.lastAssistantStopReason; if (fallbackResult && (!stopReason || stopReason === "stop")) { agent.completionResult = fallbackResult; transitionAgentStatus( agent, "completed", `${completionAction} (text fallback)`, ); notifyAgentCompletion(agent); return; } if (fallbackResult) { agent.partialResult = fallbackResult; } if (stopReason === "length") { agent.failureReason = "incomplete_result"; } else if (stopReason === "error" || stopReason === "aborted") { agent.failureReason = "assistant_error"; } else { agent.failureReason = "missing_result"; } transitionAgentStatus(agent, "error", getFailureMessage(agent)); notifyAgentCompletion(agent); } function extractAssistantMessageText(message: unknown): string { if (!message || typeof message !== "object") return ""; const candidate = message as Record; if (candidate.role !== "assistant" || !Array.isArray(candidate.content)) { return ""; } return candidate.content .filter( (item): item is { type: string; text: string } => !!item && typeof item === "object" && (item as Record).type === "text" && typeof (item as Record).text === "string", ) .map((item) => item.text) .join("") .trim(); } function handleSubAgentEvent( agent: SubAgent, event: Record, ): void { agent.receivedEvent = true; agent.lastActivity = Date.now(); if (event.type === "agent_start") { transitionAgentStatus(agent, "running", "started"); return; } if (event.type === "message_start" && event.message?.role === "assistant") { agent.currentResponsePreview = ""; return; } if (event.type === "message_update" && event.assistantMessageEvent) { const delta = event.assistantMessageEvent; if (delta.type === "text_delta") { const deltaText = typeof delta.delta === "string" ? delta.delta : ""; updateProgressFromTextDelta(agent, deltaText); agent.currentResponsePreview = `${agent.currentResponsePreview}${deltaText}`.slice( -MAX_RESPONSE_PREVIEW_CHARS, ); if (!agent.currentTool && agent.progressPercent === undefined) { agent.lastAction = "💬 responding"; } } return; } if (event.type === "message_end" && event.message?.role === "assistant") { const text = extractAssistantMessageText(event.message); agent.lastAssistantText = text || undefined; agent.lastAssistantStopReason = typeof event.message.stopReason === "string" ? event.message.stopReason : undefined; agent.lastAssistantError = typeof event.message.errorMessage === "string" ? event.message.errorMessage.trim() : undefined; if (text) recordActivity(agent, `💬 ${text}`); const usage = (event.message as { usage?: unknown }).usage; if (usage && typeof usage === "object") { const tokens = calculateContextTokens(usage as never); if (tokens > 0) agent.contextTokens = tokens; } agent.currentResponsePreview = ""; return; } if (event.type === "tool_execution_start") { const toolName = typeof event.toolName === "string" ? event.toolName : "unknown"; agent.currentTool = `${toolName}(${JSON.stringify(event.args).slice(0, 50)}...)`; agent.lastAction = `🔧 ${toolName}`; if (toolName === "subagent_complete") { const result = event.args && typeof event.args.result === "string" ? event.args.result.trim() : ""; agent.pendingCompletionResult = result || undefined; agent.pendingCompletionToolCallId = typeof event.toolCallId === "string" ? event.toolCallId : undefined; recordActivity(agent, "🏁 Completion requested"); } else { recordActivity( agent, `🔧 ${toolName}: ${JSON.stringify(event.args).slice(0, 100)}`, ); } return; } if (event.type === "tool_execution_end") { const toolName = typeof event.toolName === "string" ? event.toolName : undefined; agent.currentTool = undefined; if (toolName === "subagent_complete") { const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : undefined; const matchesPendingCall = !agent.pendingCompletionToolCallId || !toolCallId || agent.pendingCompletionToolCallId === toolCallId; if (matchesPendingCall && event.isError !== true) { agent.completionResult = agent.pendingCompletionResult; agent.lastAction = "✅ completion accepted"; recordActivity(agent, "🏁 Completion accepted"); } else { agent.lastAction = "❌ completion rejected"; recordActivity(agent, "❌ Completion rejected; retry required"); } if (matchesPendingCall) { agent.pendingCompletionResult = undefined; agent.pendingCompletionToolCallId = undefined; } return; } agent.lastAction = toolName ? `✅ ${toolName}` : "tool finished"; return; } if (event.type === "agent_end") { agent.currentTool = undefined; agent.lastAction = event.willRetry ? "automatic retry pending" : "finishing"; return; } if (event.type === "agent_settled") { settleSubAgent(agent, "finished"); return; } if (event.type === "auto_retry_start") { agent.lastAction = `retrying (${event.attempt ?? "?"}/${event.maxAttempts ?? "?"})`; recordActivity(agent, `↻ ${agent.lastAction}`); return; } if (event.type === "response") { const command = typeof event.command === "string" ? event.command : "unknown"; recordActivity( agent, `${event.success === true ? "✅" : "❌"} RPC response (${command})${event.success === true ? "" : " failed"}`, ); } } function spawnSubAgent( task: string, model: string, agentType: string, thinkingLevel: ThinkingLevel, extraContext?: string, timeoutSecondsOverride?: number, fork?: string, cwd = process.cwd(), isDefaultSubagent = false, showDefaultSubagentFooter = false, ): SubAgent { const id = String(nextAgentId++); const args = buildSubAgentArgs(model, thinkingLevel, fork); const childEnv: NodeJS.ProcessEnv = { ...process.env, PI_SUBAGENT_CHILD: "1", }; if (!allowNestedSubagents) { childEnv.PI_SUBAGENT_DISABLE_RECURSION = "1"; } const proc = spawnProcess("pi", args, { cwd, stdio: ["pipe", "pipe", "pipe"], detached: false, env: childEnv, }); const agent: SubAgent = { id, process: proc, task, taskTitle: getTaskTitle(task), agentType, model, thinkingLevel, extraContext, isDefaultSubagent, showDefaultSubagentFooter, status: "starting", activity: [], currentResponsePreview: "", startTime: Date.now(), lastAction: "starting", lastActivity: Date.now(), receivedEvent: false, }; proc.on("error", (error) => { console.error(`Failed to spawn sub-agent ${id}:`, error); agent.failureReason = "process_error"; agent.lastAssistantError = error.message; transitionAgentStatus(agent, "error", error.message); notifyAgentCompletion(agent); }); // Handle stdout (JSON events) let buffer = ""; proc.stdout?.on("data", (data: Buffer) => { buffer += data.toString(); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.trim()) continue; try { handleSubAgentEvent(agent, JSON.parse(line)); } catch { recordActivity(agent, `📄 Unparseable RPC output: ${line}`); } } updateSubAgentStatus(); }); // Handle stderr proc.stderr?.on("data", (data: Buffer) => { const stderrText = data.toString().trim(); if (stderrText) { recordActivity(agent, `stderr: ${stderrText}`); agent.lastAction = `stderr: ${stderrText.slice(0, 60)}`; } agent.lastActivity = Date.now(); }); proc.on("exit", (code, signal) => { clearSubAgentTimeout(agent); clearSubAgentShutdown(agent); agent.processExited = true; agent.exitCode = code ?? undefined; if (!isAgentFinished(agent)) { if ( agent.completionResult || agent.lastAssistantText || (code === 0 && !signal) ) { settleSubAgent(agent, "process exited"); } if (!isAgentFinished(agent)) { agent.failureReason = "process_error"; transitionAgentStatus( agent, "error", `process exited${signal ? ` from ${signal}` : ` with code ${code ?? "unknown"}`}`, ); notifyAgentCompletion(agent); } } else { notifyAgentCompletion(agent); } updateSubAgentStatus(); }); // Send the initial prompt const prompt = JSON.stringify({ type: "prompt", message: formatSubagentPrompt(task, extraContext), }); proc.stdin?.write(prompt + "\n"); activeAgents.set(id, agent); scheduleSubAgentTimeout(agent, timeoutSecondsOverride); updateSubAgentStatus(); return agent; } function getActiveAgentCount(): number { return Array.from(activeAgents.values()).filter( (agent) => !isAgentFinished(agent), ).length; } function getStatusText(): string { const activeCount = getActiveAgentCount(); const countText = maxActiveSubagents ? `${activeCount}/${maxActiveSubagents}` : `${activeCount}`; return `active subagents: ${countText} 🤖 `; } function getSpawnLimitErrorMessage(attemptedCount = 1): string | null { if (!maxActiveSubagents) return null; const activeCount = getActiveAgentCount(); if (activeCount + attemptedCount <= maxActiveSubagents) return null; const remainingSlots = Math.max(0, maxActiveSubagents - activeCount); return ( `Too many active sub-agents (${activeCount}/${maxActiveSubagents}). ` + `Requested ${attemptedCount}, available slots: ${remainingSlots}. ` + "Wait for some sub-agents to finish and try again." ); } function updateProgressFromTextDelta(agent: SubAgent, deltaText: string): void { if (!deltaText) return; const combined = `${agent.progressBuffer || ""}${deltaText}`.slice(-240); agent.progressBuffer = combined; const progressMatches = [ ...combined.matchAll(/\b(\d{1,3})\s*(?:%|percent)\b/gi), ]; const lastMatch = progressMatches[progressMatches.length - 1]; if (!lastMatch) return; const parsed = Number.parseInt(lastMatch[1], 10); if (!Number.isFinite(parsed)) return; const normalized = Math.max(0, Math.min(100, parsed)); agent.progressPercent = normalized; agent.lastAction = `progress ${normalized}%`; } function getBlockedReason(agent: SubAgent, now = Date.now()): string | null { if (agent.status !== "running") return null; if (now - agent.lastActivity < STALE_RUNNING_MS) return null; return "no_recent_activity"; } function buildAgentStatusSnapshot(agent: SubAgent) { const now = Date.now(); const durationSec = agent.endTime ? Math.floor((agent.endTime - agent.startTime) / 1000) : Math.floor((now - agent.startTime) / 1000); const blockedReason = getBlockedReason(agent, now); return { id: agent.id, status: agent.status, thinkingLevel: agent.thinkingLevel, task: agent.task, taskTitle: agent.taskTitle, agentType: agent.agentType || "(unknown)", model: agent.model || "(unknown)", durationSec, currentTool: agent.currentTool, lastAction: agent.lastAction, progressPercent: agent.progressPercent, lastMeaningfulEvent: agent.lastAction || "(none)", lastActivityMsAgo: Math.max(0, now - agent.lastActivity), blockedReason, etaConfidence: agent.status === "running" ? "low" : "high", receivedEvent: agent.receivedEvent, exitCode: agent.exitCode, timeoutSeconds: agent.timeoutSeconds, timeoutAt: agent.timeoutAt, timeoutNotified: agent.timeoutNotified, contextTokens: agent.contextTokens, contextTokensDisplay: formatContextTokens(agent.contextTokens), }; } function buildCompactAgentStatusSnapshot(agent: SubAgent) { const snapshot = buildAgentStatusSnapshot(agent); return { id: snapshot.id, status: snapshot.status, agentType: snapshot.agentType, taskTitle: snapshot.taskTitle, durationSec: snapshot.durationSec, progressPercent: snapshot.progressPercent, blockedReason: snapshot.blockedReason, lastMeaningfulEvent: snapshot.lastMeaningfulEvent, etaConfidence: snapshot.etaConfidence, contextTokens: snapshot.contextTokens, contextTokensDisplay: snapshot.contextTokensDisplay, thinkingLevel: snapshot.thinkingLevel, }; } function buildStatusSummary() { const agents = Array.from(activeAgents.values()); const activeCount = agents.filter( (agent) => agent.status === "starting" || agent.status === "running", ).length; const completedCount = agents.filter( (agent) => agent.status === "completed", ).length; const errorCount = agents.filter((agent) => agent.status === "error").length; const blockedCount = agents.filter( (agent) => !!getBlockedReason(agent), ).length; return { activeCount, queuedCount: 0, completedCount, errorCount, blockedCount, maxActiveSubagents: maxActiveSubagents ?? null, remainingSlots: maxActiveSubagents ? Math.max(0, maxActiveSubagents - activeCount) : null, defaultTimeoutSeconds: defaultTimeoutSeconds ?? null, totalKnownAgents: activeAgents.size, }; } function updateSubAgentStatus() { if (!currentCtx) return; const activeCount = getActiveAgentCount(); if (activeCount === 0) { currentCtx.ui.setStatus("subagent", undefined); return; } currentCtx.ui.setStatus("subagent", getStatusText()); } function killSubAgent(id: string): { ok: boolean; reason?: "not_found" | "already_finished"; } { const agent = activeAgents.get(id); if (!agent) { return { ok: false, reason: "not_found" }; } if (isAgentFinished(agent)) { return { ok: false, reason: "already_finished" }; } terminateSubAgentWithoutNotification(agent, "killed by parent"); return { ok: true }; } function steerSubAgent( id: string, text: string, ): { ok: boolean; reason?: | "not_found" | "already_finished" | "stdin_unavailable" | "empty_message"; } { const trimmed = text.trim(); if (!trimmed) { return { ok: false, reason: "empty_message" }; } const agent = activeAgents.get(id); if (!agent) { return { ok: false, reason: "not_found" }; } if (isAgentFinished(agent)) { return { ok: false, reason: "already_finished" }; } if (!agent.process.stdin || agent.process.stdin.destroyed) { return { ok: false, reason: "stdin_unavailable" }; } const requestId = `steer-${id}-${Date.now()}`; const steer = JSON.stringify({ id: requestId, type: "steer", message: trimmed, }); agent.process.stdin.write(steer + "\n"); recordActivity(agent, `📨 Parent guidance: ${trimmed}`); agent.lastAction = "📨 steer sent"; agent.lastActivity = Date.now(); updateSubAgentStatus(); return { ok: true }; } type SteerDelivery = { agentId: string; sent: boolean; reason?: | "not_found" | "already_finished" | "stdin_unavailable" | "empty_message"; }; type SteerAllSummary = { targeted: number; sent: number; failed: number; deliveries: SteerDelivery[]; }; function steerAllSubAgents(text: string): SteerAllSummary { const deliveries = Array.from(activeAgents.keys()).map((agentId) => { const result = steerSubAgent(agentId, text); return result.ok ? { agentId, sent: true } : { agentId, sent: false, reason: result.reason }; }); const sent = deliveries.filter((delivery) => delivery.sent).length; return { targeted: deliveries.length, sent, failed: deliveries.length - sent, deliveries, }; } function describeSteerFailure(reason: SteerDelivery["reason"]): string { if (reason === "already_finished") return "already finished"; if (reason === "stdin_unavailable") return "cannot receive guidance"; if (reason === "empty_message") return "guidance is empty"; return "not found"; } function formatSteerAllSummary( summary: SteerAllSummary, text?: string, ): string { if (summary.targeted === 0) return "No running sub-agents to steer."; const guidance = text?.trim(); const header = `Sent guidance to ${summary.sent}/${summary.targeted} running sub-agent` + `${summary.targeted === 1 ? "" : "s"}` + (guidance ? `: ${guidance}` : "."); const deliveries = summary.deliveries.map((delivery) => delivery.sent ? `✓ ${delivery.agentId}: sent` : `✗ ${delivery.agentId}: ${describeSteerFailure(delivery.reason)}`, ); return [header, ...deliveries].join("\n"); } function toFleetAgentSummary(agent: SubAgent): FleetAgentSummary { return { id: agent.id, agentType: agent.agentType, model: agent.model, status: agent.status, taskTitle: agent.taskTitle, startTime: agent.startTime, endTime: agent.endTime, currentTool: agent.currentTool, lastAction: agent.lastAction, progressPercent: agent.progressPercent, contextTokens: agent.contextTokens, }; } function toFleetAgentDetail(agent: SubAgent): FleetAgentDetail { return { ...toFleetAgentSummary(agent), task: agent.task, activity: agent.activity, currentResponsePreview: agent.currentResponsePreview, completionResult: agent.completionResult, timeoutSeconds: agent.timeoutSeconds, timeoutAt: agent.timeoutAt, }; } function createFleetDataSource(): FleetDataSource { return { listAgents: () => [ ...Array.from(activeAgents.values()).map(toFleetAgentSummary), ...recentFleetAgents.values(), ].sort((left, right) => right.startTime - left.startTime), getAgent: (id) => { const agent = activeAgents.get(id); return agent ? toFleetAgentDetail(agent) : recentFleetAgents.get(id); }, steer: (id, text) => { const result = steerSubAgent(id, text); if (result.ok) { return { ok: true, message: `Guidance sent to ${id}.` }; } const message = result.reason === "already_finished" ? `${id} already finished.` : result.reason === "stdin_unavailable" ? `${id} cannot receive guidance right now.` : result.reason === "empty_message" ? "Guidance cannot be empty." : `${id} is no longer running.`; return { ok: false, message }; }, remove: (id) => { if (!recentFleetAgents.delete(id)) { return { ok: false, message: `${id} is not a finished sub-agent.` }; } return { ok: true, message: `Removed finished sub-agent ${id}.` }; }, removeAllFinished: () => { const count = recentFleetAgents.size; recentFleetAgents.clear(); return { ok: true, message: `Removed ${count} finished sub-agent${count === 1 ? "" : "s"}.`, }; }, stop: (id) => { const result = killSubAgent(id); if (!result.ok) { return { ok: false, message: `${id} is no longer running.` }; } return { ok: true, message: `Stopped sub-agent ${id}.` }; }, stopAllRunning: () => { let count = 0; for (const id of Array.from(activeAgents.keys())) { if (killSubAgent(id).ok) count++; } return { ok: true, message: `Stopped ${count} running sub-agent${count === 1 ? "" : "s"}.`, }; }, }; } type MessageContent = string | Array<{ type: string; text?: string }>; type SubagentSpawnPresentation = { agentId?: string; agentType?: string; task?: string; taskTitle?: string; timeoutSeconds?: number; defaultSubagent?: boolean; showDefaultSubagentFooter?: boolean; }; function buildSubagentSpawnDetails(agent: SubAgent): SubagentSpawnPresentation { return { agentId: agent.id, agentType: agent.agentType, task: agent.task, taskTitle: agent.taskTitle, timeoutSeconds: agent.timeoutSeconds, defaultSubagent: agent.isDefaultSubagent, showDefaultSubagentFooter: agent.showDefaultSubagentFooter, }; } function formatSubagentTimeout(timeoutSeconds?: number): string { return timeoutSeconds ? `${timeoutSeconds}s` : "(none)"; } const DEFAULT_SUBAGENT_FOOTER = "You're using the default sub-agent.\n\n" + "To set up custom sub-agents, see https://github.com/arcanemachine/pi-subagent#quick-start"; function getDefaultSubagentFooter( details: SubagentSpawnPresentation, ): string | undefined { return details.defaultSubagent && details.showDefaultSubagentFooter ? DEFAULT_SUBAGENT_FOOTER : undefined; } function formatSubagentSpawnMessage(agent: SubAgent): string { return [ SUBAGENT_PARENT_GUIDANCE, "", "🤖🚀 Sub-agent spawned", "", `Agent ID: ${agent.id}`, `Agent type: ${agent.agentType || "(unknown)"}`, `Timeout: ${formatSubagentTimeout(agent.timeoutSeconds)}`, "", "Task:", agent.task, ].join("\n"); } function getSubagentSpawnText(content: MessageContent): string { if (typeof content === "string") return content; return content .filter((item) => item.type === "text") .map((item) => item.text || "") .join("\n"); } function getSubagentSpawnTaskTitle(details: SubagentSpawnPresentation): string { return details.taskTitle || getTaskTitle(details.task || ""); } function renderCompactSubagentSpawn( details: SubagentSpawnPresentation, theme: Theme, textColor: ThemeColor, ): Component { const title = getSubagentSpawnTaskTitle(details); const status = `🤖🚀 Sub-agent ${details.agentId || "?"} spawned`; const metadata = ` | [${details.agentType || "unknown"}] ${title}`; const text = theme.fg("accent", status) + theme.fg(textColor, metadata); return { render(width: number): string[] { const lines = [truncateToWidth(text, width, "...")]; const footer = getDefaultSubagentFooter(details); if (footer) { lines.push( "", ...footer .split("\n") .map((line) => truncateToWidth(theme.fg("muted", line), width, "..."), ), ); } return lines; }, invalidate() {}, }; } function renderExpandedSubagentSpawn( details: SubagentSpawnPresentation, theme: Theme, textColor: ThemeColor, ): Component { const container = new Container(); container.addChild( new Text(theme.fg("accent", theme.bold("🤖🚀 Sub-agent spawned")), 0, 0), ); container.addChild(new Spacer(1)); container.addChild( new Text(theme.fg(textColor, `Agent ID: ${details.agentId || "?"}`), 0, 0), ); container.addChild( new Text( theme.fg(textColor, `Agent type: ${details.agentType || "(unknown)"}`), 0, 0, ), ); container.addChild( new Text( theme.fg( textColor, `Timeout: ${formatSubagentTimeout(details.timeoutSeconds)}`, ), 0, 0, ), ); container.addChild(new Spacer(1)); container.addChild( new Text(theme.fg("toolTitle", theme.bold("Task:")), 0, 0), ); container.addChild( new Markdown(details.task || "(empty task)", 0, 0, getMarkdownTheme(), { color: (text: string) => theme.fg(textColor, text), }), ); const footer = getDefaultSubagentFooter(details); if (footer) { container.addChild(new Spacer(1)); container.addChild(new Text(theme.fg("muted", footer), 0, 0)); } return container; } function renderSubagentSpawnContent( details: SubagentSpawnPresentation, expanded: boolean, theme: Theme, textColor: ThemeColor, ): Component { return expanded ? renderExpandedSubagentSpawn(details, theme, textColor) : renderCompactSubagentSpawn(details, theme, textColor); } function renderSubagentSpawnMessage( details: SubagentSpawnPresentation, expanded: boolean, theme: Theme, ): Component { const box = new Box(1, 1, (text: string) => theme.bg("customMessageBg", text), ); box.addChild( renderSubagentSpawnContent(details, expanded, theme, "customMessageText"), ); return box; } /** * Render a sub-agent custom message as a Ctrl+O-collapsible block. * Collapsed shows a concise one-liner; expanded shows the full message content. * Styling mirrors Pi's built-in custom-message component (customMessageBg + * customMessageText) so collapsed/expanded blocks match other custom messages. */ function renderSubagentMessage( content: MessageContent, expanded: boolean, collapsedLine: string, theme: Theme, ): Component { const box = new Box(1, 1, (t: string) => theme.bg("customMessageBg", t)); if (expanded) { const text = getSubagentSpawnText(content); box.addChild( new Markdown(text, 0, 0, getMarkdownTheme(), { color: (t: string) => theme.fg("customMessageText", t), }), ); } else { box.addChild(new Text(theme.fg("customMessageText", collapsedLine), 0, 0)); } return box; } type SubagentCompleteDetails = { agentId?: string; status?: "completed" | "error" | "interrupted" | string; taskTitle?: string; agentType?: string; thinkingLevel?: ThinkingLevel; durationSec?: number; }; type SubagentSpawnedDetails = SubagentSpawnPresentation & { thinkingLevel?: ThinkingLevel; }; type SubagentSteerDetails = { all?: boolean; agentId?: string; message?: string; guidance?: string; targeted?: number; sent?: number | boolean; failed?: number; deliveries?: SteerDelivery[]; reason?: SteerDelivery["reason"]; }; function getSubagentSteerGuidance( details: SubagentSteerDetails | undefined, ): string { return (details?.guidance ?? details?.message ?? "").trim(); } function formatSubagentSteerHeader( details: SubagentSteerDetails | undefined, ): string { const guidance = getSubagentSteerGuidance(details); if ( details?.all && details.targeted !== undefined && typeof details.sent === "number" ) { const header = `Sent guidance to ${details.sent}/${details.targeted} running sub-agent` + `${details.targeted === 1 ? "" : "s"}`; return header + (guidance ? `: ${guidance}` : "."); } if (details?.sent === false && details.agentId) { const reason = describeSteerFailure(details.reason); return `Failed to send guidance to sub-agent ${details.agentId}: ${reason}`; } if (details?.agentId) { return `Sent guidance to sub-agent ${details.agentId}: ${guidance}`; } return guidance ? `Sent guidance: ${guidance}` : "Guidance sent."; } function getSubagentSteerText(result: AgentToolResult): string { return getSubagentSpawnText(result.content as MessageContent); } function renderCompactSubagentSteer( details: SubagentSteerDetails | undefined, theme: Theme, isError: boolean, ): Component { const guidance = getSubagentSteerGuidance(details).replace(/[\r\n]+/g, " "); const count = details?.targeted !== undefined && typeof details.sent === "number" ? ` (${details.sent}/${details.targeted})` : ""; const status = isError ? ` 🤖🛞 Guidance failed${guidance ? `: ${guidance}` : ""}` : ` 🤖🛞 Sent guidance${count}${guidance ? `: ${guidance}` : ""}`; const text = theme.fg("toolTitle", theme.bold("subagent_steer")) + theme.fg(isError ? "error" : "toolOutput", status); return { render(width: number): string[] { return [truncateToWidth(text, width, "...")]; }, invalidate() {}, }; } function renderExpandedSubagentSteer( result: AgentToolResult, details: SubagentSteerDetails | undefined, theme: Theme, isError: boolean, ): Component { const content = getSubagentSteerText(result); const header = formatSubagentSteerHeader(details); const body = content.startsWith(header) ? content.slice(header.length).replace(/^\n+/, "") : content; const text = body ? `${header}\n\n${body}` : header; return new Text( `\n${theme.fg(isError ? "error" : "toolOutput", text)}`, 0, 0, ); } function renderSubagentSteerResult( result: AgentToolResult, expanded: boolean, theme: Theme, isError: boolean, ): Component { const details = result.details as SubagentSteerDetails | undefined; return expanded ? renderExpandedSubagentSteer(result, details, theme, isError) : renderCompactSubagentSteer(details, theme, isError); } function getCompletionStatus( details: SubagentCompleteDetails | undefined, ): "completed" | "error" | "interrupted" { if (details?.status === "error") return "error"; if (details?.status === "interrupted") return "interrupted"; return "completed"; } function getCompletionStatusColor( status: "completed" | "error" | "interrupted", ): ThemeColor { if (status === "error") return "error"; if (status === "interrupted") return "warning"; return "success"; } function renderSubagentCompletionMessage( content: MessageContent, expanded: boolean, collapsedLine: string, details: SubagentCompleteDetails | undefined, theme: Theme, ): Component { const status = getCompletionStatus(details); const statusColor = getCompletionStatusColor(status); const background = status === "error" ? "toolErrorBg" : "customMessageBg"; const box = new Box(1, 1, (text: string) => theme.bg(background, text)); if (!expanded) { box.addChild(new Text(theme.fg(statusColor, collapsedLine), 0, 0)); return box; } const text = getSubagentSpawnText(content); const firstNewline = text.indexOf("\n"); const header = firstNewline < 0 ? text : text.slice(0, firstNewline); const body = firstNewline < 0 ? "" : text.slice(firstNewline + 1).trimStart(); box.addChild(new Text(theme.fg(statusColor, header), 0, 0)); if (body) { box.addChild(new Spacer(1)); box.addChild( new Markdown(body, 0, 0, getMarkdownTheme(), { color: (value: string) => theme.fg("customMessageText", value), }), ); } return box; } export default function (pi: ExtensionAPI) { if (isTruthyEnv(process.env.PI_SUBAGENT_CHILD)) { pi.registerTool({ name: "subagent_complete", label: "Complete Sub-Agent Task", description: "Submit the complete final deliverable to the parent and finish this child sub-agent. " + "Call this when the task is complete or blocked. If it returns an error, correct the result and retry.", parameters: { type: "object", properties: { result: { type: "string", description: "Complete answer or deliverable for the parent, using Markdown when useful", }, }, required: ["result"], } as any, async execute( toolCallId, params: { result: string }, signal, onUpdate, ctx, ) { const result = typeof params.result === "string" ? params.result.trim() : ""; if (!result) { throw new Error("A non-empty result is required before completing."); } ctx.shutdown(); return { content: [ { type: "text", text: "Result submitted. The child sub-agent will now exit.", }, ], details: { completed: true }, terminate: true, }; }, }); } if (isTruthyEnv(process.env.PI_SUBAGENT_DISABLE_RECURSION)) { return; } let fleetOpen = false; const fleetDataSource = createFleetDataSource(); sendCompletionMessage = ( content: string, details?: Record, options?: { triggerTurn?: boolean }, ) => { const shouldTriggerTurn = options?.triggerTurn !== false && !!currentCtx && currentCtx.isIdle() && !currentCtx.hasPendingMessages(); pi.sendMessage( { customType: "subagent-complete", content, display: true, details, }, shouldTriggerTurn ? { triggerTurn: true, deliverAs: "followUp", } : undefined, ); }; // Collapsible rendering for sub-agent messages. Collapsed shows a concise // one-liner; expanded shows the full content. Toggled by Pi's global // Ctrl+O (app.tools.expand), the same control used for tool output. pi.registerMessageRenderer( "subagent-complete", (message, { expanded }, theme) => { const details = (message.details ?? undefined) as | SubagentCompleteDetails | undefined; const isError = details?.status === "error"; const isInterrupted = details?.status === "interrupted"; const statusText = isInterrupted ? "was interrupted" : isError ? "errored" : "completed"; const status = isInterrupted ? "interrupted" : isError ? "error" : "completed"; const emoji = getSubagentStatusEmoji(status); const duration = typeof details?.durationSec === "number" ? `${details.durationSec}s` : "?"; const agentType = details?.agentType || "unknown"; const taskTitle = details?.taskTitle || "(untitled task)"; const collapsed = `${emoji} Sub-agent ${details?.agentId ?? "?"} ${statusText} in ${duration} | [${agentType}] ${taskTitle}`; return renderSubagentCompletionMessage( message.content, expanded, collapsed, details, theme, ); }, ); pi.registerMessageRenderer( "subagent-spawned", (message, { expanded }, theme) => { const details = (message.details ?? undefined) as | SubagentSpawnedDetails | undefined; if (!details || !Object.prototype.hasOwnProperty.call(details, "task")) { const agentType = details?.agentType || "unknown"; const taskTitle = details?.taskTitle || "(untitled task)"; const collapsed = `🤖🚀 Sub-agent ${details?.agentId ?? "?"} spawned | [${agentType}] ${taskTitle}`; return renderSubagentMessage( message.content, expanded, collapsed, theme, ); } return renderSubagentSpawnMessage(details, expanded, theme); }, ); // Register /subagent command pi.registerCommand("subagent", { description: "Spawn and manage sub-agents", getArgumentCompletions: (prefix: string) => { refreshConfiguredAgents( currentCtx?.cwd ?? process.cwd(), projectSettingsAreTrusted(currentCtx), ); const baseItems = [ { value: "fleet", label: "fleet — Open the live sub-agent window" }, { value: "kill", label: "kill — Kill a specific sub-agent" }, { value: "killall", label: "killall — Kill all sub-agents" }, { value: "steer", label: "steer — Send guidance to running sub-agents", }, ]; const spawnItems = getConfiguredAgentEntries(currentCtx).map( ({ name: agentType, profile }) => ({ value: `spawn:${agentType}`, label: `spawn:${agentType} — ` + (profile.when_to_use || `Uses ${profile.model}`), }), ); const commandPrefix = prefix.trimStart(); if (commandPrefix.includes(" ")) { return null; } const items = [...spawnItems, ...baseItems]; if ("spawn:".startsWith(commandPrefix)) { items.unshift({ value: "spawn:", label: "spawn: " }); } return items.filter((i) => i.value.startsWith(commandPrefix)); }, handler: async (args: string, ctx) => { refreshConfiguredAgents(ctx.cwd, projectSettingsAreTrusted(ctx)); const trimmedArgs = args.trim() || "fleet"; const [subcommand, ...rest] = trimmedArgs.split(/\s+/); const subArgs = rest.join(" "); if (subcommand.startsWith("spawn:")) { const agentType = subcommand.slice("spawn:".length).trim(); if (!agentType || !subArgs) { ctx.ui.notify( "Usage: /subagent spawn: [timeout:] ", "error", ); return; } let taskText = subArgs; let manualTimeoutSeconds: number | undefined; const timeoutMatch = subArgs.match(/^timeout:(\S+)\s+([\s\S]+)$/i); if (timeoutMatch) { const parsedTimeout = normalizeManualTimeoutSeconds( Number(timeoutMatch[1]), ); if (!parsedTimeout) { ctx.ui.notify( `Invalid timeout '${timeoutMatch[1]}'. Use a positive integer in seconds.`, "error", ); return; } manualTimeoutSeconds = parsedTimeout; taskText = timeoutMatch[2]; } try { const limitError = getSpawnLimitErrorMessage(1); if (limitError) { ctx.ui.notify(limitError, "error"); return; } const resolved = resolveSubagentProfile(agentType, ctx); const profile = resolved.profile; const thinkingLevel = resolveThinkingLevel( profile, pi.getThinkingLevel(), ); const agent = spawnSubAgent( taskText, profile.model, agentType, thinkingLevel, profile.extra_context, manualTimeoutSeconds, profile.fork, ctx.cwd, resolved.defaultSubagent, resolved.defaultSubagent && !hideDefaultSubagentFooter, ); ctx.ui.notify(`Spawned sub-agent ${agent.id}`, "info"); // Send a message to the conversation showing what was spawned pi.sendMessage({ customType: "subagent-spawned", content: formatSubagentSpawnMessage(agent), display: true, details: { ...buildSubagentSpawnDetails(agent), thinkingLevel: agent.thinkingLevel, }, }); } catch (error: unknown) { ctx.ui.notify( error instanceof Error ? error.message : String(error), "error", ); } return; } switch (subcommand) { case "fleet": { if (!ctx.hasUI) { return; } if (fleetOpen) { ctx.ui.notify("The sub-agent window is already open.", "info"); return; } fleetOpen = true; try { await openSubagentFleet(ctx, fleetDataSource); } finally { fleetOpen = false; } return; } case "steer": { const targetId = rest[0]; const text = rest.slice(1).join(" "); if (!targetId || !text.trim()) { ctx.ui.notify("Usage: /subagent steer ", "error"); return; } if (targetId === "all") { const summary = steerAllSubAgents(text); ctx.ui.notify( formatSteerAllSummary(summary), summary.failed > 0 || summary.targeted === 0 ? "warning" : "info", ); return; } const result = steerSubAgent(targetId, text); if (result.ok) { ctx.ui.notify(`Sent guidance to sub-agent ${targetId}`, "info"); return; } if (result.reason === "already_finished") { ctx.ui.notify( `Sub-agent ${targetId} already finished. Start a new one to continue.`, "warning", ); return; } if (result.reason === "stdin_unavailable") { ctx.ui.notify( `Sub-agent ${targetId} cannot receive messages right now.`, "error", ); return; } if (result.reason === "empty_message") { ctx.ui.notify("Message text cannot be empty", "error"); return; } ctx.ui.notify(`Sub-agent ${targetId} not found`, "error"); return; } case "kill": if (!subArgs) { ctx.ui.notify("Usage: /subagent kill ", "error"); return; } const result = killSubAgent(subArgs); if (result.ok) { ctx.ui.notify(`Killed sub-agent ${subArgs}`, "info"); } else if (result.reason === "already_finished") { ctx.ui.notify( `Sub-agent ${subArgs} already finished and has already been removed from active status.`, "warning", ); } else { ctx.ui.notify(`Sub-agent ${subArgs} not found`, "error"); } break; case "killall": for (const [id] of activeAgents) { killSubAgent(id); } ctx.ui.notify("Killed all sub-agents", "info"); break; default: ctx.ui.notify( "Usage: /subagent spawn: [timeout:] | fleet|steer|kill|killall", "error", ); } }, }); // Tool: Spawn a sub-agent and immediately show it in conversation pi.registerTool({ name: "subagent_spawn", label: "Spawn Sub-Agent", description: "Spawn an available sub-agent type to work in parallel. Do not assume any particular type exists; use `subagent_list_types` when availability has not been established.", promptGuidelines: [ "Before the first spawn in a project or session, call `subagent_list_types` to establish the available types. Reuse that result unless the configuration or project context may have changed.", "Sub-agents complete asynchronously and notify automatically; never poll for progress or completion by any means, including status checks, sleep commands, or wait loops.", ], parameters: { type: "object", properties: { task: { type: "string", description: "Self-contained, specific assignment stating the expected deliverable.", }, agent: { type: "string", description: "Available agent type name established by `subagent_list_types` or explicitly provided by the user.", }, timeout_seconds: { type: "number", description: "Optional timeout in seconds for this run.", }, }, required: ["task", "agent"], } as any, async execute( toolCallId, params: { task: string; agent: string; timeout_seconds?: number }, signal, onUpdate, ctx, ) { refreshConfiguredAgents(ctx.cwd, projectSettingsAreTrusted(ctx)); const limitError = getSpawnLimitErrorMessage(1); if (limitError) { return { content: [{ type: "text", text: limitError }], isError: true, details: { rejected: true, reason: "max_active_subagents_reached", active: getActiveAgentCount(), maxActive: maxActiveSubagents, }, }; } const manualTimeoutSeconds = normalizeManualTimeoutSeconds( params.timeout_seconds, ); if (params.timeout_seconds !== undefined && !manualTimeoutSeconds) { return { content: [ { type: "text", text: "Invalid timeout_seconds. Use a positive integer in seconds.", }, ], isError: true, details: { rejected: true, reason: "invalid_timeout_seconds", timeoutSeconds: params.timeout_seconds, }, }; } const resolved = resolveSubagentProfile(params.agent, ctx); const profile = resolved.profile; const thinkingLevel = resolveThinkingLevel( profile, pi.getThinkingLevel(), ); const agent = spawnSubAgent( params.task, profile.model, params.agent, thinkingLevel, profile.extra_context, manualTimeoutSeconds, profile.fork, ctx.cwd, resolved.defaultSubagent, resolved.defaultSubagent && !hideDefaultSubagentFooter, ); return { content: [{ type: "text", text: formatSubagentSpawnMessage(agent) }], details: { ...buildSubagentSpawnDetails(agent), model: agent.model, thinkingLevel: agent.thinkingLevel, }, }; }, renderCall() { return new Container(); }, renderResult( result: AgentToolResult, { expanded, isPartial }: ToolRenderResultOptions, theme: Theme, context: { isError: boolean }, ) { if (isPartial) { return new Text(theme.fg("muted", "🤖🚀 Starting sub-agent..."), 0, 0); } const details = result.details as SubagentSpawnPresentation | undefined; if (!details || !Object.prototype.hasOwnProperty.call(details, "task")) { const text = getSubagentSpawnText(result.content as MessageContent); const color: ThemeColor = context.isError ? "error" : "toolOutput"; return new Text(theme.fg(color, text || "(no output)"), 0, 0); } return renderSubagentSpawnContent(details, expanded, theme, "toolOutput"); }, }); // Tool: Get live structured status for one or all sub-agents pi.registerTool({ name: "subagent_status", label: "Sub-Agent Status", description: "Get status for one sub-agent, or all when agent_id is omitted.", promptGuidelines: [ "Sub-agents complete asynchronously and notify automatically; never poll for progress or completion by any means, including status checks, sleep commands, or wait loops.", ], parameters: { type: "object", properties: { agent_id: { type: "string", description: "Sub-agent ID; omit for all", }, }, required: [], } as any, async execute( toolCallId, params: { agent_id?: string }, signal, onUpdate, ctx, ) { if (params.agent_id) { const agent = activeAgents.get(params.agent_id); if (!agent) { const notFound = { found: false, error: { code: "not_found", message: `Sub-agent ${params.agent_id} not found`, agentId: params.agent_id, }, }; return { content: [ { type: "text", text: JSON.stringify(notFound, null, 2), }, ], isError: true, details: notFound, }; } const targeted = { found: true, agent: buildAgentStatusSnapshot(agent), }; return { content: [ { type: "text", text: JSON.stringify(targeted, null, 2), }, ], details: targeted, }; } const status = { summary: buildStatusSummary(), agents: Array.from(activeAgents.values()).map((agent) => buildCompactAgentStatusSnapshot(agent), ), }; return { content: [ { type: "text", text: JSON.stringify(status, null, 2), }, ], details: status, }; }, }); // Tool: Steer a running sub-agent pi.registerTool({ name: "subagent_steer", label: "Steer Sub-Agent", description: "Guide one running sub-agent, or use `all`; prefer steering over replacing an agent still making progress.", parameters: { type: "object", properties: { agent_id: { type: "string", description: "Sub-agent ID, or `all`", }, text: { type: "string", description: "Guidance to send", }, }, required: ["agent_id", "text"], } as any, renderCall(_args, theme, context) { if (!context.expanded) return new Text("", 0, 0); return new Text( theme.fg("toolTitle", theme.bold("subagent_steer")), 0, 0, ); }, renderResult(result, { expanded, isPartial }, theme, context) { if (isPartial) { return new Text(theme.fg("muted", "🤖🛞 Steering..."), 0, 0); } return renderSubagentSteerResult( result, expanded, theme, context.isError, ); }, async execute( toolCallId, params: { agent_id: string; text: string }, signal, onUpdate, ctx, ) { if (params.agent_id === "all") { const summary = steerAllSubAgents(params.text); return { content: [ { type: "text", text: formatSteerAllSummary(summary, params.text), }, ], isError: summary.targeted === 0 || summary.sent === 0, details: { all: true, ...summary, guidance: params.text.trim(), }, }; } const result = steerSubAgent(params.agent_id, params.text); if (!result.ok) { const message = result.reason === "already_finished" ? `Sub-agent ${params.agent_id} already finished. Start a new one to continue.` : result.reason === "stdin_unavailable" ? `Sub-agent ${params.agent_id} cannot receive messages right now.` : result.reason === "empty_message" ? "Message text cannot be empty" : `Sub-agent ${params.agent_id} not found`; return { content: [ { type: "text", text: message, }, ], isError: true, details: { sent: false, reason: result.reason, agentId: params.agent_id, guidance: params.text.trim(), }, }; } const sentText = params.text.trim(); return { content: [ { type: "text", text: `Sent guidance to sub-agent ${params.agent_id}: ${sentText}`, }, ], details: { sent: true, agentId: params.agent_id, message: sentText, guidance: sentText, }, }; }, }); // Tool: Kill a specific sub-agent pi.registerTool({ name: "subagent_kill", label: "Kill Sub-Agent", description: "Kill a running sub-agent by ID.", parameters: { type: "object", properties: { agent_id: { type: "string", description: "Sub-agent ID", }, }, required: ["agent_id"], } as any, async execute( toolCallId, params: { agent_id: string }, signal, onUpdate, ctx, ) { const result = killSubAgent(params.agent_id); if (!result.ok) { const message = result.reason === "already_finished" ? `Sub-agent ${params.agent_id} already finished. Use prune if you want to remove it from tracking.` : `Sub-agent ${params.agent_id} not found`; return { content: [ { type: "text", text: message, }, ], isError: true, details: { killed: false, reason: result.reason, agentId: params.agent_id, }, }; } return { content: [ { type: "text", text: `Killed sub-agent ${params.agent_id}`, }, ], details: { killed: true, agentId: params.agent_id, }, }; }, }); // Tool: List configured sub-agent types from settings pi.registerTool({ name: "subagent_list_types", label: "List Sub-Agent Types", description: "List currently available sub-agent types with model and usage metadata. Use before the first spawn or when configuration may have changed.", parameters: { type: "object", properties: {}, required: [], } as any, async execute( toolCallId, params: Record, signal, onUpdate, ctx, ) { const entries = getConfiguredAgentEntries(ctx); if (entries.length === 0) { return { content: [ { type: "text", text: "No valid sub-agent types are configured. Check `pi-subagent.agents` in settings.", }, ], isError: true, details: { agents: [] }, }; } const lines = entries.map(({ name, profile }) => { const whenToUse = profile.when_to_use || "(not provided)"; const thinkingLevel = profile.thinking_level || "inherit"; return `- ${name}\n model: ${profile.model}\n thinking_level: ${thinkingLevel}\n when_to_use: ${whenToUse}`; }); return { content: [ { type: "text", text: `Available sub-agent types:\n${lines.join("\n")}`, }, ], details: { agents: entries.map(({ name, profile }) => ({ name, model: profile.model, thinkingLevel: profile.thinking_level || "inherit", whenToUse: profile.when_to_use, })), }, }; }, }); // Clean up on shutdown. Reloads stay in the same conversation, so surface // that interruption without triggering a new turn. Other shutdown reasons // discard or replace the current session and should not emit completions. pi.on("session_shutdown", async (event) => { for (const agent of Array.from(activeAgents.values())) { if (event.reason === "reload") { interruptSubAgentForReload(agent); clearSubAgentShutdown(agent); agent.process.kill(); } else { terminateSubAgentWithoutNotification( agent, `interrupted by session ${event.reason}`, ); } } activeAgents.clear(); }); // Set up status on session start pi.on("session_start", async (_event, ctx) => { currentCtx = ctx; refreshConfiguredAgents(ctx.cwd, projectSettingsAreTrusted(ctx)); updateSubAgentStatus(); }); // Clear subagents when a new session is created (/new command) // Use session_before_switch to clean up in the OLD session before switching pi.on("session_before_switch", async (event) => { if (event.reason === "new") { // Kill any remaining processes and clear the list for (const agent of Array.from(activeAgents.values())) { terminateSubAgentWithoutNotification(agent, "interrupted by /new"); } activeAgents.clear(); recentFleetAgents.clear(); updateSubAgentStatus(); } }); } export const __test = { resetState() { for (const [, agent] of activeAgents) { clearSubAgentTimeout(agent); clearSubAgentShutdown(agent); } activeAgents.clear(); recentFleetAgents.clear(); nextAgentId = 1; configuredAgents = {}; hasAgentConfiguration = false; hideDefaultSubagentFooter = false; maxActiveSubagents = undefined; defaultTimeoutSeconds = 180; allowNestedSubagents = false; sendCompletionMessage = null; spawnProcess = spawn; }, extractAssistantMessageText, handleSubAgentEvent, settleSubAgent, recordActivity, interruptSubAgentForReload, terminateSubAgentWithoutNotification, formatContextTokens, normalizeThinkingLevel, resolveThinkingLevel, deepMergeRecords, getPiSubagentSettings, buildSubAgentArgs, getStatusText, setDefaultTimeoutSeconds(seconds: number | undefined) { defaultTimeoutSeconds = seconds; }, setMaxActiveSubagents(max: number | undefined) { maxActiveSubagents = max; }, setTimeoutEscalationDelayMs(delayMs: number) { timeoutEscalationDelayMs = delayMs; }, setCompletionSender( sender: | (( content: string, details?: Record, options?: { triggerTurn?: boolean }, ) => void) | null, ) { sendCompletionMessage = sender; }, addMockAgent(id: string, overrides: Partial = {}) { const noop = () => {}; const mockProcess = { kill: noop, stdin: { destroyed: false, write: noop, end: noop, }, } as unknown as ChildProcess; const agent: SubAgent = { id, process: mockProcess, task: "test task", taskTitle: "test task", thinkingLevel: "off", status: "running", activity: [], currentResponsePreview: "", startTime: Date.now(), lastActivity: Date.now(), receivedEvent: true, ...overrides, }; activeAgents.set(id, agent); return agent; }, scheduleSubAgentTimeout, notifyAgentCompletion, createFleetDataSource, setSpawnProcess(processSpawner: typeof spawn) { spawnProcess = processSpawner; }, };