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:
113
index.ts
113
index.ts
@@ -1,68 +1,111 @@
|
||||
import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry'
|
||||
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { normalizeConfig, type SynthesisConfig } from './core/config.js'
|
||||
import { ProcessManager } from './core/process-manager.js'
|
||||
import { SessionMapping } from './core/session-mapping.js'
|
||||
import { startBridgeServer } from './web/bridge-server.js'
|
||||
import { registerCli } from './core/cli.js'
|
||||
|
||||
// All long-lived state lives on globalThis so OpenClaw hot-reloads don't kill
|
||||
// running Claude processes (mirrors contractor-agent's convention).
|
||||
// All long-lived state survives OpenClaw hot-reloads via globalThis.
|
||||
const _G = globalThis as Record<string, unknown>
|
||||
const PM_KEY = '_synthesisProcessManager'
|
||||
const SERVER_KEY = '_synthesisBridgeServer'
|
||||
const MAPPING_KEY = '_synthesisSessionMapping'
|
||||
const LIFECYCLE_KEY = '_synthesisLifecycleRegistered'
|
||||
const OPENCLAW_API_KEY = '_synthesisOpenclawApi'
|
||||
|
||||
function resolveAgentWorkspace(agentId: string): { workspace: string } | null {
|
||||
try {
|
||||
const configPath = path.join(process.env.HOME ?? '/root', '.openclaw', 'openclaw.json')
|
||||
const raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
|
||||
agents?: { list?: Array<{ id: string; workspace?: string }> }
|
||||
}
|
||||
const agent = raw.agents?.list?.find(a => a.id === agentId)
|
||||
if (!agent?.workspace) return null
|
||||
return { workspace: agent.workspace }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
id: 'synthesis-agent',
|
||||
name: 'SynthesisAgent',
|
||||
description: 'Manages long-lived Claude Code processes per OpenClaw session',
|
||||
description: 'Routes OpenClaw agent turns through long-lived interactive Claude Code processes',
|
||||
|
||||
register(api: OpenClawPluginApi): void {
|
||||
const config: SynthesisConfig = normalizeConfig(api.pluginConfig)
|
||||
const config: SynthesisConfig = normalizeConfig((api as any).pluginConfig)
|
||||
const logger = (api as any).logger ?? console
|
||||
_G[OPENCLAW_API_KEY] = api
|
||||
|
||||
// ── Reuse existing state across hot reload ─────────────────────────────
|
||||
let mapping = _G[MAPPING_KEY] as SessionMapping | undefined
|
||||
if (!mapping) {
|
||||
mapping = new SessionMapping(config.mappingDbPath)
|
||||
_G[MAPPING_KEY] = mapping
|
||||
}
|
||||
|
||||
let pm = _G[PM_KEY] as ProcessManager | undefined
|
||||
if (!pm) {
|
||||
pm = new ProcessManager({ config, mapping })
|
||||
pm = new ProcessManager({ config, mapping, logger })
|
||||
_G[PM_KEY] = pm
|
||||
}
|
||||
|
||||
// Existing bridge server: leave it alone.
|
||||
if (!_G[SERVER_KEY]) {
|
||||
startBridgeServer({ config, mapping, processManager: pm })
|
||||
.then(server => { _G[SERVER_KEY] = server })
|
||||
.catch(err => {
|
||||
api.logger?.error?.('synthesis: bridge server failed to start', err)
|
||||
if (!_G[LIFECYCLE_KEY]) {
|
||||
_G[LIFECYCLE_KEY] = true
|
||||
;(api as any).on?.('gateway_start', () => {
|
||||
if (_G[SERVER_KEY]) return
|
||||
const server = startBridgeServer({
|
||||
config,
|
||||
mapping: mapping!,
|
||||
processManager: pm!,
|
||||
resolveAgent: resolveAgentWorkspace,
|
||||
logger,
|
||||
})
|
||||
_G[SERVER_KEY] = server
|
||||
logger.info?.(`[synthesis-agent] bridge HTTP=${config.bridgePort} WS=${config.channelWsPort}`)
|
||||
})
|
||||
;(api as any).on?.('gateway_stop', async () => {
|
||||
const s = _G[SERVER_KEY] as Awaited<ReturnType<typeof startBridgeServer>> | undefined
|
||||
if (s) await s.close?.()
|
||||
delete _G[SERVER_KEY]
|
||||
await pm!.shutdown()
|
||||
delete _G[PM_KEY]
|
||||
logger.info?.('[synthesis-agent] bridge stopped')
|
||||
})
|
||||
}
|
||||
|
||||
// ── Wire to OpenClaw inbound event bus ─────────────────────────────────
|
||||
// TODO: subscribe to api.events / api.channels.onInbound (exact API TBD).
|
||||
// Each inbound message arrives with an openclaw_session_id; we forward to
|
||||
// the process manager, which spawns/resumes claude as needed and pushes
|
||||
// the message into its plugin's bridge socket.
|
||||
//
|
||||
// api.channels.onInbound((evt) => {
|
||||
// pm.deliverInbound(evt.openclawSessionId, evt)
|
||||
// })
|
||||
//
|
||||
// Similarly for permission replies.
|
||||
|
||||
registerCli(api, { processManager: pm, mapping, config })
|
||||
|
||||
api.lifecycle?.onShutdown?.(async () => {
|
||||
const server = _G[SERVER_KEY] as Awaited<ReturnType<typeof startBridgeServer>> | undefined
|
||||
await server?.close?.()
|
||||
delete _G[SERVER_KEY]
|
||||
await pm!.shutdown()
|
||||
delete _G[PM_KEY]
|
||||
})
|
||||
logger.info?.(`[synthesis-agent] plugin registered (bridge port=${config.bridgePort})`)
|
||||
},
|
||||
})
|
||||
|
||||
// ── Standalone mode ───────────────────────────────────────────────────────────
|
||||
// When this file is executed directly (e.g. `bun index.ts`), boot the bridge
|
||||
// without the OpenClaw plugin runtime. Useful for local development & the
|
||||
// laptop integration test.
|
||||
//
|
||||
// Detect direct execution via import.meta.main (Bun) or argv[1] match.
|
||||
declare const Bun: { main: string } | undefined
|
||||
const isDirectRun =
|
||||
(typeof Bun !== 'undefined' && Bun.main && import.meta.url === `file://${Bun.main}`) ||
|
||||
(process.argv[1] && (process.argv[1].endsWith('/index.ts') || process.argv[1].endsWith('/index.js')))
|
||||
|
||||
if (isDirectRun) {
|
||||
const config = normalizeConfig({})
|
||||
const mapping = new SessionMapping(config.mappingDbPath)
|
||||
const pm = new ProcessManager({ config, mapping })
|
||||
const server = startBridgeServer({
|
||||
config,
|
||||
mapping,
|
||||
processManager: pm,
|
||||
resolveAgent: resolveAgentWorkspace,
|
||||
})
|
||||
|
||||
const shutdown = async () => {
|
||||
process.stderr.write('shutting down…\n')
|
||||
await server.close()
|
||||
await pm.shutdown()
|
||||
process.exit(0)
|
||||
}
|
||||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user