/** * agent-manager.ts — Tracks agents, background execution, resume support. * * Background agents are subject to a configurable concurrency limit (default: 4). * Excess agents are queued and auto-started as running agents complete. * Foreground agents bypass the queue (they block the parent anyway). */ import { randomUUID } from "node:crypto"; import type { Api, Model } from "@earendil-works/pi-ai"; import type { AgentSession, ExtensionAPI, ExtensionContext, ToolDefinition, } from "@earendil-works/pi-coding-agent"; import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js"; import { parseHerdrOutputFile } from "./herdr-output.js"; import { runHerdrAgent } from "./herdr-runner.js"; import { createOutputFilePath, writeInitialEntry } from "./output-file.js"; import { parentBridge } from "./parent-bridge.js"; import { buildSelectedAgentLaunchConfig } from "./selected-agent-launch-config.js"; import type { AgentRecord, IsolationMode, RunnerBackend, SubagentType, ThinkingLevel, } from "./types.js"; import { cleanupWorktree, createWorktree, pruneWorktrees } from "./worktree.js"; const CLAUDE_PREFIX_RE = /^Claude\s+/i; const IGNORE_ERROR = () => { /* ignore */ }; const MISSING_PI_INTERCOM_ERROR = 'Cannot use runnerBackend "herdr": pi-intercom is required but was not found.'; const HERDR_PI_INTERCOM_FALLBACK_WARNING = 'runnerBackend "herdr" requested but pi-intercom was not found; falling back to runnerBackend "in-process".'; function isMissingPiIntercomError(error: unknown): boolean { return error instanceof Error && error.message === MISSING_PI_INTERCOM_ERROR; } function getModelName(model?: Model): string | undefined { const label = model?.name ?? model?.id; return label ? label.replace(CLAUDE_PREFIX_RE, "").toLowerCase() : undefined; } function getParentSessionId(ctx: ExtensionContext): string | undefined { return ctx.sessionManager?.getSessionId?.(); } function isProjectTrusted(ctx: ExtensionContext): boolean { const trustAwareCtx = ctx as ExtensionContext & { isProjectTrusted?: () => boolean; }; return trustAwareCtx.isProjectTrusted?.() ?? false; } function isMissingFileError(error: unknown): boolean { return ( error instanceof Error && "code" in error && (error as { code?: unknown }).code === "ENOENT" ); } function setRunTag(record: AgentRecord, tag: string): void { const prefix = tag.split(":").slice(0, 2).join(":"); record.tags = [ ...(record.tags ?? []).filter((t) => !t.startsWith(prefix)), tag, ]; } export type OnAgentComplete = (record: AgentRecord) => void; export type OnAgentStart = (record: AgentRecord) => void; /** Default max concurrent background agents. */ const DEFAULT_MAX_CONCURRENT = 4; interface SpawnArgs { pi: ExtensionAPI; ctx: ExtensionContext; type: SubagentType; prompt: string; options: SpawnOptions; } interface AgentRunCompletion { responseText: string; session?: AgentSession; aborted: boolean; steered: boolean; warnings: string[]; } function createAbortedCompletion(warnings: string[]): AgentRunCompletion { return { responseText: "", aborted: true, steered: false, warnings, }; } export interface SpawnOptions { description: string; model?: Model; maxTurns?: number; isolated?: boolean; inheritContext?: boolean; thinkingLevel?: ThinkingLevel; runnerBackend?: RunnerBackend; isBackground?: boolean; /** Whether this run may block on ask_parent. Defaults to background runs only. */ allowAskParent?: boolean; /** Isolation mode — "worktree" creates a temp git worktree for the agent. */ isolation?: IsolationMode; /** Parent abort signal — when aborted, the subagent is also stopped. */ signal?: AbortSignal; /** Extra tools available only for this agent run. */ customTools?: ToolDefinition[]; /** Called on tool start/end with activity info (for streaming progress to UI). */ onToolActivity?: (activity: ToolActivity) => void; /** Called on streaming text deltas from the assistant response. */ onTextDelta?: (delta: string, fullText: string) => void; /** Called when the agent session is created (for accessing session stats). */ onSessionCreated?: (session: AgentSession) => void; /** Called at the end of each agentic turn with the cumulative count. */ onTurnEnd?: (turnCount: number) => void; } export class AgentManager { private readonly agents = new Map(); private readonly cleanupInterval: ReturnType; private readonly onComplete?: OnAgentComplete; private readonly onStart?: OnAgentStart; private maxConcurrent: number; /** Queue of background agents waiting to start. */ private queue: { id: string; args: SpawnArgs }[] = []; /** Number of currently running background agents. */ private runningBackground = 0; constructor( onComplete?: OnAgentComplete, maxConcurrent = DEFAULT_MAX_CONCURRENT, onStart?: OnAgentStart ) { this.onComplete = onComplete; this.onStart = onStart; this.maxConcurrent = maxConcurrent; // Cleanup completed agents after 10 minutes (but keep sessions for resume) this.cleanupInterval = setInterval(() => this.cleanup(), 60_000); this.cleanupInterval.unref(); } /** Update the max concurrent background agents limit. */ setMaxConcurrent(n: number) { this.maxConcurrent = Math.max(1, n); // Start queued agents if the new limit allows this.drainQueue(); } getMaxConcurrent(): number { return this.maxConcurrent; } /** * Spawn an agent and return its ID immediately (for background use). * If the concurrency limit is reached, the agent is queued. */ spawn( pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: SpawnOptions ): string { const id = randomUUID().slice(0, 17); const abortController = new AbortController(); const record: AgentRecord = { id, type, description: options.description, status: options.isBackground ? "queued" : "running", modelName: getModelName(options.model), thinkingLevel: options.thinkingLevel, runnerBackend: options.runnerBackend, toolUses: 0, startedAt: Date.now(), abortController, }; this.agents.set(id, record); const args: SpawnArgs = { pi, ctx, type, prompt, options }; if (options.isBackground && this.runningBackground >= this.maxConcurrent) { // Queue it — will be started when a running agent completes this.queue.push({ id, args }); return id; } // startAgent can throw (e.g. strict worktree-isolation failure) — clean // up the record so callers don't see an orphan in `listAgents()`. try { this.startAgent(id, record, args); } catch (err) { this.agents.delete(id); throw err; } return id; } /** Actually start an agent (called immediately or from queue drain). */ private startAgent( id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs ) { // Worktree isolation: try to create a temporary git worktree. Strict — // fail loud if not possible (no silent fallback to main tree). Done // BEFORE state mutation so a throw doesn't leave the record half-running. let worktreeCwd: string | undefined; if (options.isolation === "worktree") { const wt = createWorktree(ctx.cwd, id); if (!wt) { throw new Error( 'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' + "Initialize git and commit at least once, or omit `isolation`." ); } record.worktree = wt; worktreeCwd = wt.path; } record.status = "running"; record.startedAt = Date.now(); if (options.isBackground) { this.runningBackground++; } this.onStart?.(record); // Wire parent abort signal to stop the subagent when the parent is interrupted let detachParentSignal: (() => void) | undefined; if (options.signal) { const onParentAbort = () => this.abort(id); options.signal.addEventListener("abort", onParentAbort, { once: true }); detachParentSignal = () => options.signal!.removeEventListener("abort", onParentAbort); } const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; }; const parentSessionId = getParentSessionId(ctx) ?? "default"; if (options.runnerBackend === "herdr" && !record.outputFile) { record.outputFile = createOutputFilePath(ctx.cwd, id, parentSessionId); } const runInProcessAgent = ( initialWarnings: string[] = [] ): Promise => { const mergeWarnings = (warnings: string[]) => [ ...initialWarnings, ...warnings, ]; record.warnings = initialWarnings.length > 0 ? [...initialWarnings] : undefined; return runAgent(ctx, type, prompt, { pi, agentId: id, parentSessionId: getParentSessionId(ctx), allowAskParent: options.allowAskParent ?? Boolean(options.isBackground), customTools: options.customTools, model: options.model, maxTurns: options.maxTurns, isolated: options.isolated, inheritContext: options.inheritContext, thinkingLevel: options.thinkingLevel, cwd: worktreeCwd, signal: record.abortController!.signal, onToolActivity: (activity) => { if (activity.type === "end") { record.toolUses++; } options.onToolActivity?.(activity); }, onTurnEnd: options.onTurnEnd, onTextDelta: options.onTextDelta, onRunTag: (tag) => { setRunTag(record, tag); }, onWarning: (warnings) => { const nextWarnings = mergeWarnings(warnings); record.warnings = nextWarnings.length > 0 ? nextWarnings : undefined; }, notifyWarnings: !options.isBackground, onSessionCreated: (session) => { record.session = session; // Flush any steers that arrived before the session was ready if (record.pendingSteers?.length) { for (const msg of record.pendingSteers) { session.steer(msg).catch(IGNORE_ERROR); } record.pendingSteers = undefined; } options.onSessionCreated?.(session); }, }).then((result) => ({ ...result, warnings: mergeWarnings(result.warnings), })); }; const promise = ( options.runnerBackend === "herdr" ? buildSelectedAgentLaunchConfig(type, { pi, cwd: worktreeCwd, parentCwd: ctx.cwd, parentSystemPrompt: ctx.getSystemPrompt(), parentModel: ctx.model, modelRegistry: ctx.modelRegistry, model: options.model, maxTurns: options.maxTurns, isolated: options.isolated, thinkingLevel: options.thinkingLevel, }) .then((launchConfig) => runHerdrAgent({ agentId: id, cwd: launchConfig.effectiveCwd, parentSessionId, prompt, outputFile: record.outputFile, selectedAgentType: type, selectedAgentLaunchConfig: launchConfig, context: options.inheritContext ? "fork" : "fresh", approve: isProjectTrusted(ctx), signal: record.abortController!.signal, cavemanEvents: pi.events, onRunTag: (tag) => { setRunTag(record, tag); }, onWarning: (warnings) => { record.warnings = warnings.length > 0 ? warnings : undefined; }, }).then( ({ responseText, outputFile, warnings: launchWarnings = [], }) => { record.outputFile = outputFile; try { const metadata = parseHerdrOutputFile(outputFile); const warnings = [...launchWarnings, ...metadata.warnings]; record.toolUses = metadata.toolUses; record.herdrTranscript = { entries: metadata.transcriptEntries, warnings: warnings.length > 0 ? warnings : undefined, }; return { responseText: metadata.responseText ?? responseText, session: undefined, aborted: false, steered: false, warnings, }; } catch (error) { if (!isMissingFileError(error)) { throw error; } return { responseText, session: undefined, aborted: false, steered: false, warnings: launchWarnings, }; } } ) ) .catch((error) => { if (!(options.isBackground && isMissingPiIntercomError(error))) { throw error; } if ( record.status !== "running" || record.abortController?.signal.aborted ) { return createAbortedCompletion(record.warnings ?? []); } record.runnerBackend = "in-process"; if (record.outputFile) { writeInitialEntry(record.outputFile, id, prompt, ctx.cwd); } return runInProcessAgent([HERDR_PI_INTERCOM_FALLBACK_WARNING]); }) : runInProcessAgent() ) .then(({ responseText, session, aborted, steered, warnings }) => { record.warnings = warnings.length > 0 ? warnings : undefined; // Don't overwrite status if externally stopped via abort() if (record.status !== "stopped") { if (aborted) { record.status = "aborted"; } else if (steered) { record.status = "steered"; } else { record.status = "completed"; } } record.result = responseText; if (session) { record.session = session; } record.completedAt ??= Date.now(); this.disposeBridgeState(id, `Agent ${id} ${record.status}.`); detach(); // Final flush of streaming output file if (record.outputCleanup) { try { record.outputCleanup(); } catch { /* ignore */ } record.outputCleanup = undefined; } // Clean up worktree if used if (record.worktree) { const wtResult = cleanupWorktree( ctx.cwd, record.worktree, options.description ); record.worktreeResult = wtResult; if (wtResult.hasChanges && wtResult.branch) { record.result = (record.result ?? "") + `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``; } } if (options.isBackground) { this.runningBackground--; try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ } this.drainQueue(); } return responseText; }) .catch((err) => { // Don't overwrite status if externally stopped via abort() if (record.status !== "stopped") { record.status = "error"; } record.error = err instanceof Error ? err.message : String(err); record.completedAt ??= Date.now(); this.disposeBridgeState(id, `Agent ${id} ${record.status}.`); detach(); // Final flush of streaming output file on error if (record.outputCleanup) { try { record.outputCleanup(); } catch { /* ignore */ } record.outputCleanup = undefined; } // Best-effort worktree cleanup on error if (record.worktree) { try { const wtResult = cleanupWorktree( ctx.cwd, record.worktree, options.description ); record.worktreeResult = wtResult; } catch { /* ignore cleanup errors */ } } if (options.isBackground) { this.runningBackground--; this.onComplete?.(record); this.drainQueue(); } return ""; }); record.promise = promise; } /** Start queued agents up to the concurrency limit. */ private drainQueue() { while ( this.queue.length > 0 && this.runningBackground < this.maxConcurrent ) { const next = this.queue.shift()!; const record = this.agents.get(next.id); if (!record || record.status !== "queued") { continue; } try { this.startAgent(next.id, record, next.args); } catch (err) { // Late failure (e.g. strict worktree-isolation) — surface on the record // so the user/agent can see it via /agents, then keep draining. record.status = "error"; record.error = err instanceof Error ? err.message : String(err); record.completedAt = Date.now(); this.onComplete?.(record); } } } /** * Spawn an agent and wait for completion (foreground use). * Foreground agents bypass the concurrency queue. */ async spawnAndWait( pi: ExtensionAPI, ctx: ExtensionContext, type: SubagentType, prompt: string, options: Omit ): Promise { const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false, }); const record = this.agents.get(id)!; await record.promise; return record; } /** * Resume an existing agent session with a new prompt. */ async resume( id: string, prompt: string, signal?: AbortSignal ): Promise { const record = this.agents.get(id); if (!record?.session) { return undefined; } record.status = "running"; record.startedAt = Date.now(); record.completedAt = undefined; record.result = undefined; record.error = undefined; try { const responseText = await resumeAgent(record.session, prompt, { onToolActivity: (activity) => { if (activity.type === "end") { record.toolUses++; } }, signal, }); record.status = "completed"; record.result = responseText; record.completedAt = Date.now(); this.disposeBridgeState(id, `Agent ${id} completed.`); } catch (err) { record.status = "error"; record.error = err instanceof Error ? err.message : String(err); record.completedAt = Date.now(); this.disposeBridgeState(id, `Agent ${id} error.`); } return record; } getRecord(id: string): AgentRecord | undefined { return this.agents.get(id); } listAgents(): AgentRecord[] { return [...this.agents.values()].sort((a, b) => b.startedAt - a.startedAt); } abort(id: string): boolean { const record = this.agents.get(id); if (!record) { return false; } // Remove from queue if queued if (record.status === "queued") { this.queue = this.queue.filter((q) => q.id !== id); this.disposeBridgeState(id, `Agent ${id} removed from queue.`); record.status = "stopped"; record.completedAt = Date.now(); return true; } if (record.status !== "running") { return false; } record.abortController?.abort(); this.disposeBridgeState(id, `Agent ${id} stopped.`); record.status = "stopped"; record.completedAt = Date.now(); return true; } private disposeBridgeState(id: string, reason: string): void { parentBridge.disposeAgent(id, reason); } /** Dispose a record's session and remove it from the map. */ private removeRecord(id: string, record: AgentRecord): void { this.disposeBridgeState(id, `Agent ${id} record removed.`); record.session?.dispose?.(); record.session = undefined; this.agents.delete(id); } private cleanup() { const cutoff = Date.now() - 10 * 60_000; for (const [id, record] of this.agents) { if (record.status === "running" || record.status === "queued") { continue; } if ((record.completedAt ?? 0) >= cutoff) { continue; } this.removeRecord(id, record); } } /** * Remove all completed/stopped/errored records immediately. * Called on session start/switch so tasks from a prior session don't persist. */ clearCompleted(): void { for (const [id, record] of this.agents) { if (record.status === "running" || record.status === "queued") { continue; } this.removeRecord(id, record); } } /** Whether any agents are still running or queued. */ hasRunning(): boolean { return [...this.agents.values()].some( (r) => r.status === "running" || r.status === "queued" ); } /** Abort all running and queued agents immediately. */ abortAll(): number { let count = 0; // Clear queued agents first for (const queued of this.queue) { const record = this.agents.get(queued.id); if (record) { this.disposeBridgeState( queued.id, `Agent ${queued.id} removed from queue.` ); record.status = "stopped"; record.completedAt = Date.now(); count++; } } this.queue = []; // Abort running agents for (const [id, record] of this.agents) { if (record.status === "running") { record.abortController?.abort(); this.disposeBridgeState(id, `Agent ${id} stopped.`); record.status = "stopped"; record.completedAt = Date.now(); count++; } } return count; } /** Wait for all running and queued agents to complete (including queued ones). */ async waitForAll(): Promise { // Loop because drainQueue respects the concurrency limit — as running // agents finish they start queued ones, which need awaiting too. while (true) { this.drainQueue(); const pending = [...this.agents.values()] .filter((r) => r.status === "running" || r.status === "queued") .map((r) => r.promise) .filter(Boolean); if (pending.length === 0) { break; } await Promise.allSettled(pending); } } dispose() { clearInterval(this.cleanupInterval); this.abortAll(); for (const [id, record] of this.agents) { this.removeRecord(id, record); } // Prune any orphaned git worktrees (crash recovery) try { pruneWorktrees(process.cwd()); } catch { /* ignore */ } } }