@sw-market/openclaw-opencode-bridge 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,13 +9,69 @@ OpenClaw plugin bridge for OpenCode realtime sessions.
9
9
  - Support interactive confirmations (`interaction.required` -> `chat.action` -> `interaction.resolved`).
10
10
  - Emit file change summaries (`file.changed`) for downstream clients (Flutter/Feishu/others).
11
11
 
12
- ## Installation
12
+ ## Install To OpenClaw
13
13
 
14
14
  ```bash
15
- npm install @sw-market/openclaw-opencode-bridge
15
+ openclaw plugins install @sw-market/openclaw-opencode-bridge
16
16
  ```
17
17
 
18
- ## Usage (plugin runtime)
18
+ After install, verify plugin exists:
19
+
20
+ ```bash
21
+ openclaw plugins list
22
+ ```
23
+
24
+ ## OpenClaw Config
25
+
26
+ In `~/.openclaw/openclaw.json`, enable plugin and set SDK adapter config:
27
+
28
+ ```json
29
+ {
30
+ "plugins": {
31
+ "entries": {
32
+ "openclaw-opencode-bridge": {
33
+ "enabled": true,
34
+ "config": {
35
+ "sdkAdapterModule": "/abs/path/to/opencode_sdk_adapter.mjs",
36
+ "sdkAdapterExport": "createOpenCodeSdkAdapter",
37
+ "sessionTtlMs": 1800000,
38
+ "cleanupIntervalMs": 60000
39
+ }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ You can also set adapter location by environment variable:
47
+
48
+ ```bash
49
+ export OPENCODE_SDK_ADAPTER_MODULE=/abs/path/to/opencode_sdk_adapter.mjs
50
+ export OPENCODE_SDK_ADAPTER_EXPORT=createOpenCodeSdkAdapter
51
+ ```
52
+
53
+ Adapter module contract:
54
+ - export function `createOpenCodeSdkAdapter(ctx)` or export object directly.
55
+ - returned object must implement `createSession(args)`.
56
+
57
+ Note:
58
+ - Plugin installation no longer requires `sdkAdapterModule` in config.
59
+ - But runtime calls will fail until adapter module is provided via config or env.
60
+
61
+ ## Gateway Methods Provided
62
+
63
+ This plugin registers:
64
+ - `opencode.chat.send`
65
+ - `opencode.chat.action`
66
+
67
+ Set adapter side method routing:
68
+
69
+ ```env
70
+ BRIDGE_WS_CHAT_SEND_METHOD=opencode.chat.send
71
+ BRIDGE_WS_CHAT_ACTION_METHOD=opencode.chat.action
72
+ ```
73
+
74
+ ## Library Usage (standalone)
19
75
 
20
76
  ```ts
21
77
  import {
@@ -0,0 +1,40 @@
1
+ type JsonObject = Record<string, unknown>;
2
+ type PluginLogger = {
3
+ info: (message: string) => void;
4
+ warn: (message: string) => void;
5
+ error: (message: string) => void;
6
+ debug?: (message: string) => void;
7
+ };
8
+ type RespondFn = (ok: boolean, payload?: unknown, error?: {
9
+ code: string;
10
+ message: string;
11
+ [key: string]: unknown;
12
+ }, meta?: JsonObject) => void;
13
+ type GatewayRequestHandlerOptions = {
14
+ params: JsonObject;
15
+ client: {
16
+ connId?: string;
17
+ } | null;
18
+ respond: RespondFn;
19
+ context: {
20
+ broadcast: (event: string, payload: unknown, opts?: {
21
+ dropIfSlow?: boolean;
22
+ stateVersion?: JsonObject;
23
+ }) => void;
24
+ broadcastToConnIds: (event: string, payload: unknown, connIds: ReadonlySet<string>, opts?: {
25
+ dropIfSlow?: boolean;
26
+ stateVersion?: JsonObject;
27
+ }) => void;
28
+ nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
29
+ };
30
+ };
31
+ type PluginApi = {
32
+ id: string;
33
+ pluginConfig?: JsonObject;
34
+ logger: PluginLogger;
35
+ resolvePath: (input: string) => string;
36
+ registerGatewayMethod: (method: string, handler: (opts: GatewayRequestHandlerOptions) => void | Promise<void>) => void;
37
+ on?: (hookName: string, handler: () => void | Promise<void>) => void;
38
+ };
39
+ export default function register(api: PluginApi): void;
40
+ export {};
@@ -0,0 +1,305 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { OpenClawOpenCodeBridge, } from "./index.js";
5
+ const CHAT_SEND_METHOD = "opencode.chat.send";
6
+ const CHAT_ACTION_METHOD = "opencode.chat.action";
7
+ const DEFAULT_ADAPTER_EXPORT = "createOpenCodeSdkAdapter";
8
+ let bridgePromise = null;
9
+ let bridgeInstance = null;
10
+ function asObject(value) {
11
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
12
+ return null;
13
+ }
14
+ return value;
15
+ }
16
+ function readString(source, key) {
17
+ const value = source[key];
18
+ return typeof value === "string" ? value.trim() : "";
19
+ }
20
+ function readOptionalPositiveInt(source, key) {
21
+ const value = source[key];
22
+ if (typeof value !== "number" || !Number.isFinite(value)) {
23
+ return undefined;
24
+ }
25
+ const normalized = Math.floor(value);
26
+ if (normalized <= 0) {
27
+ return undefined;
28
+ }
29
+ return normalized;
30
+ }
31
+ function normalizeDecision(value) {
32
+ const lowered = value.trim().toLowerCase();
33
+ if (lowered === "approve" || lowered === "approved" || lowered === "yes" || lowered === "ok") {
34
+ return "approve";
35
+ }
36
+ if (lowered === "reject" || lowered === "rejected" || lowered === "decline" || lowered === "no") {
37
+ return "reject";
38
+ }
39
+ if (lowered === "cancel" || lowered === "cancelled" || lowered === "canceled") {
40
+ return "cancel";
41
+ }
42
+ return "approve";
43
+ }
44
+ function toErrorMessage(error) {
45
+ if (error instanceof Error) {
46
+ return error.message;
47
+ }
48
+ return String(error);
49
+ }
50
+ function parseConfig(pluginConfig) {
51
+ const raw = pluginConfig ?? {};
52
+ const sdkAdapterModuleRaw = readString(raw, "sdkAdapterModule") || String(process.env.OPENCODE_SDK_ADAPTER_MODULE || "").trim();
53
+ const sdkAdapterModule = sdkAdapterModuleRaw || undefined;
54
+ const sdkAdapterExport = readString(raw, "sdkAdapterExport") ||
55
+ String(process.env.OPENCODE_SDK_ADAPTER_EXPORT || "").trim() ||
56
+ DEFAULT_ADAPTER_EXPORT;
57
+ const sessionTtlMs = readOptionalPositiveInt(raw, "sessionTtlMs");
58
+ const cleanupIntervalMs = readOptionalPositiveInt(raw, "cleanupIntervalMs");
59
+ const emitToAllClients = raw.emitToAllClients === true;
60
+ return {
61
+ sdkAdapterModule,
62
+ sdkAdapterExport,
63
+ sessionTtlMs,
64
+ cleanupIntervalMs,
65
+ emitToAllClients,
66
+ };
67
+ }
68
+ function resolveModuleSpecifier(api, moduleSpecifier) {
69
+ const raw = moduleSpecifier.trim();
70
+ if (!raw) {
71
+ throw new Error("OpenCode SDK adapter is not configured. Set plugins.entries.openclaw-opencode-bridge.config.sdkAdapterModule " +
72
+ "or env OPENCODE_SDK_ADAPTER_MODULE.");
73
+ }
74
+ const looksLikePath = raw.startsWith("./") ||
75
+ raw.startsWith("../") ||
76
+ raw.startsWith("/") ||
77
+ raw.startsWith(".\\") ||
78
+ /^[a-zA-Z]:[\\/]/.test(raw);
79
+ if (!looksLikePath) {
80
+ return raw;
81
+ }
82
+ const absolutePath = path.isAbsolute(raw) ? raw : api.resolvePath(raw);
83
+ if (!existsSync(absolutePath)) {
84
+ throw new Error(`sdkAdapterModule path does not exist: ${absolutePath}`);
85
+ }
86
+ return pathToFileURL(absolutePath).href;
87
+ }
88
+ function isOpenCodeSdkAdapter(value) {
89
+ const obj = asObject(value);
90
+ if (!obj) {
91
+ return false;
92
+ }
93
+ return typeof obj.createSession === "function";
94
+ }
95
+ async function loadSdkAdapter(api, config) {
96
+ const moduleSpecifier = resolveModuleSpecifier(api, config.sdkAdapterModule ?? "");
97
+ const loaded = await import(moduleSpecifier);
98
+ const exported = loaded[config.sdkAdapterExport];
99
+ let candidate = exported;
100
+ if (typeof exported === "function") {
101
+ candidate = await Promise.resolve(exported({
102
+ api,
103
+ pluginConfig: config,
104
+ }));
105
+ }
106
+ if (!isOpenCodeSdkAdapter(candidate)) {
107
+ throw new Error(`Invalid OpenCode SDK adapter from "${config.sdkAdapterModule}" export "${config.sdkAdapterExport}". ` +
108
+ "Expected an object with createSession(args) function.");
109
+ }
110
+ return candidate;
111
+ }
112
+ function respondError(respond, code, message) {
113
+ respond(false, undefined, {
114
+ code,
115
+ message,
116
+ });
117
+ }
118
+ function readRequiredField(params, key) {
119
+ const value = readString(params, key);
120
+ if (!value) {
121
+ throw new Error(`${key} is required`);
122
+ }
123
+ return value;
124
+ }
125
+ function parseAction(params) {
126
+ const actionObj = asObject(params.action);
127
+ if (!actionObj) {
128
+ throw new Error("action is required and must be an object");
129
+ }
130
+ const actionType = readRequiredField(actionObj, "type");
131
+ if (actionType === "interaction.reply") {
132
+ return {
133
+ type: "interaction.reply",
134
+ interactionId: readRequiredField(actionObj, "interactionId"),
135
+ decision: normalizeDecision(readString(actionObj, "decision") || "approve"),
136
+ source: readString(actionObj, "source") || undefined,
137
+ payload: asObject(actionObj.payload) ?? undefined,
138
+ };
139
+ }
140
+ if (actionType === "cancel_run") {
141
+ return {
142
+ type: "cancel_run",
143
+ reason: readString(actionObj, "reason") || undefined,
144
+ };
145
+ }
146
+ throw new Error(`Unsupported action.type: ${actionType}`);
147
+ }
148
+ function emitEventToGateway(args) {
149
+ const payload = { ...args.payload };
150
+ if (!payload.sessionKey) {
151
+ payload.sessionKey = args.sessionKey;
152
+ }
153
+ const connId = typeof args.opts.client?.connId === "string" ? args.opts.client.connId.trim() : "";
154
+ const dropIfSlow = args.event !== "chat";
155
+ if (!args.emitToAllClients && connId) {
156
+ args.opts.context.broadcastToConnIds(args.event, payload, new Set([connId]), {
157
+ dropIfSlow,
158
+ });
159
+ }
160
+ else {
161
+ args.opts.context.broadcast(args.event, payload, {
162
+ dropIfSlow,
163
+ });
164
+ }
165
+ args.opts.context.nodeSendToSession(args.sessionKey, args.event, payload);
166
+ }
167
+ function emitFailedCompletion(args) {
168
+ const message = toErrorMessage(args.error).trim() || "OpenCode bridge invocation failed";
169
+ emitEventToGateway({
170
+ opts: args.opts,
171
+ event: "chat",
172
+ payload: {
173
+ runId: args.runId,
174
+ state: "failed",
175
+ error: message,
176
+ },
177
+ sessionKey: args.sessionKey,
178
+ emitToAllClients: args.emitToAllClients,
179
+ });
180
+ }
181
+ async function streamInvocation(args) {
182
+ try {
183
+ for await (const frame of args.events) {
184
+ if (!frame || frame.type !== "event") {
185
+ continue;
186
+ }
187
+ const payload = asObject(frame.payload) ?? {};
188
+ if (!payload.runId) {
189
+ payload.runId = args.runId;
190
+ }
191
+ emitEventToGateway({
192
+ opts: args.opts,
193
+ event: frame.event,
194
+ payload,
195
+ sessionKey: args.sessionKey,
196
+ emitToAllClients: args.emitToAllClients,
197
+ });
198
+ }
199
+ }
200
+ catch (error) {
201
+ args.logger.error(`[${CHAT_SEND_METHOD}] stream failed: ${toErrorMessage(error)}`);
202
+ emitFailedCompletion({
203
+ opts: args.opts,
204
+ runId: args.runId,
205
+ sessionKey: args.sessionKey,
206
+ emitToAllClients: args.emitToAllClients,
207
+ error,
208
+ });
209
+ }
210
+ }
211
+ async function ensureBridge(api) {
212
+ const config = parseConfig(api.pluginConfig);
213
+ if (bridgeInstance) {
214
+ return { bridge: bridgeInstance, config };
215
+ }
216
+ if (!bridgePromise) {
217
+ bridgePromise = (async () => {
218
+ const sdk = await loadSdkAdapter(api, config);
219
+ const bridge = new OpenClawOpenCodeBridge(sdk, {
220
+ sessionTtlMs: config.sessionTtlMs,
221
+ cleanupIntervalMs: config.cleanupIntervalMs,
222
+ });
223
+ bridgeInstance = bridge;
224
+ api.logger.info(`[${CHAT_SEND_METHOD}] bridge initialized`);
225
+ return bridge;
226
+ })();
227
+ }
228
+ const bridge = await bridgePromise;
229
+ return { bridge, config };
230
+ }
231
+ export default function register(api) {
232
+ api.registerGatewayMethod(CHAT_SEND_METHOD, async (opts) => {
233
+ try {
234
+ const params = asObject(opts.params) ?? {};
235
+ const sessionKey = readRequiredField(params, "sessionKey");
236
+ const message = readRequiredField(params, "message");
237
+ const runId = readString(params, "runId") || undefined;
238
+ const idempotencyKey = readString(params, "idempotencyKey") || undefined;
239
+ const { bridge, config } = await ensureBridge(api);
240
+ const invocation = await bridge.chatSend({
241
+ sessionKey,
242
+ message,
243
+ runId,
244
+ idempotencyKey,
245
+ });
246
+ opts.respond(true, {
247
+ runId: invocation.runId,
248
+ status: "started",
249
+ });
250
+ void streamInvocation({
251
+ opts,
252
+ runId: invocation.runId,
253
+ sessionKey,
254
+ emitToAllClients: config.emitToAllClients,
255
+ events: invocation.events,
256
+ logger: api.logger,
257
+ });
258
+ }
259
+ catch (error) {
260
+ respondError(opts.respond, "INVALID_REQUEST", toErrorMessage(error));
261
+ }
262
+ });
263
+ api.registerGatewayMethod(CHAT_ACTION_METHOD, async (opts) => {
264
+ try {
265
+ const params = asObject(opts.params) ?? {};
266
+ const sessionKey = readRequiredField(params, "sessionKey");
267
+ const runId = readRequiredField(params, "runId");
268
+ const idempotencyKey = readString(params, "idempotencyKey") || undefined;
269
+ const action = parseAction(params);
270
+ const { bridge, config } = await ensureBridge(api);
271
+ const invocation = await bridge.chatAction({
272
+ sessionKey,
273
+ runId,
274
+ action,
275
+ idempotencyKey,
276
+ });
277
+ opts.respond(true, {
278
+ runId: invocation.runId,
279
+ status: "started",
280
+ });
281
+ void streamInvocation({
282
+ opts,
283
+ runId: invocation.runId,
284
+ sessionKey,
285
+ emitToAllClients: config.emitToAllClients,
286
+ events: invocation.events,
287
+ logger: api.logger,
288
+ });
289
+ }
290
+ catch (error) {
291
+ respondError(opts.respond, "INVALID_REQUEST", toErrorMessage(error));
292
+ }
293
+ });
294
+ if (typeof api.on === "function") {
295
+ api.on("gateway_stop", async () => {
296
+ if (!bridgeInstance) {
297
+ return;
298
+ }
299
+ await bridgeInstance.dispose();
300
+ bridgeInstance = null;
301
+ bridgePromise = null;
302
+ api.logger.info(`[${api.id}] bridge disposed`);
303
+ });
304
+ }
305
+ }
@@ -0,0 +1,51 @@
1
+ {
2
+ "id": "openclaw-opencode-bridge",
3
+ "name": "OpenCode Bridge",
4
+ "description": "Bridge OpenClaw gateway chat.send/chat.action with OpenCode SDK streams",
5
+ "configSchema": {
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "sdkAdapterModule": {
10
+ "type": "string",
11
+ "description": "Node module specifier or path to SDK adapter module (optional if OPENCODE_SDK_ADAPTER_MODULE is set)"
12
+ },
13
+ "sdkAdapterExport": {
14
+ "type": "string",
15
+ "description": "Export name from sdkAdapterModule (default: createOpenCodeSdkAdapter)"
16
+ },
17
+ "sessionTtlMs": {
18
+ "type": "integer",
19
+ "minimum": 60000
20
+ },
21
+ "cleanupIntervalMs": {
22
+ "type": "integer",
23
+ "minimum": 15000
24
+ },
25
+ "emitToAllClients": {
26
+ "type": "boolean",
27
+ "description": "Broadcast events to all gateway clients instead of only requester"
28
+ }
29
+ }
30
+ },
31
+ "uiHints": {
32
+ "sdkAdapterModule": {
33
+ "label": "SDK Adapter Module",
34
+ "help": "Absolute/relative path or npm package that exports OpenCode SDK adapter."
35
+ },
36
+ "sdkAdapterExport": {
37
+ "label": "SDK Adapter Export",
38
+ "help": "Export name in the adapter module. Default is createOpenCodeSdkAdapter."
39
+ },
40
+ "sessionTtlMs": {
41
+ "label": "Session TTL (ms)"
42
+ },
43
+ "cleanupIntervalMs": {
44
+ "label": "Cleanup Interval (ms)"
45
+ },
46
+ "emitToAllClients": {
47
+ "label": "Emit To All Clients",
48
+ "help": "Enable if you want every connected gateway client to receive run events."
49
+ }
50
+ }
51
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@sw-market/openclaw-opencode-bridge",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "OpenClaw plugin bridge for OpenCode realtime streaming and interaction actions",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
8
  "files": [
9
9
  "dist",
10
- "README.md"
10
+ "README.md",
11
+ "openclaw.plugin.json"
11
12
  ],
12
13
  "scripts": {
13
14
  "build": "tsc -p tsconfig.json",
@@ -23,6 +24,11 @@
23
24
  ],
24
25
  "author": "SW-Market",
25
26
  "license": "MIT",
27
+ "openclaw": {
28
+ "extensions": [
29
+ "./dist/openclaw-extension.js"
30
+ ]
31
+ },
26
32
  "devDependencies": {
27
33
  "@types/node": "^22.15.0",
28
34
  "rimraf": "^6.0.1",