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 openclawSessionKey: string claudeSessionUuid: string workspace: string proc: ChildProcess startedAt: number lastActiveAt: number /** Resolves once ClaudePlugin sends `hello` over the bridge WS. */ ready: Promise markReady: () => void } export interface ProcessManagerDeps { config: SynthesisConfig mapping: SessionMapping logger?: { info: (...a: unknown[]) => void; warn: (...a: unknown[]) => void } } /** * Owns the pool of long-lived `claude` subprocesses. One per OpenClaw * session-key (`agent_id::chat_id` per contractor-agent's convention). * Lazily spawned on first dispatch and reaped after `idleKillMs` of idle. * * Spawn shape: * claude --channels server: * --dangerously-load-development-channels server: * --resume * --permission-mode * --dangerously-skip-permissions ← only when permissionMode=bypassPermissions * AND we're allowed to (non-root or sandbox) * env: SYNTHESIS_WS_URL, SYNTHESIS_OPENCLAW_SESSION, SYNTHESIS_CLAUDE_SESSION * * The spawned `--dangerously-load-development-channels` will trigger an * interactive confirmation dialog by default. We handle this by piping a * "1\n" to stdin shortly after spawn (the first option is the dev-mode * confirmation). The Claude process's first turn comes from the channel * notification, not from stdin. */ export class ProcessManager { private byKey = new Map() private idleSweeper: ReturnType | null = null private shuttingDown = false private log: NonNullable constructor(private deps: ProcessManagerDeps) { this.log = deps.logger ?? { info: (...a) => process.stderr.write(`[synthesis-pm] ${a.join(' ')}\n`), warn: (...a) => process.stderr.write(`[synthesis-pm] WARN ${a.join(' ')}\n`), } this.idleSweeper = setInterval(() => this.sweepIdle(), 60_000) } /** Spawn-if-needed and await the ClaudePlugin handshake. */ async ensure(openclawSessionKey: string, workspace: string): Promise { const existing = this.byKey.get(openclawSessionKey) if (existing && !existing.proc.killed && existing.proc.exitCode === null) { existing.lastActiveAt = Date.now() return existing } if (this.byKey.size >= this.deps.config.maxProcesses) this.evictOldestIdle() const rec = this.deps.mapping.ensure(openclawSessionKey) const handle = this.spawn(openclawSessionKey, rec.claudeSessionUuid, workspace) this.byKey.set(openclawSessionKey, handle) await Promise.race([ handle.ready, new Promise((_, rej) => setTimeout(() => rej(new Error('claude spawn ready timeout (15s)')), 15_000), ), ]) this.deps.mapping.touch(openclawSessionKey) return handle } /** Bridge server calls this when it sees a `hello` frame matching this pid. */ markReady(pid: number): void { for (const h of this.byKey.values()) { if (h.pid === pid) { h.markReady() return } } } touch(openclawSessionKey: string): void { const h = this.byKey.get(openclawSessionKey) if (h) h.lastActiveAt = Date.now() } private spawn(openclawSessionKey: string, claudeSessionUuid: string, workspace: string): ProcessHandle { const { config } = this.deps const wsUrl = `ws://127.0.0.1:${config.channelWsPort}/bridge` const channelTag = `server:${config.channelName}` const args = [ '--channels', channelTag, '--dangerously-load-development-channels', channelTag, '--resume', claudeSessionUuid, '--permission-mode', config.permissionMode, ] this.log.info(`spawning claude session=${openclawSessionKey} claude_uuid=${claudeSessionUuid} workspace=${workspace}`) const proc = spawn('claude', args, { cwd: workspace, env: { ...process.env, SYNTHESIS_WS_URL: wsUrl, SYNTHESIS_OPENCLAW_SESSION: openclawSessionKey, SYNTHESIS_CLAUDE_SESSION: claudeSessionUuid, }, stdio: ['pipe', 'pipe', 'pipe'], detached: false, }) // After ~500ms, send "1\n" to confirm the dev-channels dialog. Idempotent // if the dialog isn't there (claude eats the keystroke harmlessly). setTimeout(() => { try { proc.stdin?.write('1\n') } catch { /* ignore */ } }, 500) let resolveReady!: () => void const ready = new Promise(r => { resolveReady = r }) const handle: ProcessHandle = { pid: proc.pid ?? -1, openclawSessionKey, claudeSessionUuid, workspace, proc, startedAt: Date.now(), lastActiveAt: Date.now(), ready, markReady: () => resolveReady(), } proc.on('exit', code => { this.byKey.delete(openclawSessionKey) this.log.info(`claude exit session=${openclawSessionKey} pid=${handle.pid} code=${code}`) }) 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.byKey.values()) { if (h.lastActiveAt < cutoff) { this.log.info(`idle-killing session=${h.openclawSessionKey} pid=${h.pid}`) h.proc.kill('SIGTERM') } } } private evictOldestIdle(): void { let oldest: ProcessHandle | null = null for (const h of this.byKey.values()) { if (!oldest || h.lastActiveAt < oldest.lastActiveAt) oldest = h } if (oldest) { this.log.info(`evicting session=${oldest.openclawSessionKey} (max processes reached)`) oldest.proc.kill('SIGTERM') } } list(): ProcessHandle[] { return [...this.byKey.values()] } async shutdown(): Promise { this.shuttingDown = true if (this.idleSweeper) clearInterval(this.idleSweeper) for (const h of this.byKey.values()) { try { h.proc.kill('SIGTERM') } catch { /* ignore */ } } this.byKey.clear() } }