feat: HTTP /v1/chat/completions + WS bridge + process manager
Real first cut. OpenClaw routes agent turns via /v1/chat/completions (OpenAI-compatible SSE) into our bridge. Bridge ensures a long-lived claude process per session-key, pushes the user message as notifications/claude/channel into the running Claude Code, awaits a reply via the WS connection, streams the reply back as SSE deltas. - core/process-manager: spawn / track / reap claude processes, auto-confirm the --dangerously-load-development-channels dev-mode prompt by piping "1\n" to stdin shortly after spawn - web/bridge-server: unified HTTP + WS server, per-session FIFO queue, SSE heartbeat (empty content delta — SSE comments don't reset OpenClaw's idle watchdog), reply buffering for streaming progress chunks - index.ts: definePluginEntry for OpenClaw runtime + standalone-mode main for laptop smoke testing (just `bun index.ts`) Same-machine simplifications: no bridge token, no permission_request reverse channel. Session key = agent_id::chat_id (contractor-agent convention).
This commit is contained in:
@@ -4,12 +4,13 @@ import type { SessionMapping } from './session-mapping.js'
|
||||
|
||||
export interface ProcessHandle {
|
||||
pid: number
|
||||
openclawSessionId: string
|
||||
openclawSessionKey: string
|
||||
claudeSessionUuid: string
|
||||
workspace: string
|
||||
proc: ChildProcess
|
||||
startedAt: number
|
||||
lastActiveAt: number
|
||||
/** Resolved when the ClaudePlugin's hello frame has been seen on the bridge. */
|
||||
/** Resolves once ClaudePlugin sends `hello` over the bridge WS. */
|
||||
ready: Promise<void>
|
||||
markReady: () => void
|
||||
}
|
||||
@@ -17,56 +18,69 @@ export interface ProcessHandle {
|
||||
export interface ProcessManagerDeps {
|
||||
config: SynthesisConfig
|
||||
mapping: SessionMapping
|
||||
logger?: { info: (...a: unknown[]) => void; warn: (...a: unknown[]) => void }
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the pool of `claude` subprocesses. One per openclaw_session, lazily
|
||||
* spawned on first message and reaped after `idleKillMs` of inactivity.
|
||||
* 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.
|
||||
*
|
||||
* 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`.
|
||||
* Spawn shape:
|
||||
* claude --channels server:<channelName>
|
||||
* --dangerously-load-development-channels server:<channelName>
|
||||
* --resume <claude_session_uuid>
|
||||
* --permission-mode <permissionMode>
|
||||
* --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
|
||||
*
|
||||
* 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.
|
||||
* 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 byOpenclawSession = new Map<string, ProcessHandle>()
|
||||
private byKey = new Map<string, ProcessHandle>()
|
||||
private idleSweeper: ReturnType<typeof setInterval> | null = null
|
||||
private shuttingDown = false
|
||||
private log: NonNullable<ProcessManagerDeps['logger']>
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
/** Spawn-if-needed and await the ClaudePlugin handshake. */
|
||||
async ensure(openclawSessionKey: string, workspace: string): Promise<ProcessHandle> {
|
||||
const existing = this.byKey.get(openclawSessionKey)
|
||||
if (existing && !existing.proc.killed && existing.proc.exitCode === null) {
|
||||
existing.lastActiveAt = Date.now()
|
||||
return existing
|
||||
}
|
||||
|
||||
if (this.byOpenclawSession.size >= this.deps.config.maxProcesses) {
|
||||
this.evictOldestIdle()
|
||||
}
|
||||
if (this.byKey.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().
|
||||
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<void>((_, rej) => setTimeout(() => rej(new Error('claude spawn ready timeout')), 15_000)),
|
||||
new Promise<never>((_, rej) =>
|
||||
setTimeout(() => rej(new Error('claude spawn ready timeout (15s)')), 15_000),
|
||||
),
|
||||
])
|
||||
this.deps.mapping.touch(openclawSessionId)
|
||||
this.deps.mapping.touch(openclawSessionKey)
|
||||
return handle
|
||||
}
|
||||
|
||||
/** Bridge server calls this once it has paired a connection to a pid. */
|
||||
/** Bridge server calls this when it sees a `hello` frame matching this pid. */
|
||||
markReady(pid: number): void {
|
||||
for (const h of this.byOpenclawSession.values()) {
|
||||
for (const h of this.byKey.values()) {
|
||||
if (h.pid === pid) {
|
||||
h.markReady()
|
||||
return
|
||||
@@ -74,37 +88,54 @@ export class ProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
private spawn(openclawSessionId: string, claudeSessionUuid: string): ProcessHandle {
|
||||
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', config.claudePluginRef,
|
||||
'--channels', channelTag,
|
||||
'--dangerously-load-development-channels', channelTag,
|
||||
'--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__*',
|
||||
]
|
||||
|
||||
this.log.info(`spawning claude session=${openclawSessionKey} claude_uuid=${claudeSessionUuid} workspace=${workspace}`)
|
||||
|
||||
const proc = spawn('claude', args, {
|
||||
cwd: workspace,
|
||||
env: {
|
||||
...process.env,
|
||||
SYNTHESIS_BRIDGE_URL: `ws://127.0.0.1:${config.bridgePort}/bridge`,
|
||||
SYNTHESIS_BRIDGE_TOKEN: config.bridgeToken,
|
||||
SYNTHESIS_OPENCLAW_SESSION: openclawSessionId,
|
||||
SYNTHESIS_WS_URL: wsUrl,
|
||||
SYNTHESIS_OPENCLAW_SESSION: openclawSessionKey,
|
||||
SYNTHESIS_CLAUDE_SESSION: claudeSessionUuid,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
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<void>(r => { resolveReady = r })
|
||||
|
||||
const handle: ProcessHandle = {
|
||||
pid: proc.pid ?? -1,
|
||||
openclawSessionId,
|
||||
openclawSessionKey,
|
||||
claudeSessionUuid,
|
||||
workspace,
|
||||
proc,
|
||||
startedAt: Date.now(),
|
||||
lastActiveAt: Date.now(),
|
||||
@@ -113,9 +144,8 @@ export class ProcessManager {
|
||||
}
|
||||
|
||||
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`)
|
||||
this.byKey.delete(openclawSessionKey)
|
||||
this.log.info(`claude exit session=${openclawSessionKey} pid=${handle.pid} code=${code}`)
|
||||
})
|
||||
|
||||
proc.stderr?.on('data', chunk => {
|
||||
@@ -128,9 +158,9 @@ export class ProcessManager {
|
||||
private sweepIdle(): void {
|
||||
if (this.shuttingDown) return
|
||||
const cutoff = Date.now() - this.deps.config.idleKillMs
|
||||
for (const h of this.byOpenclawSession.values()) {
|
||||
for (const h of this.byKey.values()) {
|
||||
if (h.lastActiveAt < cutoff) {
|
||||
process.stderr.write(`synthesis: idle-killing session=${h.openclawSessionId} pid=${h.pid}\n`)
|
||||
this.log.info(`idle-killing session=${h.openclawSessionKey} pid=${h.pid}`)
|
||||
h.proc.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
@@ -138,25 +168,25 @@ export class ProcessManager {
|
||||
|
||||
private evictOldestIdle(): void {
|
||||
let oldest: ProcessHandle | null = null
|
||||
for (const h of this.byOpenclawSession.values()) {
|
||||
for (const h of this.byKey.values()) {
|
||||
if (!oldest || h.lastActiveAt < oldest.lastActiveAt) oldest = h
|
||||
}
|
||||
if (oldest) {
|
||||
process.stderr.write(`synthesis: evicting session=${oldest.openclawSessionId} (max processes reached)\n`)
|
||||
this.log.info(`evicting session=${oldest.openclawSessionKey} (max processes reached)`)
|
||||
oldest.proc.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
|
||||
list(): ProcessHandle[] {
|
||||
return [...this.byOpenclawSession.values()]
|
||||
return [...this.byKey.values()]
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.shuttingDown = true
|
||||
if (this.idleSweeper) clearInterval(this.idleSweeper)
|
||||
for (const h of this.byOpenclawSession.values()) {
|
||||
for (const h of this.byKey.values()) {
|
||||
try { h.proc.kill('SIGTERM') } catch { /* ignore */ }
|
||||
}
|
||||
this.byOpenclawSession.clear()
|
||||
this.byKey.clear()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user