import type { AgentToolResult, ExtensionAPI, ExtensionContext, SessionShutdownEvent, } from "@earendil-works/pi-coding-agent"; import { keyHint } from "@earendil-works/pi-coding-agent"; import { Type, type Static } from "@sinclair/typebox"; import { Box, Text, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { readdirSync, readFileSync, existsSync, rmSync, statSync, } from "node:fs"; import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; import { isTerminalAvailable, terminalSetupHint, createSubagentPane, runScriptInPane, closePane, interruptPane, shellQuote, readPaneAsync, inspectPane, listPanes, waitForShellReady, } from "./terminal.ts"; import { waitForCompletion } from "./completion.ts"; import { SupervisionCoordinator, type SupervisionRegistration, } from "./supervision.ts"; import { loadSupervisionConfig } from "./supervision-config.ts"; import { buildAuthenticatedModelCatalog, resolveRuntimePlan, resolveRuntimePlans, wrapPiModelRegistry, THINKING_LEVELS, isThinkingLevel, type ResolvedRuntimePlan, type ThinkingLevel, } from "./runtime-routing.ts"; import { loadModelConfig, resolveModelDefault } from "./model-config.ts"; import { loadRoleConfig, type RoleConfig } from "./role-config.ts"; import { loadPersistentConfig, type PersistentConfig, } from "./persistent-config.ts"; import { appendPersistentDeliveryLedger, findLastAssistantMessage, findObservedSessionRuntime, inspectNoProgressSessionTail, type NoProgressClassification, type NoProgressSessionTail, getNewEntries, createBtwSessionSnapshot, readPersistentDeliveryLedger, readPersistentTaskEvents, readSubagentSessionPolicy, writePersistentTaskInbox, } from "./session.ts"; import { type SubagentStatusState, capStatusLines, formatElapsedDuration, formatStatusAggregate, normalizeStatusName, loadStatusConfig, } from "./status.ts"; import { readSubagentActivityFile, isSubagentActivityScope, type ActivityReadResult, type SubagentActivityState, } from "./activity.ts"; import { isFiniteNumber, isPlainObject, isString } from "./type-guards.ts"; import { createLifecycle, formatLifecycleTransitionLine, lifecycleTransition, markCompleted, markCompletionDetected, markDelivery, markFailed, markInterruptRequested, markProcessRunning, observeActivity, observePaneInspection, projectLifecycle, type LifecycleProjection, type SubagentLifecycle, type PaneInspection, } from "./lifecycle.ts"; import { listHerdrWorktrees } from "./herdr.ts"; import { captureWorktreeHandoff, launchPiSubagent, launchPiWorktreeHandoff, persistWorktreeResult, runSubagentScript, writeWorktreeManifest, buildSubagentToolAllowlist, type WorktreeHandoff, type WorktreeLaunch, } from "./launch.ts"; /** Absolute path to `pi-extension/subagents`. https://github.com/nodejs/node/issues/37845 */ const SUBAGENTS_DIR = dirname(fileURLToPath(import.meta.url)); // Survive /reload: replace presentation timers while keeping active completion // watchers and their registry alive. Old module closures continue watching the // children; the reloaded module adopts the shared registry for status/interrupts. const WIDGET_INTERVAL_KEY = Symbol.for("pi-subagents/widget-interval"); const STATUS_INTERVAL_KEY = Symbol.for("pi-subagents/status-interval"); const RUNTIME_KEY = Symbol.for("pi-subagents/runtime"); function readGlobalSlot(key: symbol): T | undefined { // SAFETY: `globalThis` has no index signature for our extension-private // symbol keys; only this module ever writes the values read back here. return (globalThis as Record)[key]; } function writeGlobalSlot(key: symbol, value: T): void { // SAFETY: see readGlobalSlot above; this module is the sole writer. (globalThis as Record)[key] = value; } const BTW_BOUNDARY = `You are answering an ephemeral BTW side question. Treat inherited conversation history only as reference context. Do not resume or complete an earlier task. Answer only the question after this boundary. Do not modify the workspace unless that side question explicitly requests a mutation. BTW question: `; interface BtwChild { surface: string; sessionFile: string; launchScriptFile: string; } function getFirstText( content: readonly { type: string; text?: string }[], ): string { const first = content[0]; return first?.type === "text" ? (first.text ?? "") : ""; } { const prevInterval = readGlobalSlot>(WIDGET_INTERVAL_KEY); if (prevInterval) { clearInterval(prevInterval); writeGlobalSlot | null>( WIDGET_INTERVAL_KEY, null, ); } const prevStatusInterval = readGlobalSlot>(STATUS_INTERVAL_KEY); if (prevStatusInterval) { clearInterval(prevStatusInterval); writeGlobalSlot | null>( STATUS_INTERVAL_KEY, null, ); } } function buildSubagentRoutingGuidelines(catalog?: string): string[] { return [ "Act as the coordinator: decompose the work, give each child one bounded outcome — goal, allowed files, verification, and whether to commit — and keep dependent writes sequential; parallelize only independent tasks.", "Children are leaves by default: they do not push, merge, deploy, or orchestrate further agents unless their task explicitly authorizes it. The parent inspects each result or worktree handoff (diff against the reported base, run relevant tests) and owns integration, verification, and cleanup.", "For orchestrated subagent work, explicitly set both model and thinking for every child: first choose a fast, mid, or frontier provider-family tier matched to task complexity, then set thinking within that model's supported range.", "Use fast tier for bounded mechanical work and recon, mid tier for ordinary implementation or review, and frontier tier for architecture, security, hard diagnosis, or adversarial review. Use minimal/low thinking for mechanical work, medium for ordinary work, and high+ for hard work.", "Review agents must use a different provider/family than the model that produced the work; a stronger model in the same family is quality escalation, not independent review. Use an exact authenticated provider/model-id from the live catalog below, never an alias or fuzzy name.", "Omitting model and thinking still inherits the parent runtime, but this is a discouraged fallback for orchestrated children.", "Before launching a new group of subagents, choose a short task slug and name each new child -[-n], for example login-api or login-test2. Use only plan, research, ui, api, build, test, review, browser, security, perf, or merge as roles; leave existing names unchanged. After the final launch, print name | agent kind | role | model | worktree (if any), then use each name in prompts, handoffs, and results.", 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. Pick the model tier first, then set thinking within that model's range: minimal/low for bounded mechanical work, medium for ordinary implementation or review, high+ for architecture, security, or hard diagnosis. Omitting still inherits the parent level; do not omit on orchestrated child work.", }, ); const SubagentParams = Type.Object({ name: Type.String({ description: "Short stable label for the subagent; for a new coordinated group use -[-n] (shown in the widget and pane title)", }), task: Type.String({ description: "Task/prompt for the sub-agent" }), agent: Type.Optional( Type.String({ description: "Agent name to load defaults from (e.g. 'worker', 'scout', 'reviewer'). Discovery precedence is project .pi/agents, global ~/.pi/agent/agents, then package-bundled agents.", }), ), systemPrompt: Type.Optional( Type.String({ description: "Role/system-prompt text for a bare spawn. Named agents keep their definition body.", }), ), model: Type.Optional( Type.String({ description: "Explicitly pick an exact authenticated provider/model-id in a fast, mid, or frontier provider-family tier matched to the task, or use an ordered comma-separated fallback list. Review must use a different provider/family than the producing model. Omitting still inherits the parent model; do not omit for orchestrated children. Fallbacks are Pi-backed only and cannot be used with worktrees.", }), ), 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. Without worktree, the agent starts in this folder. With worktree, this selects the source Git repository and the agent starts at the created worktree root.", }), ), worktree: Type.Optional( Type.Object({ branch: Type.String({ minLength: 1, description: "New branch name for an isolated Herdr-managed Git worktree", }), base: Type.Optional( Type.String({ description: "Git revision to branch from. Defaults to the source checkout's committed HEAD.", }), ), }), ), fork: Type.Optional( Type.Boolean({ description: "Force the full-context fork mode for this spawn. The sub-agent inherits the current session conversation, overriding any agent frontmatter session-mode.", }), ), persistent: Type.Optional( Type.Boolean({ description: "Keep this stable specialist session alive between turn-based tasks. Persistent agents accept follow-up work only through subagent_send.", }), ), interactive: Type.Optional( Type.Boolean({ description: "Mark the subagent as interactive (long-running, user drives the conversation in its own pane). When true, the main session is not woken by status transitions (stalled/recovered) for this subagent. If omitted, falls back to the agent's `interactive` frontmatter, otherwise the inverse of `auto-exit` (agents that auto-exit are autonomous and get stall pings; agents that don't are interactive and stay quiet).", }), ), }); type SubagentSessionMode = "standalone" | "lineage-only" | "fork"; interface AgentDefaults { model?: string; tools?: string; skills?: string; thinking?: ThinkingLevel; denyTools?: string; spawning?: boolean; persistent?: boolean; autoExit?: boolean; interactive?: boolean; systemPromptMode?: "append" | "replace"; sessionMode?: SubagentSessionMode; cwd?: string; body?: string; disableModelInvocation?: boolean; } type AgentSource = "package" | "global" | "project"; interface AgentDefinition extends AgentDefaults { name: string; description?: string; disableModelInvocation: boolean; } interface ListedAgentDefinition extends AgentDefinition { source: AgentSource; path: string; provider?: string; providerVersion?: string; } interface AgentDiagnostic { code: string; message: string; path?: string; agentName?: string; provider?: string; } interface AgentCatalog { agents: ListedAgentDefinition[]; diagnostics: AgentDiagnostic[]; } const ROLE_PACK_DISCOVERY_EVENT = "pi-herdr-subagents:roles:discover:v1"; /** Tools that are gated by `spawning: false` */ const SPAWNING_TOOLS = new Set([ "subagent", "subagent_interrupt", "subagents_list", "subagent_resume", "subagent_send", "subagent_stop", ]); /** * Resolve the effective set of denied tool names from agent defaults. * `spawning: false` expands to all SPAWNING_TOOLS. * `deny-tools` adds individual tool names on top. */ function resolveDenyTools(agentDefs: AgentDefaults | null): Set { const denied = new Set(); if (!agentDefs) return denied; // spawning: false → deny all spawning tools if (agentDefs.spawning === false) { for (const t of SPAWNING_TOOLS) denied.add(t); } // deny-tools: explicit list if (agentDefs.denyTools) { for (const t of agentDefs.denyTools .split(",") .map((s) => s.trim()) .filter(Boolean)) { denied.add(t); } } return denied; } /** Resolve the global agent config directory, respecting PI_CODING_AGENT_DIR. */ function getAgentConfigDir(): string { return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"); } function getBundledAgentsDir(): string { return join(SUBAGENTS_DIR, "../../agents"); } function getFrontmatterLines(frontmatter: string, key: string): string[] { const prefix = `${key}:`; return frontmatter .split("\n") .filter((candidate) => candidate.startsWith(prefix)); } function getFrontmatterValue( frontmatter: string, key: string, ): string | undefined { const line = getFrontmatterLines(frontmatter, key)[0]; return line?.slice(`${key}:`.length).trim() || undefined; } interface CapabilityDeclarations { canonical: string[]; hasNoncanonical: boolean; } function isCapabilityDeclaration( line: string, field: "tools" | "deny-tools" | "spawning" | "persistent", ): boolean { const trimmed = line.trimStart(); const colon = trimmed.indexOf(":"); if (colon === -1) return false; const key = trimmed.slice(0, colon).trim(); return key === field || key === `"${field}"` || key === `'${field}'`; } function getCapabilityDeclarations( frontmatter: string, field: "tools" | "deny-tools" | "spawning" | "persistent", ): CapabilityDeclarations { const canonicalPrefix = `${field}:`; const lines = frontmatter.split("\n"); return { canonical: lines.filter((line) => line.startsWith(canonicalPrefix)), hasNoncanonical: lines.some( (line) => isCapabilityDeclaration(line, field) && !line.startsWith(canonicalPrefix), ), }; } function validateCapabilityDeclarations( frontmatter: string, ): string | undefined { for (const field of [ "tools", "deny-tools", "spawning", "persistent", ] as const) { const declarations = getCapabilityDeclarations(frontmatter, field); if (declarations.hasNoncanonical) { return `${field} must use an unquoted, unindented key written exactly as ${field}:`; } if (declarations.canonical.length > 1) { return `${field} may be declared only once.`; } if (declarations.canonical.length === 0) continue; const value = declarations.canonical[0].slice(`${field}:`.length).trim(); if (field === "spawning" || field === "persistent") { if (value !== "true" && value !== "false") { return `${field} must be true or false.`; } continue; } if ( !value || value.startsWith("[") || value.startsWith("{") || value.startsWith("|") || value.startsWith(">") || value.includes("#") || value.includes('"') || value.includes("'") || value.split(",").some((entry) => !entry.trim()) ) { return `${field} must use a non-empty comma-separated scalar; YAML lists and containers, comments and quotes are unsupported.`; } } return undefined; } function parseOptionalBoolean(value: string | undefined): boolean | undefined { return value == null ? undefined : value === "true"; } function parseSessionMode( value: string | undefined, ): SubagentSessionMode | undefined { if (value === "standalone" || value === "lineage-only" || value === "fork") { return value; } return undefined; } function parseAgentDefinition( content: string, fallbackName: string, ): AgentDefinition | null { const match = content.match(/^---\n([\s\S]*?)\n---/); if (!match) return null; const frontmatter = match[1]; const body = content.replace(/^---\n[\s\S]*?\n---\n*/, "").trim(); const systemPromptMode = getFrontmatterValue(frontmatter, "system-prompt"); const thinking = getFrontmatterValue(frontmatter, "thinking"); return { name: getFrontmatterValue(frontmatter, "name") ?? fallbackName, description: getFrontmatterValue(frontmatter, "description"), model: getFrontmatterValue(frontmatter, "model"), tools: getFrontmatterValue(frontmatter, "tools"), systemPromptMode: systemPromptMode === "replace" ? "replace" : systemPromptMode === "append" ? "append" : undefined, skills: getFrontmatterValue(frontmatter, "skills") ?? getFrontmatterValue(frontmatter, "skill"), thinking: thinking && isThinkingLevel(thinking) ? thinking : undefined, denyTools: getFrontmatterValue(frontmatter, "deny-tools"), spawning: parseOptionalBoolean( getFrontmatterValue(frontmatter, "spawning"), ), persistent: parseOptionalBoolean( getFrontmatterValue(frontmatter, "persistent"), ), autoExit: parseOptionalBoolean( getFrontmatterValue(frontmatter, "auto-exit"), ), interactive: parseOptionalBoolean( getFrontmatterValue(frontmatter, "interactive"), ), sessionMode: parseSessionMode( getFrontmatterValue(frontmatter, "session-mode"), ), cwd: getFrontmatterValue(frontmatter, "cwd"), body: body || undefined, disableModelInvocation: getFrontmatterValue( frontmatter, "disable-model-invocation", )?.toLowerCase() === "true", }; } function invalidCapabilityDeclarationDiagnostic( content: string, agentName: string, path: string, ): AgentDiagnostic | null { const match = content.match(/^---\n([\s\S]*?)\n---/); if (!match) return null; const resolvedAgentName = getFrontmatterValue(match[1], "name") ?? agentName; const error = validateCapabilityDeclarations(match[1]); if (!error) return null; return { code: "invalid-capability-declaration", message: `Role "${resolvedAgentName}" has an invalid capability declaration in ${path}: ${error} Use documented comma-separated tools or deny-tools values, true or false for spawning, or omit the field.`, path, agentName: resolvedAgentName, }; } function legacyExternalCliDiagnostic( content: string, agentName: string, path: string, ): AgentDiagnostic | null { const match = content.match(/^---\n([\s\S]*?)\n---/); const cli = match ? getFrontmatterValue(match[1], "cli") : undefined; if (!match || !cli) return null; const resolvedAgentName = getFrontmatterValue(match[1], "name") ?? agentName; return { code: "external-cli-unsupported", message: `Role "${resolvedAgentName}" requests external CLI "${cli}" in ${path}. pi-herdr-agents is Pi-only; remove the cli and cli-model fields and select Claude through an authenticated Pi provider/model ID.`, path, agentName: resolvedAgentName, }; } function listMarkdownFiles(path: string): string[] { const stat = statSync(path); if (stat.isFile()) return path.endsWith(".md") ? [path] : []; if (!stat.isDirectory()) return []; return readdirSync(path) .filter((entry) => entry.endsWith(".md")) .sort((left, right) => left.localeCompare(right)) .map((entry) => join(path, entry)); } interface PackageMetadata { provider?: string; providerVersion?: string; } function findPackageMetadata(path: string): PackageMetadata { let current = statSync(path).isDirectory() ? path : dirname(path); while (true) { const packagePath = join(current, "package.json"); if (existsSync(packagePath)) { try { const pkg = JSON.parse(readFileSync(packagePath, "utf8")); return { provider: isString(pkg.name) ? pkg.name : undefined, providerVersion: isString(pkg.version) ? pkg.version : undefined, }; } catch { return {}; } } const parent = dirname(current); if (parent === current) return {}; current = parent; } } interface RolePackDiscoveryResult { paths: string[]; diagnostics: AgentDiagnostic[]; } function discoverRolePackPaths( pi?: Pick, ): RolePackDiscoveryResult { const paths = new Set(); const diagnostics: AgentDiagnostic[] = []; if (!pi?.events) return { paths: [], diagnostics }; try { pi.events.emit(ROLE_PACK_DISCOVERY_EVENT, { apiVersion: 1, register(path: any) { if (!isString(path) || !isAbsolute(path)) { diagnostics.push({ code: "invalid-role-pack-path", message: "Role packs must register an absolute file or directory path.", }); return; } paths.add(resolve(path)); }, }); } catch (error) { diagnostics.push({ code: "role-pack-discovery-failed", message: `Role-pack discovery failed: ${error instanceof Error ? error.message : String(error)}`, }); } return { paths: [...paths], diagnostics }; } function discoverAgentCatalog( pi?: Pick, roleConfig: RoleConfig = bundledRoleConfig, ): AgentCatalog { const agents = new Map(); const diagnostics: AgentDiagnostic[] = []; const addDirectory = (path: string, source: AgentSource) => { if (!existsSync(path)) return; for (const filePath of listMarkdownFiles(path)) { const fallbackName = basename(filePath, ".md"); const content = readFileSync(filePath, "utf8"); const legacyDiagnostic = legacyExternalCliDiagnostic( content, fallbackName, filePath, ); if (legacyDiagnostic) { diagnostics.push(legacyDiagnostic); agents.delete(legacyDiagnostic.agentName ?? fallbackName); continue; } const capabilityDiagnostic = invalidCapabilityDeclarationDiagnostic( content, fallbackName, filePath, ); if (capabilityDiagnostic) { diagnostics.push(capabilityDiagnostic); agents.delete(capabilityDiagnostic.agentName ?? fallbackName); continue; } const parsed = parseAgentDefinition(content, fallbackName); if (parsed) agents.set(parsed.name, { ...parsed, source, path: filePath }); } }; if (roleConfig.bundled) addDirectory(getBundledAgentsDir(), "package"); const discovered = discoverRolePackPaths(pi); diagnostics.push(...discovered.diagnostics); const contributed = new Map(); for (const registeredPath of discovered.paths) { if (!existsSync(registeredPath)) { diagnostics.push({ code: "missing-role-pack-path", message: `Registered role-pack path does not exist: ${registeredPath}`, path: registeredPath, }); continue; } let metadata: ReturnType; let roleFiles: string[]; try { metadata = findPackageMetadata(registeredPath); roleFiles = listMarkdownFiles(registeredPath); } catch (error) { diagnostics.push({ code: "unreadable-role-pack-path", message: `Cannot read registered role-pack path ${registeredPath}: ${error instanceof Error ? error.message : String(error)}`, path: registeredPath, }); continue; } if (roleFiles.length === 0 && statSync(registeredPath).isFile()) { diagnostics.push({ code: "invalid-role-pack-file", message: `Registered role-pack file must use the .md extension: ${registeredPath}`, path: registeredPath, provider: metadata.provider, }); continue; } for (const filePath of roleFiles) { const fallbackName = basename(filePath, ".md"); let content: string; try { content = readFileSync(filePath, "utf8"); } catch (error) { diagnostics.push({ code: "unreadable-role-definition", message: `Cannot read role definition ${filePath}: ${error instanceof Error ? error.message : String(error)}`, path: filePath, agentName: fallbackName, provider: metadata.provider, }); continue; } const legacyDiagnostic = legacyExternalCliDiagnostic( content, fallbackName, filePath, ); if (legacyDiagnostic) { diagnostics.push({ ...legacyDiagnostic, provider: metadata.provider }); continue; } const capabilityDiagnostic = invalidCapabilityDeclarationDiagnostic( content, fallbackName, filePath, ); if (capabilityDiagnostic) { diagnostics.push({ ...capabilityDiagnostic, provider: metadata.provider, }); continue; } const parsed = parseAgentDefinition(content, fallbackName); if (!parsed) { diagnostics.push({ code: "invalid-role-definition", message: `Role definition must start with frontmatter: ${filePath}`, path: filePath, agentName: fallbackName, provider: metadata.provider, }); continue; } if (parsed.name !== fallbackName) { diagnostics.push({ code: "role-name-mismatch", message: `Role name "${parsed.name}" must match filename "${fallbackName}" in ${filePath}`, path: filePath, agentName: fallbackName, provider: metadata.provider, }); continue; } if (!parsed.description) { diagnostics.push({ code: "missing-role-description", message: `Role "${parsed.name}" must declare a description in ${filePath}`, path: filePath, agentName: parsed.name, provider: metadata.provider, }); continue; } const definitions = contributed.get(parsed.name) ?? []; definitions.push({ ...parsed, source: "package", path: filePath, ...metadata, }); contributed.set(parsed.name, definitions); } } for (const [name, definitions] of contributed) { if (agents.has(name)) { diagnostics.push({ code: "bundled-role-collision", message: `Role pack cannot replace bundled role "${name}"; use a global or project override instead.`, agentName: name, }); continue; } if (definitions.length > 1) { const providers = definitions .map((definition) => definition.provider ?? definition.path) .sort((left, right) => left.localeCompare(right)) .join(", "); diagnostics.push({ code: "duplicate-package-role", message: `Role "${name}" is contributed by multiple role packs: ${providers}`, agentName: name, }); continue; } agents.set(name, definitions[0]); } addDirectory(join(getAgentConfigDir(), "agents"), "global"); addDirectory(join(process.cwd(), ".pi", "agents"), "project"); return { agents: [...agents.values()], diagnostics }; } function discoverAgentDefinitions( pi?: Pick, ): ListedAgentDefinition[] { return discoverAgentCatalog(pi).agents; } function formatAgentSource(agent: ListedAgentDefinition): string { return agent.source === "package" && agent.provider ? `package:${agent.provider}` : agent.source; } function formatVisibleAgentDefinitions( agents: ListedAgentDefinition[], ): string[] { return agents .filter((agent) => !agent.disableModelInvocation) .map((agent) => { const badge = ` (${formatAgentSource(agent)})`; const desc = agent.description ? ` — ${agent.description}` : ""; const model = agent.model ? ` [${agent.model}]` : ""; return `• ${agent.name}${badge}${model}${desc}`; }); } function formatLivePersistentSpecialists(): string[] { const specialists = Array.from(runningSubagents.values()).filter( (running) => running.persistent, ); if (specialists.length === 0) return []; return [ "Live persistent specialists:", ...specialists.map((running) => { const allowlist = running.policyTools?.join(",") ?? "unrestricted"; return `• ${running.name} | ${running.logicalId} | ${running.generationId} | ${running.agent ?? "bare"} | ${persistentSpecialistState(running)} | ${running.tasksCompleted ?? 0} completed | tools: ${allowlist}; denied: ${running.policyDeniedTools?.join(",") || "none"}; persistent: true`; }), ]; } function formatSupervisionDiagnostics(): string[] { const diagnostics = runtime.supervision?.diagnostics() ?? { mode: supervisionConfig.forcePolling ? "polling(forced)" : "wake+batch", watcherCount: 0, }; return [ `Supervision: ${diagnostics.mode}; ${diagnostics.watcherCount} watcher${diagnostics.watcherCount === 1 ? "" : "s"}`, ]; } function formatAgentDiagnostics(diagnostics: AgentDiagnostic[]): string[] { return diagnostics.map((diagnostic) => `! ${diagnostic.message}`); } function resolveEffectiveSessionMode( params: Static, agentDefs: AgentDefaults | null, ): SubagentSessionMode { if (params.fork) return "fork"; return agentDefs?.sessionMode ?? "standalone"; } interface LaunchBehavior { sessionMode: SubagentSessionMode; seededSessionMode: "lineage-only" | "fork" | null; inheritsConversationContext: boolean; taskDelivery: "direct" | "artifact"; } function resolveLaunchBehavior( params: Static, agentDefs: AgentDefaults | null, ): LaunchBehavior { const sessionMode = resolveEffectiveSessionMode(params, agentDefs); const inheritsConversationContext = sessionMode === "fork"; return { sessionMode, seededSessionMode: sessionMode === "standalone" ? null : sessionMode, inheritsConversationContext, taskDelivery: inheritsConversationContext ? "direct" : "artifact", }; } /** * Decide whether a subagent is interactive (user-driven, long-running). * * Resolution order: * 1. Explicit `interactive` tool parameter wins. * 2. Explicit `interactive` frontmatter field on the agent. * 3. Default: the inverse of `auto-exit`. Agents that auto-exit are * autonomous (scout, worker, reviewer) and the parent session should be * woken on stall/recovery transitions. Agents that don't auto-exit are * driven by the user in their own pane (planner, iterate/fork) and * stall pings are noise. * * When no agent defs exist at all (bare `subagent({ name, task })` call, * typical for `/iterate` with `fork: true`), `autoExit` is undefined and the * subagent is treated as interactive — matching the intent of iterate. */ function resolveEffectivePersistent( params: Static, agentDefs: AgentDefaults | null, ): boolean { return params.persistent ?? agentDefs?.persistent ?? false; } function resolveEffectiveAutoExit( params: Static, agentDefs: AgentDefaults | null, ): boolean { if (resolveEffectivePersistent(params, agentDefs)) return false; // Named agents preserve their declared behavior. Bare tool calls are // autonomous by default, including full-context forks: `fork` controls // context inheritance, not whether the child should remain open. Interactive // flows such as /iterate opt out explicitly with `interactive: true`. if (agentDefs) return agentDefs.autoExit ?? false; return params.interactive !== true; } function resolveEffectiveInteractive( params: Static, agentDefs: AgentDefaults | null, ): boolean { if (params.interactive != null) return params.interactive; if (resolveEffectivePersistent(params, agentDefs)) return false; if (agentDefs?.interactive != null) return agentDefs.interactive; return !resolveEffectiveAutoExit(params, agentDefs); } function loadAgentDefaults( agentName: string, pi?: Pick, roleConfig?: RoleConfig, ): ListedAgentDefinition | null { return ( discoverAgentCatalog(pi, roleConfig).agents.find( (agent) => agent.name === agentName, ) ?? null ); } function formatElapsed(seconds: number): string { if (seconds < 60) return `${seconds}s`; const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}m ${s}s`; } function muxUnavailableResult() { return { content: [ { type: "text" as const, text: `Subagents require herdr. ${terminalSetupHint()}`, }, ], details: { error: "herdr not available" }, }; } /** * Build the internal artifact directory path for the current session. * Used by the subagents extension to stash task files, system prompts, and * launch scripts for sub-agents. Path convention: * /artifacts// */ function getArtifactDir(sessionDir: string, sessionId: string): string { return join(sessionDir, "artifacts", sessionId); } function shouldRetainSubagentSurface( running: Pick | { worktree?: unknown }, ): boolean { return !!running.worktree; } const BUNDLED_WORKTREE_WARNINGS = { scout: "The bundled scout role is read-only and normally does not need a new worktree. " + "Use an ordinary pane instead; to inspect an existing worker result, start it in the retained worktree path. " + "Herdr worktree workspaces persist until explicitly removed.", reviewer: "The bundled reviewer role is read-only and normally does not need a new worktree. " + "Use an ordinary pane instead; to review an existing worker result, start it in the retained worktree path. " + "Herdr worktree workspaces persist until explicitly removed.", "adversarial-reviewer": "The bundled adversarial-reviewer coordinates read-only reviewers and does not write artifacts in the reviewed checkout. " + "It normally uses an ordinary pane, not a new worktree. " + "Herdr worktree workspaces persist until explicitly removed.", } satisfies Readonly>; function resolveWorktreeLaunchWarning( params: Pick, "agent" | "worktree">, pi?: Pick, roleConfig?: RoleConfig, ): string | undefined { if (!params.worktree || !params.agent) return undefined; const warning = Object.entries(BUNDLED_WORKTREE_WARNINGS).find( ([agent]) => agent === params.agent, )?.[1]; const definition = loadAgentDefaults(params.agent, pi, roleConfig); return warning && dirname(definition?.path ?? "") === getBundledAgentsDir() ? warning : undefined; } function finalizeSubagentWorktree( running: RunningSubagent, state: "ready_for_review" | "failed" | "needs_help", ): WorktreeHandoff | undefined { if (running.worktree) { let handoff = captureWorktreeHandoff(running.worktree); try { persistWorktreeResult(running.worktree, state, handoff); } catch (error: any) { handoff = { ...handoff, gitError: [ handoff.gitError, `Manifest update failed: ${error?.message ?? String(error)}`, ] .filter(Boolean) .join("; "), }; } return handoff; } return undefined; } function closeCompletedPanes(panes: Iterable): void { for (const pane of panes) { try { closePane(pane); } catch { /* Result delivery remains authoritative. */ } } } const statusConfig = loadStatusConfig(); const modelConfig = loadModelConfig(); const bundledRoleConfig = loadRoleConfig(); const persistentConfig = loadPersistentConfig(); const supervisionConfig = loadSupervisionConfig(); const MAX_RESULT_PRESENTATION_CHARS = 16_000; const MAX_SESSION_REFERENCE_CHARS = 10_000; const RESULT_CONTINUATION_PROMPT = "Parent action: Continue the parent task using this result; do not return an empty response."; function abbreviateMiddle( value: string, maxChars: number, marker: string, ): string { if (value.length <= maxChars) return value; const retainedChars = maxChars - marker.length; const headChars = Math.ceil(retainedChars / 2); const tailChars = Math.floor(retainedChars / 2); return ( value.slice(0, headChars) + marker + (tailChars ? value.slice(-tailChars) : "") ); } function boundResultPresentation(body: string, sessionRef: string): string { const boundedSessionRef = abbreviateMiddle( sessionRef, MAX_SESSION_REFERENCE_CHARS, "\n[... session reference abbreviated ...]\n", ); if (body.length + boundedSessionRef.length <= MAX_RESULT_PRESENTATION_CHARS) { return body + boundedSessionRef; } const marker = boundedSessionRef ? "\n\n[... result abbreviated; full output remains in the child session below ...]\n\n" : "\n\n[... result abbreviated ...]\n\n"; const retainedChars = MAX_RESULT_PRESENTATION_CHARS - marker.length - boundedSessionRef.length; return ( abbreviateMiddle(body, retainedChars + marker.length, marker) + boundedSessionRef ); } function formatSessionReference(sessionFile?: string): string { return sessionFile ? `\n\nSession: ${sessionFile}\nResume: pi --session ${sessionFile}` : ""; } function resolveUnexpectedErrorPresentation( prefix: string, error: any, sessionFile?: string, ): string { const message = error instanceof Error ? error.message : String(error); return boundResultPresentation( `${prefix}: ${message}`, formatSessionReference(sessionFile), ); } interface ModelFailure { model: string; error: string; } interface SubagentResultDetails { name: string; task?: string; agent?: string; exitCode?: number; elapsed?: number; sessionFile?: string; logicalId?: string; generationId?: string; policyHash?: string; error?: string; errorMessage?: string; fallbackAttempts?: string[]; fallbackFailures?: ModelFailure[]; worktree?: WorktreeHandoff; runtimePlan?: ResolvedRuntimePlan; } interface SubagentPingDetails { name: string; message: string; agent?: string; sessionFile: string; worktree?: WorktreeHandoff; } interface SubagentStartedDetails { id: string; name: string; task: string; agent?: string; sessionFile: string; launchScriptFile?: string; model?: string; thinking?: ThinkingLevel; runtimePlan?: ResolvedRuntimePlan; worktree?: WorktreeLaunch; warning?: string; status: "started"; } interface PartialWorktreeArgs { branch?: unknown; } interface PartialSubagentArgs { name?: unknown; task?: unknown; agent?: unknown; cwd?: unknown; worktree?: PartialWorktreeArgs; } function sendSubagentResult( api: Pick, content: string, details: SubagentResultDetails, ): void { const resultContent = boundResultPresentation(content, ""); const promptContent = boundResultPresentation( `${resultContent}\n\n${RESULT_CONTINUATION_PROMPT}`, "", ); api.sendMessage( { customType: "subagent_result", content: promptContent, display: true, details: { ...details, resultContent }, }, { triggerTurn: true, deliverAs: "steer" }, ); } function formatWorktreeHandoff(worktree: WorktreeHandoff): string { const state = worktree.gitError ? "inspection unknown" : worktree.conflicted ? "conflicted" : worktree.clean ? "clean" : "dirty"; const ahead = worktree.commitsAhead == null ? "commits ahead unknown" : `${worktree.commitsAhead} commit${worktree.commitsAhead === 1 ? "" : "s"} ahead`; const lines = [ "Worktree result retained for review:", `Worktree: ${worktree.path}`, `Workspace: ${worktree.workspaceId}`, `Branch: ${worktree.branch}`, `Base/head: ${worktree.baseSha} -> ${worktree.headSha ?? "unknown"}`, `State: ${state} · ${ahead}`, ]; if (worktree.changedFiles?.length) lines.push(`Changed: ${worktree.changedFiles.join(", ")}`); if (worktree.untrackedFiles?.length) lines.push(`Untracked: ${worktree.untrackedFiles.join(", ")}`); if (worktree.gitError) lines.push(`Git inspection warning: ${worktree.gitError}`); lines.push( "After review and preservation, remove the workspace with:", ` herdr worktree remove --workspace ${worktree.workspaceId}`, ); return lines.join("\n"); } function resolveResultPresentation( result: Pick< SubagentResult, | "exitCode" | "elapsed" | "summary" | "sessionFile" | "errorMessage" | "fallbackAttempts" | "fallbackFailures" | "runtimePlan" | "worktree" >, name: string, runtimeMismatch?: string, ): string { const sessionRef = formatSessionReference(result.sessionFile); let body: string; const attempted = result.fallbackAttempts ?? []; const requestedModel = attempted[0] ?? result.runtimePlan?.requestedModel ?? result.runtimePlan?.model; const usedModel = result.runtimePlan?.observed?.model ?? result.runtimePlan?.model; if (result.errorMessage) { // Pi owns provider retry policy and exposes the settled error as text. Do // not infer retry counts or permanence from that text; preserve it as-is. body = `Sub-agent "${name}" failed after ${formatElapsed(result.elapsed)} ` + `(provider/agent error).\n\n` + `Error: ${result.errorMessage}\n\n` + `The subagent did not produce a result. Next action: check the raw ` + `provider reason and verify model access for this account. Spawn a new ` + `subagent with a supported model or configured fallback; use ` + `subagent_resume only after resolving access for this session's stored ` + `model because resume does not select a model.`; } else { body = result.exitCode === 0 ? `Sub-agent "${name}" completed (${formatElapsed(result.elapsed)}).\n\n${result.summary}` : `Sub-agent "${name}" failed (exit code ${result.exitCode}).\n\n${result.summary}`; } if (requestedModel) body += `\n\nRequested model: ${requestedModel}`; if (attempted.length > 1) body += `\nModels attempted: ${attempted.join(", ")}`; if (usedModel) body += `\nModel used: ${usedModel}`; if (result.fallbackFailures?.length) { body += "\nModel failures (raw errors, in attempt order):" + result.fallbackFailures .map(({ model, error }) => `\n- ${model}: ${error}`) .join(""); } if (result.worktree) body += `\n\n${formatWorktreeHandoff(result.worktree)}`; const runtimeWarning = runtimeMismatch ? `\n\nRuntime warning: ${runtimeMismatch}` : ""; return boundResultPresentation(body, sessionRef + runtimeWarning); } /** * Result from running a single subagent. */ interface SubagentResult { name: string; task: string; summary: string; sessionFile?: string; exitCode: number; elapsed: number; error?: string; /** Settled provider/agent error text from the child, preserved verbatim. */ errorMessage?: string; /** Ordered models launched for this run, including failed fallback attempts. */ fallbackAttempts?: string[]; /** Ordered raw errors associated with failed model attempts. */ fallbackFailures?: ModelFailure[]; ping?: { name: string; message: string }; worktree?: WorktreeHandoff; runtimePlan?: ResolvedRuntimePlan; } /** * State for a launched (but not yet completed) subagent. */ interface RunningSubagent { id: string; name: string; task: string; agent?: string; surface: string; startTime: number; sessionFile: string; launchScriptFile?: string; activityFile?: string; activity?: SubagentActivityState; activityRead?: { ok: boolean; reason?: "missing" | "invalid" | "wrong-id"; error?: string; }; abortController?: AbortController; /** * Optional legacy status snapshot retained only for hydrating pre-lifecycle * runtime entries after /reload. Live observation uses `lifecycle` only. */ statusState?: SubagentStatusState; lifecycle: SubagentLifecycle; /** Last projected kind used to detect stalled/recovered transitions. */ lastProjectedKind?: LifecycleProjection["kind"]; /** One active no-progress warning episode, reset when durable progress resumes. */ noProgressEpisode?: { active: true; progressAt: number; idleMs: number; classification: NoProgressClassification; lastEntryKind: NoProgressSessionTail["lastEntryKind"]; }; /** * When true, status transitions (stalled/recovered) do not wake the parent * session via a steer message. The widget still updates locally. Used for * long-running agents where the user drives the conversation in the * subagent's pane (e.g. planner). */ interactive: boolean; /** Parent-resolved model/thinking selection and provenance. */ runtimePlan: ResolvedRuntimePlan | undefined; worktree?: WorktreeLaunch; persistent?: boolean; logicalId?: string; generationId?: string; policyHash?: string; policyTools?: string[] | null; policyDeniedTools?: string[]; tasksCompleted?: number; taskId?: string; inboxSequence?: number; observedTaskEvents?: number; stopState?: "requested" | "pending" | "failed"; stopFailure?: string; stopTimeout?: ReturnType; stopTimeoutMs?: number; crashNotified?: boolean; supervisionRegistration?: SupervisionRegistration; } interface SubagentRuntime { runningSubagents: Map; supervision?: SupervisionCoordinator; pi?: ExtensionAPI; latestCtx?: ExtensionContext; modelCatalog?: string; } function createSubagentRuntime(): SubagentRuntime { return { runningSubagents: new Map(), }; } /** Runtime state preserved across /reload. */ const runtime: SubagentRuntime = readGlobalSlot(RUNTIME_KEY) ?? createSubagentRuntime(); writeGlobalSlot(RUNTIME_KEY, runtime); const runningSubagents = runtime.runningSubagents; export function shouldPreserveSubagentsOnShutdown( reason: SessionShutdownEvent["reason"] | undefined, ): boolean { return ( reason === "reload" || reason === "new" || reason === "resume" || reason === "fork" ); } export function cleanupSubagentsForShutdown( reason: SessionShutdownEvent["reason"] | undefined, agents: Map>, ): void { if (shouldPreserveSubagentsOnShutdown(reason)) return; for (const agent of agents.values()) { if (agent.lifecycle) { agent.lifecycle = markDelivery(agent.lifecycle, "suppressed"); } agent.abortController?.abort(); } agents.clear(); } export function shouldDeliverSubagentCompletion( running: Pick, ): boolean { // Authoritative gate: only pending deliveries may be sent. // Missing lifecycle (pre-migration fixtures) defaults to pending/true. return (running.lifecycle?.delivery ?? "pending") === "pending"; } export function selectCompletionApi(previous: T, current: T | undefined): T { return current ?? previous; } // ── Widget management ── /** Interval timer for widget re-renders. */ let widgetInterval: ReturnType | null = null; /** Interval timer for status transition checks. */ let statusInterval: ReturnType | null = null; function formatElapsedMMSS(startTime: number, endTime = Date.now()): string { const seconds = Math.floor((endTime - startTime) / 1000); const m = Math.floor(seconds / 60); const s = seconds % 60; return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; } const ACTIVE_ACCENT = "\x1b[38;2;77;163;255m"; const OPEN_ACCENT = "\x1b[38;2;214;158;46m"; const RST = "\x1b[0m"; /** * Build a bordered content line: │left right│ * Left content is truncated if needed, right is preserved, padded to fill width. */ function borderLine( left: string, right: string, width: number, accent = ACTIVE_ACCENT, ): string { if (width <= 0) return ""; if (width === 1) return `${accent}│${RST}`; // width = total visible chars for the whole line including │ and │ const contentWidth = Math.max(0, width - 2); // space inside the two │ chars const rightVis = visibleWidth(right); // If the status chunk alone is too wide, prefer preserving it in compact form // rather than overflowing the terminal. if (rightVis >= contentWidth) { const truncRight = truncateToWidth(right, contentWidth); const rightPad = Math.max(0, contentWidth - visibleWidth(truncRight)); return `${accent}│${RST}${truncRight}${" ".repeat(rightPad)}${accent}│${RST}`; } const maxLeft = Math.max(0, contentWidth - rightVis); const truncLeft = truncateToWidth(left, maxLeft); const leftVis = visibleWidth(truncLeft); const pad = Math.max(0, contentWidth - leftVis - rightVis); return `${accent}│${RST}${truncLeft}${" ".repeat(pad)}${right}${accent}│${RST}`; } /** * Build the bordered top line: ╭─ Title ──── info ─╮ * All chars are accounted for within `width`. */ function borderTop( title: string, info: string, width: number, accent = ACTIVE_ACCENT, ): string { if (width <= 0) return ""; if (width === 1) return `${accent}╭${RST}`; // ╭─ Title ───...─── info ─╮ // overhead: ╭─ (2) + space around title (2) + space around info (2) + ─╮ (2) = but we simplify const inner = Math.max(0, width - 2); // inside ╭ and ╮ const titlePart = `─ ${title} `; const infoPart = ` ${info} ─`; const fillLen = Math.max(0, inner - titlePart.length - infoPart.length); const fill = "─".repeat(fillLen); const content = `${titlePart}${fill}${infoPart}` .slice(0, inner) .padEnd(inner, "─"); return `${accent}╭${content}╮${RST}`; } /** * Build the bordered bottom line: ╰──────────────────╯ */ function borderBottom(width: number, accent = ACTIVE_ACCENT): string { if (width <= 0) return ""; if (width === 1) return `${accent}╰${RST}`; const inner = Math.max(0, width - 2); return `${accent}╰${"─".repeat(inner)}╯${RST}`; } function formatLifecycleWidgetLabel( projection: ReturnType, now: number, ): string { const duration = projection.stateDurationSince == null ? "" : ` ${formatElapsedDuration(now - projection.stateDurationSince)}`; if (projection.kind === "active") return projection.label ? ` active · ${projection.label}${duration} ` : ` active${duration} `; if (projection.kind === "blocked") return ` blocked${duration} `; if (projection.kind === "running") return " running… "; if (projection.kind === "waiting") return ` waiting${duration} `; if (projection.kind === "interrupted") return ` interrupted${duration} `; if (projection.kind === "stalled") return ` stalled${duration} `; // completed/failed exist as lifecycle projections for delivery bookkeeping, // but the row is removed immediately after result delivery — so the only // visible terminal handoff label is finalizing. if ( projection.kind === "finalizing" || projection.kind === "completed" || projection.kind === "failed" ) { return " finalizing… "; } return " starting… "; } function renderSubagentWidgetLines( agents: RunningSubagent[], width: number, ): string[] { const now = Date.now(); const rendered = agents.map((agent) => ({ agent, projection: projectLifecycle(ensureLifecycle(agent), now), })); const activeCount = rendered.filter( ({ projection }) => projection.kind === "active" || projection.kind === "starting" || projection.kind === "running" || projection.kind === "blocked", ).length; const openCount = agents.length - activeCount; const info = activeCount > 0 ? openCount > 0 ? `${activeCount} active · ${openCount} open` : `${activeCount} active` : `${openCount} open`; const accent = activeCount > 0 ? ACTIVE_ACCENT : OPEN_ACCENT; const lines: string[] = [borderTop("Subagents", info, width, accent)]; for (const { agent, projection } of rendered) { const elapsed = formatElapsedMMSS( agent.startTime, projection.runtimeEndedAt ?? now, ); const agentTag = agent.agent ? ` (${agent.agent})` : ""; const left = ` ${elapsed} ${agent.name}${agentTag} `; const runtimeTag = agent.runtimePlan ? `${agent.runtimePlan.modelId}|${agent.runtimePlan.thinking} · ` : ""; const right = statusConfig.enabled ? ` ${runtimeTag}${formatLifecycleWidgetLabel(projection, now).trim()} ` : ` ${runtimeTag}starting… `; lines.push(borderLine(left, right, width, accent)); } lines.push(borderBottom(width, accent)); return lines; } function updateWidget() { const latestCtx = runtime.latestCtx; if (!latestCtx?.hasUI) return; if (runningSubagents.size === 0) { latestCtx.ui.setWidget("subagent-status", undefined); if (widgetInterval) { clearInterval(widgetInterval); widgetInterval = null; writeGlobalSlot | null>( WIDGET_INTERVAL_KEY, null, ); } return; } latestCtx.ui.setWidget( "subagent-status", (_tui: any, _theme: any) => { return { invalidate() {}, render(width: number) { return renderSubagentWidgetLines( Array.from(runningSubagents.values()), width, ); }, }; }, { placement: "aboveEditor" }, ); } /** * Build the positional prompt args for a Pi CLI subagent launch. * * In artifact-backed launches (lineage-only, standalone), Pi's buildInitialMessage() * concatenates @file content with messages[0] into one initial prompt. That breaks * /skill: expansion because the message no longer starts with "/skill:". Only * messages[1..] are sent as separate follow-up prompts where /skill: is recognized. * * When there are skill prompts AND artifact-backed delivery, we prepend an empty * first positional message so that /skill: args land in messages[1..] and arrive * as standalone prompts in the child session. */ function buildPiPromptArgs(params: { effectiveSkills?: string; taskDelivery: "direct" | "artifact"; taskArg: string; }): string[] { const skillPrompts = (params.effectiveSkills ?? "") .split(",") .map((s) => s.trim()) .filter(Boolean) .map((skill) => `/skill:${skill}`); const needsSeparator = params.taskDelivery === "artifact" && skillPrompts.length > 0; return [...(needsSeparator ? [""] : []), ...skillPrompts, params.taskArg]; } function ensureLifecycle(running: RunningSubagent): SubagentLifecycle { if (running.lifecycle) return running.lifecycle; let lifecycle = createLifecycle(running.startTime); const state = running.statusState; if ( state?.activityLabel === "interrupted" && state.localOverrideAtMs != null ) { lifecycle = markInterruptRequested(lifecycle, state.localOverrideAtMs); } else if (state?.phase === "done") { // Legacy activity "done" means the turn ended, not that completion // evidence was recorded. Hydrate as Herdr-style waiting and let the // preserved watcher consume sidecar/sentinel evidence. const observedAt = state.lastActivityAtMs ?? running.startTime; lifecycle = observePaneInspection( lifecycle, { kind: "present", observedAt, agentStatus: "done" }, observedAt, ); } else if ( state?.phase === "active" || state?.phase === "waiting" || state?.phase === "starting" ) { const activity: SubagentActivityState = { version: 1, runningChildId: running.id, createdAt: running.startTime, updatedAt: state.lastActivityAtMs ?? running.startTime, sequence: state.lastActivitySequence ?? 0, latestEvent: state.latestEvent === "agent_end" ? "agent_end" : "agent_start", phase: state.phase, agentActive: state.phase === "active", turnActive: state.phase === "active", providerActive: false, toolActive: state.activeScope === "tool", }; if (isSubagentActivityScope(state.activeScope)) { activity.activeScope = state.activeScope; } if (state.activeSinceMs != null) activity.activeSince = state.activeSinceMs; if (state.waitingSinceMs != null) activity.waitingSince = state.waitingSinceMs; if (state.activityLabel && state.activeScope === "tool") { activity.toolName = state.activityLabel; } lifecycle = observeActivity( lifecycle, { ok: true, activity }, state.lastActivityAtMs ?? running.startTime, ); } else if (running.startTime) { // Pre-lifecycle Pi agents without a known phase still get a running process. lifecycle = markProcessRunning(lifecycle, running.startTime); } running.lifecycle = lifecycle; return lifecycle; } function observeRunningSubagent( running: RunningSubagent, observedAt = Date.now(), ) { ensureLifecycle(running); const activityFile = running.activityFile; const read: ActivityReadResult = activityFile ? readSubagentActivityFile(activityFile, running.id) : { ok: false, reason: "missing" }; running.activityRead = read.ok ? { ok: true } : { ok: false, reason: read.reason, error: read.error }; if (read.ok) running.activity = read.activity; running.lifecycle = observeActivity( ensureLifecycle(running), read, observedAt, ); } type NoProgressAdvisoryEvent = | { kind: "warning"; idleMs: number; classification: NoProgressClassification; lastEntryKind: NoProgressSessionTail["lastEntryKind"]; notify: boolean; } | { kind: "recovered"; idleMs: number; classification: NoProgressClassification; lastEntryKind: NoProgressSessionTail["lastEntryKind"]; notify: boolean; }; function evaluateNoProgressAdvisory( running: RunningSubagent, projection: LifecycleProjection, now: number, hangWarningMinutes: number, ): NoProgressAdvisoryEvent | undefined { if (hangWarningMinutes === 0) { delete running.noProgressEpisode; return; } if (projection.kind !== "active" && projection.kind !== "blocked") { delete running.noProgressEpisode; return; } let sessionMtime: number; try { sessionMtime = statSync(running.sessionFile).mtimeMs; } catch { // Session evidence is unavailable; do not turn that I/O problem into a hang. return; } const progressAt = Math.min( now, Math.max( sessionMtime, running.activity?.updatedAt ?? Number.NEGATIVE_INFINITY, ), ); const idleMs = Math.max(0, now - progressAt); if (idleMs <= hangWarningMinutes * 60_000) { const previous = running.noProgressEpisode; delete running.noProgressEpisode; return previous ? { kind: "recovered", idleMs: Math.max(0, now - previous.progressAt), classification: previous.classification, lastEntryKind: previous.lastEntryKind, notify: !running.interactive, } : undefined; } if (running.noProgressEpisode) return; let tail: NoProgressSessionTail = { classification: "generic-no-progress", lastEntryKind: "other", }; try { // The bounded reader is deliberately cold-path only: mtime/snapshot checks // above run on every refresh, but JSONL parsing happens once per episode. tail = inspectNoProgressSessionTail(running.sessionFile); } catch { // A session can disappear between stat and read; preserve a facts-only // generic advisory rather than failing the status loop. } running.noProgressEpisode = { active: true, progressAt, idleMs, ...tail }; return { kind: "warning", idleMs, ...tail, notify: !running.interactive }; } function formatNoProgressAdvisoryLine( running: RunningSubagent, event: NoProgressAdvisoryEvent, ): string { const name = normalizeStatusName(running.name); const persistentIds = running.persistent ? ` Logical ID: ${running.logicalId ?? "unknown"}; generation ID: ${running.generationId ?? "unknown"}.` : ""; if (event.kind === "recovered") { return `${name} no-progress advisory recovered after ${formatElapsedDuration(event.idleMs)}.${persistentIds}`; } const classification = event.classification === "blocked-tool" ? "blocked-tool; the outstanding tool may still complete" : event.classification === "truncated-turn" ? "truncated-turn; observed toolUse stop with no tool call; cause unknown" : "generic no-progress"; const options = running.worktree ? "interrupt, or retain the workspace and continue there after confirming the previous process exited" : running.persistent ? "interrupt, or use subagent_stop then replace with a new persistent specialist" : "interrupt, or after manual termination use subagent_resume or a new spawn"; return `${name} no-progress advisory: ${formatElapsedDuration(event.idleMs)} idle while active. Classification: ${classification}. Last entry: ${event.lastEntryKind}. Session: ${running.sessionFile}. Recovery options: ${options}.${persistentIds}`; } function resolveInterruptTarget(params: { id?: string; name?: string; }): { running: RunningSubagent } | { error: string } { const requestedId = params.id?.trim(); if (requestedId) { const running = runningSubagents.get(requestedId); return running ? { running } : { error: `No running subagent with id "${requestedId}".` }; } const requestedName = params.name?.trim(); if (!requestedName) { return { error: "Provide a running subagent id or exact display name." }; } const matches = Array.from(runningSubagents.values()).filter( (running) => running.name === requestedName, ); if (matches.length === 1) return { running: matches[0] }; if (matches.length === 0) { return { error: `No running subagent named "${requestedName}".` }; } const candidates = matches .map((running) => `${running.name} [${running.id}]`) .join(", "); return { error: `Ambiguous subagent name "${requestedName}". Matches: ${candidates}`, }; } function resolvePersistentTarget(params: { id?: string; name?: string }) { const resolved = resolveInterruptTarget(params); if ("error" in resolved) return resolved; if (!resolved.running.persistent) { return { error: `Subagent "${resolved.running.name}" is not persistent.` }; } return resolved; } function persistentSpecialistState( running: RunningSubagent, ): "idle" | "working" | "stalled" | "stopped" { const projection = projectLifecycle( ensureLifecycle(running), Date.now(), ).kind; if (running.stopState === "failed") return "stalled"; if (running.stopState) return projection === "stalled" ? "stalled" : "working"; if (running.taskId) return projection === "stalled" ? "stalled" : "working"; // Persistent task completion is authoritative for logical specialist state. // Herdr can continue reporting the long-lived Pi pane as working while the // process remains open between turns. if (running.persistent && running.tasksCompleted != null) return "idle"; if (projection === "stalled") return "stalled"; if ( projection === "active" || projection === "blocked" || projection === "starting" || projection === "running" ) return "working"; if ( projection === "completed" || projection === "failed" || projection === "finalizing" ) return "stopped"; return "idle"; } interface SubagentSendDetails { error?: string; id?: string; task?: string; inbox?: string; outcome?: "dispatched" | "rejected-busy"; } interface PersistentSpecialistFacts { logicalId: string; generationId: string; policyHash: string; tasks: Array<{ task: string; outcome: string }>; sessionFile: string; worktree?: WorktreeHandoff; lastObservedPhase: string; } function persistentSpecialistFacts( running: RunningSubagent, ): PersistentSpecialistFacts { const facts: PersistentSpecialistFacts = { logicalId: running.logicalId!, generationId: running.generationId!, policyHash: running.policyHash!, tasks: readPersistentDeliveryLedger(running.sessionFile).map((entry) => ({ task: entry.task, outcome: entry.outcome, })), sessionFile: running.sessionFile, lastObservedPhase: projectLifecycle(ensureLifecycle(running), Date.now()) .kind, }; if (running.worktree) facts.worktree = captureWorktreeHandoff(running.worktree); return facts; } function formatPersistentSpecialistFacts( facts: PersistentSpecialistFacts, ): string { const lines = [ `Logical ID: ${facts.logicalId}`, `Generation ID: ${facts.generationId}`, `Policy hash: ${facts.policyHash}`, `Task outcomes: ${facts.tasks.map((task) => `${task.task}=${task.outcome}`).join(", ") || "none"}`, `Session: ${facts.sessionFile}`, `Last observed phase: ${facts.lastObservedPhase}`, ]; if (facts.worktree) lines.push( `Worktree Git state: ${facts.worktree.gitError ? "unknown" : facts.worktree.conflicted ? "conflicted" : facts.worktree.clean ? "clean" : "dirty"}`, ); return lines.join("\n"); } function persistentCapacityError( config: PersistentConfig = persistentConfig, ): string | undefined { const specialists = Array.from(runningSubagents.values()).filter( (running) => running.persistent, ); if (specialists.length < config.maxAgents) return undefined; return `Persistent specialist cap (${config.maxAgents}) reached. Current specialists: ${specialists.map((running) => `${running.name} (${persistentSpecialistState(running)}, ${running.tasksCompleted ?? 0} completed)`).join(", ")}.`; } function sendPersistentStopFailure( api: Pick, running: RunningSubagent, ): void { const facts = persistentSpecialistFacts(running); api.sendMessage( { customType: "subagent_stop", content: `Persistent specialist stop failed: process exit was not confirmed. Evidence is retained.\n\n${formatPersistentSpecialistFacts(facts)}`, display: true, details: { status: "failed", facts }, }, { triggerTurn: true, deliverAs: "steer" }, ); } function startPersistentStopTimeout( running: RunningSubagent, api: Pick, stopTimeoutMs = 15_000, ): void { if ( running.stopTimeout || (running.stopState !== "requested" && running.stopState !== "pending") ) return; running.stopTimeout = setTimeout(() => { running.stopTimeout = undefined; if (!runningSubagents.has(running.id)) return; if (running.stopState === "requested" || running.stopState === "pending") { running.stopState = "failed"; running.stopFailure = "process exit was not confirmed within the bounded stop wait"; sendPersistentStopFailure(api, running); } }, stopTimeoutMs); running.stopTimeout.unref(); } interface SubagentStopDetails { error?: string; id?: string; name?: string; status?: "stop_requested" | "stop_pending"; } function handleSubagentStop( params: { id?: string; name?: string }, api: Pick, stopTimeoutMs = 15_000, ): AgentToolResult { const resolved = resolvePersistentTarget(params); if ("error" in resolved) { return { content: [{ type: "text", text: resolved.error }], details: { error: resolved.error }, }; } const running = resolved.running; if (running.stopState === "requested" || running.stopState === "pending") { const error = `Stop is already requested for persistent specialist "${running.name}".`; return { content: [{ type: "text", text: error }], details: { error, id: running.id, name: running.name }, }; } const state = persistentSpecialistState(running); if (state === "stopped") { const error = `Persistent specialist "${running.name}" is already stopped.`; return { content: [{ type: "text", text: error }], details: { error, id: running.id, name: running.name }, }; } const task = running.taskId ?? "stop"; const pending = running.taskId != null; running.stopState = pending ? "pending" : "requested"; running.stopTimeoutMs = stopTimeoutMs; if (pending) { appendPersistentDeliveryLedger(running.sessionFile, { task, outcome: "stop-pending", generation: running.generationId!, logicalId: running.logicalId!, policyHash: running.policyHash!, }); } writePersistentTaskInbox( running.sessionFile, (running.inboxSequence = (running.inboxSequence ?? 0) + 1), { type: "stop", task, message: "" }, ); if (!pending) startPersistentStopTimeout(running, api, stopTimeoutMs); return { content: [ { type: "text", text: pending ? `Stop pending for persistent specialist "${running.name}"; its active task will settle first.` : `Stop requested for persistent specialist "${running.name}".`, }, ], details: { id: running.id, name: running.name, status: pending ? "stop_pending" : "stop_requested", }, }; } function handleSubagentSend(params: { id?: string; name?: string; message: string; }): AgentToolResult { const resolved = resolvePersistentTarget(params); if ("error" in resolved) return { content: [{ type: "text", text: resolved.error }], details: { error: resolved.error }, }; const running = resolved.running; const task = randomUUID(); if (running.stopState === "failed") { appendPersistentDeliveryLedger(running.sessionFile, { task, outcome: "rejected-busy", generation: running.generationId!, logicalId: running.logicalId!, policyHash: running.policyHash!, }); const error = `Persistent specialist "${running.name}" is in an unconfirmed-stop state; task ${task} was rejected-busy. Process exit is unconfirmed and evidence is retained at session ${running.sessionFile}. Request subagent_stop again or spawn a new specialist.`; return { content: [{ type: "text", text: error }], details: { error, task, outcome: "rejected-busy" }, }; } const state = persistentSpecialistState(running); if (state !== "idle") { appendPersistentDeliveryLedger(running.sessionFile, { task, outcome: "rejected-busy", generation: running.generationId!, logicalId: running.logicalId!, policyHash: running.policyHash!, }); const error = `Persistent specialist "${running.name}" is ${state}; task ${task} was rejected-busy. Resend after the pending result.`; return { content: [{ type: "text", text: error }], details: { error, task, outcome: "rejected-busy" }, }; } const inbox = writePersistentTaskInbox( running.sessionFile, (running.inboxSequence = (running.inboxSequence ?? 0) + 1), { task, message: params.message }, ); running.taskId = task; appendPersistentDeliveryLedger(running.sessionFile, { task, outcome: "dispatched", generation: running.generationId!, logicalId: running.logicalId!, policyHash: running.policyHash!, }); return { content: [ { type: "text", text: `Task ${task} dispatched to persistent specialist "${running.name}".`, }, ], details: { id: running.id, task, inbox, outcome: "dispatched" }, }; } function requestSubagentInterrupt( running: RunningSubagent, interruptPaneKey: (surface: string) => void = interruptPane, ): { ok: true } | { error: string } { try { interruptPaneKey(running.surface); return { ok: true }; } catch (error: any) { return { error: `Failed to send Escape to subagent "${running.name}" via herdr: ` + `${error?.message ?? String(error)}`, }; } } interface SubagentInterruptDetails { error?: string; id?: string; name?: string; status?: "interrupt_requested"; } function handleSubagentInterrupt( params: { id?: string; name?: string }, interruptPaneKey: (surface: string) => void = interruptPane, ): AgentToolResult { const resolved = resolveInterruptTarget(params); if ("error" in resolved) { return { content: [{ type: "text" as const, text: resolved.error }], details: { error: resolved.error }, }; } const running = resolved.running; const now = Date.now(); observeRunningSubagent(running, now); const interruption = requestSubagentInterrupt(running, interruptPaneKey); if ("error" in interruption) { return { content: [{ type: "text" as const, text: interruption.error }], details: { error: interruption.error, id: running.id, name: running.name, }, }; } running.lifecycle = markInterruptRequested(ensureLifecycle(running), now); updateWidget(); return { content: [ { type: "text" as const, text: `Interrupt requested for subagent "${running.name}".`, }, ], details: { id: running.id, name: running.name, status: "interrupt_requested", }, }; } function startStatusRefresh(pi: ExtensionAPI) { if (!statusConfig.enabled || statusInterval) return; statusInterval = setInterval(() => { if (runningSubagents.size === 0) { if (statusInterval) { clearInterval(statusInterval); statusInterval = null; writeGlobalSlot | null>( STATUS_INTERVAL_KEY, null, ); } return; } const transitionLines: string[] = []; const now = Date.now(); let shouldRefreshWidget = false; for (const running of runningSubagents.values()) { // Dual-writes lifecycle + statusState for reload hydration; steers use lifecycle only. observeRunningSubagent(running, now); const projection = projectLifecycle(ensureLifecycle(running), now); const transition = lifecycleTransition( running.lastProjectedKind, projection.kind, ); if (running.lastProjectedKind !== projection.kind) { shouldRefreshWidget = true; } running.lastProjectedKind = projection.kind; // Interactive subagents (long-running, user-driven) intentionally don't // wake the parent session on stalled/recovered transitions — the user is // working in the subagent's pane, and a steer message here would burn an // orchestrator turn on a no-op "still waiting" ping. Widget still updates. if (transition && !running.interactive) { transitionLines.push( formatLifecycleTransitionLine( normalizeStatusName(running.name), projection, transition, now, running.startTime, formatElapsedDuration, ), ); } const noProgress = evaluateNoProgressAdvisory( running, projection, now, supervisionConfig.hangWarningMinutes, ); if (noProgress?.notify) { transitionLines.push(formatNoProgressAdvisoryLine(running, noProgress)); } } if (shouldRefreshWidget) updateWidget(); if (transitionLines.length > 0) { const capped = capStatusLines(transitionLines, statusConfig.lineLimit); pi.sendMessage( { customType: "subagent_status", content: formatStatusAggregate( transitionLines, statusConfig.lineLimit, ), display: true, details: { lines: capped.visibleLines, overflow: capped.overflow }, }, { triggerTurn: true, deliverAs: "steer" }, ); } }, 1000); writeGlobalSlot(STATUS_INTERVAL_KEY, statusInterval); } function buildBtwLaunchCommand(params: { cwd: string; sessionFile: string; question: string; model: string; thinking: string; agentDir?: string; }): string { const parts = [ "pi", "--session", shellQuote(params.sessionFile), "--no-extensions", "--model", shellQuote(params.model), "--thinking", shellQuote(params.thinking), shellQuote(BTW_BOUNDARY + params.question), ]; const envPrefix = params.agentDir ? `PI_CODING_AGENT_DIR=${shellQuote(params.agentDir)} ` : ""; return `cd ${shellQuote(params.cwd)} && ${envPrefix}${parts.join(" ")}`; } export const __test__ = { borderLine, renderSubagentWidgetLines, loadAgentDefaults, discoverAgentDefinitions, discoverAgentCatalog, resolveEffectiveSessionMode, resolveLaunchBehavior, resolveEffectiveAutoExit, resolveEffectiveInteractive, buildSubagentToolAllowlist, buildPiPromptArgs, buildBtwLaunchCommand, resolveEffectivePersistent, observeRunningSubagent, evaluateNoProgressAdvisory, formatNoProgressAdvisoryLine, resolveDenyTools, resolveInterruptTarget, requestSubagentInterrupt, handleSubagentInterrupt, handleSubagentSend, handleSubagentStop, persistentSpecialistState, persistentCapacityError, resolveResultPresentation, resolveUnexpectedErrorPresentation, shouldAdvanceToFallback, deliverPersistentTaskEvent, notifyPersistentCrash, sendSubagentResult, shouldRetainSubagentSurface, resolveWorktreeLaunchWarning, formatLivePersistentSpecialists, captureWorktreeHandoff, runSubagentScript, writeWorktreeManifest, runningSubagents, formatElapsed, }; function startWidgetRefresh() { if (widgetInterval) return; updateWidget(); // immediate first render widgetInterval = setInterval(() => { updateWidget(); }, 1000); writeGlobalSlot(WIDGET_INTERVAL_KEY, widgetInterval); } /** * Launch a subagent: creates the herdr pane, builds the command, and * sends it. Returns a RunningSubagent — does NOT poll. * * Call watchSubagent() on the returned object to observe completion. */ async function launchSubagent( params: typeof SubagentParams.static, ctx: { sessionManager: { getSessionFile(): string | null | undefined; getSessionId(): string; getSessionDir(): string; }; cwd: string; model?: { provider: string; id: string }; modelRegistry: { find(provider: string, modelId: string): any; getAvailable?: () => any[]; getAll?: () => any[]; hasConfiguredAuth?: (model: any) => boolean; }; }, parentThinking: ThinkingLevel, options?: { surface?: string; runtimePlan?: ResolvedRuntimePlan; id?: string; }, ): Promise { const agentDefs = params.agent ? loadAgentDefaults(params.agent, runtime.pi) : null; if (params.agent && !agentDefs) { const diagnostic = discoverAgentCatalog(runtime.pi).diagnostics.find( (candidate) => candidate.agentName === params.agent, ); throw new Error( diagnostic?.message ?? `Agent "${params.agent}" was not found.`, ); } if (!ctx.model) throw new Error("Subagent launch requires a resolved parent model"); const runtimePlan = options?.runtimePlan ?? resolveRuntimePlan( { model: params.model, thinking: params.thinking }, { model: resolveModelDefault(params.agent, agentDefs?.model, modelConfig), thinking: agentDefs?.thinking, }, { provider: ctx.model.provider, modelId: ctx.model.id, thinking: parentThinking, }, wrapPiModelRegistry(ctx.modelRegistry), ); const effectiveTools = params.tools ?? agentDefs?.tools; const effectiveSkills = params.skills ?? agentDefs?.skills; const persistent = resolveEffectivePersistent(params, agentDefs); const effectiveAutoExit = resolveEffectiveAutoExit(params, agentDefs); const effectiveInteractive = resolveEffectiveInteractive(params, agentDefs); const logicalId = options?.id ?? randomUUID(); const generationId = randomUUID(); const taskId = randomUUID(); const parentSessionFile = ctx.sessionManager.getSessionFile(); if (!parentSessionFile) throw new Error("No session file"); const running = await launchPiSubagent({ kind: "fresh", id: logicalId, name: params.name, task: params.task, agent: params.agent, cwd: params.cwd, worktree: params.worktree, fork: params.fork, surface: options?.surface, parent: { cwd: ctx.cwd, invocationCwd: process.cwd(), sessionFile: parentSessionFile, sessionId: ctx.sessionManager.getSessionId(), sessionDir: ctx.sessionManager.getSessionDir(), agentDir: getAgentConfigDir(), }, runtimePlan, behavior: { tools: effectiveTools, skills: effectiveSkills, deniedTools: [...resolveDenyTools(agentDefs)], autoExit: effectiveAutoExit, interactive: effectiveInteractive, persistent, logicalId, generationId, taskId, identity: agentDefs?.body ?? params.systemPrompt, systemPromptMode: agentDefs?.systemPromptMode, sessionMode: resolveEffectiveSessionMode(params, agentDefs), cwd: agentDefs?.cwd, }, }); if (persistent) { const policy = readSubagentSessionPolicy(running.sessionFile); if (policy.version !== 2) throw new Error("Persistent launch policy was not written as v2."); running.persistent = true; running.logicalId = policy.logicalId; running.generationId = policy.generationId; running.policyHash = policy.policyHash; running.policyTools = policy.tools; running.policyDeniedTools = policy.deniedTools; running.tasksCompleted = 0; running.taskId = taskId; running.inboxSequence = 0; running.observedTaskEvents = 0; appendPersistentDeliveryLedger(running.sessionFile, { task: taskId, outcome: "dispatched", generation: policy.generationId, logicalId: policy.logicalId, policyHash: policy.policyHash, }); } runningSubagents.set(logicalId, running); return running; } /** * Watch a launched subagent until it exits. Polls for completion, extracts * the summary from the session file. Temporary panes close only after parent * delivery; worktree workspaces remain retained for review. */ function resolveSubagentRuntimePlans( params: typeof SubagentParams.static, ctx: Parameters[1], parentThinking: ThinkingLevel, ): ResolvedRuntimePlan[] { const agentDefs = params.agent ? loadAgentDefaults(params.agent, runtime.pi) : null; if (params.agent && !agentDefs) { const diagnostic = discoverAgentCatalog(runtime.pi).diagnostics.find( (candidate) => candidate.agentName === params.agent, ); throw new Error( diagnostic?.message ?? `Agent "${params.agent}" was not found.`, ); } if (!ctx.model) throw new Error("Subagent launch requires a resolved parent model"); const plans = resolveRuntimePlans( { model: params.model, thinking: params.thinking }, { model: resolveModelDefault(params.agent, agentDefs?.model, modelConfig), thinking: agentDefs?.thinking, }, { provider: ctx.model.provider, modelId: ctx.model.id, thinking: parentThinking, }, wrapPiModelRegistry(ctx.modelRegistry), ); if (params.worktree && plans.length > 1) { throw new Error( "Model fallbacks are not supported for worktree subagents.", ); } return plans; } async function launchSubagentWithFallbacks( params: typeof SubagentParams.static, ctx: Parameters[1], parentThinking: ThinkingLevel, plans: ResolvedRuntimePlan[], ): Promise<{ running: RunningSubagent; index: number; launchFailures: ModelFailure[]; }> { const launchFailures: ModelFailure[] = []; for (const [index, plan] of plans.entries()) { try { return { running: await launchSubagent(params, ctx, parentThinking, { runtimePlan: plan, }), index, launchFailures, }; } catch (error) { launchFailures.push({ model: plan.model, error: error instanceof Error ? error.message : String(error), }); } } throw new Error( `Subagent could not launch with any configured model. Attempted: ${plans.map((plan) => plan.model).join(", ")}. ${launchFailures.map(({ model, error }) => `${model}: ${error}`).join("; ")}`, ); } const inFlightPersistentTaskDeliveries = new Set(); function deliverPersistentTaskEvent( running: RunningSubagent, event: ReturnType[number], api: Pick, ): void { if (!running.persistent || event.generation !== running.generationId) return; const deliveryKey = `${running.id}:${event.type}:${event.task}`; if (inFlightPersistentTaskDeliveries.has(deliveryKey)) return; const ledger = readPersistentDeliveryLedger(running.sessionFile); if (event.type === "help-request") { if ( ledger.some( (entry) => entry.task === event.task && entry.outcome === "help-requested", ) ) return; inFlightPersistentTaskDeliveries.add(deliveryKey); try { api.sendMessage( { customType: "subagent_ping", content: `Persistent specialist "${running.name}" requests help for task ${event.task}:\n\n${event.message ?? ""}\n\nReply with subagent_send to ${running.name}.`, display: true, details: { name: running.name, task: event.task, sessionFile: running.sessionFile, }, }, { triggerTurn: true, deliverAs: "steer" }, ); appendPersistentDeliveryLedger(running.sessionFile, { task: event.task, outcome: "help-requested", generation: running.generationId!, logicalId: running.logicalId!, policyHash: running.policyHash!, }); if (running.taskId === event.task) running.taskId = undefined; } finally { inFlightPersistentTaskDeliveries.delete(deliveryKey); } return; } if ( ledger.some( (entry) => entry.task === event.task && entry.outcome === "delivered", ) ) return; inFlightPersistentTaskDeliveries.add(deliveryKey); try { const completed = (running.tasksCompleted ?? 0) + 1; const summary = existsSync(running.sessionFile) ? (findLastAssistantMessage(getNewEntries(running.sessionFile, 0)) ?? "Persistent specialist completed without output.") : "Persistent specialist session is unavailable."; sendSubagentResult( api, `Persistent specialist "${running.name}" completed task ${event.task} (${completed} tasks completed) and is idle and accepting subagent_send.\n\n${summary}`, { name: running.name, task: event.task, agent: running.agent, sessionFile: running.sessionFile, logicalId: running.logicalId!, generationId: running.generationId!, policyHash: running.policyHash!, }, ); appendPersistentDeliveryLedger(running.sessionFile, { task: event.task, outcome: "delivered", generation: running.generationId!, logicalId: running.logicalId!, policyHash: running.policyHash!, }); running.tasksCompleted = completed; if (running.taskId === event.task) running.taskId = undefined; if (running.stopState === "pending") startPersistentStopTimeout(running, api, running.stopTimeoutMs); } finally { inFlightPersistentTaskDeliveries.delete(deliveryKey); } } function drainPersistentTaskEvents( running: RunningSubagent, api: Pick, ): void { const events = readPersistentTaskEvents(running.sessionFile); for (const event of events.slice(running.observedTaskEvents ?? 0)) { deliverPersistentTaskEvent( running, event, selectCompletionApi(api, runtime.pi), ); } running.observedTaskEvents = events.length; } function notifyPersistentCrash( running: RunningSubagent, api: Pick, ): void { drainPersistentTaskEvents(running, api); if (running.crashNotified) return; running.crashNotified = true; const facts = persistentSpecialistFacts(running); api.sendMessage( { customType: "subagent_result", content: `Persistent specialist crashed. Evidence is retained. Persistent sessions cannot be resumed in v1; spawn a new specialist.\n\n${formatPersistentSpecialistFacts(facts)}`, display: true, details: { error: "persistent-crash", facts }, }, { triggerTurn: true, deliverAs: "steer" }, ); } function getSupervisionCoordinator(): SupervisionCoordinator { if (runtime.supervision) return runtime.supervision; runtime.supervision = new SupervisionCoordinator( async () => { const panes = await listPanes(); if (!panes) return { complete: false, panes: [] }; return { complete: true, panes }; }, inspectPane, supervisionConfig.forcePolling, ); return runtime.supervision; } function drainPersistentEventsSafely(running: RunningSubagent): void { if (!running.persistent || !runtime.pi) return; try { drainPersistentTaskEvents(running, runtime.pi); } catch { // Leave an unread task event for the next file wake-up or reconciliation. } } async function watchSubagent( running: RunningSubagent, signal: AbortSignal, ): Promise { const { name, task, surface, startTime, sessionFile } = running; const supervision = getSupervisionCoordinator().register( sessionFile, surface, ); running.supervisionRegistration = supervision; try { const result = await waitForCompletion(signal, { intervalMs: 1000, sessionFile, waitForNextCheck: supervision.wait, readTerminalTail: () => readPaneAsync(surface, 5), inspectPane: supervision.inspectPane, onLocalEvidence: () => drainPersistentEventsSafely(running), onPaneInspection: (inspection: PaneInspection, observedAt: number) => { ensureLifecycle(running); running.lifecycle = observePaneInspection( running.lifecycle, inspection, observedAt, ); updateWidget(); }, onTick() { observeRunningSubagent(running); }, }); const detectedAt = Date.now(); running.lifecycle = markCompletionDetected( running.lifecycle, result, detectedAt, ); updateWidget(); const elapsed = Math.floor((detectedAt - startTime) / 1000); let summary: string; if (existsSync(sessionFile)) { const allEntries = getNewEntries(sessionFile, 0); const observed = findObservedSessionRuntime(allEntries); if (running.runtimePlan && observed.provider && observed.modelId) { const observedModel = `${observed.provider}/${observed.modelId}`; const observedThinking = observed.thinking === "off" || observed.thinking === "minimal" || observed.thinking === "low" || observed.thinking === "medium" || observed.thinking === "high" || observed.thinking === "xhigh" || observed.thinking === "max" ? observed.thinking : undefined; const mismatch = observedModel === running.runtimePlan.model ? undefined : `Resolved model ${running.runtimePlan.model} but child reported ${observedModel}`; const updatedPlan: ResolvedRuntimePlan = { ...running.runtimePlan }; if (observedThinking) updatedPlan.thinking = observedThinking; updatedPlan.observed = { model: observedModel }; if (observedThinking) updatedPlan.observed.thinking = observedThinking; if (mismatch) updatedPlan.runtimeMismatch = mismatch; running.runtimePlan = updatedPlan; } summary = findLastAssistantMessage(allEntries) ?? (result.errorMessage ? `Subagent error: ${result.errorMessage}` : result.exitCode === 0 ? "Sub-agent exited without output" : `Sub-agent exited with code ${result.exitCode}`); } else { summary = result.errorMessage ? `Subagent error: ${result.errorMessage}` : result.exitCode === 0 ? "Sub-agent exited without output" : `Sub-agent exited with code ${result.exitCode}`; } const worktreeHandoff = finalizeSubagentWorktree( running, result.ping ? "needs_help" : result.exitCode === 0 ? "ready_for_review" : "failed", ); running.lifecycle = result.exitCode === 0 ? markCompleted(running.lifecycle, Date.now()) : markFailed( running.lifecycle, result.errorMessage ?? summary, Date.now(), result.exitCode, ); const watchResult: SubagentResult = { name, task, summary, sessionFile, exitCode: result.exitCode, elapsed, ping: result.ping, runtimePlan: running.runtimePlan, }; if (result.errorMessage) watchResult.errorMessage = result.errorMessage; if (worktreeHandoff) watchResult.worktree = worktreeHandoff; return watchResult; } catch (err: any) { const worktreeHandoff = finalizeSubagentWorktree(running, "failed"); running.lifecycle = markFailed( running.lifecycle, signal.aborted ? "Subagent cancelled." : (err?.message ?? String(err)), Date.now(), 1, ); updateWidget(); if (signal.aborted) { const cancelledResult: SubagentResult = { name, task, summary: "Subagent cancelled.", exitCode: 1, elapsed: Math.floor((Date.now() - startTime) / 1000), error: "cancelled", sessionFile, }; if (worktreeHandoff) cancelledResult.worktree = worktreeHandoff; return cancelledResult; } const errorResult: SubagentResult = { name, task, summary: `Subagent error: ${err?.message ?? String(err)}`, exitCode: 1, elapsed: Math.floor((Date.now() - startTime) / 1000), error: err?.message ?? String(err), }; if (worktreeHandoff) errorResult.worktree = worktreeHandoff; return errorResult; } finally { supervision.unregister(); if (running.supervisionRegistration === supervision) { running.supervisionRegistration = undefined; } } } export function shouldAdvanceToFallback( result: Pick, remainingPlans: number, persistent = false, ): boolean { return !persistent && result.errorMessage !== undefined && remainingPlans > 0; } async function watchSubagentWithFallbacks( initial: RunningSubagent, initialPlanIndex: number, params: typeof SubagentParams.static, ctx: Parameters[1], parentThinking: ThinkingLevel, plans: ResolvedRuntimePlan[], signal: AbortSignal, completedPanes: Set, initialLaunchFailures: ModelFailure[] = [], ): Promise<{ running: RunningSubagent; result: SubagentResult }> { let running = initial; let nextPlan = initialPlanIndex + 1; const attempts = plans .slice(0, initialPlanIndex + 1) .map((plan) => plan.model); const modelFailures = [...initialLaunchFailures]; for (;;) { const result = await watchSubagent(running, signal); if (!running.worktree) completedPanes.add(running.surface); const shouldRetry = shouldAdvanceToFallback( result, plans.length - nextPlan, running.persistent, ); if (result.errorMessage) { modelFailures.push({ model: running.runtimePlan?.model ?? attempts[attempts.length - 1], error: result.errorMessage, }); } if (!shouldRetry) { return { running, result: { ...result, fallbackAttempts: attempts, fallbackFailures: modelFailures, }, }; } runningSubagents.delete(running.id); updateWidget(); const launchFailures: ModelFailure[] = []; let launchedFallback = false; while (nextPlan < plans.length) { const plan = plans[nextPlan++]; attempts.push(plan.model); try { running = await launchSubagent(params, ctx, parentThinking, { runtimePlan: plan, id: initial.id, }); running.abortController = initial.abortController; launchedFallback = true; startWidgetRefresh(); startStatusRefresh(runtime.pi!); break; } catch (error) { launchFailures.push({ model: plan.model, error: error instanceof Error ? error.message : String(error), }); } } modelFailures.push(...launchFailures); if (!launchedFallback) { return { running, result: { ...result, errorMessage: `${result.errorMessage}\n\nFallback launch failures: ${launchFailures.map(({ model, error }) => `${model}: ${error}`).join("; ")}`, fallbackAttempts: attempts, fallbackFailures: modelFailures, }, }; } } } export default function subagentsExtension(pi: ExtensionAPI) { runtime.pi = pi; let btwChild: BtwChild | undefined; const closeBtw = async (): Promise => { const child = btwChild; if (!child) return false; let paneMissing = false; try { paneMissing = (await inspectPane(child.surface)).kind === "missing"; } catch { // Best effort: try closing the pane directly when inspection is unavailable. } if (!paneMissing) { try { interruptPane(child.surface); } catch { // Escape is best effort; pane close is authoritative for this MVP. } closePane(child.surface); } btwChild = undefined; for (const file of [child.sessionFile, child.launchScriptFile]) { try { rmSync(file, { force: true }); } catch { // Ephemeral artifact cleanup is best effort. } } return true; }; // Capture the UI context for widget updates and restore presentation for // subagents whose watchers survived a reload. pi.on("session_start", (_event, ctx) => { runtime.latestCtx = ctx; runtime.modelCatalog = buildAuthenticatedModelCatalog( wrapPiModelRegistry(ctx.modelRegistry), ); const refreshedGuidelines = buildSubagentRoutingGuidelines( runtime.modelCatalog, ); subagentRoutingGuidelines.splice( 0, subagentRoutingGuidelines.length, ...refreshedGuidelines, ); if (runningSubagents.size > 0) { startWidgetRefresh(); startStatusRefresh(pi); updateWidget(); } }); // Clean up on session shutdown pi.on("session_shutdown", async (event, _ctx) => { if (widgetInterval) { clearInterval(widgetInterval); widgetInterval = null; writeGlobalSlot | null>( WIDGET_INTERVAL_KEY, null, ); } if (statusInterval) { clearInterval(statusInterval); statusInterval = null; writeGlobalSlot | null>( STATUS_INTERVAL_KEY, null, ); } cleanupSubagentsForShutdown(event.reason, runningSubagents); if (!shouldPreserveSubagentsOnShutdown(event.reason)) { runtime.supervision?.close(); runtime.supervision = undefined; } try { await closeBtw(); } catch { // Best effort during parent shutdown; the Herdr pane remains recoverable. } }); // Tools denied via PI_DENY_TOOLS env var (set by parent agent based on frontmatter) const deniedTools = new Set( (process.env.PI_SUBAGENT_ID ? (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, or in an isolated Herdr-managed Git worktree when worktree is provided. " + "Use ordinary panes for read-only tasks; a single or sequential writer can work in the parent checkout without a worktree. " + "Reserve unique worktree branches for parallel independent writers starting from committed state — the worktree base is committed HEAD, so uncommitted parent changes are not copied. " + "Worktree runs retain their workspace after completion for parent review; they are not pushed, merged, or removed automatically. " + "To inspect a retained worktree result, spawn read-only agents in an ordinary pane with cwd set to that worktree path — do not create a new worktree for them. " + "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " + "When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " + "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " + "DO NOT fabricate, assume, or summarize results after calling this tool. " + "After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.", promptSnippet: "Spawn a sub-agent in a dedicated terminal herdr pane, or in an isolated Herdr-managed Git worktree when worktree is provided. " + "Use ordinary panes for read-only tasks; a single or sequential writer can work in the parent checkout without a worktree. " + "Reserve unique worktree branches for parallel independent writers starting from committed state — the worktree base is committed HEAD, so uncommitted parent changes are not copied. " + "Worktree runs retain their workspace after completion for parent review; they are not pushed, merged, or removed automatically. " + "To inspect a retained worktree result, spawn read-only agents in an ordinary pane with cwd set to that worktree path — do not create a new worktree for them. " + "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " + "When the sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " + "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT call subagents_list or any other tool to 'check' status. All of that is wasted work — the harness handles delivery for you. " + "DO NOT fabricate, assume, or summarize results after calling this tool. " + "After spawning, either end your turn immediately, or work on other independent tasks (including spawning more subagents in parallel). The harness will wake you with the result when it is ready.", promptGuidelines: subagentRoutingGuidelines, parameters: SubagentParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { // Prevent self-spawning (e.g. planner spawning another planner) const currentAgent = process.env.PI_SUBAGENT_AGENT; if (params.agent && currentAgent && params.agent === currentAgent) { return { content: [ { type: "text", text: `You are the ${currentAgent} agent — do not start another ${currentAgent}. You were spawned to do this work yourself. Complete the task directly.`, }, ], details: { error: "self-spawn blocked" }, }; } const catalog = params.agent ? discoverAgentCatalog(runtime.pi) : undefined; const roleDiagnostic = catalog && !catalog.agents.some((agent) => agent.name === params.agent) ? catalog.diagnostics.find( (candidate) => candidate.agentName === params.agent && (candidate.code === "external-cli-unsupported" || candidate.code === "invalid-capability-declaration"), ) : undefined; if (roleDiagnostic) { return { content: [ { type: "text", text: `Error: ${roleDiagnostic.message}` }, ], details: { error: roleDiagnostic.code }, }; } const persistent = resolveEffectivePersistent( params, params.agent ? loadAgentDefaults(params.agent, runtime.pi) : null, ); const capError = persistent ? persistentCapacityError() : undefined; if (capError) { return { content: [{ type: "text", text: capError }], details: { error: "persistent-cap" }, }; } // Validate prerequisites if (!isTerminalAvailable()) { return muxUnavailableResult(); } if (!ctx.sessionManager.getSessionFile()) { return { content: [ { type: "text", text: "Error: no session file. Start pi with a persistent session to use subagents.", }, ], details: { error: "no session file" }, }; } // Launch the subagent (creates pane, sends command) const parentThinking = pi.getThinkingLevel(); if ( parentThinking !== "off" && parentThinking !== "minimal" && parentThinking !== "low" && parentThinking !== "medium" && parentThinking !== "high" && parentThinking !== "xhigh" && parentThinking !== "max" ) { throw new Error( `Unsupported parent thinking level: ${parentThinking}`, ); } const runtimePlans = resolveSubagentRuntimePlans( params, ctx, parentThinking, ); const worktreeLaunchWarning = resolveWorktreeLaunchWarning( params, runtime.pi, ); const { running: initialRunning, index: initialPlanIndex, launchFailures: initialLaunchFailures, } = await launchSubagentWithFallbacks( params, ctx, parentThinking, runtimePlans, ); let running = initialRunning; // Create a separate AbortController for the watcher // (the tool's signal completes when we return) const watcherAbort = new AbortController(); running.abortController = watcherAbort; // Start widget refresh and status supervision when the first agent launches startWidgetRefresh(); startStatusRefresh(pi); // Keep all temporary attempt panes until the final parent handoff. const completedPanes = new Set(); // Close after accepted delivery or explicit parent shutdown, not failed delivery. let shouldCloseTemporaryPanes = false; // Fire-and-forget: start watching in background watchSubagentWithFallbacks( running, initialPlanIndex, params, ctx, parentThinking, runtimePlans, watcherAbort.signal, completedPanes, initialLaunchFailures, ) .then(({ running: completedRunning, result }) => { running = completedRunning; if (completedRunning.stopTimeout) clearTimeout(completedRunning.stopTimeout); if (completedRunning.persistent) { if (!shouldDeliverSubagentCompletion(completedRunning)) { shouldCloseTemporaryPanes = true; return; } drainPersistentTaskEvents( completedRunning, selectCompletionApi(pi, runtime.pi), ); completedRunning.lifecycle = markDelivery( completedRunning.lifecycle, "delivered", ); const completionApi = selectCompletionApi(pi, runtime.pi); if ( completedRunning.stopState === "requested" || completedRunning.stopState === "pending" ) { appendPersistentDeliveryLedger(completedRunning.sessionFile, { task: "stop", outcome: "stopped", generation: completedRunning.generationId!, logicalId: completedRunning.logicalId!, policyHash: completedRunning.policyHash!, }); const facts = persistentSpecialistFacts(completedRunning); completionApi.sendMessage( { customType: "subagent_stop", content: `Persistent specialist stopped.\n\n${formatPersistentSpecialistFacts(facts)}`, display: true, details: { status: "stopped", facts }, }, { triggerTurn: true, deliverAs: "steer" }, ); } else if (completedRunning.stopState !== "failed") { notifyPersistentCrash(completedRunning, completionApi); } shouldCloseTemporaryPanes = true; runningSubagents.delete(completedRunning.id); updateWidget(); return; } if (!shouldDeliverSubagentCompletion(completedRunning)) { // Explicit parent shutdown still releases temporary panes. shouldCloseTemporaryPanes = true; completedRunning.lifecycle = markDelivery( completedRunning.lifecycle, "suppressed", ); runningSubagents.delete(completedRunning.id); updateWidget(); return; } completedRunning.lifecycle = markDelivery( completedRunning.lifecycle, "delivered", ); runningSubagents.delete(completedRunning.id); updateWidget(); const completionApi = selectCompletionApi(pi, runtime.pi); if (result.ping) { // Subagent is requesting help — steer a ping message with session path for resume const worktreeRef = result.worktree ? `\n\n${formatWorktreeHandoff(result.worktree)}` : ""; const sessionRef = `\n\nSession: ${result.sessionFile}\nResume: pi --session ${result.sessionFile}`; const pingDetails: SubagentPingDetails = { name: result.ping.name, message: result.ping.message, agent: running.agent, sessionFile: result.sessionFile!, }; if (result.worktree) pingDetails.worktree = result.worktree; completionApi.sendMessage( { customType: "subagent_ping", content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${worktreeRef}${sessionRef}`, display: true, details: pingDetails, }, { triggerTurn: true, deliverAs: "steer" }, ); shouldCloseTemporaryPanes = true; return; } const presentation = resolveResultPresentation( result, completedRunning.name, completedRunning.runtimePlan?.runtimeMismatch, ); const resultDetails: SubagentResultDetails = { name: completedRunning.name, task: completedRunning.task, agent: completedRunning.agent, exitCode: result.exitCode, elapsed: result.elapsed, sessionFile: result.sessionFile, }; if (result.errorMessage) resultDetails.errorMessage = result.errorMessage; if (result.fallbackAttempts) resultDetails.fallbackAttempts = result.fallbackAttempts; if (result.fallbackFailures) resultDetails.fallbackFailures = result.fallbackFailures; if (result.worktree) resultDetails.worktree = captureWorktreeHandoff(result.worktree); if (completedRunning.runtimePlan) resultDetails.runtimePlan = completedRunning.runtimePlan; sendSubagentResult(completionApi, presentation, resultDetails); shouldCloseTemporaryPanes = true; }) .catch((err) => { if (!shouldDeliverSubagentCompletion(running)) { running.lifecycle = markDelivery(running.lifecycle, "suppressed"); runningSubagents.delete(running.id); updateWidget(); return; } running.lifecycle = markDelivery(running.lifecycle, "delivered"); runningSubagents.delete(running.id); updateWidget(); if (running.persistent) { notifyPersistentCrash( running, selectCompletionApi(pi, runtime.pi), ); shouldCloseTemporaryPanes = true; return; } const errDetails: SubagentResultDetails = { name: running.name, task: running.task, error: err?.message, sessionFile: running.sessionFile, }; if (running.worktree) errDetails.worktree = captureWorktreeHandoff(running.worktree); sendSubagentResult( selectCompletionApi(pi, runtime.pi), resolveUnexpectedErrorPresentation( `Sub-agent "${running.name}" error`, err, running.sessionFile, ), errDetails, ); shouldCloseTemporaryPanes = true; }) .finally(() => { if (shouldCloseTemporaryPanes) closeCompletedPanes(completedPanes); }); // Return immediately const startedDetails: SubagentStartedDetails = { id: running.id, name: params.name, task: params.task, agent: params.agent, sessionFile: running.sessionFile, launchScriptFile: running.launchScriptFile, model: running.runtimePlan?.model, thinking: running.runtimePlan?.thinking, runtimePlan: running.runtimePlan, status: "started", }; if (running.worktree) startedDetails.worktree = running.worktree; if (worktreeLaunchWarning) startedDetails.warning = worktreeLaunchWarning; return { content: [ { type: "text", text: `Sub-agent "${params.name}" launched and is now running in the background` + (running.worktree ? ` in worktree ${running.worktree.path} on branch ${running.worktree.branch}. ` : ". ") + (worktreeLaunchWarning ? `Warning: ${worktreeLaunchWarning} ` : "") + `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: startedDetails, }; }, renderCall(args, theme) { const partialArgs: PartialSubagentArgs = isPlainObject(args) ? args : {}; const name = isString(partialArgs.name) && partialArgs.name ? partialArgs.name : "(unnamed)"; const task = isString(partialArgs.task) ? partialArgs.task : ""; const agent = isString(partialArgs.agent) && partialArgs.agent ? theme.fg("dim", ` (${partialArgs.agent})`) : ""; const cwdHint = isString(partialArgs.cwd) && partialArgs.cwd ? theme.fg("dim", ` in ${partialArgs.cwd}`) : ""; const worktree = isPlainObject(partialArgs.worktree) ? partialArgs.worktree : undefined; const worktreeHint = isString(worktree?.branch) ? theme.fg("dim", ` on ${worktree.branch} (worktree)`) : ""; let text = "▸ " + theme.fg("toolTitle", theme.bold(name)) + agent + cwdHint + worktreeHint; // 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) { // SAFETY: renderResult only ever receives the details this tool's own // execute() above returned; the framework's TDetails type isn't threaded // through this callback precisely enough for TypeScript to see that. const details = result.details as any; const name = details?.name ?? "(unnamed)"; // "Started" result — tool returned immediately if (details?.status === "started") { const runtime = details?.model ? ` — ${details.model}${details.thinking ? ` · ${details.thinking}` : ""}` : " — started"; const worktree = details?.worktree?.branch ? ` · ${details.worktree.branch}` : ""; return new Text( theme.fg("accent", "▸") + " " + theme.fg("toolTitle", theme.bold(name)) + theme.fg("dim", runtime + worktree), 0, 0, ); } // Fallback (shouldn't happen) return new Text(theme.fg("dim", getFirstText(result.content)), 0, 0); }, }); // ── subagent_send tool ── if (shouldRegister("subagent_send")) pi.registerTool({ name: "subagent_send", label: "Send Persistent Task", description: "Deliver one follow-up task to an idle persistent specialist. Busy specialists reject tasks; no queue is kept.", parameters: Type.Object({ id: Type.Optional( Type.String({ description: "Exact persistent specialist logical ID", }), ), name: Type.Optional( Type.String({ description: "Exact unambiguous persistent specialist name", }), ), message: Type.String({ description: "The next task" }), }), async execute(_toolCallId, params) { return handleSubagentSend(params); }, }); // ── subagent_stop tool ── if (shouldRegister("subagent_stop")) pi.registerTool({ name: "subagent_stop", label: "Stop Persistent Specialist", description: "Gracefully stop a persistent specialist after its active task settles. Exit is confirmed before the specialist is removed.", parameters: Type.Object({ id: Type.Optional( Type.String({ description: "Exact persistent specialist logical ID", }), ), name: Type.Optional( Type.String({ description: "Exact unambiguous persistent specialist name", }), ), }), async execute(_toolCallId, params) { return handleSubagentStop(params, selectCompletionApi(pi, runtime.pi)); }, }); // ── subagent_interrupt tool ── if (shouldRegister("subagent_interrupt")) pi.registerTool({ name: "subagent_interrupt", label: "Interrupt Subagent", description: "Send Escape to the active turn of a currently running Pi-backed subagent. " + "The child pane, session, watcher, and running entry remain alive; this returns only a local acknowledgement " + "and does not emit a subagent_result solely because of this request.", promptSnippet: "Send Escape to the active turn of a currently running Pi-backed subagent. " + "The child pane, session, watcher, and running entry remain alive; this returns only a local acknowledgement " + "and does not emit a subagent_result solely because of this request.", 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 handleSubagentInterrupt(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", " — interrupt turn"), 0, 0, ); }, renderResult(result, _opts, theme) { // SAFETY: renderResult only ever receives the details this tool's own // execute() above returned; the framework's TDetails type isn't threaded // through this callback precisely enough for TypeScript to see that. const details = result.details as any; if (details?.status === "interrupt_requested") { return new Text( theme.fg("accent", "▸") + " " + theme.fg( "toolTitle", theme.bold(details.name ?? details.id ?? "subagent"), ) + theme.fg("dim", " — interrupt requested"), 0, 0, ); } return new Text(theme.fg("dim", getFirstText(result.content)), 0, 0); }, }); // ── subagents_list tool ── if (shouldRegister("subagents_list")) pi.registerTool({ name: "subagents_list", label: "List Subagents", description: "List all available package, global, and project subagent definitions. " + "Project agents override global definitions, which override package definitions.", promptSnippet: "List all available package, global, and project subagent definitions. " + "Project agents override global definitions, which override package definitions.", parameters: Type.Object({}), async execute() { const catalog = discoverAgentCatalog(pi); const list = catalog.agents.filter( (agent) => !agent.disableModelInvocation, ); const lines = [ ...formatVisibleAgentDefinitions(list), ...formatLivePersistentSpecialists(), ...formatSupervisionDiagnostics(), ...formatAgentDiagnostics(catalog.diagnostics), ]; return { content: [ { type: "text", text: lines.join("\n") || "No subagent definitions found.", }, ], details: { agents: list, diagnostics: catalog.diagnostics }, }; }, renderResult(result, _opts, theme) { // SAFETY: renderResult only ever receives the details this tool's own // execute() above returned; the framework's TDetails type isn't threaded // through this callback precisely enough for TypeScript to see that. const details = result.details as any; const agents = details?.agents ?? []; const diagnostics = details?.diagnostics ?? []; if (agents.length === 0 && diagnostics.length === 0) { return new Text( theme.fg("dim", "No subagent definitions found."), 0, 0, ); } const lines = agents.map((a: any) => { const source = a.source === "package" && a.provider ? `package:${a.provider}` : a.source; const badge = theme.fg("accent", ` (${source})`); const desc = a.description ? theme.fg("dim", ` — ${a.description}`) : ""; const model = a.model ? theme.fg("dim", ` [${a.model}]`) : ""; return ` ${theme.fg("toolTitle", theme.bold(a.name))}${badge}${model}${desc}`; }); for (const diagnostic of diagnostics) { lines.push(theme.fg("warning", ` ! ${diagnostic.message}`)); } return new Text(lines.join("\n"), 0, 0); }, }); // ── subagent_resume tool ── if (shouldRegister("subagent_resume")) pi.registerTool({ name: "subagent_resume", label: "Resume Subagent", description: "Resume a previous Pi-backed sub-agent session in a new herdr pane. " + "This does not reattach a retained managed worktree; continue worktree-bound follow-up in its existing workspace. " + "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " + "When the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " + "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT poll for status. All of that is wasted work — the harness handles delivery for you. " + "DO NOT fabricate or assume results. After resuming, either end your turn or work on other independent tasks; the harness will wake you when the result is ready. " + "Use when a sub-agent was cancelled or needs follow-up work.", promptSnippet: "Resume a previous Pi-backed sub-agent session in a new herdr pane. " + "This does not reattach a retained managed worktree; continue worktree-bound follow-up in its existing workspace. " + "This is a fire-and-forget async tool: the call returns immediately with only an acknowledgement. " + "When the resumed sub-agent finishes, the harness AUTOMATICALLY delivers its result as a steer message that wakes you up and starts a new turn — you do not need to do anything to receive it. " + "DO NOT write polling loops, sleep/wait commands, tail/watch scripts, or repeatedly read session/log files to detect completion. DO NOT poll for status. All of that is wasted work — the harness handles delivery for you. " + "DO NOT fabricate or assume results. After resuming, either end your turn or work on other independent tasks; the harness will wake you when the result is ready. " + "Use when a sub-agent was cancelled or needs follow-up work.", parameters: Type.Object({ sessionPath: Type.String({ description: "Path to the session .jsonl file to resume", }), name: Type.Optional( Type.String({ description: "Display name for the terminal tab. Default: 'Resume'", }), ), message: Type.Optional( Type.String({ description: "Optional message to send after resuming (e.g. follow-up instructions)", }), ), autoExit: Type.Optional( Type.Boolean({ description: "Whether the resumed session should automatically exit after completing its response. Defaults to true for autonomous follow-up work; set false for interactive resumed sessions.", }), ), }), renderCall(args, theme) { const name = args.name ?? "Resume"; const text = "▸ " + theme.fg("toolTitle", theme.bold(name)) + theme.fg("dim", " — resuming session"); return new Text(text, 0, 0); }, renderResult(result, _opts, theme) { // SAFETY: renderResult only ever receives the details this tool's own // execute() above returned; the framework's TDetails type isn't threaded // through this callback precisely enough for TypeScript to see that. const details = result.details as any; const name = details?.name ?? "Resume"; if (details?.status === "started") { return new Text( theme.fg("accent", "▸") + " " + theme.fg("toolTitle", theme.bold(name)) + theme.fg("dim", " — resumed"), 0, 0, ); } // Fallback return new Text(theme.fg("dim", getFirstText(result.content)), 0, 0); }, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const name = params.name ?? "Resume"; const id = Math.random().toString(16).slice(2, 10); if (!isTerminalAvailable()) { return muxUnavailableResult(); } if (!existsSync(params.sessionPath)) { return { content: [ { type: "text", text: `Error: session file not found: ${params.sessionPath}`, }, ], details: { error: "session not found" }, }; } // Record entry count before resuming so we can extract new messages const entryCountBefore = getNewEntries(params.sessionPath, 0).length; const running: RunningSubagent = await launchPiSubagent({ kind: "resume", id, name, sessionFile: params.sessionPath, message: params.message, parent: { sessionId: ctx.sessionManager.getSessionId(), sessionDir: ctx.sessionManager.getSessionDir(), }, behavior: { autoExit: params.autoExit }, }); runningSubagents.set(id, running); startWidgetRefresh(); startStatusRefresh(pi); // Fire-and-forget watcher const watcherAbort = new AbortController(); running.abortController = watcherAbort; // Close after accepted delivery or explicit parent shutdown, not failed delivery. let shouldCloseTemporaryPanes = false; watchSubagent(running, watcherAbort.signal) .then((result) => { if (!shouldDeliverSubagentCompletion(running)) { shouldCloseTemporaryPanes = true; running.lifecycle = markDelivery(running.lifecycle, "suppressed"); runningSubagents.delete(running.id); updateWidget(); return; } running.lifecycle = markDelivery(running.lifecycle, "delivered"); runningSubagents.delete(running.id); updateWidget(); const completionApi = selectCompletionApi(pi, runtime.pi); if (result.ping) { const sessionRef = `\n\nSession: ${params.sessionPath}\nResume: pi --session ${params.sessionPath}`; completionApi.sendMessage( { customType: "subagent_ping", content: `Sub-agent "${result.ping.name}" needs help (${formatElapsed(result.elapsed)}):\n\n${result.ping.message}${sessionRef}`, display: true, details: { name: result.ping.name, message: result.ping.message, sessionFile: params.sessionPath, }, }, { triggerTurn: true, deliverAs: "steer" }, ); shouldCloseTemporaryPanes = true; return; } const allEntries = getNewEntries( params.sessionPath, entryCountBefore, ); const summary = findLastAssistantMessage(allEntries) ?? (result.errorMessage ? `Subagent error: ${result.errorMessage}` : result.exitCode === 0 ? "Resumed session exited without new output" : `Resumed session exited with code ${result.exitCode}`); const presentation = resolveResultPresentation( { ...result, summary, sessionFile: params.sessionPath }, name, running.runtimePlan?.runtimeMismatch, ); const resumeDetails: SubagentResultDetails = { name, task: params.message ?? "resumed session", exitCode: result.exitCode, elapsed: result.elapsed, sessionFile: params.sessionPath, }; if (result.errorMessage) resumeDetails.errorMessage = result.errorMessage; if (running.runtimePlan) resumeDetails.runtimePlan = running.runtimePlan; sendSubagentResult(completionApi, presentation, resumeDetails); shouldCloseTemporaryPanes = true; }) .catch((err) => { if (!shouldDeliverSubagentCompletion(running)) { running.lifecycle = markDelivery(running.lifecycle, "suppressed"); runningSubagents.delete(running.id); updateWidget(); return; } running.lifecycle = markDelivery(running.lifecycle, "delivered"); runningSubagents.delete(running.id); updateWidget(); sendSubagentResult( selectCompletionApi(pi, runtime.pi), resolveUnexpectedErrorPresentation( "Resume error", err, params.sessionPath, ), { name, error: err?.message, sessionFile: params.sessionPath }, ); shouldCloseTemporaryPanes = true; }) .finally(() => { if (shouldCloseTemporaryPanes) closeCompletedPanes([running.surface]); }); return { content: [{ type: "text", text: `Session "${name}" resumed.` }], details: { id, name, sessionPath: params.sessionPath, launchScriptFile: running.launchScriptFile, status: "started", }, }; }, }); pi.registerCommand("btw", { description: "Open an ephemeral side-question session in a background Herdr tab", handler: async (args, ctx) => { const question = args.trim(); if (!question) { ctx.ui.notify("Usage: /btw ", "warning"); return; } if (!isTerminalAvailable()) { ctx.ui.notify(terminalSetupHint(), "error"); return; } let sessionFile: string | undefined; let surface: string | undefined; let launchScriptFile: string | undefined; try { await ctx.waitForIdle(); if (btwChild) await closeBtw(); const parentSessionFile = ctx.sessionManager.getSessionFile(); const leafId = ctx.sessionManager.getLeafId(); if (!parentSessionFile || !leafId) { throw new Error("No completed session context is available for BTW"); } if (!ctx.model) throw new Error("No parent model is selected"); sessionFile = createBtwSessionSnapshot(parentSessionFile, leafId); surface = createSubagentPane("BTW"); await waitForShellReady(surface); const artifactDir = getArtifactDir( ctx.sessionManager.getSessionDir(), ctx.sessionManager.getSessionId(), ); launchScriptFile = join( artifactDir, "subagent-scripts", `btw-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`, ); const command = buildBtwLaunchCommand({ cwd: ctx.cwd, sessionFile, question, model: `${ctx.model.provider}/${ctx.model.id}`, thinking: pi.getThinkingLevel(), agentDir: process.env.PI_CODING_AGENT_DIR, }); runScriptInPane(surface, command, { scriptPath: launchScriptFile, scriptPreamble: [ "# BTW side-question session", `# Session: ${sessionFile}`, `# Generated: ${new Date().toISOString()}`, ].join("\n"), }); btwChild = { surface, sessionFile, launchScriptFile }; ctx.ui.notify("BTW opened in a background Herdr tab.", "info"); } catch (error) { if (surface) { try { closePane(surface); } catch { // Leave the pane for manual recovery if launch cleanup fails. } } for (const file of [sessionFile, launchScriptFile]) { if (!file) continue; try { rmSync(file, { force: true }); } catch { // Best effort. } } ctx.ui.notify( `BTW failed: ${error instanceof Error ? error.message : String(error)}`, "error", ); } }, }); pi.registerCommand("btw-close", { description: "Close the current BTW side-question session", handler: async (_args, ctx) => { try { if (!(await closeBtw())) { ctx.ui.notify("No BTW session is open.", "info"); return; } ctx.ui.notify("BTW session closed.", "info"); } catch (error) { ctx.ui.notify( `Could not close BTW session: ${error instanceof Error ? error.message : String(error)}`, "warning", ); } }, }); pi.registerCommand("worktree", { description: "Fork this session into a worktree; use /worktree list to inspect them", handler: async (args, ctx) => { const trimmed = args.trim(); const parts = trimmed.split(/\s+/).filter(Boolean); if (trimmed === "list") { if (!isTerminalAvailable()) { ctx.ui.notify(terminalSetupHint(), "error"); return; } try { const worktrees = listHerdrWorktrees(ctx.cwd); ctx.ui.notify( worktrees .map( (worktree) => `${worktree.branch} — ${worktree.path}${worktree.workspaceId ? ` (${worktree.workspaceId})` : ""}`, ) .join("\n") || "No worktrees found.", "info", ); } catch (error) { ctx.ui.notify( `Worktree list failed: ${error instanceof Error ? error.message : String(error)}`, "error", ); } return; } const branch = parts.shift(); if (!branch || branch === "list") { ctx.ui.notify( "Usage: /worktree [task] | /worktree list", "warning", ); return; } if (!isTerminalAvailable()) { ctx.ui.notify(terminalSetupHint(), "error"); return; } try { await ctx.waitForIdle(); const sessionFile = ctx.sessionManager.getSessionFile(); const leafId = ctx.sessionManager.getLeafId(); if (!sessionFile || !leafId) { throw new Error( "Start pi with a completed persistent session before handing off", ); } if (!ctx.model) throw new Error("No parent model is selected"); const thinking = pi.getThinkingLevel(); if (!isThinkingLevel(thinking)) { throw new Error(`Unsupported parent thinking level: ${thinking}`); } const task = parts.join(" ") || "Continue the current work in the new worktree."; const runtimePlan = resolveRuntimePlan( {}, {}, { provider: ctx.model.provider, modelId: ctx.model.id, thinking, }, wrapPiModelRegistry(ctx.modelRegistry), ); const result = await launchPiWorktreeHandoff({ kind: "fresh", name: `wt: ${branch}`, task, cwd: ctx.cwd, worktree: { branch }, handoff: { leafId }, parent: { cwd: ctx.cwd, invocationCwd: process.cwd(), sessionFile, sessionId: ctx.sessionManager.getSessionId(), sessionDir: ctx.sessionManager.getSessionDir(), agentDir: getAgentConfigDir(), }, runtimePlan, behavior: { deniedTools: [], autoExit: false, interactive: true, sessionMode: "standalone", }, }); const worktree = result.running.worktree; if (!worktree) { throw new Error("Worktree handoff did not return worktree metadata"); } ctx.ui.notify( result.focusError ? `Worktree launched, but workspace focus failed: ${result.focusError}\nWorktree: ${worktree.path}` : `Worktree launched in ${worktree.path} (workspace ${worktree.workspaceId}).`, result.focusError ? "warning" : "info", ); } catch (error) { ctx.ui.notify( `Worktree launch failed: ${error instanceof Error ? error.message : String(error)}`, "error", ); } }, }); // /iterate command — fork the session into a subagent pi.registerCommand("iterate", { description: "Fork session into a subagent for focused work (bugfixes, iteration)", handler: async (args, _ctx) => { const task = args.trim() || ""; const toolCall = task ? `Use subagent to fork an interactive session. fork: true, interactive: true, name: "Iterate", task: ${JSON.stringify(task)}` : `Use subagent to fork an interactive session. fork: true, interactive: true, name: "Iterate", task: "The user wants to do some hands-on work. Help them with whatever they need."`; pi.sendUserMessage(toolCall); }, }); // /subagent command — spawn a subagent by name, or list available agents pi.registerCommand("subagent", { description: "Spawn a subagent: /subagent ; list agents: /subagent list", handler: async (args, ctx) => { const trimmed = args.trim(); if (trimmed === "list") { const catalog = discoverAgentCatalog(pi); const lines = [ ...formatVisibleAgentDefinitions(catalog.agents), ...formatAgentDiagnostics(catalog.diagnostics), ]; ctx.ui.notify( lines.join("\n") || "No subagent definitions found.", "info", ); return; } if (!trimmed) { ctx.ui.notify( "Usage: /subagent [task] | /subagent list", "warning", ); return; } const spaceIdx = trimmed.indexOf(" "); const agentName = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); const task = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); const catalog = discoverAgentCatalog(pi); const defs = catalog.agents.find((agent) => agent.name === agentName); if (!defs) { const diagnostic = catalog.diagnostics.find( (candidate) => candidate.agentName === agentName, ); ctx.ui.notify( diagnostic?.message ?? `Agent "${agentName}" not found.`, "error", ); return; } const taskText = task || `You are the ${agentName} agent. Wait for instructions.`; const displayName = agentName[0].toUpperCase() + agentName.slice(1); const toolCall = `Use subagent with agent: "${agentName}", name: "${displayName}", task: ${JSON.stringify(taskText)}`; pi.sendUserMessage(toolCall); }, }); // ── subagent_result message renderer ── pi.registerMessageRenderer("subagent_result", (message, options, theme) => { // SAFETY: this renderer is only ever wired to messages this extension sends // with customType "subagent_result"; registerMessageRenderer has no static // link between the customType string and a details shape. const details = message.details as any; if (!details) return undefined; return { invalidate() {}, render(width: number): string[] { const name = details.name ?? "subagent"; const exitCode = details.exitCode ?? 0; const errorMessage = isString(details.errorMessage) ? details.errorMessage : ""; const failed = exitCode !== 0 || !!errorMessage; const elapsed = details.elapsed == null ? "?" : formatElapsed(details.elapsed); const bgFn = failed ? (text: string) => theme.bg("toolErrorBg", text) : (text: string) => theme.bg("toolSuccessBg", text); const icon = failed ? theme.fg("error", "✗") : theme.fg("success", "✓"); const status = errorMessage ? "failed (provider/agent error)" : failed ? `failed (exit ${exitCode})` : "completed"; const agentTag = details.agent ? theme.fg("dim", ` (${details.agent})`) : ""; const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "—")} ${status} ${theme.fg("dim", `(${elapsed})`)}`; const rawContent = isString(details.resultContent) ? details.resultContent : isString(message.content) ? message.content : ""; // Clean summary (remove session ref and leading label for display) const summary = rawContent .replace(/\n\nSession: .+\nResume: .+$/, "") .replace(`Sub-agent "${name}" completed (${elapsed}).\n\n`, "") .replace( `Sub-agent "${name}" failed (exit code ${exitCode}).\n\n`, "", ) .replace( new RegExp( `^Sub-agent "${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}" failed after ${elapsed} \\(provider/agent error\\)\\.\\n\\n`, ), "", ); // Build content for the box const contentLines = [header]; if (options.expanded) { // Full view: complete summary + session info if (summary) { for (const line of summary.split("\n")) { contentLines.push(line.slice(0, width - 6)); } } if (details.sessionFile) { contentLines.push(""); contentLines.push( theme.fg("dim", `Session: ${details.sessionFile}`), ); contentLines.push( theme.fg("dim", `Resume: pi --session ${details.sessionFile}`), ); } } else { // Collapsed: preview + expand hint if (summary) { const previewLines = summary.split("\n").slice(0, 5); for (const line of previewLines) { contentLines.push(theme.fg("dim", line.slice(0, width - 6))); } const totalLines = summary.split("\n").length; if (totalLines > 5) { contentLines.push( theme.fg("muted", `… ${totalLines - 5} more lines`), ); } } contentLines.push( theme.fg("muted", keyHint("app.tools.expand", "to expand")), ); } // Render via Box for background + padding, with blank line above for separation const box = new Box(1, 1, bgFn); box.addChild(new Text(contentLines.join("\n"), 0, 0)); return ["", ...box.render(width)]; }, }; }); // ── subagent_status message renderer ── pi.registerMessageRenderer("subagent_status", (message, options, theme) => { // SAFETY: this renderer is only ever wired to messages this extension sends // with customType "subagent_status"; registerMessageRenderer has no static // link between the customType string and a details shape. const details = message.details as any; const lines = Array.isArray(details?.lines) ? details.lines : []; const overflow = isFiniteNumber(details?.overflow) ? details.overflow : 0; if (lines.length === 0 && overflow === 0) return undefined; return { invalidate() {}, render(width: number): string[] { const lineWidth = Math.max(0, width - 6); const contentLines = [ `${theme.fg("accent", "•")} ${theme.fg("toolTitle", theme.bold("Subagent status"))}`, ...lines.map((line: string) => theme.fg("dim", truncateToWidth(line, lineWidth)), ), ]; if (overflow > 0) { contentLines.push(theme.fg("muted", `+${overflow} more running.`)); } if (!options.expanded) { contentLines.push( theme.fg("muted", keyHint("app.tools.expand", "to expand")), ); } const box = new Box(1, 1, (text: string) => theme.bg("customMessageBg", text), ); box.addChild(new Text(contentLines.join("\n"), 0, 0)); return ["", ...box.render(width)]; }, }; }); // ── subagent_ping message renderer ── pi.registerMessageRenderer("subagent_ping", (message, options, theme) => { // SAFETY: this renderer is only ever wired to messages this extension sends // with customType "subagent_ping"; registerMessageRenderer has no static // link between the customType string and a details shape. const details = message.details as any; if (!details) return undefined; return { invalidate() {}, render(width: number): string[] { const name = details.name ?? "subagent"; const agentTag = details.agent ? theme.fg("dim", ` (${details.agent})`) : ""; const bgFn = (text: string) => theme.bg("toolSuccessBg", text); const icon = theme.fg("accent", "?"); const header = `${icon} ${theme.fg("toolTitle", theme.bold(name))}${agentTag} ${theme.fg("dim", "— needs help")}`; const contentLines = [header]; if (options.expanded) { contentLines.push(""); contentLines.push(details.message ?? ""); if (details.sessionFile) { contentLines.push(""); contentLines.push( theme.fg("dim", `Session: ${details.sessionFile}`), ); } } else { const preview = (details.message ?? "") .split("\n")[0] .slice(0, width - 10); contentLines.push(theme.fg("dim", preview)); contentLines.push( theme.fg("muted", keyHint("app.tools.expand", "to expand")), ); } const box = new Box(1, 1, bgFn); box.addChild(new Text(contentLines.join("\n"), 0, 0)); return ["", ...box.render(width)]; }, }; }); // /plan command — start the full planning workflow pi.registerCommand("plan", { description: "Start a planning session: /plan ", handler: async (args, ctx) => { const task = args.trim(); if (!task) { ctx.ui.notify("Usage: /plan ", "warning"); return; } // Load the plan skill from the subagents extension directory const planSkillPath = join(SUBAGENTS_DIR, "plan-skill.md"); let content = readFileSync(planSkillPath, "utf8"); content = content.replace(/^---\n[\s\S]*?\n---\n*/, ""); pi.sendUserMessage( `\n${content.trim()}\n\n\n${task}`, ); }, }); }