commit 0fb46e318edc863cc76ef930a83d7553040f17b9 Author: zhi Date: Thu May 14 09:39:02 2026 +0000 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0bca9c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.log +.DS_Store +bun.lock +package-lock.json +.bun/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9580138 --- /dev/null +++ b/README.md @@ -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). diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 0000000..abe6fd5 --- /dev/null +++ b/STATUS.md @@ -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:@` or `server:` +- ✅ `--plugin-dir ` 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. diff --git a/SynthesisAgent.ClaudePlugin/.claude-plugin/plugin.json b/SynthesisAgent.ClaudePlugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..1c8faeb --- /dev/null +++ b/SynthesisAgent.ClaudePlugin/.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/SynthesisAgent.ClaudePlugin/README.md b/SynthesisAgent.ClaudePlugin/README.md new file mode 100644 index 0000000..c5bfaff --- /dev/null +++ b/SynthesisAgent.ClaudePlugin/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/SynthesisAgent.ClaudePlugin/package.json b/SynthesisAgent.ClaudePlugin/package.json new file mode 100644 index 0000000..5c4527b --- /dev/null +++ b/SynthesisAgent.ClaudePlugin/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/SynthesisAgent.ClaudePlugin/server.ts b/SynthesisAgent.ClaudePlugin/server.ts new file mode 100644 index 0000000..c8f8d96 --- /dev/null +++ b/SynthesisAgent.ClaudePlugin/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/SynthesisAgent.ClaudePlugin/skills/configure/SKILL.md b/SynthesisAgent.ClaudePlugin/skills/configure/SKILL.md new file mode 100644 index 0000000..bcb1e5c --- /dev/null +++ b/SynthesisAgent.ClaudePlugin/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/SynthesisAgent.ClaudePlugin/tsconfig.json b/SynthesisAgent.ClaudePlugin/tsconfig.json new file mode 100644 index 0000000..b9fba85 --- /dev/null +++ b/SynthesisAgent.ClaudePlugin/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"] + } +} diff --git a/SynthesisAgent.OpenclawPlugin/README.md b/SynthesisAgent.OpenclawPlugin/README.md new file mode 100644 index 0000000..2a02cde --- /dev/null +++ b/SynthesisAgent.OpenclawPlugin/README.md @@ -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 ...` + - 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": "", + "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). diff --git a/SynthesisAgent.OpenclawPlugin/core/cli.ts b/SynthesisAgent.OpenclawPlugin/core/cli.ts new file mode 100644 index 0000000..16377bc --- /dev/null +++ b/SynthesisAgent.OpenclawPlugin/core/cli.ts @@ -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 — fake an inbound message + * kill — terminate a process (mapping preserved) + * forget — 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 _ +} diff --git a/SynthesisAgent.OpenclawPlugin/core/config.ts b/SynthesisAgent.OpenclawPlugin/core/config.ts new file mode 100644 index 0000000..b8db286 --- /dev/null +++ b/SynthesisAgent.OpenclawPlugin/core/config.ts @@ -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 + 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 +} diff --git a/SynthesisAgent.OpenclawPlugin/core/process-manager.ts b/SynthesisAgent.OpenclawPlugin/core/process-manager.ts new file mode 100644 index 0000000..b3e6ff1 --- /dev/null +++ b/SynthesisAgent.OpenclawPlugin/core/process-manager.ts @@ -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 + 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() + private idleSweeper: ReturnType | 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 { + 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((_, 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(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 { + 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() + } +} diff --git a/SynthesisAgent.OpenclawPlugin/core/session-mapping.ts b/SynthesisAgent.OpenclawPlugin/core/session-mapping.ts new file mode 100644 index 0000000..563b8cc --- /dev/null +++ b/SynthesisAgent.OpenclawPlugin/core/session-mapping.ts @@ -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() + 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()] + } +} diff --git a/SynthesisAgent.OpenclawPlugin/index.ts b/SynthesisAgent.OpenclawPlugin/index.ts new file mode 100644 index 0000000..1366202 --- /dev/null +++ b/SynthesisAgent.OpenclawPlugin/index.ts @@ -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 +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> | undefined + await server?.close?.() + delete _G[SERVER_KEY] + await pm!.shutdown() + delete _G[PM_KEY] + }) + }, +}) diff --git a/SynthesisAgent.OpenclawPlugin/openclaw.plugin.json b/SynthesisAgent.OpenclawPlugin/openclaw.plugin.json new file mode 100644 index 0000000..c966181 --- /dev/null +++ b/SynthesisAgent.OpenclawPlugin/openclaw.plugin.json @@ -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" + } + } + } +} diff --git a/SynthesisAgent.OpenclawPlugin/package.json b/SynthesisAgent.OpenclawPlugin/package.json new file mode 100644 index 0000000..8fc3dfb --- /dev/null +++ b/SynthesisAgent.OpenclawPlugin/package.json @@ -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": "*" + } +} diff --git a/SynthesisAgent.OpenclawPlugin/tsconfig.json b/SynthesisAgent.OpenclawPlugin/tsconfig.json new file mode 100644 index 0000000..5c2d790 --- /dev/null +++ b/SynthesisAgent.OpenclawPlugin/tsconfig.json @@ -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"] +} diff --git a/SynthesisAgent.OpenclawPlugin/web/bridge-server.ts b/SynthesisAgent.OpenclawPlugin/web/bridge-server.ts new file mode 100644 index 0000000..ad9b387 --- /dev/null +++ b/SynthesisAgent.OpenclawPlugin/web/bridge-server.ts @@ -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 }> { + const { config, processManager } = deps + + const wss = new WebSocketServer({ + host: '127.0.0.1', + port: config.bridgePort, + path: '/bridge', + }) + + const byOpenclawSession = new Map() + + 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; 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(r => wss.close(() => r())) + }, + } as unknown as { close(): Promise } +} diff --git a/docs/wire-protocol.md b/docs/wire-protocol.md new file mode 100644 index 0000000..135b293 --- /dev/null +++ b/docs/wire-protocol.md @@ -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": "", + "claude_session": "", + "pid": 12345, + "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": "", + "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 `"). + +## 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.