chore: initial scaffolding for SynthesisAgent
Bridge between OpenClaw (multi-channel hub) and interactive Claude Code, keeping autonomous agent usage on the subscription quota after the 2026-06-15 Agent SDK credit split. Initial scaffolding only — two submodules with skeletons: - SynthesisAgent.ClaudePlugin: stdio MCP server registered as a --channels source. Declares experimental.claude/channel capability (verified 2026-05-14 against the official Anthropic discord plugin) and emits notifications/claude/channel to push OpenClaw inbound messages into the Claude turn loop. - SynthesisAgent.OpenclawPlugin: process manager + session mapping + bridge WebSocket. Currently shaped around a custom ws protocol; will be rewritten as a model-provider HTTP server (contractor-agent pattern) so OpenClaw routes inbound channel messages through it via /v1/chat/completions. See STATUS.md for the open punch list and docs/wire-protocol.md for the (soon-to-change) inter-plugin frame schema.
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
bun.lock
|
||||
package-lock.json
|
||||
.bun/
|
||||
61
README.md
Normal file
61
README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# SynthesisAgent
|
||||
|
||||
Bridge between **OpenClaw** (multi-channel hub) and **Claude Code** (interactive Claude session), letting OpenClaw drive Claude Code as an autonomous worker **without using `claude -p` or the Agent SDK** — so all usage stays on the interactive subscription quota.
|
||||
|
||||
## Motivation
|
||||
|
||||
Starting 2026-06-15, Claude Agent SDK and `claude -p` usage on Max plans draws from a separate `$100` monthly credit pool. Interactive Claude Code usage continues to draw from the subscription. Today OpenClaw's contractor agents spawn a fresh `claude -p` per external event, which after the cutover will consume credit instead of subscription.
|
||||
|
||||
SynthesisAgent moves contractor agents to a model where:
|
||||
|
||||
- A **long-lived interactive `claude` process** serves each OpenClaw session.
|
||||
- External events (Discord, Telegram, Slack…) flow into that process via the official `notifications/claude/channel` MCP notification protocol, the same one Anthropic's own `discord@claude-plugins-official` plugin uses.
|
||||
- The process exposes OpenClaw's tool surface (pcexec, sessions_*, memory_*, wiki_*, canvas, …) back to the model.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Discord / Telegram / Slack / ...
|
||||
│
|
||||
▼
|
||||
OpenClaw Gateway (existing)
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────┐
|
||||
│ SynthesisAgent.OpenclawPlugin │
|
||||
│ - Process Manager (spawn / resume) │
|
||||
│ - Session Mapping DB │
|
||||
│ - Bridge WebSocket Server │
|
||||
└──────────────────────────────────────┘
|
||||
│ ▲
|
||||
│ spawn │ WebSocket
|
||||
▼ │
|
||||
┌──────────────────────────────────────┐
|
||||
│ claude --channels plugin:synthesis │ (one per OpenClaw session)
|
||||
│ ├─ Claude Code (interactive) │
|
||||
│ └─ SynthesisAgent.ClaudePlugin │
|
||||
│ (MCP stdio server) │
|
||||
│ - notifications/claude/channel │
|
||||
│ - tool proxy → OpenClaw │
|
||||
│ - permission bridge │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Submodules
|
||||
|
||||
| Module | Lives in | Role |
|
||||
|-------------------------------------|-----------------------|------|
|
||||
| [`SynthesisAgent.OpenclawPlugin`](./SynthesisAgent.OpenclawPlugin/) | OpenClaw process | Spawns/manages Claude processes per session; bridges Claude `↔` OpenClaw event bus |
|
||||
| [`SynthesisAgent.ClaudePlugin`](./SynthesisAgent.ClaudePlugin/) | Each Claude process | MCP stdio server registered as a `--channels` plugin; receives events from OpenclawPlugin bridge, proxies tool calls back |
|
||||
|
||||
## Wire protocol
|
||||
|
||||
See [`docs/wire-protocol.md`](./docs/wire-protocol.md) for the message schema between the two plugins.
|
||||
|
||||
## Status
|
||||
|
||||
Scaffolding only. Nothing wired up. See each submodule's TODO list.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 (matches the upstream Anthropic Discord channel plugin this work is modeled after).
|
||||
68
STATUS.md
Normal file
68
STATUS.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# SynthesisAgent — Status (paused 2026-05-14)
|
||||
|
||||
## Where we left off
|
||||
|
||||
Scaffolding complete. Two submodules wired up with their core skeletons:
|
||||
|
||||
- `SynthesisAgent.ClaudePlugin/server.ts` — stdio MCP server + bridge WebSocket client. Reads 4 env vars, forwards `tool_call` / `permission_request` to OpenClaw side, translates `inbound_message` frames to `notifications/claude/channel`.
|
||||
- `SynthesisAgent.OpenclawPlugin` — process manager (spawn/resume/idle-GC), session mapping (JSON persist), bridge WS server, OpenClaw plugin entry. Tool-dispatch and inbound-event-subscription are stubbed.
|
||||
|
||||
Wire protocol fully specified in `docs/wire-protocol.md`.
|
||||
|
||||
## Open blockers (in priority order)
|
||||
|
||||
### Done / validated 2026-05-14 on laptop (SSH via T2)
|
||||
|
||||
- ✅ `--channels` flag exists in Claude Code 2.1.140/141 (hidden from `--help`)
|
||||
- ✅ Syntax: `plugin:<name>@<marketplace>` or `server:<name>`
|
||||
- ✅ `--plugin-dir <path>` IS documented and works for local plugin loading
|
||||
- ✅ Capability declaration must be `experimental.claude/channel` not top-level — **scaffolding patched**
|
||||
- ✅ `-p` mode does NOT trigger turns from channel notifications — interactive only
|
||||
- ✅ Full gating chain decoded from binary (see `memory/claude_channels_protocol.md`)
|
||||
- ❌ Did NOT capture an end-to-end "claude responds to channel notification" trace — got stuck at `--dangerously-load-development-channels` allowlist gate in script-PTY runs (interactive confirm may not have flowed through). Indirect evidence is overwhelming via the Discord plugin's working production pattern; remaining doubt is small.
|
||||
|
||||
### Remaining
|
||||
|
||||
1. **`.mcp.json` not written** — sandbox flagged it as sensitive on Write. Five-line file, need to drop in by hand or approve the write.
|
||||
2. **Confirm channel notification → new turn empirically** — Have user run interactively (no PTY tricks):
|
||||
```
|
||||
claude --mcp-config /tmp/synthesis-mcp.json \
|
||||
--channels server:synthesis-stub \
|
||||
--dangerously-load-development-channels server:synthesis-stub
|
||||
```
|
||||
(in a trusted dir), confirm dev mode at the prompt, watch for the stub's 1.5s-delayed channel-test ping triggering a turn.
|
||||
3. **Decide deployment path**:
|
||||
- `server:` channels are flagged "dangerous dev only" — fine for our own use, awkward for shipping
|
||||
- `plugin:` channels need to be in marketplace allowlist (org `allowedChannelPlugins`) — requires distribution mechanism
|
||||
- Easiest dev path: local marketplace pointing at our repo
|
||||
4. **OpenClaw plugin-sdk inbound subscription API** — `index.ts` has a TODO where `api.channels.onInbound(...)` should live. Need to read `contractor-agent/index.ts` carefully and copy the actual pattern.
|
||||
5. **Tool catalog source** — `web/bridge-server.ts` sends an empty `tools: []` in `hello_ack`. Must enumerate OpenClaw's real tool surface and forward.
|
||||
6. **`tool_call` dispatch** — currently returns `not implemented`. Need to wire to OpenClaw's tool execution path.
|
||||
7. **Permission routing back to source channel** — bridge auto-denies any `permission_request` today.
|
||||
|
||||
## Quick-start tests to run when resuming
|
||||
|
||||
```bash
|
||||
# 0. write .mcp.json (blocked last time)
|
||||
|
||||
# 1. verify --channels accepts local path or local marketplace
|
||||
claude --channels plugin:synthesis-claude@/root/.openclaw/workspace/workspace-developer/SynthesisAgent/SynthesisAgent.ClaudePlugin 2>&1 | head -20
|
||||
|
||||
# 2. once #1 works: smoke-test notifications/claude/channel from a stub server
|
||||
# (build a 30-line MCP server that fakes inbound_message after 5s)
|
||||
```
|
||||
|
||||
## Files to start from when picking up
|
||||
|
||||
- `README.md` — context refresher
|
||||
- `docs/wire-protocol.md` — protocol contract
|
||||
- `SynthesisAgent.OpenclawPlugin/README.md` — TODO list for that side
|
||||
- `SynthesisAgent.ClaudePlugin/README.md` — TODO list for that side
|
||||
|
||||
Reference implementations to crib from:
|
||||
- `~/.claude/plugins/marketplaces/claude-plugins-official/external_plugins/discord/server.ts` — channel protocol model
|
||||
- `/root/.openclaw/plugins/contractor-agent/index.ts` — OpenClaw plugin lifecycle model
|
||||
|
||||
## Why paused
|
||||
|
||||
User pivoted to investigate a recurrence of the Discord duplicate-messages bug ([[discord_duplicate_messages]]). SynthesisAgent is the longer-term fix for that whole class of problem; the duplicate-messages bug is the short-term hotfix path.
|
||||
6
SynthesisAgent.ClaudePlugin/.claude-plugin/plugin.json
Normal file
6
SynthesisAgent.ClaudePlugin/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
58
SynthesisAgent.ClaudePlugin/README.md
Normal file
58
SynthesisAgent.ClaudePlugin/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 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".
|
||||
18
SynthesisAgent.ClaudePlugin/package.json
Normal file
18
SynthesisAgent.ClaudePlugin/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
223
SynthesisAgent.ClaudePlugin/server.ts
Normal file
223
SynthesisAgent.ClaudePlugin/server.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
#!/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()
|
||||
15
SynthesisAgent.ClaudePlugin/skills/configure/SKILL.md
Normal file
15
SynthesisAgent.ClaudePlugin/skills/configure/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
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.
|
||||
14
SynthesisAgent.ClaudePlugin/tsconfig.json
Normal file
14
SynthesisAgent.ClaudePlugin/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun-types"]
|
||||
}
|
||||
}
|
||||
73
SynthesisAgent.OpenclawPlugin/README.md
Normal file
73
SynthesisAgent.OpenclawPlugin/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 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).
|
||||
37
SynthesisAgent.OpenclawPlugin/core/cli.ts
Normal file
37
SynthesisAgent.OpenclawPlugin/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
SynthesisAgent.OpenclawPlugin/core/config.ts
Normal file
42
SynthesisAgent.OpenclawPlugin/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
SynthesisAgent.OpenclawPlugin/core/process-manager.ts
Normal file
162
SynthesisAgent.OpenclawPlugin/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
SynthesisAgent.OpenclawPlugin/core/session-mapping.ts
Normal file
83
SynthesisAgent.OpenclawPlugin/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()]
|
||||
}
|
||||
}
|
||||
68
SynthesisAgent.OpenclawPlugin/index.ts
Normal file
68
SynthesisAgent.OpenclawPlugin/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
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]
|
||||
})
|
||||
},
|
||||
})
|
||||
52
SynthesisAgent.OpenclawPlugin/openclaw.plugin.json
Normal file
52
SynthesisAgent.OpenclawPlugin/openclaw.plugin.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
SynthesisAgent.OpenclawPlugin/package.json
Normal file
16
SynthesisAgent.OpenclawPlugin/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"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": "*"
|
||||
}
|
||||
}
|
||||
14
SynthesisAgent.OpenclawPlugin/tsconfig.json
Normal file
14
SynthesisAgent.OpenclawPlugin/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
146
SynthesisAgent.OpenclawPlugin/web/bridge-server.ts
Normal file
146
SynthesisAgent.OpenclawPlugin/web/bridge-server.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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> }
|
||||
}
|
||||
122
docs/wire-protocol.md
Normal file
122
docs/wire-protocol.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Wire Protocol — OpenclawPlugin ↔ ClaudePlugin
|
||||
|
||||
A WebSocket connection from each spawned ClaudePlugin instance back to OpenclawPlugin's bridge server. JSON messages, one per frame.
|
||||
|
||||
## Connection bootstrap
|
||||
|
||||
On spawn, OpenclawPlugin sets these env vars on the Claude process so ClaudePlugin can find its way home:
|
||||
|
||||
| Env var | Purpose |
|
||||
|--------------------------------|---------------------------------------------------------------|
|
||||
| `SYNTHESIS_BRIDGE_URL` | WebSocket URL, e.g. `ws://127.0.0.1:18801/bridge` |
|
||||
| `SYNTHESIS_BRIDGE_TOKEN` | Shared secret, validated server-side |
|
||||
| `SYNTHESIS_OPENCLAW_SESSION` | The OpenClaw session ID this Claude process serves |
|
||||
| `SYNTHESIS_CLAUDE_SESSION` | The Claude session UUID (for traceability) |
|
||||
|
||||
ClaudePlugin connects on startup and sends:
|
||||
|
||||
```json
|
||||
{ "type": "hello",
|
||||
"openclaw_session": "<sid>",
|
||||
"claude_session": "<uuid>",
|
||||
"pid": 12345,
|
||||
"token": "<bridge_token>" }
|
||||
```
|
||||
|
||||
OpenclawPlugin responds with:
|
||||
|
||||
```json
|
||||
{ "type": "hello_ack", "tools": [ /* tool catalog */ ], "session_meta": { ... } }
|
||||
```
|
||||
|
||||
If `hello` is rejected (bad token, unknown session), the server closes the socket.
|
||||
|
||||
## Direction: OpenClaw → Claude (inbound events)
|
||||
|
||||
```json
|
||||
{ "type": "inbound_message",
|
||||
"request_id": "msg_abc123",
|
||||
"content": "user message text",
|
||||
"meta": {
|
||||
"chat_id": "<openclaw_session_id>",
|
||||
"message_id": "...",
|
||||
"user": "alice",
|
||||
"user_id": "...",
|
||||
"ts": "2026-05-14T...Z",
|
||||
"source_channel": "discord",
|
||||
"attachments": [ ... ]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
ClaudePlugin translates each `inbound_message` into:
|
||||
|
||||
```json
|
||||
{ "method": "notifications/claude/channel",
|
||||
"params": { "content": ..., "meta": ... } }
|
||||
```
|
||||
|
||||
…and emits it on its MCP stdio. Claude Code reacts by starting a new turn.
|
||||
|
||||
## Direction: OpenClaw → Claude (permission reply)
|
||||
|
||||
When the user answers a permission prompt out of band (e.g. "yes abxyz" in Discord), OpenClaw forwards:
|
||||
|
||||
```json
|
||||
{ "type": "permission_reply",
|
||||
"request_id": "abxyz",
|
||||
"behavior": "allow" }
|
||||
```
|
||||
|
||||
ClaudePlugin emits an MCP `notifications/claude/channel/permission` for that request_id.
|
||||
|
||||
## Direction: Claude → OpenClaw (tool calls)
|
||||
|
||||
Each `tools/call` Claude makes on ClaudePlugin (excluding internal ones the plugin owns) is forwarded over the bridge:
|
||||
|
||||
```json
|
||||
{ "type": "tool_call",
|
||||
"call_id": "tc_001",
|
||||
"tool": "pcexec",
|
||||
"args": { ... } }
|
||||
```
|
||||
|
||||
OpenclawPlugin invokes the corresponding OpenClaw tool and responds:
|
||||
|
||||
```json
|
||||
{ "type": "tool_result",
|
||||
"call_id": "tc_001",
|
||||
"ok": true,
|
||||
"result": { ... } }
|
||||
```
|
||||
|
||||
…or with `"ok": false, "error": "..."`.
|
||||
|
||||
## Direction: Claude → OpenClaw (permission request)
|
||||
|
||||
When Claude Code asks for a permission via `notifications/claude/channel/permission_request`, ClaudePlugin forwards:
|
||||
|
||||
```json
|
||||
{ "type": "permission_request",
|
||||
"request_id": "<5 lowercase letters minus 'l'>",
|
||||
"tool": "Bash",
|
||||
"args": { ... },
|
||||
"rationale": "..." }
|
||||
```
|
||||
|
||||
OpenclawPlugin routes this to the user via the source channel (e.g. posts a Discord message: "Approve? Reply `yes <code>`").
|
||||
|
||||
## Direction: bidirectional health
|
||||
|
||||
```json
|
||||
{ "type": "ping", "ts": ... }
|
||||
{ "type": "pong", "ts": ... }
|
||||
```
|
||||
|
||||
OpenclawPlugin sends `ping` every 30s. ClaudePlugin missing 2 consecutive pings → server marks the connection dead, allows reconnect.
|
||||
|
||||
## Reconnect semantics
|
||||
|
||||
- ClaudePlugin reconnects on socket close with exponential backoff (1s, 2s, 4s, max 30s).
|
||||
- During disconnect, OpenclawPlugin buffers up to N inbound messages per session (configurable). Buffer overflow drops the oldest with a warning.
|
||||
- On reconnect, the `hello` message re-establishes session binding.
|
||||
Reference in New Issue
Block a user