import { spawn, type ChildProcess } from 'node:child_process' import type { SynthesisConfig } from './config.js' import type { SessionMapping } from './session-mapping.js' export interface ProcessHandle { pid: number openclawSessionId: string claudeSessionUuid: string proc: ChildProcess startedAt: number lastActiveAt: number /** Resolved when the ClaudePlugin's hello frame has been seen on the bridge. */ ready: Promise markReady: () => void } export interface ProcessManagerDeps { config: SynthesisConfig mapping: SessionMapping } /** * Owns the pool of `claude` subprocesses. One per openclaw_session, lazily * spawned on first message and reaped after `idleKillMs` of inactivity. * * Hand-off to ClaudePlugin happens via env vars + a shared WebSocket bridge: * we know a process is "live" when its plugin connects back and sends `hello`. * * Responsibilities NOT handled here (delegated): * - Routing inbound messages to a specific process → done by bridge-server, * which holds the ws connection per pid. * - Tool catalog → bridge-server sends in hello_ack. */ export class ProcessManager { private byOpenclawSession = new Map() private idleSweeper: ReturnType | null = null private shuttingDown = false constructor(private deps: ProcessManagerDeps) { this.idleSweeper = setInterval(() => this.sweepIdle(), 60_000) } /** Returns the live handle, spawning if needed. Awaits hello handshake. */ async ensure(openclawSessionId: string): Promise { const existing = this.byOpenclawSession.get(openclawSessionId) if (existing && !existing.proc.killed) { existing.lastActiveAt = Date.now() return existing } if (this.byOpenclawSession.size >= this.deps.config.maxProcesses) { this.evictOldestIdle() } const rec = this.deps.mapping.ensure(openclawSessionId) const handle = this.spawn(openclawSessionId, rec.claudeSessionUuid) this.byOpenclawSession.set(openclawSessionId, handle) // Wait for ClaudePlugin → bridge → bridge-server → handle.markReady(). await Promise.race([ handle.ready, new Promise((_, rej) => setTimeout(() => rej(new Error('claude spawn ready timeout')), 15_000)), ]) this.deps.mapping.touch(openclawSessionId) return handle } /** Bridge server calls this once it has paired a connection to a pid. */ markReady(pid: number): void { for (const h of this.byOpenclawSession.values()) { if (h.pid === pid) { h.markReady() return } } } private spawn(openclawSessionId: string, claudeSessionUuid: string): ProcessHandle { const { config } = this.deps const args = [ '--channels', config.claudePluginRef, '--resume', claudeSessionUuid, '--permission-mode', config.permissionMode, // Tool allowlist scoped to our MCP namespace plus the basics models need // for any non-trivial assist (Read, Edit, Bash). Tighten if/when policy // demands. '--allowed-tools', 'Read Edit Bash mcp__synthesis__*', ] const proc = spawn('claude', args, { env: { ...process.env, SYNTHESIS_BRIDGE_URL: `ws://127.0.0.1:${config.bridgePort}/bridge`, SYNTHESIS_BRIDGE_TOKEN: config.bridgeToken, SYNTHESIS_OPENCLAW_SESSION: openclawSessionId, SYNTHESIS_CLAUDE_SESSION: claudeSessionUuid, }, stdio: ['ignore', 'pipe', 'pipe'], detached: false, }) let resolveReady!: () => void const ready = new Promise(r => { resolveReady = r }) const handle: ProcessHandle = { pid: proc.pid ?? -1, openclawSessionId, claudeSessionUuid, proc, startedAt: Date.now(), lastActiveAt: Date.now(), ready, markReady: () => resolveReady(), } proc.on('exit', code => { this.byOpenclawSession.delete(openclawSessionId) // mapping is preserved so the next message resumes via --resume process.stderr.write(`synthesis: claude exit session=${openclawSessionId} pid=${handle.pid} code=${code}\n`) }) proc.stderr?.on('data', chunk => { process.stderr.write(`[claude:${handle.pid}] ${chunk}`) }) return handle } private sweepIdle(): void { if (this.shuttingDown) return const cutoff = Date.now() - this.deps.config.idleKillMs for (const h of this.byOpenclawSession.values()) { if (h.lastActiveAt < cutoff) { process.stderr.write(`synthesis: idle-killing session=${h.openclawSessionId} pid=${h.pid}\n`) h.proc.kill('SIGTERM') } } } private evictOldestIdle(): void { let oldest: ProcessHandle | null = null for (const h of this.byOpenclawSession.values()) { if (!oldest || h.lastActiveAt < oldest.lastActiveAt) oldest = h } if (oldest) { process.stderr.write(`synthesis: evicting session=${oldest.openclawSessionId} (max processes reached)\n`) oldest.proc.kill('SIGTERM') } } list(): ProcessHandle[] { return [...this.byOpenclawSession.values()] } async shutdown(): Promise { this.shuttingDown = true if (this.idleSweeper) clearInterval(this.idleSweeper) for (const h of this.byOpenclawSession.values()) { try { h.proc.kill('SIGTERM') } catch { /* ignore */ } } this.byOpenclawSession.clear() } }