/** * Server-side manager for subagent runs spawned by the web chat agent. * * Mirrors the ActiveRunManager pattern: buffers SSE events, supports * subscriber fan-out, and tracks subagent metadata per parent web session. * * Events are fed from CLI NDJSON streams (parent run + subscribe continuations). */ import { type ChildProcess, spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; import { createInterface } from "node:readline"; import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from "node:fs"; import { join } from "node:path"; import { type AgentEvent, spawnAgentSubscribeProcess, resolvePackageRoot, extractToolResult, buildToolOutput, parseAgentErrorMessage, parseErrorBody, } from "./agent-runner"; import { resolveOpenClawStateDir, resolveWebChatDir } from "./workspace"; // ── Types ── export type SseEvent = Record & { type: string }; export type SubagentSubscriber = (event: SseEvent | null) => void; export type SubagentInfo = { sessionKey: string; runId: string; parentWebSessionId: string; task: string; label?: string; status: "running" | "completed" | "error"; startedAt: number; endedAt?: number; }; type SubagentRun = SubagentInfo & { eventBuffer: SseEvent[]; subscribers: Set; /** Internal state for event-to-SSE transformation */ _state: TransformState; _subscribeProcess: ChildProcess | null; _cleanupTimer: ReturnType | null; /** Last globalSeq seen from the gateway event stream for replay cursor. */ lastGlobalSeq: number; }; type TransformState = { idCounter: number; currentTextId: string; currentReasoningId: string; textStarted: boolean; reasoningStarted: boolean; everSentText: boolean; statusReasoningActive: boolean; }; // ── Constants ── const CLEANUP_GRACE_MS = 24 * 60 * 60_000; // 24 hours — events are persisted to disk const GLOBAL_KEY = "__openclaw_subagentRuns" as const; // ── Singleton registry ── type SubagentRegistry = { runs: Map; /** Reverse index: parent web session ID → subagent session keys */ parentIndex: Map>; }; function getRegistry(): SubagentRegistry { const existing = (globalThis as Record)[GLOBAL_KEY] as | SubagentRegistry | undefined; if (existing) {return existing;} const registry: SubagentRegistry = { runs: new Map(), parentIndex: new Map(), }; (globalThis as Record)[GLOBAL_KEY] = registry; return registry; } // ── Event persistence ── /** Profile-scoped directory for subagent event JSONL files. */ function subagentEventsDir(): string { return join(resolveWebChatDir(), "subagent-events"); } /** Pre-profile-scoping legacy path — used as a read fallback for migration. */ function legacySubagentEventsDir(): string { return join(resolveOpenClawStateDir(), "web-chat", "subagent-events"); } /** Filesystem-safe filename derived from a session key. */ function safeFilename(sessionKey: string): string { return sessionKey.replaceAll(":", "_") + ".jsonl"; } function persistEvent(sessionKey: string, event: SseEvent): void { try { const dir = subagentEventsDir(); mkdirSync(dir, { recursive: true }); appendFileSync(join(dir, safeFilename(sessionKey)), JSON.stringify(event) + "\n"); } catch { /* best-effort */ } } function loadPersistedEvents(sessionKey: string): SseEvent[] { const fname = safeFilename(sessionKey); // Try profile-scoped dir first, fall back to legacy shared dir. let filePath = join(subagentEventsDir(), fname); if (!existsSync(filePath)) { const legacyPath = join(legacySubagentEventsDir(), fname); if (existsSync(legacyPath)) { filePath = legacyPath; } else { return []; } } try { const lines = readFileSync(filePath, "utf-8").split("\n"); const events: SseEvent[] = []; for (const line of lines) { if (!line.trim()) {continue;} try { events.push(JSON.parse(line) as SseEvent); } catch { /* skip */ } } return events; } catch { return []; } } // ── Profile-scoped subagent index ── type SubagentIndexEntry = { runId: string; parentWebSessionId: string; task: string; label?: string; status: "running" | "completed" | "error"; startedAt: number; endedAt?: number; }; function subagentIndexPath(): string { return join(resolveWebChatDir(), "subagent-index.json"); } function loadSubagentIndex(): Record { const p = subagentIndexPath(); if (!existsSync(p)) {return {};} try { return JSON.parse(readFileSync(p, "utf-8")) as Record; } catch { return {}; } } function upsertSubagentIndex(sessionKey: string, entry: SubagentIndexEntry): void { try { const dir = resolveWebChatDir(); mkdirSync(dir, { recursive: true }); const index = loadSubagentIndex(); index[sessionKey] = entry; writeFileSync(subagentIndexPath(), JSON.stringify(index, null, 2)); } catch { /* best-effort */ } } /** Read the on-disk registry entry and derive the proper status. */ function readDiskStatus(sessionKey: string): "running" | "completed" | "error" { // Check profile-scoped index first. const profileIndex = loadSubagentIndex(); const profileEntry = profileIndex[sessionKey]; if (profileEntry) { return profileEntry.status; } // Fall back to the shared gateway registry. const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json"); if (!existsSync(registryPath)) {return "running";} try { const raw = JSON.parse(readFileSync(registryPath, "utf-8")) as { runs?: Record> }; const runs = raw?.runs; if (!runs) {return "running";} for (const entry of Object.values(runs)) { if (entry.childSessionKey === sessionKey) { if (typeof entry.endedAt !== "number") {return "running";} const outcome = entry.outcome as { status?: string } | undefined; if (outcome?.status === "error") {return "error";} return "completed"; } } } catch { /* ignore */ } return "running"; } // ── Public API ── /** * Register a newly spawned subagent. Called when the parent agent's * `sessions_spawn` tool result is detected in active-runs.ts. * * When `fromDisk` is true, the run is being rehydrated after a refresh, * so we load persisted events and set the correct status from the registry. */ export function registerSubagent( parentWebSessionId: string, info: { sessionKey: string; runId: string; task: string; label?: string }, options?: { fromDisk?: boolean }, ): void { const reg = getRegistry(); // Avoid duplicate registration if (reg.runs.has(info.sessionKey)) {return;} const fromDisk = options?.fromDisk ?? false; const diskStatus = fromDisk ? readDiskStatus(info.sessionKey) : "running"; const run: SubagentRun = { sessionKey: info.sessionKey, runId: info.runId, parentWebSessionId, task: info.task, label: info.label, status: diskStatus, startedAt: Date.now(), eventBuffer: [], subscribers: new Set(), _state: createTransformState(), _subscribeProcess: null, _cleanupTimer: null, lastGlobalSeq: 0, }; // Load persisted events from disk (fills the replay buffer) if (fromDisk) { run.eventBuffer = loadPersistedEvents(info.sessionKey); } reg.runs.set(info.sessionKey, run); // Update parent index let keys = reg.parentIndex.get(parentWebSessionId); if (!keys) { keys = new Set(); reg.parentIndex.set(parentWebSessionId, keys); } keys.add(info.sessionKey); // Persist to the profile-scoped subagent index. upsertSubagentIndex(info.sessionKey, { runId: info.runId, parentWebSessionId, task: info.task, label: info.label, status: run.status, startedAt: run.startedAt, endedAt: run.endedAt, }); // Subagents are first-class sessions; subscribe immediately. if (run.status === "running") { startSubagentSubscribeStream(run); } } /** * Ensure a rehydrated subagent can receive live events. Called when a client * actually connects to the subagent's SSE stream after a page refresh. * For still-running subagents, this activates the subscribe-child fallback. */ export function ensureSubagentStreamable(sessionKey: string): void { const run = getRegistry().runs.get(sessionKey); if (!run || run.status !== "running" || run._subscribeProcess) {return;} startSubagentSubscribeStream(run); } /** Get metadata for all subagents belonging to a parent web session. */ export function getSubagentsForSession(parentWebSessionId: string): SubagentInfo[] { const reg = getRegistry(); const keys = reg.parentIndex.get(parentWebSessionId); if (!keys) {return [];} const result: SubagentInfo[] = []; for (const key of keys) { const run = reg.runs.get(key); if (run) { result.push({ sessionKey: run.sessionKey, runId: run.runId, parentWebSessionId: run.parentWebSessionId, task: run.task, label: run.label, status: run.status, startedAt: run.startedAt, endedAt: run.endedAt, }); } } return result; } /** * Subscribe to a subagent's SSE events. Replays buffered events first * (synchronously), then live events follow. */ export function subscribeToSubagent( sessionKey: string, callback: SubagentSubscriber, options?: { replay?: boolean }, ): (() => void) | null { const reg = getRegistry(); const run = reg.runs.get(sessionKey); if (!run) {return null;} const replay = options?.replay ?? true; if (replay) { for (const event of run.eventBuffer) { callback(event); } } if (run.status !== "running") { callback(null); return () => {}; } run.subscribers.add(callback); return () => { run.subscribers.delete(callback); }; } /** Check if a subagent run exists (running or completed with buffered data). */ export function hasActiveSubagent(sessionKey: string): boolean { return getRegistry().runs.has(sessionKey); } /** Check if a subagent is currently running (not yet completed). */ export function isSubagentRunning(sessionKey: string): boolean { const run = getRegistry().runs.get(sessionKey); return run !== undefined && run.status === "running"; } /** * Activate subscribe-child streams for all subagent runs that are * still in "running" status and don't already have a gateway subscription. * * Called when the parent agent's NDJSON stream ends (child process exits). * After that point the NDJSON routing is no longer available, so the * subscribe child streams become the only event source for orphaned subagents. */ export function hasRunningSubagentsForParent(parentWebSessionId: string): boolean { const reg = getRegistry(); const keys = reg.parentIndex.get(parentWebSessionId); if (!keys) {return false;} let anyRunning = false; for (const key of keys) { const run = reg.runs.get(key); if (run?.status !== "running") {continue;} const diskStatus = readDiskStatus(key); if (diskStatus !== "running") { finalizeRun(run, diskStatus === "error" ? "error" : "completed"); continue; } anyRunning = true; } return anyRunning; } /** Return session keys of all currently running subagents. */ export function getRunningSubagentKeys(): string[] { const keys: string[] = []; for (const [key, run] of getRegistry().runs) { if (run.status === "running") { keys.push(key); } } return keys; } export function persistUserMessage( sessionKey: string, msg: { id?: string; text: string }, ): boolean { const run = getRegistry().runs.get(sessionKey); if (!run) {return false;} const event: SseEvent = { type: "user-message", id: msg.id ?? `user-${Date.now()}-${Math.random().toString(36).slice(2)}`, text: msg.text, }; run.eventBuffer.push(event); persistEvent(sessionKey, event); for (const sub of run.subscribers) { try { sub(event); } catch { /* ignore */ } } return true; } export function reactivateSubagent(sessionKey: string): boolean { const run = getRegistry().runs.get(sessionKey); if (!run) {return false;} if (run._cleanupTimer) { clearTimeout(run._cleanupTimer); run._cleanupTimer = null; } run.status = "running"; run.endedAt = undefined; upsertSubagentIndex(run.sessionKey, { runId: run.runId, parentWebSessionId: run.parentWebSessionId, task: run.task, label: run.label, status: run.status, startedAt: run.startedAt, endedAt: run.endedAt, }); startSubagentSubscribeStream(run); return true; } function sendGatewayAbortForSubagent(sessionKey: string): void { try { const root = resolvePackageRoot(); const devScript = join(root, "scripts", "run-node.mjs"); const prodScript = join(root, "openclaw.mjs"); const scriptPath = existsSync(devScript) ? devScript : prodScript; const child = spawn( "node", [ scriptPath, "gateway", "call", "chat.abort", "--params", JSON.stringify({ sessionKey }), "--json", "--timeout", "4000", ], { cwd: root, env: { ...process.env }, stdio: "ignore", detached: true, }, ); child.unref(); } catch { // best effort } } export function abortSubagent(sessionKey: string): boolean { const run = getRegistry().runs.get(sessionKey); if (!run || run.status !== "running") {return false;} sendGatewayAbortForSubagent(sessionKey); finalizeRun(run, "error"); return true; } export function spawnSubagentMessage(sessionKey: string, message: string): boolean { try { const run = getRegistry().runs.get(sessionKey); if (!run) {return false;} const root = resolvePackageRoot(); const devScript = join(root, "scripts", "run-node.mjs"); const prodScript = join(root, "openclaw.mjs"); const scriptPath = existsSync(devScript) ? devScript : prodScript; const idempotencyKey = randomUUID(); const child = spawn( "node", [ scriptPath, "gateway", "call", "agent", "--params", JSON.stringify({ message, sessionKey, idempotencyKey, deliver: false, channel: "webchat", lane: "subagent", timeout: 0, }), "--json", "--timeout", "10000", ], { cwd: root, env: { ...process.env }, stdio: "ignore", detached: true, }, ); child.unref(); return true; } catch { return false; } } /** * Lazily register a subagent by reading the on-disk registries. * Checks the profile-scoped subagent-index.json first, then falls back * to the shared gateway registry (~/.openclaw/subagents/runs.json). * Returns true if the subagent was found and registered (or already registered). */ export function ensureRegisteredFromDisk( sessionKey: string, parentWebSessionId: string, ): boolean { if (getRegistry().runs.has(sessionKey)) {return true;} // 1. Check profile-scoped index. const profileIndex = loadSubagentIndex(); const profileEntry = profileIndex[sessionKey]; if (profileEntry) { registerSubagent(profileEntry.parentWebSessionId || parentWebSessionId, { sessionKey, runId: profileEntry.runId, task: profileEntry.task, label: profileEntry.label, }, { fromDisk: true }); return true; } // 2. Fall back to the shared gateway registry. const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json"); if (!existsSync(registryPath)) {return false;} try { const raw = JSON.parse(readFileSync(registryPath, "utf-8")) as { runs?: Record> }; const runs = raw?.runs; if (!runs) {return false;} for (const entry of Object.values(runs)) { if (entry.childSessionKey === sessionKey) { registerSubagent(parentWebSessionId, { sessionKey, runId: typeof entry.runId === "string" ? entry.runId : "", task: typeof entry.task === "string" ? entry.task : "", label: typeof entry.label === "string" ? entry.label : undefined, }, { fromDisk: true }); return true; } } } catch { /* ignore */ } return false; } // ── Event transformation (gateway event → SSE events) ── function createTransformState(): TransformState { return { idCounter: 0, currentTextId: "", currentReasoningId: "", textStarted: false, reasoningStarted: false, everSentText: false, statusReasoningActive: false, }; } function handleAgentEvent(run: SubagentRun, evt: AgentEvent): void { if (evt.event !== "agent") {return;} const gSeq = typeof (evt as Record).globalSeq === "number" ? (evt as Record).globalSeq as number : undefined; if (gSeq !== undefined) { if (gSeq <= run.lastGlobalSeq) {return;} run.lastGlobalSeq = gSeq; } const stream = typeof evt.stream === "string" ? evt.stream : undefined; const data = evt.data && typeof evt.data === "object" ? (evt.data) : undefined; if (!stream || !data) {return;} const st = run._state; const nextId = (prefix: string) => `${prefix}-${Date.now()}-${++st.idCounter}`; const emit = (event: SseEvent) => { run.eventBuffer.push(event); persistEvent(run.sessionKey, event); for (const sub of run.subscribers) { try { sub(event); } catch { /* ignore */ } } }; const closeReasoning = () => { if (st.reasoningStarted) { emit({ type: "reasoning-end", id: st.currentReasoningId }); st.reasoningStarted = false; st.statusReasoningActive = false; } }; const closeText = () => { if (st.textStarted) { emit({ type: "text-end", id: st.currentTextId }); st.textStarted = false; } }; const openStatusReasoning = (label: string) => { closeReasoning(); closeText(); st.currentReasoningId = nextId("status"); emit({ type: "reasoning-start", id: st.currentReasoningId }); emit({ type: "reasoning-delta", id: st.currentReasoningId, delta: label }); st.reasoningStarted = true; st.statusReasoningActive = true; }; const emitError = (message: string) => { closeReasoning(); closeText(); const tid = nextId("text"); emit({ type: "text-start", id: tid }); emit({ type: "text-delta", id: tid, delta: `[error] ${message}` }); emit({ type: "text-end", id: tid }); st.everSentText = true; }; // Lifecycle start if (stream === "lifecycle" && data.phase === "start") { openStatusReasoning("Preparing response..."); } // Thinking / reasoning if (stream === "thinking") { const delta = typeof data.delta === "string" ? data.delta : undefined; if (delta) { if (st.statusReasoningActive) {closeReasoning();} if (!st.reasoningStarted) { st.currentReasoningId = nextId("reasoning"); emit({ type: "reasoning-start", id: st.currentReasoningId }); st.reasoningStarted = true; } emit({ type: "reasoning-delta", id: st.currentReasoningId, delta }); } } // Assistant text if (stream === "assistant") { const delta = typeof data.delta === "string" ? data.delta : undefined; if (delta) { closeReasoning(); if (!st.textStarted) { st.currentTextId = nextId("text"); emit({ type: "text-start", id: st.currentTextId }); st.textStarted = true; } st.everSentText = true; emit({ type: "text-delta", id: st.currentTextId, delta }); } // Inline error if ( typeof data.stopReason === "string" && data.stopReason === "error" && typeof data.errorMessage === "string" ) { emitError(parseErrorBody(data.errorMessage)); } } // Tool events if (stream === "tool") { const phase = typeof data.phase === "string" ? data.phase : undefined; const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : ""; const toolName = typeof data.name === "string" ? data.name : ""; if (phase === "start") { closeReasoning(); closeText(); const args = data.args && typeof data.args === "object" ? (data.args as Record) : {}; emit({ type: "tool-input-start", toolCallId, toolName }); emit({ type: "tool-input-available", toolCallId, toolName, input: args }); } else if (phase === "result") { const isError = data.isError === true; const result = extractToolResult(data.result); if (isError) { const errorText = result?.text || (result?.details?.error as string | undefined) || "Tool execution failed"; emit({ type: "tool-output-error", toolCallId, errorText }); } else { const output = buildToolOutput(result); emit({ type: "tool-output-available", toolCallId, output }); } } } // Compaction if (stream === "compaction") { const phase = typeof data.phase === "string" ? data.phase : undefined; if (phase === "start") { openStatusReasoning("Optimizing session context..."); } else if (phase === "end") { if (st.statusReasoningActive) { if (data.willRetry === true) { emit({ type: "reasoning-delta", id: st.currentReasoningId, delta: "\nRetrying with compacted context...", }); } else { closeReasoning(); } } } } // Lifecycle end → mark run completed if (stream === "lifecycle" && data.phase === "end") { closeReasoning(); closeText(); finalizeRun(run, "completed"); } // Lifecycle error if (stream === "lifecycle" && data.phase === "error") { const msg = parseAgentErrorMessage(data); if (msg) {emitError(msg);} finalizeRun(run, "error"); } } function finalizeRun(run: SubagentRun, status: "completed" | "error"): void { if (run.status !== "running") {return;} run.status = status; run.endedAt = Date.now(); // Update the profile-scoped subagent index with final status. upsertSubagentIndex(run.sessionKey, { runId: run.runId, parentWebSessionId: run.parentWebSessionId, task: run.task, label: run.label, status: run.status, startedAt: run.startedAt, endedAt: run.endedAt, }); // Signal completion to all subscribers for (const sub of run.subscribers) { try { sub(null); } catch { /* ignore */ } } run.subscribers.clear(); stopSubagentSubscribeStream(run); // Schedule cleanup after grace period run._cleanupTimer = setTimeout(() => { cleanupRun(run.sessionKey); }, CLEANUP_GRACE_MS); } function cleanupRun(sessionKey: string): void { const reg = getRegistry(); const run = reg.runs.get(sessionKey); if (!run) {return;} if (run._cleanupTimer) { clearTimeout(run._cleanupTimer); run._cleanupTimer = null; } stopSubagentSubscribeStream(run); reg.runs.delete(sessionKey); // Clean up parent index const keys = reg.parentIndex.get(run.parentWebSessionId); if (keys) { keys.delete(sessionKey); if (keys.size === 0) { reg.parentIndex.delete(run.parentWebSessionId); } } } function startSubagentSubscribeStream(run: SubagentRun): void { stopSubagentSubscribeStream(run); const child = spawnAgentSubscribeProcess(run.sessionKey, run.lastGlobalSeq); run._subscribeProcess = child; const rl = createInterface({ input: child.stdout! }); rl.on("line", (line: string) => { if (!line.trim()) {return;} let ev: AgentEvent; try { ev = JSON.parse(line) as AgentEvent; } catch { return; } if (ev.sessionKey && ev.sessionKey !== run.sessionKey) { return; } handleAgentEvent(run, ev); }); child.on("close", () => { if (run._subscribeProcess === child) { run._subscribeProcess = null; } if (run.status !== "running") {return;} setTimeout(() => { if (run.status === "running" && !run._subscribeProcess) { startSubagentSubscribeStream(run); } }, 300); }); child.on("error", (err) => { console.error("[subagent-runs] Subscribe child error:", err); }); child.stderr?.on("data", (chunk: Buffer) => { console.error("[subagent-runs subscribe stderr]", chunk.toString()); }); } function stopSubagentSubscribeStream(run: SubagentRun): void { if (!run._subscribeProcess) {return;} try { run._subscribeProcess.kill("SIGTERM"); } catch { /* ignore */ } run._subscribeProcess = null; }