Discovered during smoke-testing on hzhang's laptop: 1. `--channels server:X --dangerously-load-development-channels server:X` makes Claude Code list the channel twice and the second copy never inherits dev-mode, leaving "server: entries need --dangerously-load-development-channels" stuck in the status panel. Fix: pass channel ONLY via --dangerously-load-development-channels. 2. Without a controlling TTY, Claude Code's dev-mode confirmation dialog blocks forever waiting for keystrokes that never arrive. Fix: spawn claude wrapped in `script -q -c CMD PTYLOG` so it gets a PTY, then write "\r" to stdin at several timeouts (cheap to over-send). 3. process-manager.markReady was matching on PID, but the PID in the bridge hello frame is the ClaudePlugin (bun) process's pid, not the script-wrapped claude process's pid we tracked. Fix: match on openclaw-session-key, which is consistent on both sides. 4. First spawn for a new session can't use --resume (no transcript exists yet) — claude errors out. Fix: probe ~/.claude/projects/<workspace-slug>/<uuid>.jsonl for existence and use --session-id on fresh sessions, --resume after a process restart. 5. Add --debug-file per session so future debugging has the gating logs. 6. Local definePluginEntry shim (no openclaw runtime dependency) so `bun index.ts` works standalone for laptop smoke tests. End-to-end verified twice on laptop: curl POST -> SSE delta with the exact reply text. Average cold-start ~10s, hot path 2-3s.
119 lines
4.5 KiB
TypeScript
119 lines
4.5 KiB
TypeScript
// Local `definePluginEntry` shim. The OpenClaw plugin runtime evaluates this
|
|
// module and calls .register() on the default export — it doesn't care where
|
|
// definePluginEntry came from. Defining it locally lets us run standalone
|
|
// (`bun index.ts`) without the `openclaw` package installed.
|
|
function definePluginEntry<T extends { id: string; name: string; description?: string; register: (api: any) => void | Promise<void> }>(opts: T): T {
|
|
return opts
|
|
}
|
|
type OpenClawPluginApi = unknown
|
|
|
|
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'
|
|
|
|
// 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: 'Routes OpenClaw agent turns through long-lived interactive Claude Code processes',
|
|
|
|
register(api: OpenClawPluginApi): void {
|
|
const config: SynthesisConfig = normalizeConfig((api as any).pluginConfig)
|
|
const logger = (api as any).logger ?? console
|
|
_G[OPENCLAW_API_KEY] = api
|
|
|
|
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, logger })
|
|
_G[PM_KEY] = pm
|
|
}
|
|
|
|
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')
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|