From cb9446406010ee0b8eaf303a103ed9b45f3bd2c1 Mon Sep 17 00:00:00 2001 From: zhi Date: Thu, 14 May 2026 09:41:14 +0000 Subject: [PATCH] chore: initial scaffolding for SynthesisAgent.ClaudePlugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stdio MCP server registered as a Claude Code --channels source. Receives inbound events from SynthesisAgent.OpenclawPlugin and emits notifications/claude/channel to push them into the running Claude turn loop. Forwards tools/call requests back to OpenClaw for execution. Capability declaration uses experimental.claude/channel — required nesting discovered 2026-05-14 against the official Anthropic discord plugin source. --- .claude-plugin/plugin.json | 6 + .gitignore | 6 + README.md | 58 ++++++++++ package.json | 18 +++ server.ts | 223 +++++++++++++++++++++++++++++++++++++ skills/configure/SKILL.md | 15 +++ tsconfig.json | 14 +++ 7 files changed, 340 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 server.ts create mode 100644 skills/configure/SKILL.md create mode 100644 tsconfig.json diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..1c8faeb --- /dev/null +++ b/.claude-plugin/plugin.json @@ -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"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e9c7f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.DS_Store +bun.lock +package-lock.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5bfaff --- /dev/null +++ b/README.md @@ -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` | `` | 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". diff --git a/package.json b/package.json new file mode 100644 index 0000000..5c4527b --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..c8f8d96 --- /dev/null +++ b/server.ts @@ -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 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 { + 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() diff --git a/skills/configure/SKILL.md b/skills/configure/SKILL.md new file mode 100644 index 0000000..bcb1e5c --- /dev/null +++ b/skills/configure/SKILL.md @@ -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. diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b9fba85 --- /dev/null +++ b/tsconfig.json @@ -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"] + } +}