import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { basename } from "node:path"; import { compareByStartedAt, INTERACTIVE_SUPERVISOR_SHORTCUT, type AsyncSupervisorItem, type InProcessSupervisorItem, type InteractiveSupervisorItem, type WorkflowSupervisorItem, showInteractiveSupervisor, } from "./interactive-supervisor-ui"; import { cancelInteractiveSubagent, cancelInteractiveDescendantByState, captureInteractiveSubagent, focusInteractiveSubagent, interactiveSubagentHasAttachedClient, interactiveSubagentRegistry, showInteractiveSubagentNativeViewer, type InteractiveSubagentState, } from "./interactive-tmux"; import { cancelLineageSubtreeBestEffort, flattenLineageTree, projectLineageStore, pruneTerminalLineageNodes, resolveLineageStorePaths, type CancelSubtreeResult, type LineageManifest, type ProjectedLineageNode, type ProjectionIssue, } from "./interactive-lineage"; import type { ParsedSpawnTreeContext } from "./spawn-tree-context"; import { getMux, type MuxName, type PaneLiveness } from "./multiplexer"; import { abortJobTree, inProcessJobOwner, inProcessJobsForOwner, scheduleJobCleanup, type JobState, } from "./helpers"; import { snapshotInProcessSession } from "./cancellation-snapshots"; import { updateRunningSubagentFooter } from "./artifact-poller"; import { normalizeCancelledWorkflowState, workflowJobsForOwner, } from "./workflow-jobs"; import { interactiveStateBelongsToOwner, ownerlessEntitiesVisible, resolveLiveSessionScope, type SessionOwnerToken, type SessionScope, } from "./session-scope"; const SUPERVISOR_CAPTURE_MAX_BYTES = 16 * 1024; const SUPERVISOR_CAPTURE_MAX_LINES = 200; const EMPTY_INTERACTIVE_STATES: ReadonlyMap = new Map(); function supervisorInteractiveStates( owner: SessionOwnerToken | undefined, ): ReadonlyMap { if (owner) { return ( resolveLiveSessionScope(owner)?.interactiveStates ?? EMPTY_INTERACTIVE_STATES ); } return ownerlessEntitiesVisible() ? interactiveSubagentRegistry : EMPTY_INTERACTIVE_STATES; } interface SupervisorProjection { items: InteractiveSupervisorItem[]; nodes: Map; /** Raw manifests, including nodes the caps dropped — subtree cancel needs them. */ manifests: LineageManifest[]; issues: ProjectionIssue[]; truncated: boolean; } function isInteractiveStateActionable( state: InteractiveSubagentState, ): boolean { return ( state.status === "running" || state.status === "idle" || state.status === "unknown" ); } export function directSupervisorItems( sessionId?: string, owner?: SessionOwnerToken, ): InteractiveSupervisorItem[] { return [...supervisorInteractiveStates(owner).values()] .filter((state) => interactiveStateBelongsToOwner(state, owner, sessionId)) .sort(compareByStartedAt) .map((state) => ({ kind: "interactive", state, // Workflow indentation is decided by buildAsyncSupervisorItems, which is the // only place that knows whether the owning workflow row is actually present. depth: 0, actionable: isInteractiveStateActionable(state), origin: { source: "registry", ownerSessionId: state.parentSessionId, }, })); } export function buildAsyncSupervisorItems( interactiveItems: InteractiveSupervisorItem[], owner: SessionOwnerToken | undefined, ): AsyncSupervisorItem[] { const processJobs = [...inProcessJobsForOwner(owner).values()] .filter((job) => owner !== undefined || !inProcessJobOwner(job)) .sort(compareByStartedAt); const visibleJobs = new Map(processJobs.map((job) => [job.id, job])); const workflowItems: WorkflowSupervisorItem[] = workflowJobsForOwner(owner) .filter((job) => job.status === "running") .sort(compareByStartedAt) .map((job) => ({ kind: "workflow", job, depth: 0, actionable: job.status === "running", })); const workflowIds = new Set(workflowItems.map((item) => item.job.id)); /** * Only a child whose workflow row is present may be indented. Teardown deletes * the workflow job synchronously while its children take a microtask or more to * unwind, and during that window an indented row would render under no parent. */ const inVisibleWorkflow = ( workflowId: string | undefined, ): workflowId is string => workflowId !== undefined && workflowIds.has(workflowId); const processItems: InProcessSupervisorItem[] = processJobs.map((job) => ({ kind: "in-process", job, depth: inVisibleWorkflow(job.workflowId) ? 1 : inProcessSupervisorDepth(job, visibleJobs), actionable: job.status === "running", reasons: job.status === "running" ? undefined : [job.status], })); const normalizedInteractive: InteractiveSupervisorItem[] = interactiveItems.map((item) => ({ ...item, kind: "interactive", depth: item.state.workflowId ? inVisibleWorkflow(item.state.workflowId) ? Math.max(1, item.depth) : 0 : item.depth, })); const processByWorkflow = new Map(); const interactiveByWorkflow = new Map(); const standalone: GroupableSupervisorItem[] = []; for (const item of processItems) { const workflowId = item.job.workflowId; if (inVisibleWorkflow(workflowId)) addWorkflowChild(processByWorkflow, workflowId, item); else standalone.push(item); } for (const item of normalizedInteractive) { const workflowId = item.state.workflowId; if (inVisibleWorkflow(workflowId)) addWorkflowChild(interactiveByWorkflow, workflowId, item); else standalone.push(item); } const grouped: AsyncSupervisorItem[] = []; for (const root of workflowItems) { grouped.push(root); const children = [ ...(processByWorkflow.get(root.job.id) ?? []), ...(interactiveByWorkflow.get(root.job.id) ?? []), ].sort(compareSupervisorItems); grouped.push(...children); } standalone.sort(compareSupervisorItems); return [...grouped, ...standalone]; } /** Items that can sit under a workflow root; workflow rows are always roots. */ type GroupableSupervisorItem = InProcessSupervisorItem | InteractiveSupervisorItem; function addWorkflowChild( groups: Map, workflowId: string, item: GroupableSupervisorItem, ): void { const children = groups.get(workflowId) ?? []; children.push(item); groups.set(workflowId, children); } /** * Order in-process rows before interactive rows, then by age — the same relative * order the supervisor listed before workflow grouping existed, when the return * value was `[...processItems, ...workflowItems, ...normalizedInteractive]`. */ function compareSupervisorItems( left: GroupableSupervisorItem, right: GroupableSupervisorItem, ): number { const isInteractive = ( item: GroupableSupervisorItem, ): item is InteractiveSupervisorItem => item.kind !== "in-process"; const kindRank = (item: GroupableSupervisorItem): number => item.kind === "in-process" ? 0 : 1; const leftStartedAt = isInteractive(left) ? left.state.startedAt : left.job.startedAt; const rightStartedAt = isInteractive(right) ? right.state.startedAt : right.job.startedAt; const leftId = isInteractive(left) ? left.state.id : left.job.id; const rightId = isInteractive(right) ? right.state.id : right.job.id; return ( kindRank(left) - kindRank(right) || leftStartedAt - rightStartedAt || leftId.localeCompare(rightId) ); } function inProcessSupervisorDepth( job: JobState, visibleJobs: Map, visiting = new Set(), ): number { const parent = job.parentJobId ? visibleJobs.get(job.parentJobId) : undefined; const nextVisiting = new Set(visiting); nextVisiting.add(job.id); if (!job.parentJobId || visiting.has(job.id)) return 0; if (!parent) return 0; return 1 + inProcessSupervisorDepth(parent, visibleJobs, nextVisiting); } function muxNameForManifest(manifest: LineageManifest): MuxName | undefined { if (manifest.pane.backend === "tmux") return "tmux"; if (manifest.pane.backend === "zellij") return "zellij"; return undefined; } function stateForNode( node: ProjectedLineageNode, interactiveStates: ReadonlyMap, paneLivenessById?: Map, ): InteractiveSubagentState | undefined { const manifest = node.manifest; const mux = muxNameForManifest(manifest); if (!mux) return undefined; const existing = interactiveStates.get(manifest.agentId); if (existing) return existing; const paneLiveness = paneLivenessById?.get(manifest.agentId); // tmux's buildAttachCommands resolves the pane's window via execMuxOrThrow, // which THROWS for a dead pane. It runs for every projected node, so one // finished tmux agent used to make the whole projection reject and the overlay // silently degrade to registry-only. Fall back to a session-less string. let attach = { attachCommand: "unavailable", focusCommand: "unavailable" }; try { attach = getMux({ preference: mux }).buildAttachCommands({ paneId: manifest.pane.paneId, windowName: manifest.pane.windowName, session: manifest.pane.muxSession, }); } catch { const target = manifest.pane.windowName ?? manifest.pane.paneId; const detail = paneLiveness === "dead" ? `pane ${target} is gone` : `pane ${target} could not be resolved`; attach = { attachCommand: `unavailable (${detail})`, focusCommand: `unavailable (${detail})`, }; } // Liveness comes from the pane probe, not from `node.state`. Deriving it from // the same field the actionable gate then tests made that gate a tautology // for lineage-only nodes, and mislabelled a live pane hidden for a cycle. return { id: manifest.agentId, name: manifest.name, task: manifest.taskPreview, paneId: manifest.pane.paneId, windowName: manifest.pane.windowName, mux, muxSession: manifest.pane.muxSession, sessionFile: manifest.childSessionFile ?? "unknown", cwd: manifest.cwd, parentSessionId: manifest.ownerSessionId, startedAt: parseStartedAt(manifest.startedAt), status: paneLiveness === undefined ? node.state === "actionable" ? "running" : "unknown" : paneLiveness === "alive" ? "running" : "unknown", attachCommand: attach.attachCommand, selectPaneCommand: attach.focusCommand, launchScriptFile: "unknown", artifactDir: manifest.artifactDir ?? "unknown", }; } /** * `validateLineageManifest` rejects an unparseable startedAt, but a manifest can * still reach here from other sources. NaN would render as "NaNs" and make the * startedAt comparators non-transitive. */ function parseStartedAt(value: string): number { const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : 0; } async function loadSupervisorProjection( context: ParsedSpawnTreeContext | undefined, sessionId: string | undefined, owner: SessionOwnerToken | undefined, ): Promise { if (!context) return undefined; const { rootId, sessionRoot } = context; const interactiveStates = supervisorInteractiveStates(owner); const paths = await resolveLineageStorePaths(sessionRoot, rootId); // Reused across the projection AND the prune sweep so no manifest is probed // twice per refresh. const paneLivenessById = new Map(); const isNodeStale = async (manifest: LineageManifest): Promise => { const cached = paneLivenessById.get(manifest.agentId); if (cached !== undefined) return cached === "dead"; if ( manifest.pane.backend !== "tmux" && manifest.pane.backend !== "zellij" ) { paneLivenessById.set(manifest.agentId, "unknown"); return false; } let paneLiveness: PaneLiveness; try { paneLiveness = await getMux({ preference: manifest.pane.backend, }).getPaneLivenessAsync(manifest.pane.paneId, manifest.pane.muxSession); } catch { paneLiveness = "unknown"; } paneLivenessById.set(manifest.agentId, paneLiveness); return paneLiveness === "dead"; }; const projection = await projectLineageStore( paths.nodesDir, basename(paths.treeDir), isNodeStale, ); // Nothing else unlinks a node manifest, so without this sweep the store grows // by one file per spawn forever and the spawn gate eventually refuses every // new agent. Probes are already cached above, so the sweep costs no extra // subprocesses. Best effort: a failed prune is retried on the next refresh. await pruneTerminalLineageNodes(paths.nodesDir, isNodeStale).catch( () => undefined, ); const flattened = flattenLineageTree(projection.roots); const seen = new Set(flattened.map((node) => node.manifest.agentId)); for (const node of projection.nonActionable) { if (!seen.has(node.manifest.agentId)) { flattened.push(node); seen.add(node.manifest.agentId); } } const items = flattened.flatMap((node): InteractiveSupervisorItem[] => { const state = stateForNode(node, interactiveStates, paneLivenessById); if (!state) return []; if (!interactiveStateBelongsToOwner(state, owner, sessionId)) return []; return [ { state, depth: node.depth, actionable: node.state === "actionable" && isInteractiveStateActionable(state), origin: { source: "lineage", rootId: node.manifest.rootId, ownerSessionId: node.manifest.ownerSessionId, parentAgentId: node.manifest.parentAgentId, }, }, ]; }); for (const state of interactiveStates.values()) { if (!interactiveStateBelongsToOwner(state, owner, sessionId)) continue; if (!seen.has(state.id)) { items.push({ state, depth: 0, actionable: isInteractiveStateActionable(state), origin: { source: "registry", ownerSessionId: state.parentSessionId, }, }); seen.add(state.id); } } return { items, nodes: new Map(flattened.map((node) => [node.manifest.agentId, node])), manifests: projection.manifests, issues: projection.issues, truncated: projection.truncated, }; } /** * Warning lines for the overlay footer. * * `projection.issues` and `truncated` were computed carefully and then thrown * away, which is the mechanism by which a dropped node, an orphan, a cycle, or * an unreadable manifest produced zero user-visible signal. */ export function supervisorStatusLines( projection: SupervisorProjection | undefined, refreshError: string | undefined, ): string[] { const lines: string[] = []; const counts = new Map(); for (const issue of projection?.issues ?? []) { counts.set(issue.kind, (counts.get(issue.kind) ?? 0) + 1); } const hidden = [...counts.values()].reduce((sum, count) => sum + count, 0); if (hidden > 0) { const detail = [...counts.entries()] .sort(([left], [right]) => left.localeCompare(right)) .map(([kind, count]) => `${count} ${kind === "truncated" ? "cap" : kind}`) .join(", "); lines.push( `⚠ ${hidden} lineage node${hidden === 1 ? "" : "s"} hidden (${detail})`, ); } if (projection?.truncated) { lines.push( "⚠ lineage view is truncated — subtree cancellation may reach nodes not listed here", ); } if (refreshError) { lines.push(`⚠ lineage refresh failing: ${refreshError}`); } return lines; } export function registerInteractiveSupervisor( pi: ExtensionAPI, sessionScope?: SessionScope, explicitSpawnTreeContext?: ParsedSpawnTreeContext, ): void { const owner = (): SessionOwnerToken | undefined => sessionScope ? { id: sessionScope.id, generation: sessionScope.generation } : undefined; const spawnTreeContext = (): ParsedSpawnTreeContext | undefined => sessionScope?.spawnTreeContext ?? explicitSpawnTreeContext; const open = async (ctx: { ui: Parameters[0]; sessionManager?: { getSessionId?: () => string }; }) => { const sessionId = ctx.sessionManager?.getSessionId?.(); const activeOwner = owner(); const activeSpawnTreeContext = spawnTreeContext(); let refreshError: string | undefined; let projection = await loadSupervisorProjection( activeSpawnTreeContext, sessionId, activeOwner, ).catch((error: unknown) => { refreshError = errorMessage(error); return undefined; }); await showInteractiveSupervisor( ctx.ui, { items: () => buildAsyncSupervisorItems( projection?.items ?? directSupervisorItems(sessionId, activeOwner), activeOwner, ), status: () => supervisorStatusLines(projection, refreshError), refresh: async () => { // A swallowed failure used to freeze the snapshot indefinitely with no // indication; the error now reaches the overlay footer. try { projection = await loadSupervisorProjection( activeSpawnTreeContext, sessionId, activeOwner, ); refreshError = undefined; } catch (error) { refreshError = errorMessage(error); } }, focus: focusInteractiveSubagent, hasAttachedClient: interactiveSubagentHasAttachedClient, view: async (state) => { const capture = await captureInteractiveSubagent(state, { maxBytes: SUPERVISOR_CAPTURE_MAX_BYTES, maxLines: SUPERVISOR_CAPTURE_MAX_LINES, }); const suffix = capture.truncated ? "\n… output truncated" : ""; ctx.ui.notify( capture.output.length > 0 ? `${state.name} terminal output:\n${capture.output}${suffix}` : `${state.name} has no captured terminal output yet.`, "info", ); }, nativeView: async (state) => { const capture = await captureInteractiveSubagent(state, { maxBytes: SUPERVISOR_CAPTURE_MAX_BYTES, maxLines: SUPERVISOR_CAPTURE_MAX_LINES, }); const opened = await showInteractiveSubagentNativeViewer( state, capture.output || `${state.name} has no captured terminal output yet.`, ); if (!opened) { ctx.ui.notify( "Native presentation is unavailable here; continuing with the portable Pi overlay.", "info", ); } }, cancelInProcess: (job) => { if (!cancelInProcessFromSupervisor(job, sessionId, activeOwner)) return false; updateRunningSubagentFooter(ctx.ui, owner()); return true; }, cancelWorkflow: (job) => { if (job.status !== "running") return false; job.abort.abort(); job.status = "cancelled"; normalizeCancelledWorkflowState(job); return true; }, cancel: (id) => { const item = projection?.items.find( (candidate) => candidate.state.id === id, ); if (!item?.actionable) return undefined; const direct = supervisorInteractiveStates(activeOwner).get(id); if (direct === item.state) { cancelInteractiveSubagent(id, "supervisor", direct); } else { cancelInteractiveDescendantByState(item.state); } updateRunningSubagentFooter(ctx.ui, owner()); return item.state; }, cancelSubtree: async (state) => { // Snapshot the tree BEFORE the confirm blocks for human time. The 1 Hz // refresh reassigns `projection` while the dialog is open, so reading it // afterwards acted on a newer tree than the one the user confirmed. const snapshotRoot = projection?.nodes.get(state.id); const snapshotManifests = projection?.manifests ?? []; const snapshotTruncated = projection?.truncated === true; const descendantCount = snapshotRoot ? subtreeManifestIds(snapshotRoot, snapshotManifests).size - 1 : 0; const truncationWarning = snapshotTruncated ? " The lineage view is truncated, so the tree may be larger than shown." : ""; const confirmed = await ctx.ui.confirm( "Cancel interactive subagent subtree?", `Cancel ${state.name} and its ${descendantCount} descendant${descendantCount === 1 ? "" : "s"}? This closes their mux panes but retains artifacts.${truncationWarning}`, ); if (!confirmed) return; if (!snapshotRoot) { const direct = supervisorInteractiveStates(activeOwner).get( state.id, ); if (direct === state) { cancelInteractiveSubagent(state.id, "supervisor", direct); } else { cancelInteractiveDescendantByState(state); } updateRunningSubagentFooter(ctx.ui, owner()); return; } const result = await cancelLineageSubtreeBestEffort(snapshotRoot, { // The raw manifest set, so descendants past maxDepth or the node cap // are cancelled instead of left running under a dead parent. allManifests: snapshotManifests, projectionTruncated: snapshotTruncated, isStale: async (node) => node.state !== "actionable" || muxNameForManifest(node.manifest) === undefined, isTerminal: async (node) => { const direct = supervisorInteractiveStates(activeOwner).get( node.manifest.agentId, ); return direct ? direct.status === "cancelled" || direct.status === "exited" : false; }, cancel: async (node) => { const nodeState = stateForNode( node, supervisorInteractiveStates(activeOwner), ); if (!nodeState) return; const direct = supervisorInteractiveStates(activeOwner).get( nodeState.id, ); if (direct === nodeState) { cancelInteractiveSubagent(nodeState.id, "supervisor", direct); } else { cancelInteractiveDescendantByState(nodeState); } }, }); updateRunningSubagentFooter(ctx.ui, owner()); ctx.ui.notify( formatSubtreeCancellation(result), result.failed.length > 0 || result.projectionTruncated || result.recovered.length > 0 ? "warning" : "info", ); }, }, activeOwner, ); }; if (typeof pi.registerShortcut === "function") { pi.registerShortcut(INTERACTIVE_SUPERVISOR_SHORTCUT, { description: "Open the async subagent supervisor", handler: open, }); } if (typeof pi.registerCommand === "function") { pi.registerCommand("subagents", { description: "Open the async subagent supervisor", handler: async (_args, ctx) => open(ctx), }); } } /** Agent ids in a subtree, derived from raw parent links (cycle-safe). */ function subtreeManifestIds( root: ProjectedLineageNode, manifests: LineageManifest[], ): Set { const childrenByParent = new Map(); for (const manifest of manifests) { if (!manifest.parentAgentId) continue; const siblings = childrenByParent.get(manifest.parentAgentId) ?? []; siblings.push(manifest.agentId); childrenByParent.set(manifest.parentAgentId, siblings); } const ids = new Set(); const stack = [root.manifest.agentId]; while (stack.length > 0) { const agentId = stack.pop()!; if (ids.has(agentId)) continue; ids.add(agentId); stack.push(...(childrenByParent.get(agentId) ?? [])); } return ids; } /** * Report skip reasons separately. "N stale" used to absorb cycles, orphans and * cap truncation, telling the user the wrong thing about what was not cancelled. */ export function formatSubtreeCancellation(result: CancelSubtreeResult): string { const parts = [ `${result.cancelled.length} cancelled`, `${result.alreadyTerminal.length} already terminal`, ]; const buckets: [string, string[]][] = [ ["stale", result.stale], ["orphaned", result.orphan], ["cyclic", result.cycle], ["beyond the cap", result.truncated], ["malformed", result.malformed], ]; for (const [label, ids] of buckets) { if (ids.length > 0) parts.push(`${ids.length} ${label}`); } parts.push(`${result.failed.length} failed`); const suffixes: string[] = []; if (result.recovered.length > 0) { suffixes.push( `${result.recovered.length} descendant${result.recovered.length === 1 ? "" : "s"} were missing from the displayed tree and were reached via the raw lineage manifests`, ); } if (result.projectionTruncated) { suffixes.push( "the lineage view was truncated, so treat this result as incomplete", ); } const suffix = suffixes.length > 0 ? ` ⚠ ${suffixes.join("; ")}.` : ""; return `Subtree cancellation: ${parts.join(", ")}.${suffix}`; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function cancelInProcessFromSupervisor( job: JobState, sessionId: string | undefined, owner?: SessionOwnerToken, ): boolean { const info = { source: "supervisor" as const, initiator: sessionId, reason: `async supervisor cancelled job ${job.id}`, }; if (job.status !== "running") return false; job.cancellation = { ...info, at: Date.now() }; job.cancellationSnapshot = snapshotInProcessSession({ kind: "in-process", jobId: job.id, session: job.session, cwd: job.cwd ?? process.cwd(), parentSessionId: sessionId, model: job.modelLabel, activeTool: job.liveStatus.activeTool, partialOutput: job.liveStatus.output, startedAt: job.startedAt, source: "supervisor", initiator: info.initiator, reason: info.reason, }); abortJobTree(job.id, info, owner); job.status = "cancelled"; scheduleJobCleanup(job.id, true, undefined, owner); return true; }