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:
zhi
2026-05-14 13:28:00 +00:00
parent 38ac6d20b7
commit 0324a47d13
8 changed files with 492 additions and 286 deletions

View File

@@ -1,37 +1,4 @@
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core'
import type { ProcessManager } from './process-manager.js'
import type { SessionMapping } from './session-mapping.js'
import type { SynthesisConfig } from './config.js'
interface CliDeps {
processManager: ProcessManager
mapping: SessionMapping
config: SynthesisConfig
}
/**
* `openclaw synthesis ...` admin commands. Used to inspect & poke the running
* pool from outside (helpful for testing without a real channel inbound).
*
* Subcommands (planned):
* list — show live processes + mapping
* push <session> <text> — fake an inbound message
* kill <session> — terminate a process (mapping preserved)
* forget <session> — drop mapping entirely (next msg = new claude session)
*/
export function registerCli(api: OpenClawPluginApi, deps: CliDeps): void {
// The exact API shape for command registration depends on the OpenClaw
// plugin-sdk version. Two common forms seen in existing plugins:
//
// api.commands?.register('synthesis', { describe, handler })
// api.cli?.command('synthesis', sub => sub.command('list', '...', () => {...}))
//
// Both are stubbed below — pick whichever the loaded SDK exposes when
// wiring this for real.
const _ = { api, deps }
// TODO: wire actual command registration once the surrounding plugin
// patterns are confirmed (see contractor-agent/commands/register-cli.ts
// for the canonical example in this codebase).
void _
}
// Reserved for future `openclaw synthesis ...` admin commands (list, kill,
// forget). Stub kept in place so index.ts can wire registerCli once OpenClaw
// plugin-sdk's exact CLI registration surface is confirmed.
export {}

View File

@@ -3,22 +3,22 @@ import { resolve as resolvePath } from 'node:path'
export interface SynthesisConfig {
bridgePort: number
bridgeToken: string
claudePluginRef: string
channelWsPort: number
permissionMode: string
idleKillMs: number
maxProcesses: number
mappingDbPath: string
channelName: string
}
const DEFAULTS: SynthesisConfig = {
bridgePort: 18801,
bridgeToken: 'synthesis-local',
claudePluginRef: 'plugin:synthesis-claude@local',
permissionMode: 'acceptEdits',
bridgePort: 18900,
channelWsPort: 18901,
permissionMode: 'bypassPermissions',
idleKillMs: 3_600_000,
maxProcesses: 16,
mappingDbPath: '~/.openclaw/synthesis/sessions.json',
channelName: 'synthesis',
}
function expand(p: string): string {
@@ -30,12 +30,12 @@ export function normalizeConfig(raw: unknown): SynthesisConfig {
const r = (raw ?? {}) as Partial<SynthesisConfig>
const merged: SynthesisConfig = {
bridgePort: typeof r.bridgePort === 'number' ? r.bridgePort : DEFAULTS.bridgePort,
bridgeToken: typeof r.bridgeToken === 'string' && r.bridgeToken ? r.bridgeToken : DEFAULTS.bridgeToken,
claudePluginRef: typeof r.claudePluginRef === 'string' && r.claudePluginRef ? r.claudePluginRef : DEFAULTS.claudePluginRef,
channelWsPort: typeof r.channelWsPort === 'number' ? r.channelWsPort : DEFAULTS.channelWsPort,
permissionMode: typeof r.permissionMode === 'string' && r.permissionMode ? r.permissionMode : DEFAULTS.permissionMode,
idleKillMs: typeof r.idleKillMs === 'number' ? r.idleKillMs : DEFAULTS.idleKillMs,
maxProcesses: typeof r.maxProcesses === 'number' ? r.maxProcesses : DEFAULTS.maxProcesses,
mappingDbPath: typeof r.mappingDbPath === 'string' && r.mappingDbPath ? r.mappingDbPath : DEFAULTS.mappingDbPath,
channelName: typeof r.channelName === 'string' && r.channelName ? r.channelName : DEFAULTS.channelName,
}
merged.mappingDbPath = expand(merged.mappingDbPath)
return merged

View File

@@ -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()
}
}