/** * Extension loaded into sub-agents. * - Shows agent identity + available tools as a styled widget above the editor (toggle with Ctrl+J) * - Provides a `subagent_done` tool for autonomous agents to self-terminate */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Box, Text } from "@earendil-works/pi-tui"; import { Type } from "@sinclair/typebox"; import { writeFileSync } from "node:fs"; import { createSubagentActivityRecorder } from "./activity.ts"; export function shouldMarkUserTookOver(agentStarted: boolean): boolean { return agentStarted; } export function shouldAutoExitOnAgentEnd( _userTookOver: boolean, messages: any[] | undefined, ): boolean { // Manual input should not strand an auto-exit subagent. If the latest agent // turn completed normally, close the session. Escape/abort still leaves it // open for inspection or another prompt. // // stopReason: "error" (e.g. exhausted retries on a provider overload) also // returns true — we want to shut down so the parent is woken up — but we // pair this with findLatestAssistantError() so the parent learns it was an // error, not a clean completion. if (messages) { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg?.role === "assistant") { return msg.stopReason !== "aborted"; } } } return true; } export interface SubagentErrorInfo { errorMessage: string; stopReason: "error"; } /** * If the last assistant message in the turn ended with `stopReason: "error"` * (typically auto-retry exhausted on an overload / rate limit / server error), * return its error info so the parent orchestrator can surface a clear * failure instead of silently treating the run as completed. * * Returns `null` when the latest assistant turn completed normally or was * aborted by the user (handled separately by shouldAutoExitOnAgentEnd). */ export function findLatestAssistantError( messages: any[] | undefined, ): SubagentErrorInfo | null { if (!messages) return null; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg?.role !== "assistant") continue; if (msg.stopReason !== "error") return null; const raw = typeof msg.errorMessage === "string" ? msg.errorMessage.trim() : ""; return { errorMessage: raw || "Subagent agent loop ended with stopReason=error (no errorMessage field).", stopReason: "error", }; } return null; } export function buildCompletionSidecar(messages: any[] | undefined): | { type: "done" } | { type: "error"; errorMessage: string; stopReason: "error" } { const errorInfo = findLatestAssistantError(messages); return errorInfo ? { type: "error", ...errorInfo } : { type: "done" }; } export function parseDeniedTools(rawValue: string | undefined): string[] { return (rawValue ?? "") .split(",") .map((value) => value.trim()) .filter(Boolean); } export default function (pi: ExtensionAPI) { let toolNames: string[] = []; let denied: string[] = []; let expanded = false; // Read subagent identity from env vars (set by parent orchestrator) const subagentName = process.env.PI_SUBAGENT_NAME ?? ""; const subagentAgent = process.env.PI_SUBAGENT_AGENT ?? ""; const deniedToolsValue = process.env.PI_DENY_TOOLS; const autoExit = process.env.PI_SUBAGENT_AUTO_EXIT === "1"; const recorder = createSubagentActivityRecorder({ runningChildId: process.env.PI_SUBAGENT_ID, activityFile: process.env.PI_SUBAGENT_ACTIVITY_FILE, }); function renderWidget(ctx: { ui: { setWidget: Function } }, _theme: any) { ctx.ui.setWidget( "subagent-tools", (_tui: any, theme: any) => { const box = new Box(1, 0, (text: string) => theme.bg("toolSuccessBg", text)); const label = subagentAgent || subagentName; const agentTag = label ? theme.bold(theme.fg("accent", `[${label}]`)) : ""; if (expanded) { // Expanded: full tool list + denied const countInfo = theme.fg("dim", ` — ${toolNames.length} available`); const hint = theme.fg("muted", " (Ctrl+J to collapse)"); const toolList = toolNames .map((name: string) => theme.fg("dim", name)) .join(theme.fg("muted", ", ")); let deniedLine = ""; if (denied.length > 0) { const deniedList = denied .map((name: string) => theme.fg("error", name)) .join(theme.fg("muted", ", ")); deniedLine = "\n" + theme.fg("muted", "denied: ") + deniedList; } const content = new Text( `${agentTag}${countInfo}${hint}\n${toolList}${deniedLine}`, 0, 0, ); box.addChild(content); } else { // Collapsed: one-line summary const countInfo = theme.fg("dim", ` — ${toolNames.length} tools`); const deniedInfo = denied.length > 0 ? theme.fg("dim", " · ") + theme.fg("error", `${denied.length} denied`) : ""; const hint = theme.fg("muted", " (Ctrl+J to expand)"); const content = new Text(`${agentTag}${countInfo}${deniedInfo}${hint}`, 0, 0); box.addChild(content); } return box; }, { placement: "aboveEditor" }, ); } let userTookOver = false; let agentStarted = false; // Show widget + status bar on session start pi.on("session_start", (_event, ctx) => { recorder.sessionStart(); const tools = pi.getAllTools(); toolNames = tools.map((t) => t.name).sort(); denied = parseDeniedTools(deniedToolsValue); renderWidget(ctx, null); }); pi.on("input", () => { recorder.input(); // Ignore the initial task message that starts an autonomous subagent. // Only inputs after the first agent run has started count as user takeover. if (!shouldMarkUserTookOver(agentStarted)) return; userTookOver = true; }); pi.on("before_agent_start", () => { recorder.beforeAgentStart(); }); pi.on("agent_start", () => { agentStarted = true; recorder.agentStart(); }); pi.on("agent_end", (event, ctx) => { const messages = (event as any).messages as any[] | undefined; const shouldExit = autoExit && shouldAutoExitOnAgentEnd(userTookOver, messages); if (shouldExit) { // Surface stopReason: "error" turns (auto-retry exhausted, provider // overload, etc.) to the parent via the .exit sidecar so the watcher // can report a clear failure with the underlying error message. // Without this the parent would only see exit code 0 and a stale // assistant message, mistaking the crash for a successful completion. const sessionFile = process.env.PI_SUBAGENT_SESSION; if (sessionFile) { try { writeFileSync( `${sessionFile}.exit`, JSON.stringify(buildCompletionSidecar(messages)), ); } catch { // Best effort — the watcher can still detect the terminal sentinel // after shutdown if the completion sidecar cannot be written. } } recorder.agentEndDone(); ctx.shutdown(); return; } recorder.agentEndWaiting(); if (autoExit) { // Reset any recorded manual input marker. Auto-exit is decided by whether // the latest agent turn completed normally, not by who initiated it. userTookOver = false; } }); pi.on("turn_start", (event) => { recorder.turnStart((event as any).turnIndex); }); pi.on("turn_end", (event) => { recorder.turnEnd((event as any).turnIndex); }); pi.on("before_provider_request", () => { recorder.beforeProviderRequest(); }); pi.on("after_provider_response", () => { recorder.afterProviderResponse(); }); pi.on("message_update", (event) => { recorder.messageUpdate((event as any).assistantMessageEvent?.type); }); pi.on("tool_execution_start", (event) => { recorder.toolExecutionStart((event as any).toolCallId, (event as any).toolName); }); pi.on("tool_call", (event) => { recorder.toolCall((event as any).toolCallId, (event as any).toolName); }); pi.on("tool_execution_update", (event) => { recorder.toolExecutionUpdate((event as any).toolCallId, (event as any).toolName); }); pi.on("tool_result", (event) => { recorder.toolResult((event as any).toolCallId, (event as any).toolName); }); pi.on("tool_execution_end", (event) => { recorder.toolExecutionEnd((event as any).toolCallId, (event as any).toolName); }); pi.on("session_shutdown", (event) => { recorder.sessionShutdown((event as any).reason); }); // Toggle expand/collapse with Ctrl+J pi.registerShortcut("ctrl+j", { description: "Toggle subagent tools widget", handler: (ctx) => { expanded = !expanded; renderWidget(ctx, null); }, }); pi.registerTool({ name: "caller_ping", label: "Caller Ping", description: "Send a help request to the parent agent and exit this session. " + "The parent will be notified with your message and can resume this session with a response. " + "Use when you're stuck, need clarification, or need the parent to take action.", parameters: Type.Object({ message: Type.String({ description: "What you need help with" }), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const sessionFile = process.env.PI_SUBAGENT_SESSION; if (!sessionFile) { throw new Error( "caller_ping is only available in subagent contexts. " + "PI_SUBAGENT_SESSION environment variable is not set.", ); } recorder.callerPing(); const exitData = { type: "ping" as const, name: process.env.PI_SUBAGENT_NAME ?? "subagent", message: params.message, }; writeFileSync(`${sessionFile}.exit`, JSON.stringify(exitData)); ctx.shutdown(); return { content: [{ type: "text", text: "Ping sent. Session will exit and parent will be notified." }], details: {}, }; }, }); pi.registerTool({ name: "subagent_done", label: "Subagent Done", description: "Call this tool when you have completed your task. " + "It will close this session and return your results to the main session. " + "Your LAST assistant message before calling this becomes the summary returned to the caller.", parameters: Type.Object({}), async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { const sessionFile = process.env.PI_SUBAGENT_SESSION; recorder.subagentDone(); if (sessionFile) { writeFileSync(`${sessionFile}.exit`, JSON.stringify({ type: "done" })); } ctx.shutdown(); return { content: [{ type: "text", text: "Shutting down subagent session." }], details: {}, }; }, }); }