@sw-market/openclaw-opencode-bridge 0.1.0

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 ADDED
@@ -0,0 +1,48 @@
1
+ # @sw-market/openclaw-opencode-bridge
2
+
3
+ OpenClaw plugin bridge for OpenCode realtime sessions.
4
+
5
+ ## Goals
6
+
7
+ - Keep long-lived sessions keyed by `conversationKey`/`sessionKey`.
8
+ - Stream OpenCode realtime events back to OpenClaw gateway.
9
+ - Support interactive confirmations (`interaction.required` -> `chat.action` -> `interaction.resolved`).
10
+ - Emit file change summaries (`file.changed`) for downstream clients (Flutter/Feishu/others).
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ npm install @sw-market/openclaw-opencode-bridge
16
+ ```
17
+
18
+ ## Usage (plugin runtime)
19
+
20
+ ```ts
21
+ import {
22
+ OpenClawOpenCodeBridge,
23
+ type OpenCodeSdkAdapter,
24
+ } from "@sw-market/openclaw-opencode-bridge";
25
+
26
+ const sdk: OpenCodeSdkAdapter = createYourOpenCodeSdkAdapter();
27
+ const bridge = new OpenClawOpenCodeBridge(sdk, {
28
+ sessionTtlMs: 30 * 60 * 1000,
29
+ });
30
+
31
+ const started = await bridge.chatSend({
32
+ sessionKey: "sw:feishu:chat_001",
33
+ message: "list files and update README",
34
+ });
35
+
36
+ for await (const frame of started.events) {
37
+ // forward frame to OpenClaw gateway `event` pipeline
38
+ console.log(frame);
39
+ }
40
+ ```
41
+
42
+ ## Contract
43
+
44
+ - `chatSend`: start or continue a coding run.
45
+ - `chatAction`: handle action replay (interaction reply, cancel run).
46
+ - `dispose`: stop cleanup timer and release all in-memory sessions.
47
+
48
+ The bridge is SDK-agnostic. You provide an adapter that knows how to invoke OpenCode SDK and return typed async events.
@@ -0,0 +1,123 @@
1
+ export type GatewayEventName = "agent" | "chat" | "interaction" | "file" | "progress";
2
+ export interface GatewayEventFrame {
3
+ type: "event";
4
+ event: GatewayEventName;
5
+ payload: Record<string, unknown>;
6
+ }
7
+ export interface BridgeInvocation {
8
+ runId: string;
9
+ events: AsyncIterable<GatewayEventFrame>;
10
+ }
11
+ export interface ChatSendRequest {
12
+ sessionKey: string;
13
+ message: string;
14
+ runId?: string;
15
+ idempotencyKey?: string;
16
+ }
17
+ export interface ChatActionRequest {
18
+ sessionKey: string;
19
+ runId: string;
20
+ action: BridgeAction;
21
+ idempotencyKey?: string;
22
+ }
23
+ export interface InteractionReplyAction {
24
+ type: "interaction.reply";
25
+ interactionId: string;
26
+ decision: "approve" | "reject" | "cancel";
27
+ source?: string;
28
+ payload?: Record<string, unknown>;
29
+ }
30
+ export interface CancelRunAction {
31
+ type: "cancel_run";
32
+ reason?: string;
33
+ }
34
+ export type BridgeAction = InteractionReplyAction | CancelRunAction;
35
+ export type OpenCodeEvent = {
36
+ kind: "assistant_delta";
37
+ delta: string;
38
+ text?: string;
39
+ } | {
40
+ kind: "tool_called";
41
+ tool: string;
42
+ args?: Record<string, unknown>;
43
+ } | {
44
+ kind: "tool_result";
45
+ tool: string;
46
+ ok: boolean;
47
+ result?: string;
48
+ } | {
49
+ kind: "interaction_required";
50
+ interactionId: string;
51
+ title: string;
52
+ message: string;
53
+ options: Array<{
54
+ id: string;
55
+ title: string;
56
+ description?: string;
57
+ }>;
58
+ defaultOption?: string;
59
+ timeoutSec?: number;
60
+ interactionType?: string;
61
+ } | {
62
+ kind: "interaction_resolved";
63
+ interactionId: string;
64
+ decision: "approve" | "reject" | "cancel";
65
+ status?: string;
66
+ source?: string;
67
+ } | {
68
+ kind: "file_changed";
69
+ path: string;
70
+ op: "create" | "update" | "delete" | "rename";
71
+ summary: string;
72
+ language?: string;
73
+ } | {
74
+ kind: "progress";
75
+ stage: string;
76
+ message: string;
77
+ percent?: number;
78
+ } | {
79
+ kind: "assistant_final";
80
+ text: string;
81
+ summary?: string;
82
+ status?: "success" | "failed";
83
+ } | {
84
+ kind: "run_completed";
85
+ status: "succeeded" | "failed";
86
+ error?: string;
87
+ };
88
+ export interface OpenCodeSession {
89
+ sendMessage(args: {
90
+ runId: string;
91
+ message: string;
92
+ idempotencyKey: string;
93
+ }): AsyncIterable<OpenCodeEvent>;
94
+ sendAction(args: {
95
+ runId: string;
96
+ action: BridgeAction;
97
+ idempotencyKey: string;
98
+ }): AsyncIterable<OpenCodeEvent>;
99
+ close?(): Promise<void>;
100
+ }
101
+ export interface OpenCodeSdkAdapter {
102
+ createSession(args: {
103
+ sessionKey: string;
104
+ }): Promise<OpenCodeSession>;
105
+ }
106
+ export interface OpenClawOpenCodeBridgeOptions {
107
+ sessionTtlMs?: number;
108
+ cleanupIntervalMs?: number;
109
+ }
110
+ export declare class OpenClawOpenCodeBridge {
111
+ private readonly sdk;
112
+ private readonly sessionTtlMs;
113
+ private readonly sessions;
114
+ private readonly cleanupTimer;
115
+ constructor(sdk: OpenCodeSdkAdapter, options?: OpenClawOpenCodeBridgeOptions);
116
+ chatSend(request: ChatSendRequest): Promise<BridgeInvocation>;
117
+ chatAction(request: ChatActionRequest): Promise<BridgeInvocation>;
118
+ dispose(): Promise<void>;
119
+ private getOrCreateSession;
120
+ private cleanupExpiredSessions;
121
+ private toGatewayEventStream;
122
+ }
123
+ export declare function mapOpenCodeEventToGatewayFrames(event: OpenCodeEvent, runId: string): GatewayEventFrame[];
package/dist/index.js ADDED
@@ -0,0 +1,276 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
3
+ const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 1000;
4
+ export class OpenClawOpenCodeBridge {
5
+ sdk;
6
+ sessionTtlMs;
7
+ sessions = new Map();
8
+ cleanupTimer;
9
+ constructor(sdk, options = {}) {
10
+ this.sdk = sdk;
11
+ this.sessionTtlMs = Math.max(60_000, options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS);
12
+ const cleanupIntervalMs = Math.max(15_000, options.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS);
13
+ this.cleanupTimer = setInterval(() => {
14
+ void this.cleanupExpiredSessions();
15
+ }, cleanupIntervalMs);
16
+ this.cleanupTimer.unref();
17
+ }
18
+ async chatSend(request) {
19
+ const session = await this.getOrCreateSession(request.sessionKey);
20
+ const runId = request.runId ?? createRunId();
21
+ const idempotencyKey = request.idempotencyKey ?? createIdempotencyKey();
22
+ const source = session.sendMessage({
23
+ runId,
24
+ message: request.message,
25
+ idempotencyKey,
26
+ });
27
+ return {
28
+ runId,
29
+ events: this.toGatewayEventStream({
30
+ source,
31
+ runId,
32
+ }),
33
+ };
34
+ }
35
+ async chatAction(request) {
36
+ const session = await this.getOrCreateSession(request.sessionKey);
37
+ const idempotencyKey = request.idempotencyKey ?? createIdempotencyKey();
38
+ const source = session.sendAction({
39
+ runId: request.runId,
40
+ action: request.action,
41
+ idempotencyKey,
42
+ });
43
+ return {
44
+ runId: request.runId,
45
+ events: this.toGatewayEventStream({
46
+ source,
47
+ runId: request.runId,
48
+ }),
49
+ };
50
+ }
51
+ async dispose() {
52
+ clearInterval(this.cleanupTimer);
53
+ const closing = [];
54
+ for (const [sessionKey, state] of this.sessions) {
55
+ this.sessions.delete(sessionKey);
56
+ if (state.session.close) {
57
+ closing.push(state.session.close());
58
+ }
59
+ }
60
+ await Promise.allSettled(closing);
61
+ }
62
+ async getOrCreateSession(sessionKey) {
63
+ const nowMs = Date.now();
64
+ const existing = this.sessions.get(sessionKey);
65
+ if (existing) {
66
+ existing.lastActiveAtMs = nowMs;
67
+ return existing.session;
68
+ }
69
+ const session = await this.sdk.createSession({ sessionKey });
70
+ this.sessions.set(sessionKey, {
71
+ session,
72
+ lastActiveAtMs: nowMs,
73
+ });
74
+ return session;
75
+ }
76
+ async cleanupExpiredSessions() {
77
+ const nowMs = Date.now();
78
+ for (const [sessionKey, state] of this.sessions) {
79
+ if (nowMs - state.lastActiveAtMs <= this.sessionTtlMs) {
80
+ continue;
81
+ }
82
+ this.sessions.delete(sessionKey);
83
+ if (state.session.close) {
84
+ await state.session.close();
85
+ }
86
+ }
87
+ }
88
+ async *toGatewayEventStream(args) {
89
+ let finalSeen = false;
90
+ let completedSeen = false;
91
+ let assistantText = "";
92
+ for await (const event of args.source) {
93
+ if (event.kind === "assistant_delta") {
94
+ assistantText += event.delta;
95
+ }
96
+ if (event.kind === "assistant_final") {
97
+ finalSeen = true;
98
+ }
99
+ if (event.kind === "run_completed") {
100
+ completedSeen = true;
101
+ }
102
+ for (const frame of mapOpenCodeEventToGatewayFrames(event, args.runId)) {
103
+ yield frame;
104
+ }
105
+ }
106
+ if (!finalSeen && assistantText.trim()) {
107
+ yield {
108
+ type: "event",
109
+ event: "chat",
110
+ payload: {
111
+ runId: args.runId,
112
+ state: "final",
113
+ text: assistantText.trim(),
114
+ },
115
+ };
116
+ finalSeen = true;
117
+ }
118
+ if (!completedSeen) {
119
+ yield {
120
+ type: "event",
121
+ event: "chat",
122
+ payload: {
123
+ runId: args.runId,
124
+ state: "completed",
125
+ },
126
+ };
127
+ }
128
+ }
129
+ }
130
+ export function mapOpenCodeEventToGatewayFrames(event, runId) {
131
+ switch (event.kind) {
132
+ case "assistant_delta":
133
+ return [
134
+ {
135
+ type: "event",
136
+ event: "agent",
137
+ payload: {
138
+ runId,
139
+ stream: "assistant",
140
+ data: {
141
+ delta: event.delta,
142
+ text: event.text,
143
+ },
144
+ },
145
+ },
146
+ ];
147
+ case "tool_called":
148
+ return [
149
+ {
150
+ type: "event",
151
+ event: "agent",
152
+ payload: {
153
+ runId,
154
+ stream: "tool",
155
+ data: {
156
+ phase: "start",
157
+ tool: event.tool,
158
+ args: event.args ?? {},
159
+ },
160
+ },
161
+ },
162
+ ];
163
+ case "tool_result":
164
+ return [
165
+ {
166
+ type: "event",
167
+ event: "agent",
168
+ payload: {
169
+ runId,
170
+ stream: "tool",
171
+ data: {
172
+ phase: "result",
173
+ tool: event.tool,
174
+ ok: event.ok,
175
+ result: event.result ?? "",
176
+ },
177
+ },
178
+ },
179
+ ];
180
+ case "interaction_required":
181
+ return [
182
+ {
183
+ type: "event",
184
+ event: "interaction",
185
+ payload: {
186
+ runId,
187
+ state: "required",
188
+ interactionId: event.interactionId,
189
+ kind: event.interactionType ?? "approval",
190
+ title: event.title,
191
+ message: event.message,
192
+ options: event.options,
193
+ defaultOption: event.defaultOption ?? "approve",
194
+ timeoutSec: event.timeoutSec ?? 0,
195
+ },
196
+ },
197
+ ];
198
+ case "interaction_resolved":
199
+ return [
200
+ {
201
+ type: "event",
202
+ event: "interaction",
203
+ payload: {
204
+ runId,
205
+ state: "resolved",
206
+ interactionId: event.interactionId,
207
+ decision: event.decision,
208
+ status: event.status ?? "resolved",
209
+ source: event.source ?? "openclaw_plugin",
210
+ },
211
+ },
212
+ ];
213
+ case "file_changed":
214
+ return [
215
+ {
216
+ type: "event",
217
+ event: "file",
218
+ payload: {
219
+ runId,
220
+ path: event.path,
221
+ op: event.op,
222
+ summary: event.summary,
223
+ language: event.language,
224
+ },
225
+ },
226
+ ];
227
+ case "progress":
228
+ return [
229
+ {
230
+ type: "event",
231
+ event: "progress",
232
+ payload: {
233
+ runId,
234
+ stage: event.stage,
235
+ message: event.message,
236
+ percent: event.percent,
237
+ },
238
+ },
239
+ ];
240
+ case "assistant_final":
241
+ return [
242
+ {
243
+ type: "event",
244
+ event: "chat",
245
+ payload: {
246
+ runId,
247
+ state: "final",
248
+ text: event.text,
249
+ summary: event.summary,
250
+ status: event.status ?? "success",
251
+ },
252
+ },
253
+ ];
254
+ case "run_completed":
255
+ return [
256
+ {
257
+ type: "event",
258
+ event: "chat",
259
+ payload: {
260
+ runId,
261
+ state: "completed",
262
+ status: event.status,
263
+ error: event.error,
264
+ },
265
+ },
266
+ ];
267
+ default:
268
+ return [];
269
+ }
270
+ }
271
+ function createRunId() {
272
+ return `run_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
273
+ }
274
+ function createIdempotencyKey() {
275
+ return `idem_${randomUUID().replace(/-/g, "")}`;
276
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@sw-market/openclaw-opencode-bridge",
3
+ "version": "0.1.0",
4
+ "description": "OpenClaw plugin bridge for OpenCode realtime streaming and interaction actions",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "README.md"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.json",
14
+ "clean": "rimraf dist",
15
+ "prepare": "npm run clean && npm run build"
16
+ },
17
+ "keywords": [
18
+ "openclaw",
19
+ "opencode",
20
+ "plugin",
21
+ "bridge",
22
+ "realtime"
23
+ ],
24
+ "author": "SW-Market",
25
+ "license": "MIT",
26
+ "devDependencies": {
27
+ "@types/node": "^22.15.0",
28
+ "rimraf": "^6.0.1",
29
+ "typescript": "^5.8.3"
30
+ },
31
+ "engines": {
32
+ "node": ">=20"
33
+ }
34
+ }