// Declarative shell for the subagents extension: tool schemas, prompt text, // renderers, and event-handler registration. All parent-side orchestration // state lives in the shared parent runtime. import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { Text } from "@earendil-works/pi-tui"; import { isHerdrAvailable, herdrSetupHint, } from "./herdr.ts"; import { THINKING_LEVELS, type ThinkingLevel, } from "./runtime-routing.ts"; import { BUILTIN_SUBAGENT_TYPES, } from "./launch-policy.ts"; import { getSharedParentRuntime } from "./parent-runtime.ts"; import { isNestedSubagentProcess, SUBAGENT_ENV_AGENT, } from "./operation-env.ts"; import { renderSubagentResultMessage, renderSubagentStatusMessage, } from "./presentation.ts"; function buildSubagentRoutingGuidelines(catalog?: string): string[] { return [ "Exactly two built-in subagent types exist: 'explore' is a fast read-only agent for searching and analyzing codebases without modifying anything; 'general-purpose' handles complex multi-step tasks that need investigation followed by action.", "Subagent tasks are standalone: put any required context explicitly in the task prompt; do not rely on parent conversation context.", "For subagent model and thinking selection, inherit the parent runtime by omitting both fields unless the task warrants an override.", "For subagent tasks, prefer changing thinking before changing models: minimal/low for bounded mechanical work, medium for ordinary implementation or review, and high+ for architecture, concurrency, security, or hard diagnosis.", "When overriding a subagent model, use an exact authenticated provider/model-id from the live catalog below. Do not invent aliases or fuzzy names.", catalog ?? "Authenticated subagent model catalog becomes available after session start.", ]; } const subagentRoutingGuidelines = buildSubagentRoutingGuidelines(); const ThinkingLevelSchema = Type.Union( THINKING_LEVELS.map((level) => Type.Literal(level)), { description: "Pi thinking level. Omit to inherit the parent level. Prefer changing thinking before changing models: minimal/low for bounded mechanical work, medium for ordinary implementation or review, high+ for architecture, concurrency, security, or hard diagnosis.", }, ); const runInBackgroundDescription = "Whether to run without blocking: true returns immediately in the background; false waits for the result. Omit to preserve the existing background behavior."; const SubagentParams = Type.Object({ name: Type.String({ description: "Display name for the subagent" }), task: Type.String({ description: "Task/prompt for the sub-agent" }), run_in_background: Type.Optional( Type.Boolean({ description: runInBackgroundDescription }), ), agent: Type.String({ description: "Required built-in subagent type. Exactly two exist: 'explore' — a fast, read-only agent for searching and analyzing codebases (maps files, finds patterns, understands code without modifying anything); 'general-purpose' — a full-toolset agent for complex, multi-step tasks requiring exploration and action (writes code, runs tests).", }), systemPrompt: Type.Optional( Type.String({ description: "Appended to system prompt (role instructions)" }), ), model: Type.Optional( Type.String({ description: "Exact authenticated provider/model-id. Omit to inherit the parent model. Select another model only when task capability, speed, cost, modality, or context requirements warrant it.", }), ), thinking: Type.Optional(ThinkingLevelSchema), skills: Type.Optional( Type.String({ description: "Comma-separated skills (overrides agent default)" }), ), tools: Type.Optional( Type.String({ description: "Comma-separated tools (overrides agent default)" }), ), cwd: Type.Optional( Type.String({ description: "Working directory for the sub-agent. The agent starts in this folder and picks up its local .pi/ config, CLAUDE.md, skills, and extensions. Use for role-specific subfolders.", }), ), }); function muxUnavailableResult() { return { content: [ { type: "text" as const, text: `Subagents require herdr. ${herdrSetupHint()}`, }, ], details: { error: "herdr not available" }, }; } function resolveParentThinkingLevel(pi: ExtensionAPI): ThinkingLevel { const thinking = pi.getThinkingLevel(); if ((THINKING_LEVELS as readonly string[]).includes(thinking)) { return thinking as ThinkingLevel; } throw new Error(`Unsupported parent thinking level: ${thinking}`); } export function resolveRunInBackground(value: boolean | undefined): boolean { return value ?? true; } export default function subagentsExtension(pi: ExtensionAPI) { const runtime = getSharedParentRuntime(pi); pi.on("session_start", (_event, ctx) => { const modelCatalog = runtime.sessionStarting(ctx); const refreshedGuidelines = buildSubagentRoutingGuidelines(modelCatalog); subagentRoutingGuidelines.splice(0, subagentRoutingGuidelines.length, ...refreshedGuidelines); }); // Clean up on session shutdown pi.on("session_shutdown", async (event, _ctx) => { await runtime.sessionShuttingDown((event as any).reason); }); // Tools denied via PI_DENY_TOOLS env var (set by the parent for leaf subagent types) const deniedTools = new Set( (isNestedSubagentProcess() ? process.env.PI_DENY_TOOLS ?? "" : "") .split(",") .map((s) => s.trim()) .filter(Boolean), ); const shouldRegister = (name: string) => !deniedTools.has(name); // ── subagent tool ── if (shouldRegister("subagent")) pi.registerTool({ name: "subagent", label: "Subagent", description: "Spawn a sub-agent in a dedicated terminal herdr pane using a built-in subagent type ('explore' or 'general-purpose'). " + "Tasks are standalone: include any context the child needs explicitly in the task prompt. " + "By default, or when run_in_background is true, this returns immediately and delivers the result later as a steer message. " + "Set run_in_background to false to wait for the sub-agent and receive its result directly from this tool call. " + "Use the returned foreground result or wait for the automatic background steer; do not infer a result from the child surface.", promptSnippet: "Spawn a sub-agent in a dedicated terminal herdr pane with a built-in subagent type ('explore' or 'general-purpose'). " + "Tasks are standalone: include any context the child needs explicitly in the task prompt. " + "Use run_in_background: false when you need the result before continuing; omit it or use true for background work and automatic steer delivery.", promptGuidelines: subagentRoutingGuidelines, parameters: SubagentParams, async execute(_toolCallId, params, signal, _onUpdate, ctx) { // Prevent direct self-spawning (e.g. an agent spawning another of itself). const currentAgent = process.env[SUBAGENT_ENV_AGENT]?.trim(); const requestedAgent = params.agent?.trim(); const selfSpawn = runtime.agentProfileAdmission.classifySelfSpawn( currentAgent ?? "", requestedAgent ?? "", ); if (selfSpawn.blocked) { const other = BUILTIN_SUBAGENT_TYPES.find((type) => type.name !== requestedAgent); const guidance = selfSpawn.hasAlternative && other ? `Choose the other built-in subagent type instead: '${other.name}'.` : "No different built-in subagent type is available. Complete the task directly."; return { content: [ { type: "text", text: `You are the ${currentAgent} agent — do not start another ${currentAgent}. ${guidance}`, }, ], details: { error: "self-spawn blocked" }, }; } // Validate prerequisites if (!isHerdrAvailable()) { return muxUnavailableResult(); } // Launch the subagent (creates pane, sends command) const parentThinking = resolveParentThinkingLevel(pi); const runInBackground = resolveRunInBackground(params.run_in_background); const startedAt = Date.now(); let running; try { running = await runtime.subagentLauncher.spawnFresh({ params, parentThinking, foreground: !runInBackground, ctx, preparer: runtime.getCompletionOperationPreparer(ctx), }); } catch (error) { const failed = runtime.failedLaunchToolResult(params, error, startedAt); if (failed) return failed; throw error; } // Start widget refresh and status supervision when the first agent launches runtime.startLiveSupervision(); if (!runInBackground) { return runtime.watchForeground(running, signal); } // The same watch arc owns delivery and lifecycle cleanup; only the // destination differs for background calls. runtime.watchBackground(running); // Return immediately return { content: [ { type: "text", text: `Sub-agent "${params.name}" launched and is now running in the background. ` + `Do NOT generate or assume any results — you have no idea what the sub-agent will do or produce. ` + `The results will be delivered to you automatically as a steer message when the sub-agent finishes. ` + `Until then, move on to other work or tell the user you're waiting.`, }, ], details: { id: running.id, name: params.name, task: params.task, agent: params.agent, model: running.runtimePlan?.model, thinking: running.runtimePlan?.thinking, runtimePlan: running.runtimePlan, status: "started", }, }; }, renderCall(args, theme) { const partialArgs = args as Record; const name = typeof partialArgs.name === "string" && partialArgs.name ? partialArgs.name : "(unnamed)"; const task = typeof partialArgs.task === "string" ? partialArgs.task : ""; const agent = typeof partialArgs.agent === "string" && partialArgs.agent ? theme.fg("dim", ` (${partialArgs.agent})`) : ""; const cwdHint = typeof partialArgs.cwd === "string" && partialArgs.cwd ? theme.fg("dim", ` in ${partialArgs.cwd}`) : ""; let text = "▸ " + theme.fg("toolTitle", theme.bold(name)) + agent + cwdHint; // Show a one-line task preview. renderCall is called repeatedly as the // LLM generates tool arguments, so args.task grows token by token. // We keep it compact here — Ctrl+O on renderResult expands the full content. if (task) { const firstLine = task.split("\n").find((l: string) => l.trim()) ?? ""; const preview = firstLine.length > 100 ? firstLine.slice(0, 100) + "…" : firstLine; if (preview) { text += "\n" + theme.fg("toolOutput", preview); } const totalLines = task.split("\n").length; if (totalLines > 1) { text += theme.fg("muted", ` (${totalLines} lines)`); } } return new Text(text, 0, 0); }, renderResult(result, _opts, theme) { const details = result.details as any; const name = details?.name ?? "(unnamed)"; // "Started" result — tool returned immediately if (details?.status === "started") { const runtimePlan = details?.model ? ` — ${details.model}${details.thinking ? ` · ${details.thinking}` : ""}` : " — started"; return new Text( theme.fg("accent", "▸") + " " + theme.fg("toolTitle", theme.bold(name)) + theme.fg("dim", runtimePlan), 0, 0, ); } // Fallback (shouldn't happen) const text = typeof result.content[0]?.text === "string" ? result.content[0].text : ""; return new Text(theme.fg("dim", text), 0, 0); }, }); // ── subagent_cancel tool ── if (shouldRegister("subagent_cancel")) pi.registerTool({ name: "subagent_cancel", label: "Cancel Subagent", description: "Cancel a currently running Pi-backed subagent: requests child-side cancellation, " + "terminates its work, and reclaims its herdr pane once the child acknowledges " + "(active descendants drain first). No result is delivered for the cancelled attempt; " + "a cancellation steer reports the terminal state. Returns only a local acknowledgement.", promptSnippet: "Cancel a currently running Pi-backed subagent by exact id or display name. " + "The child terminates, its herdr pane is reclaimed once descendants have drained, " + "and no result is delivered for the cancelled attempt.", parameters: Type.Object({ id: Type.Optional(Type.String({ description: "Exact running subagent id" })), name: Type.Optional(Type.String({ description: "Exact running subagent display name" })), }), async execute(_toolCallId, params) { return runtime.handleSubagentCancel(params); }, renderCall(args, theme) { const target = args.id ? `${args.id}` : args.name ?? "(unknown)"; return new Text( theme.fg("accent", "▸") + " " + theme.fg("toolTitle", theme.bold(target)) + theme.fg("dim", " — cancel"), 0, 0, ); }, renderResult(result, _opts, theme) { const details = result.details as any; if (details?.status === "cancel_requested") { return new Text( theme.fg("accent", "▸") + " " + theme.fg("toolTitle", theme.bold(details.name ?? details.id ?? "subagent")) + theme.fg("dim", " — cancel requested"), 0, 0, ); } const text = typeof result.content[0]?.text === "string" ? result.content[0].text : ""; return new Text(theme.fg("dim", text), 0, 0); }, }); // ── Custom message renderers ── pi.registerMessageRenderer("subagent_result", renderSubagentResultMessage); pi.registerMessageRenderer("subagent_status", renderSubagentStatusMessage); }