chore: extract plugins into submodules

SynthesisAgent.ClaudePlugin and SynthesisAgent.OpenclawPlugin are now
separate gitea repos, referenced as submodules from this one. Each can be
iterated on independently while this repo stays as the cross-cutting
design/architecture / wire-protocol home.
This commit is contained in:
zhi
2026-05-14 09:42:40 +00:00
parent 0fb46e318e
commit 7a632697a9
19 changed files with 8 additions and 1027 deletions

6
.gitmodules vendored Normal file
View File

@@ -0,0 +1,6 @@
[submodule "SynthesisAgent.ClaudePlugin"]
path = SynthesisAgent.ClaudePlugin
url = https://git.hangman-lab.top/zhi/SynthesisAgent.ClaudePlugin.git
[submodule "SynthesisAgent.OpenclawPlugin"]
path = SynthesisAgent.OpenclawPlugin
url = https://git.hangman-lab.top/zhi/SynthesisAgent.OpenclawPlugin.git

View File

@@ -1,6 +0,0 @@
{
"name": "synthesis-claude",
"description": "Claude Code channel plugin for SynthesisAgent — bridges OpenClaw events into Claude Code via notifications/claude/channel, and proxies OpenClaw tool calls back to OpenClaw.",
"version": "0.0.1",
"keywords": ["openclaw", "channel", "mcp", "synthesis"]
}

View File

@@ -1,58 +0,0 @@
# SynthesisAgent.ClaudePlugin
The **Claude Code side** of SynthesisAgent. A stdio MCP server that registers as a `--channels` plugin and bridges to `SynthesisAgent.OpenclawPlugin`.
## What it does
- Receives inbound messages from OpenClaw and emits `notifications/claude/channel` so Claude Code starts a new turn.
- Receives the OpenClaw tool catalog at `hello_ack` time and exposes them as MCP tools.
- Forwards every `tools/call` over the bridge for OpenClaw to execute.
- Forwards Claude's `permission_request` notifications to OpenClaw, and surfaces the user's reply back.
## Required env vars
Set by OpenclawPlugin when it spawns Claude:
| Var | Example | Meaning |
|-----|---------|---------|
| `SYNTHESIS_BRIDGE_URL` | `ws://127.0.0.1:18801/bridge` | Where to connect |
| `SYNTHESIS_BRIDGE_TOKEN` | `...` | Shared secret |
| `SYNTHESIS_OPENCLAW_SESSION` | `discord:alice` | This Claude process serves *this* session |
| `SYNTHESIS_CLAUDE_SESSION` | `<uuid>` | The Claude session UUID, for traceability |
## Spawn shape (done by OpenclawPlugin)
```bash
claude \
--channels plugin:synthesis-claude@local \
--resume "$SYNTHESIS_CLAUDE_SESSION" \
--allowed-tools "Read Edit Bash mcp__synthesis__*" \
--append-system-prompt-file ./session-context.md
```
## TODO
- [ ] Verify exact semantics of `--channels plugin:foo@bar` against the running Claude Code build.
- [ ] Verify `mcp.notification('notifications/claude/channel', ...)` triggers a new turn (vs being silently accepted). May need to first land on `claude/channel` capability declaration.
- [ ] Implement attachment download flow (`download_attachment` style) — currently passthrough.
- [ ] Implement `fetch_messages` to query OpenClaw history.
- [ ] Wire `bun-types` install in `package.json`.
## Manual test (once OpenclawPlugin is wired up)
```bash
# Terminal 1: start OpenclawPlugin bridge server
openclaw # with SynthesisAgent.OpenclawPlugin loaded
# Terminal 2: simulate the spawn manually
SYNTHESIS_BRIDGE_URL=ws://127.0.0.1:18801/bridge \
SYNTHESIS_BRIDGE_TOKEN=local-dev \
SYNTHESIS_OPENCLAW_SESSION=test:1 \
SYNTHESIS_CLAUDE_SESSION=$(uuidgen) \
claude --channels plugin:synthesis-claude@local
# Terminal 3: push a fake inbound message via OpenclawPlugin admin CLI
openclaw synthesis push --session test:1 --content "hello"
```
A successful run: Claude Code visibly starts a new turn in Terminal 2 reacting to "hello".

View File

@@ -1,18 +0,0 @@
{
"name": "synthesis-claude-plugin",
"version": "0.0.1",
"license": "Apache-2.0",
"type": "module",
"bin": "./server.ts",
"scripts": {
"start": "bun install --no-summary && bun server.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"ws": "^8.18.0",
"zod": "^3.23.0"
},
"devDependencies": {
"@types/ws": "^8.5.10"
}
}

View File

@@ -1,223 +0,0 @@
#!/usr/bin/env bun
/**
* SynthesisAgent.ClaudePlugin
*
* Claude Code "channel" MCP server. Spawned per Claude Code process by
* SynthesisAgent.OpenclawPlugin. Acts as the bidirectional bridge:
*
* OpenClaw bridge WebSocket ←→ this process ←→ Claude Code (stdio MCP)
*
* Inbound (OpenClaw → Claude):
* - inbound_message → notifications/claude/channel
* - permission_reply → notifications/claude/channel/permission
* - tool_result → resolve pending tool_call promise
*
* Outbound (Claude → OpenClaw):
* - tools/call → bridge "tool_call" frame
* - permission_request → bridge "permission_request" frame
*
* State (process-scoped, dies with the process):
* - one WebSocket to the bridge
* - pending tool call map (call_id → resolver)
* - tool catalog (received in hello_ack)
*
* No persistent state — OpenclawPlugin owns the session DB.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import {
ListToolsRequestSchema,
CallToolRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import { z } from 'zod'
import WebSocket from 'ws'
// ─── Env ─────────────────────────────────────────────────────────────────────
const BRIDGE_URL = process.env.SYNTHESIS_BRIDGE_URL
const BRIDGE_TOKEN = process.env.SYNTHESIS_BRIDGE_TOKEN ?? ''
const OPENCLAW_SESSION = process.env.SYNTHESIS_OPENCLAW_SESSION
const CLAUDE_SESSION = process.env.SYNTHESIS_CLAUDE_SESSION ?? ''
if (!BRIDGE_URL || !OPENCLAW_SESSION) {
process.stderr.write(
`synthesis-claude: required env vars missing\n` +
` SYNTHESIS_BRIDGE_URL=${BRIDGE_URL ?? '(unset)'}\n` +
` SYNTHESIS_OPENCLAW_SESSION=${OPENCLAW_SESSION ?? '(unset)'}\n`,
)
process.exit(1)
}
const log = (s: string) => process.stderr.write(`synthesis-claude: ${s}\n`)
process.on('unhandledRejection', e => log(`unhandled rejection: ${e}`))
process.on('uncaughtException', e => log(`uncaught exception: ${e}`))
// ─── MCP server ──────────────────────────────────────────────────────────────
const mcp = new Server(
{ name: 'synthesis', version: '0.0.1' },
{
capabilities: {
tools: {},
// Channel capabilities MUST be nested under `experimental` — Claude Code
// gating function `poH` reads `caps.experimental['claude/channel']` and
// skips notification processing if absent. Verified 2026-05-14 by reading
// the Anthropic Discord plugin source AND watching the live debug log:
// "server X: Channel notifications skipped: server did not declare
// claude/channel capability" ← appears when these are at top level
experimental: {
'claude/channel': {},
'claude/channel/permission': {},
},
},
},
)
// Tool catalog is supplied by OpenclawPlugin in the hello_ack frame.
// We default to empty until then; tools/list responds immediately so Claude
// doesn't block.
let toolCatalog: Array<{ name: string; description: string; inputSchema: unknown }> = []
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: toolCatalog }))
// Every tool call is forwarded to OpenclawPlugin and awaits its result.
const pendingCalls = new Map<string, (r: unknown) => void>()
let nextCallId = 0
mcp.setRequestHandler(CallToolRequestSchema, async req => {
const callId = `tc_${++nextCallId}`
const result = await new Promise<{ ok: boolean; result?: unknown; error?: string }>(resolve => {
pendingCalls.set(callId, resolve as (r: unknown) => void)
bridgeSend({
type: 'tool_call',
call_id: callId,
tool: req.params.name,
args: req.params.arguments ?? {},
})
setTimeout(() => {
if (pendingCalls.delete(callId)) {
resolve({ ok: false, error: 'bridge timeout (60s)' })
}
}, 60_000)
})
if (!result.ok) {
return { content: [{ type: 'text', text: `error: ${result.error ?? 'unknown'}` }], isError: true }
}
return { content: [{ type: 'text', text: JSON.stringify(result.result ?? null) }] }
})
// Claude → us → bridge: permission request from Claude
mcp.setNotificationHandler(
z.object({
method: z.literal('notifications/claude/channel/permission_request'),
params: z.object({
request_id: z.string(),
tool: z.string().optional(),
args: z.unknown().optional(),
rationale: z.string().optional(),
}).passthrough(),
}),
async ({ params }) => {
bridgeSend({
type: 'permission_request',
request_id: params.request_id,
tool: params.tool ?? '',
args: params.args ?? {},
rationale: params.rationale ?? '',
})
},
)
// ─── Bridge WebSocket ────────────────────────────────────────────────────────
let ws: WebSocket | null = null
let backoff = 1000
let outbox: unknown[] = []
function bridgeSend(frame: unknown): void {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(frame))
} else {
outbox.push(frame)
}
}
function connect(): void {
ws = new WebSocket(BRIDGE_URL!)
ws.on('open', () => {
log(`bridge connected: ${BRIDGE_URL}`)
backoff = 1000
bridgeSend({
type: 'hello',
openclaw_session: OPENCLAW_SESSION,
claude_session: CLAUDE_SESSION,
pid: process.pid,
token: BRIDGE_TOKEN,
})
const flushing = outbox
outbox = []
for (const f of flushing) bridgeSend(f)
})
ws.on('message', raw => {
let frame: any
try { frame = JSON.parse(String(raw)) } catch { return log(`bad frame: ${raw}`) }
handleBridgeFrame(frame).catch(e => log(`handler error: ${e}`))
})
ws.on('close', () => {
log(`bridge disconnected, reconnect in ${backoff}ms`)
setTimeout(connect, backoff)
backoff = Math.min(backoff * 2, 30_000)
})
ws.on('error', err => log(`bridge error: ${(err as Error).message}`))
}
async function handleBridgeFrame(frame: any): Promise<void> {
switch (frame.type) {
case 'hello_ack':
if (Array.isArray(frame.tools)) toolCatalog = frame.tools
log(`hello_ack: ${toolCatalog.length} tools registered`)
return
case 'inbound_message':
await mcp.notification({
method: 'notifications/claude/channel',
params: { content: frame.content, meta: frame.meta },
})
return
case 'permission_reply':
await mcp.notification({
method: 'notifications/claude/channel/permission',
params: { request_id: frame.request_id, behavior: frame.behavior },
})
return
case 'tool_result': {
const resolver = pendingCalls.get(frame.call_id)
if (resolver) {
pendingCalls.delete(frame.call_id)
resolver({ ok: frame.ok, result: frame.result, error: frame.error })
}
return
}
case 'ping':
bridgeSend({ type: 'pong', ts: Date.now() })
return
default:
log(`unknown frame type: ${frame.type}`)
}
}
// ─── Boot ────────────────────────────────────────────────────────────────────
await mcp.connect(new StdioServerTransport())
log(`MCP transport ready (session=${OPENCLAW_SESSION}, pid=${process.pid})`)
connect()

View File

@@ -1,15 +0,0 @@
---
name: synthesis-status
description: Inspect the SynthesisAgent channel — current OpenClaw session binding, bridge connection state, pending tool calls
argument-hint: ""
allowed-tools: [Bash]
---
Print SynthesisAgent ClaudePlugin status from the env + bridge:
- `SYNTHESIS_OPENCLAW_SESSION`: which OpenClaw session this Claude process serves
- `SYNTHESIS_CLAUDE_SESSION`: the resumable Claude session UUID
- `SYNTHESIS_BRIDGE_URL`: bridge endpoint
- Bridge health: last `pong` timestamp, pending tool calls
When the user asks "what session am I in?", "is the bridge connected?", or "show synthesis status", read the env vars and report.

View File

@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"types": ["bun-types"]
}
}

View File

@@ -1,73 +0,0 @@
# SynthesisAgent.OpenclawPlugin
The **OpenClaw side** of SynthesisAgent. Lives inside the OpenClaw process; spawns and manages one long-lived interactive `claude` per OpenClaw session.
## Components
```
index.ts entry: definePluginEntry, wires up the others
core/config.ts plugin config schema + defaults
core/session-mapping.ts persistent openclaw_session ↔ claude_session_uuid
core/process-manager.ts spawn/resume/kill claude processes
core/cli.ts admin commands (`openclaw synthesis ...`)
web/bridge-server.ts WebSocket server that ClaudePlugins connect into
```
## Lifecycle
```
plugin loaded
SessionMapping reads ~/.openclaw/synthesis/sessions.json
ProcessManager idle, no spawns yet
BridgeServer binds 127.0.0.1:18801
inbound Discord msg for session=alice
processManager.ensure('alice')
- mapping.ensure → claude_session_uuid (new or existing)
- spawn `claude --channels plugin:synthesis-claude@local --resume <uuid> ...`
- inject env: SYNTHESIS_BRIDGE_URL / TOKEN / OPENCLAW_SESSION / CLAUDE_SESSION
ClaudePlugin (in spawned claude) dials ws://127.0.0.1:18801/bridge
- sends hello frame
- server validates token, marks process ready, sends hello_ack with tool catalog
processManager.ensure resolves
inbound message pushed → notifications/claude/channel → Claude starts new turn
Claude calls tools/call → forwarded to OpenClaw tool surface
Claude finishes → process stays alive, idle timer reset
1 hour idle → idleSweeper SIGTERMs the process
mapping is preserved → next message resumes via --resume
```
## Config
See `openclaw.plugin.json` `configSchema`. Override in `~/.openclaw/openclaw.json`:
```json
{
"plugins": {
"entries": {
"synthesis-agent": {
"bridgePort": 18801,
"bridgeToken": "<random secret>",
"idleKillMs": 1800000,
"maxProcesses": 8
}
}
}
}
```
## TODO
- [ ] Discover exact OpenClaw plugin-sdk API for: inbound event subscription, CLI command registration, tool catalog enumeration. See `contractor-agent/index.ts` for current best example.
- [ ] Wire `api.channels.onInbound(...)` to `pm.ensure()` + `bridgeServer.pushInbound()`.
- [ ] Implement tool dispatch in `web/bridge-server.ts` (current `tool_call` handler returns not-implemented).
- [ ] Implement permission routing back to source channel.
- [ ] CLI: `openclaw synthesis list`, `push`, `kill`, `forget`.
- [ ] Settle path conflict: this plugin needs to live at `/root/.openclaw/plugins/synthesis-agent/` to be auto-loaded. Either symlink from this repo or document copy-install.
- [ ] Decide policy on `--no-session-persistence` flag (currently we rely on default persistence so `--resume` works).

View File

@@ -1,37 +0,0 @@
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 _
}

View File

@@ -1,42 +0,0 @@
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
}

View File

@@ -1,162 +0,0 @@
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()
}
}

View File

@@ -1,83 +0,0 @@
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()]
}
}

View File

@@ -1,68 +0,0 @@
import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry'
import type { OpenClawPluginApi } from 'openclaw/plugin-sdk/core'
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).
const _G = globalThis as Record<string, unknown>
const PM_KEY = '_synthesisProcessManager'
const SERVER_KEY = '_synthesisBridgeServer'
const MAPPING_KEY = '_synthesisSessionMapping'
export default definePluginEntry({
id: 'synthesis-agent',
name: 'SynthesisAgent',
description: 'Manages long-lived Claude Code processes per OpenClaw session',
register(api: OpenClawPluginApi): void {
const config: SynthesisConfig = normalizeConfig(api.pluginConfig)
// ── 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 })
_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)
})
}
// ── 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]
})
},
})

View File

@@ -1,52 +0,0 @@
{
"id": "synthesis-agent",
"name": "SynthesisAgent",
"description": "Manages long-lived interactive Claude Code processes per OpenClaw session; bridges OpenClaw events <-> SynthesisAgent.ClaudePlugin",
"activation": {
"onStartup": true
},
"commandAliases": [
{ "name": "synthesis" }
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"bridgePort": {
"type": "number",
"default": 18801,
"description": "TCP port the bridge WebSocket server binds on (127.0.0.1)"
},
"bridgeToken": {
"type": "string",
"default": "synthesis-local",
"description": "Shared secret each ClaudePlugin instance must present in its hello frame"
},
"claudePluginRef": {
"type": "string",
"default": "plugin:synthesis-claude@local",
"description": "The --channels argument passed to claude on spawn"
},
"permissionMode": {
"type": "string",
"default": "acceptEdits",
"description": "Claude Code permission mode for spawned sessions"
},
"idleKillMs": {
"type": "number",
"default": 3600000,
"description": "Idle time after which a session's claude process is killed; next message resumes it"
},
"maxProcesses": {
"type": "number",
"default": 16,
"description": "Hard cap on concurrent claude processes; new sessions beyond this evict the oldest idle one"
},
"mappingDbPath": {
"type": "string",
"default": "~/.openclaw/synthesis/sessions.json",
"description": "Where openclaw_session <-> claude_session UUID mapping is persisted"
}
}
}
}

View File

@@ -1,16 +0,0 @@
{
"name": "synthesis-agent-openclaw-plugin",
"version": "0.0.1",
"license": "Apache-2.0",
"description": "OpenClaw plugin: manages long-lived Claude Code processes per OpenClaw session for SynthesisAgent",
"type": "module",
"main": "index.ts",
"dependencies": {
"ws": "^8.18.0"
},
"devDependencies": {
"@types/ws": "^8.5.10",
"typescript": "^5.0.0",
"openclaw": "*"
}
}

View File

@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["index.ts", "core/**/*.ts", "web/**/*.ts"]
}

View File

@@ -1,146 +0,0 @@
import { WebSocketServer, type WebSocket } from 'ws'
import type { SynthesisConfig } from '../core/config.js'
import type { SessionMapping } from '../core/session-mapping.js'
import type { ProcessManager } from '../core/process-manager.js'
interface BridgeServerDeps {
config: SynthesisConfig
mapping: SessionMapping
processManager: ProcessManager
}
interface ClientConn {
ws: WebSocket
pid: number
openclawSessionId: string
claudeSessionUuid: string
isAlive: boolean
}
/**
* WebSocket server that ClaudePlugin instances dial into. One connection per
* spawned Claude process; identified by env-injected (openclaw_session, pid).
*
* Bridge frames are defined in docs/wire-protocol.md.
*/
export async function startBridgeServer(deps: BridgeServerDeps): Promise<{ close(): Promise<void> }> {
const { config, processManager } = deps
const wss = new WebSocketServer({
host: '127.0.0.1',
port: config.bridgePort,
path: '/bridge',
})
const byOpenclawSession = new Map<string, ClientConn>()
wss.on('connection', ws => {
let conn: ClientConn | null = null
ws.on('message', async raw => {
let frame: any
try { frame = JSON.parse(String(raw)) } catch { return }
// First frame must be hello.
if (!conn) {
if (frame.type !== 'hello') return ws.close(4001, 'expected hello first')
if (frame.token !== config.bridgeToken) return ws.close(4002, 'bad token')
conn = {
ws,
pid: frame.pid,
openclawSessionId: frame.openclaw_session,
claudeSessionUuid: frame.claude_session,
isAlive: true,
}
// Evict any previous connection for this session (e.g. plugin reconnect).
const prev = byOpenclawSession.get(conn.openclawSessionId)
if (prev && prev.ws !== ws) try { prev.ws.close(4003, 'replaced by reconnect') } catch {}
byOpenclawSession.set(conn.openclawSessionId, conn)
processManager.markReady(conn.pid)
ws.send(JSON.stringify({
type: 'hello_ack',
// TODO: real catalog from api.tools.list() — empty for the scaffold.
tools: [],
session_meta: { openclaw_session: conn.openclawSessionId },
}))
return
}
// Subsequent frames.
switch (frame.type) {
case 'tool_call':
// TODO: dispatch frame.tool with frame.args against OpenClaw's tool
// surface. For the scaffold we echo not-implemented.
ws.send(JSON.stringify({
type: 'tool_result',
call_id: frame.call_id,
ok: false,
error: `tool dispatch not implemented (tool=${frame.tool})`,
}))
return
case 'permission_request':
// TODO: route to the user via the original source channel
// (Discord/Telegram/…). The OpenClaw side knows where the session
// came from. For now, log and auto-deny.
process.stderr.write(`synthesis: permission_request ${frame.request_id} (auto-denied)\n`)
ws.send(JSON.stringify({
type: 'permission_reply',
request_id: frame.request_id,
behavior: 'deny',
}))
return
case 'pong':
conn.isAlive = true
return
default:
process.stderr.write(`synthesis: unknown frame type from plugin: ${frame.type}\n`)
}
})
ws.on('close', () => {
if (conn && byOpenclawSession.get(conn.openclawSessionId)?.ws === ws) {
byOpenclawSession.delete(conn.openclawSessionId)
}
})
})
// Heartbeat: ping every 30s, kill if no pong by next round.
const heartbeat = setInterval(() => {
for (const c of byOpenclawSession.values()) {
if (!c.isAlive) {
try { c.ws.terminate() } catch {}
continue
}
c.isAlive = false
try { c.ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })) } catch {}
}
}, 30_000)
process.stderr.write(`synthesis: bridge listening on ws://127.0.0.1:${config.bridgePort}/bridge\n`)
return {
/** Public helper for outsiders (e.g. inbound event subscriber) to push messages. */
pushInbound(openclawSessionId: string, payload: { content: string; meta: Record<string, unknown>; request_id?: string }) {
const c = byOpenclawSession.get(openclawSessionId)
if (!c) throw new Error(`no live plugin for session ${openclawSessionId}`)
c.ws.send(JSON.stringify({ type: 'inbound_message', ...payload }))
},
pushPermissionReply(openclawSessionId: string, request_id: string, behavior: 'allow' | 'deny') {
const c = byOpenclawSession.get(openclawSessionId)
if (!c) throw new Error(`no live plugin for session ${openclawSessionId}`)
c.ws.send(JSON.stringify({ type: 'permission_reply', request_id, behavior }))
},
async close() {
clearInterval(heartbeat)
for (const c of byOpenclawSession.values()) try { c.ws.close() } catch {}
await new Promise<void>(r => wss.close(() => r()))
},
} as unknown as { close(): Promise<void> }
}