chore: initial scaffolding for SynthesisAgent.OpenclawPlugin
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.
This commit is contained in:
37
core/cli.ts
Normal file
37
core/cli.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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 _
|
||||
}
|
||||
42
core/config.ts
Normal file
42
core/config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { homedir } from 'node:os'
|
||||
import { resolve as resolvePath } from 'node:path'
|
||||
|
||||
export interface SynthesisConfig {
|
||||
bridgePort: number
|
||||
bridgeToken: string
|
||||
claudePluginRef: string
|
||||
permissionMode: string
|
||||
idleKillMs: number
|
||||
maxProcesses: number
|
||||
mappingDbPath: string
|
||||
}
|
||||
|
||||
const DEFAULTS: SynthesisConfig = {
|
||||
bridgePort: 18801,
|
||||
bridgeToken: 'synthesis-local',
|
||||
claudePluginRef: 'plugin:synthesis-claude@local',
|
||||
permissionMode: 'acceptEdits',
|
||||
idleKillMs: 3_600_000,
|
||||
maxProcesses: 16,
|
||||
mappingDbPath: '~/.openclaw/synthesis/sessions.json',
|
||||
}
|
||||
|
||||
function expand(p: string): string {
|
||||
if (p.startsWith('~')) return resolvePath(homedir(), p.slice(2))
|
||||
return resolvePath(p)
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
}
|
||||
merged.mappingDbPath = expand(merged.mappingDbPath)
|
||||
return merged
|
||||
}
|
||||
162
core/process-manager.ts
Normal file
162
core/process-manager.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
83
core/session-mapping.ts
Normal file
83
core/session-mapping.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
||||
import { dirname } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
export interface SessionRecord {
|
||||
openclawSessionId: string
|
||||
claudeSessionUuid: string
|
||||
createdAt: number
|
||||
lastActiveAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Persistent openclaw_session ↔ claude_session mapping.
|
||||
*
|
||||
* Single-writer (the OpenclawPlugin process). File-level on every mutation —
|
||||
* simple and correct for the volume we expect. If it ever matters, swap for
|
||||
* sqlite.
|
||||
*/
|
||||
export class SessionMapping {
|
||||
private records = new Map<string, SessionRecord>()
|
||||
private path: string
|
||||
|
||||
constructor(path: string) {
|
||||
this.path = path
|
||||
this.load()
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
try {
|
||||
const raw = readFileSync(this.path, 'utf8')
|
||||
const parsed = JSON.parse(raw) as { records?: SessionRecord[] }
|
||||
for (const r of parsed.records ?? []) {
|
||||
this.records.set(r.openclawSessionId, r)
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist or unreadable — start fresh.
|
||||
}
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
mkdirSync(dirname(this.path), { recursive: true })
|
||||
writeFileSync(
|
||||
this.path,
|
||||
JSON.stringify({ records: [...this.records.values()] }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
get(openclawSessionId: string): SessionRecord | undefined {
|
||||
return this.records.get(openclawSessionId)
|
||||
}
|
||||
|
||||
/** Get-or-create. Returns the (possibly freshly assigned) claude_session_uuid. */
|
||||
ensure(openclawSessionId: string): SessionRecord {
|
||||
const existing = this.records.get(openclawSessionId)
|
||||
if (existing) return existing
|
||||
const now = Date.now()
|
||||
const rec: SessionRecord = {
|
||||
openclawSessionId,
|
||||
claudeSessionUuid: randomUUID(),
|
||||
createdAt: now,
|
||||
lastActiveAt: now,
|
||||
}
|
||||
this.records.set(openclawSessionId, rec)
|
||||
this.flush()
|
||||
return rec
|
||||
}
|
||||
|
||||
touch(openclawSessionId: string): void {
|
||||
const r = this.records.get(openclawSessionId)
|
||||
if (!r) return
|
||||
r.lastActiveAt = Date.now()
|
||||
this.flush()
|
||||
}
|
||||
|
||||
forget(openclawSessionId: string): void {
|
||||
if (this.records.delete(openclawSessionId)) this.flush()
|
||||
}
|
||||
|
||||
all(): SessionRecord[] {
|
||||
return [...this.records.values()]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user