diff --git a/packages/coding-agent/src/core/subagent-runner.ts b/packages/coding-agent/src/core/subagent-runner.ts index 9ac267a26..d462379f7 100644 --- a/packages/coding-agent/src/core/subagent-runner.ts +++ b/packages/coding-agent/src/core/subagent-runner.ts @@ -79,18 +79,29 @@ function createSubagentUIContext(): ExtensionUIContext { }; } +export interface RunSubagentOptions { + signal?: AbortSignal; + timeoutMs?: number; + /** + * Called for each agent session event (forwarded from session.subscribe). + * Use this to drive live UI updates without polling. + * Errors thrown from this callback are caught and logged but do not abort the subagent. + */ + onEvent?: (event: AgentSessionEvent) => void; +} + /** * Run a subagent in-process with an isolated AgentSession. * * @param config - Subagent configuration (system prompt, model, tools, cwd). * @param task - Task string to send as the first prompt. - * @param options - Optional AbortSignal and timeout. + * @param options - Optional AbortSignal, timeout, and event callback. * @returns SubagentResult with ok, output, stderr, exitCode. */ export async function runSubagent( config: SubagentConfig, task: string, - options?: { signal?: AbortSignal; timeoutMs?: number }, + options?: RunSubagentOptions, ): Promise { const name = config.name ?? "subagent"; const cwd = config.cwd ?? process.cwd(); @@ -182,6 +193,17 @@ export async function runSubagent( partialOutput += streamEvent.delta; } } + // Forward every event to the caller's callback (if provided). + // Errors from the callback are caught so a buggy caller cannot crash the agent. + if (options?.onEvent) { + try { + options.onEvent(event); + } catch (cbErr) { + process.stderr.write( + `[subagent:${name}] onEvent callback error: ${cbErr instanceof Error ? cbErr.message : String(cbErr)}\n`, + ); + } + } }); // Helper to extract final output from session state messages. diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 9fe47c64a..b720f2f44 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -14,6 +14,7 @@ export { parseSkillBlock, type SessionStats, } from "./core/agent-session.js"; + export { ArtifactManager } from "./core/artifact-manager.js"; // Auth and model registry export { @@ -304,6 +305,7 @@ export { } from "./core/skills.js"; // Subagent runner export { + type RunSubagentOptions, runSubagent, type SubagentConfig, type SubagentResult, diff --git a/src/resources/extensions/sf/subagent/index.js b/src/resources/extensions/sf/subagent/index.js index 8726d7d06..afff31082 100644 --- a/src/resources/extensions/sf/subagent/index.js +++ b/src/resources/extensions/sf/subagent/index.js @@ -62,6 +62,7 @@ const COLLAPSED_ITEM_COUNT = 10; */ const CODEBASE_SEARCH_TIMEOUT_MS = 120_000; const liveSubagentProcesses = new Set(); +const liveSubagentControllers = new Set(); const AGENT_ALIASES = { default: "worker", code: "reviewer", @@ -91,6 +92,16 @@ function resolveAgentByName(agents, agentName) { }; } async function stopLiveSubagents() { + // Abort in-process subagents (RunSubagentOptions-based controllers) + for (const controller of liveSubagentControllers) { + try { + controller.abort(); + } catch { + /* ignore */ + } + } + liveSubagentControllers.clear(); + // Kill spawned processes (sift codebase_search, cmux split path, etc.) const active = Array.from(liveSubagentProcesses); if (active.length === 0) return; for (const proc of active) { @@ -1206,6 +1217,18 @@ async function runSingleAgent( }); } }; + const controller = new AbortController(); + // If the caller supplied a signal, chain it into our local controller + if (signal) { + if (signal.aborted) { + controller.abort(); + } else { + signal.addEventListener("abort", () => controller.abort(), { + once: true, + }); + } + } + liveSubagentControllers.add(controller); try { const systemPrompt = agent.systemPrompt.trim() ? composeAgentPrompt(agent, { @@ -1223,18 +1246,35 @@ async function runSingleAgent( name: agent.name, }, task, - { signal }, + { + signal: controller.signal, + onEvent: (event) => { + processSubagentEventLine( + JSON.stringify(event), + currentResult, + emitUpdate, + ); + }, + }, ); currentResult.exitCode = result.exitCode; - currentResult.stderr = result.stderr ?? ""; - currentResult.messages.push({ - role: "assistant", - content: [{ type: "text", text: result.output }], - model: modelOverride ?? agent.model, - stopReason: result.ok ? "stop" : "error", - errorMessage: result.ok ? undefined : result.stderr, - }); - currentResult.usage.turns = result.output ? 1 : 0; + if (result.stderr) { + currentResult.stderr += currentResult.stderr + ? `\n${result.stderr}` + : result.stderr; + } + // If processSubagentEventLine didn't capture messages from events, + // fall back to synthesising one from the final output text. + if (currentResult.messages.length === 0 && result.output) { + currentResult.messages.push({ + role: "assistant", + content: [{ type: "text", text: result.output }], + model: modelOverride ?? agent.model, + stopReason: result.ok ? "stop" : "error", + errorMessage: result.ok ? undefined : result.stderr, + }); + currentResult.usage.turns = 1; + } if (!result.ok) currentResult.errorMessage = result.stderr; emitUpdate(); return currentResult; @@ -1243,11 +1283,13 @@ async function runSingleAgent( error instanceof Error ? error.message : `Subagent failed: ${String(error)}`; - currentResult.exitCode = signal?.aborted ? 1 : 1; + currentResult.exitCode = 1; currentResult.stderr += currentResult.stderr ? `\n${message}` : message; currentResult.errorMessage = message; emitUpdate(); return currentResult; + } finally { + liveSubagentControllers.delete(controller); } } async function runSingleAgentInCmuxSplit(