OpenClaw plugin that manages long-lived interactive Claude Code processes per OpenClaw session. Process manager + session-mapping persistence + bridge WebSocket server skeleton. Will be rewritten to follow the contractor-agent HTTP model-provider pattern (register as `synthesis-claude-bridge` provider, receive /v1/chat/completions, dispatch to channel notification on the bound Claude process). See parent repo's STATUS.md for the punch list.
163 lines
5.3 KiB
TypeScript
163 lines
5.3 KiB
TypeScript
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<void>
|
|
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<string, ProcessHandle>()
|
|
private idleSweeper: ReturnType<typeof setInterval> | 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<ProcessHandle> {
|
|
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<void>((_, 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<void>(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<void> {
|
|
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()
|
|
}
|
|
}
|