@sw-market/openclaw-opencode-bridge 0.1.3 → 0.1.4
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 +40 -76
- package/dist/openclaw-extension.js +30 -82
- package/dist/sdk-adapter-opencode.d.ts +18 -0
- package/dist/sdk-adapter-opencode.js +727 -0
- package/openclaw.plugin.json +43 -11
- package/package.json +4 -1
- package/dist/sdk-adapter-default.d.ts +0 -13
- package/dist/sdk-adapter-default.js +0 -133
package/README.md
CHANGED
|
@@ -1,44 +1,37 @@
|
|
|
1
1
|
# @sw-market/openclaw-opencode-bridge
|
|
2
2
|
|
|
3
|
-
OpenClaw plugin
|
|
3
|
+
OpenClaw plugin that bridges gateway methods to a built-in `@opencode-ai/sdk` runtime.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## What It Does
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
7
|
+
- Registers gateway methods:
|
|
8
|
+
- `opencode.chat.send`
|
|
9
|
+
- `opencode.chat.action`
|
|
10
|
+
- Maintains OpenCode sessions by `sessionKey`.
|
|
11
|
+
- Streams realtime events to OpenClaw:
|
|
12
|
+
- assistant deltas
|
|
13
|
+
- tool start/result
|
|
14
|
+
- interaction required/resolved (permission/question)
|
|
15
|
+
- file change summaries
|
|
16
|
+
- progress and run completion
|
|
11
17
|
|
|
12
|
-
## Install
|
|
18
|
+
## Install
|
|
13
19
|
|
|
14
20
|
```bash
|
|
15
|
-
openclaw plugins install @sw-market/openclaw-opencode-bridge
|
|
21
|
+
openclaw plugins install @sw-market/openclaw-opencode-bridge@0.1.4
|
|
16
22
|
```
|
|
17
23
|
|
|
18
|
-
|
|
24
|
+
Verify:
|
|
19
25
|
|
|
20
26
|
```bash
|
|
21
|
-
openclaw plugins
|
|
27
|
+
openclaw plugins info openclaw-opencode-bridge
|
|
22
28
|
```
|
|
23
29
|
|
|
24
|
-
##
|
|
30
|
+
## Config
|
|
25
31
|
|
|
26
|
-
|
|
32
|
+
Plugin no longer requires external adapter modules (`sdkAdapterModule` was removed).
|
|
27
33
|
|
|
28
|
-
|
|
29
|
-
- `<gateway cwd>/opencode_sdk_adapter.mjs`
|
|
30
|
-
- `<gateway cwd>/opencode_sdk_adapter.js`
|
|
31
|
-
- `~/.openclaw/opencode_sdk_adapter.mjs`
|
|
32
|
-
- `~/.openclaw/opencode_sdk_adapter.js`
|
|
33
|
-
2. If none found, plugin falls back to bundled guided adapter.
|
|
34
|
-
3. Bundled guided adapter will auto-generate template file:
|
|
35
|
-
- `~/.openclaw/opencode_sdk_adapter.mjs`
|
|
36
|
-
|
|
37
|
-
This means plugin install can be simplified: install first, then edit generated template on target machine.
|
|
38
|
-
|
|
39
|
-
## OpenClaw Config
|
|
40
|
-
|
|
41
|
-
Optional explicit config in `~/.openclaw/openclaw.json`:
|
|
34
|
+
Configure in `~/.openclaw/openclaw.json`:
|
|
42
35
|
|
|
43
36
|
```json
|
|
44
37
|
{
|
|
@@ -47,8 +40,12 @@ Optional explicit config in `~/.openclaw/openclaw.json`:
|
|
|
47
40
|
"openclaw-opencode-bridge": {
|
|
48
41
|
"enabled": true,
|
|
49
42
|
"config": {
|
|
50
|
-
"
|
|
51
|
-
"
|
|
43
|
+
"opencodeBaseUrl": "http://127.0.0.1:4096",
|
|
44
|
+
"opencodeDirectory": "/home/xxll-gpu-5080/code/openclaw_workspace",
|
|
45
|
+
"opencodeAgent": "codex",
|
|
46
|
+
"opencodeProviderId": "custom-www-right-codes",
|
|
47
|
+
"opencodeModelId": "gpt-5.2",
|
|
48
|
+
"opencodeSystemPrompt": "",
|
|
52
49
|
"sessionTtlMs": 1800000,
|
|
53
50
|
"cleanupIntervalMs": 60000,
|
|
54
51
|
"emitToAllClients": false
|
|
@@ -59,63 +56,30 @@ Optional explicit config in `~/.openclaw/openclaw.json`:
|
|
|
59
56
|
}
|
|
60
57
|
```
|
|
61
58
|
|
|
62
|
-
|
|
59
|
+
## Environment Variables
|
|
60
|
+
|
|
61
|
+
Config can also come from env:
|
|
63
62
|
|
|
64
63
|
```bash
|
|
65
|
-
export
|
|
66
|
-
export
|
|
64
|
+
export OPENCODE_BASE_URL=http://127.0.0.1:4096
|
|
65
|
+
export OPENCODE_WORKSPACE_DIR=/home/xxll-gpu-5080/code/openclaw_workspace
|
|
66
|
+
export OPENCODE_AGENT=codex
|
|
67
|
+
export OPENCODE_PROVIDER_ID=custom-www-right-codes
|
|
68
|
+
export OPENCODE_MODEL_ID=gpt-5.2
|
|
69
|
+
export OPENCODE_SYSTEM_PROMPT=""
|
|
67
70
|
```
|
|
68
71
|
|
|
69
|
-
Adapter
|
|
70
|
-
- export function `createOpenCodeSdkAdapter(ctx)` or export object directly.
|
|
71
|
-
- returned object must implement `createSession(args)`.
|
|
72
|
+
## Adapter Routing (your Python adapter)
|
|
72
73
|
|
|
73
|
-
|
|
74
|
-
- Plugin installation does not require `sdkAdapterModule` in config.
|
|
75
|
-
- If not configured, plugin can auto-discover local adapter file names.
|
|
76
|
-
- If still missing, plugin now falls back to guided adapter and generates template at `~/.openclaw/opencode_sdk_adapter.mjs`.
|
|
77
|
-
|
|
78
|
-
## Gateway Methods Provided
|
|
79
|
-
|
|
80
|
-
This plugin registers:
|
|
81
|
-
- `opencode.chat.send`
|
|
82
|
-
- `opencode.chat.action`
|
|
83
|
-
|
|
84
|
-
Set adapter side method routing:
|
|
74
|
+
Keep your adapter method mapping:
|
|
85
75
|
|
|
86
76
|
```env
|
|
87
77
|
BRIDGE_WS_CHAT_SEND_METHOD=opencode.chat.send
|
|
88
78
|
BRIDGE_WS_CHAT_ACTION_METHOD=opencode.chat.action
|
|
89
79
|
```
|
|
90
80
|
|
|
91
|
-
##
|
|
92
|
-
|
|
93
|
-
```ts
|
|
94
|
-
import {
|
|
95
|
-
OpenClawOpenCodeBridge,
|
|
96
|
-
type OpenCodeSdkAdapter,
|
|
97
|
-
} from "@sw-market/openclaw-opencode-bridge";
|
|
98
|
-
|
|
99
|
-
const sdk: OpenCodeSdkAdapter = createYourOpenCodeSdkAdapter();
|
|
100
|
-
const bridge = new OpenClawOpenCodeBridge(sdk, {
|
|
101
|
-
sessionTtlMs: 30 * 60 * 1000,
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
const started = await bridge.chatSend({
|
|
105
|
-
sessionKey: "sw:feishu:chat_001",
|
|
106
|
-
message: "list files and update README",
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
for await (const frame of started.events) {
|
|
110
|
-
// forward frame to OpenClaw gateway `event` pipeline
|
|
111
|
-
console.log(frame);
|
|
112
|
-
}
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
## Contract
|
|
116
|
-
|
|
117
|
-
- `chatSend`: start or continue a coding run.
|
|
118
|
-
- `chatAction`: handle action replay (interaction reply, cancel run).
|
|
119
|
-
- `dispose`: stop cleanup timer and release all in-memory sessions.
|
|
81
|
+
## Notes
|
|
120
82
|
|
|
121
|
-
|
|
83
|
+
- This plugin is now SDK-first and beta-oriented. It does not keep legacy template compatibility.
|
|
84
|
+
- The OpenCode server must be reachable at `opencodeBaseUrl`.
|
|
85
|
+
- `opencodeProviderId` and `opencodeModelId` are optional. If omitted, OpenCode server defaults are used.
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
5
1
|
import { OpenClawOpenCodeBridge, } from "./index.js";
|
|
2
|
+
import { createOpenCodeSdkAdapter } from "./sdk-adapter-opencode.js";
|
|
6
3
|
const CHAT_SEND_METHOD = "opencode.chat.send";
|
|
7
4
|
const CHAT_ACTION_METHOD = "opencode.chat.action";
|
|
8
|
-
const
|
|
9
|
-
const DEFAULT_TEMPLATE_FILE = "opencode_sdk_adapter.mjs";
|
|
5
|
+
const DEFAULT_OPENCODE_BASE_URL = "http://127.0.0.1:4096";
|
|
10
6
|
let bridgePromise = null;
|
|
11
7
|
let bridgeInstance = null;
|
|
12
8
|
function asObject(value) {
|
|
@@ -51,89 +47,33 @@ function toErrorMessage(error) {
|
|
|
51
47
|
}
|
|
52
48
|
function parseConfig(pluginConfig) {
|
|
53
49
|
const raw = pluginConfig ?? {};
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
50
|
+
const opencodeBaseUrl = readString(raw, "opencodeBaseUrl") ||
|
|
51
|
+
String(process.env.OPENCODE_BASE_URL || "").trim() ||
|
|
52
|
+
DEFAULT_OPENCODE_BASE_URL;
|
|
53
|
+
const opencodeDirectory = readString(raw, "opencodeDirectory") || String(process.env.OPENCODE_WORKSPACE_DIR || "").trim() || undefined;
|
|
54
|
+
const opencodeAgent = readString(raw, "opencodeAgent") || String(process.env.OPENCODE_AGENT || "").trim() || undefined;
|
|
55
|
+
const opencodeProviderId = readString(raw, "opencodeProviderId") ||
|
|
56
|
+
String(process.env.OPENCODE_PROVIDER_ID || "").trim() ||
|
|
57
|
+
undefined;
|
|
58
|
+
const opencodeModelId = readString(raw, "opencodeModelId") || String(process.env.OPENCODE_MODEL_ID || "").trim() || undefined;
|
|
59
|
+
const opencodeSystemPrompt = readString(raw, "opencodeSystemPrompt") ||
|
|
60
|
+
String(process.env.OPENCODE_SYSTEM_PROMPT || "").trim() ||
|
|
61
|
+
undefined;
|
|
59
62
|
const sessionTtlMs = readOptionalPositiveInt(raw, "sessionTtlMs");
|
|
60
63
|
const cleanupIntervalMs = readOptionalPositiveInt(raw, "cleanupIntervalMs");
|
|
61
64
|
const emitToAllClients = raw.emitToAllClients === true;
|
|
62
65
|
return {
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
opencodeBaseUrl,
|
|
67
|
+
opencodeDirectory,
|
|
68
|
+
opencodeAgent,
|
|
69
|
+
opencodeProviderId,
|
|
70
|
+
opencodeModelId,
|
|
71
|
+
opencodeSystemPrompt,
|
|
65
72
|
sessionTtlMs,
|
|
66
73
|
cleanupIntervalMs,
|
|
67
74
|
emitToAllClients,
|
|
68
75
|
};
|
|
69
76
|
}
|
|
70
|
-
function discoverAdapterPath(api) {
|
|
71
|
-
const candidates = [
|
|
72
|
-
path.resolve(process.cwd(), DEFAULT_TEMPLATE_FILE),
|
|
73
|
-
path.resolve(process.cwd(), "opencode_sdk_adapter.js"),
|
|
74
|
-
path.resolve(os.homedir(), ".openclaw", DEFAULT_TEMPLATE_FILE),
|
|
75
|
-
path.resolve(os.homedir(), ".openclaw", "opencode_sdk_adapter.js"),
|
|
76
|
-
api.resolvePath(`./${DEFAULT_TEMPLATE_FILE}`),
|
|
77
|
-
];
|
|
78
|
-
for (const candidate of candidates) {
|
|
79
|
-
if (existsSync(candidate)) {
|
|
80
|
-
return candidate;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
85
|
-
function bundledAdapterSpecifier() {
|
|
86
|
-
return new URL("./sdk-adapter-default.js", import.meta.url).href;
|
|
87
|
-
}
|
|
88
|
-
function resolveModuleSpecifier(api, moduleSpecifier) {
|
|
89
|
-
const raw = moduleSpecifier.trim();
|
|
90
|
-
if (!raw) {
|
|
91
|
-
const discovered = discoverAdapterPath(api);
|
|
92
|
-
if (discovered) {
|
|
93
|
-
api.logger.info(`[${api.id}] discovered sdkAdapterModule: ${discovered}`);
|
|
94
|
-
return pathToFileURL(discovered).href;
|
|
95
|
-
}
|
|
96
|
-
api.logger.warn(`[${api.id}] sdkAdapterModule is not configured; falling back to bundled guided adapter.`);
|
|
97
|
-
return bundledAdapterSpecifier();
|
|
98
|
-
}
|
|
99
|
-
const looksLikePath = raw.startsWith("./") ||
|
|
100
|
-
raw.startsWith("../") ||
|
|
101
|
-
raw.startsWith("/") ||
|
|
102
|
-
raw.startsWith(".\\") ||
|
|
103
|
-
/^[a-zA-Z]:[\\/]/.test(raw);
|
|
104
|
-
if (!looksLikePath) {
|
|
105
|
-
return raw;
|
|
106
|
-
}
|
|
107
|
-
const absolutePath = path.isAbsolute(raw) ? raw : api.resolvePath(raw);
|
|
108
|
-
if (!existsSync(absolutePath)) {
|
|
109
|
-
throw new Error(`sdkAdapterModule path does not exist: ${absolutePath}`);
|
|
110
|
-
}
|
|
111
|
-
return pathToFileURL(absolutePath).href;
|
|
112
|
-
}
|
|
113
|
-
function isOpenCodeSdkAdapter(value) {
|
|
114
|
-
const obj = asObject(value);
|
|
115
|
-
if (!obj) {
|
|
116
|
-
return false;
|
|
117
|
-
}
|
|
118
|
-
return typeof obj.createSession === "function";
|
|
119
|
-
}
|
|
120
|
-
async function loadSdkAdapter(api, config) {
|
|
121
|
-
const moduleSpecifier = resolveModuleSpecifier(api, config.sdkAdapterModule ?? "");
|
|
122
|
-
const loaded = await import(moduleSpecifier);
|
|
123
|
-
const exported = loaded[config.sdkAdapterExport];
|
|
124
|
-
let candidate = exported;
|
|
125
|
-
if (typeof exported === "function") {
|
|
126
|
-
candidate = await Promise.resolve(exported({
|
|
127
|
-
api,
|
|
128
|
-
pluginConfig: config,
|
|
129
|
-
}));
|
|
130
|
-
}
|
|
131
|
-
if (!isOpenCodeSdkAdapter(candidate)) {
|
|
132
|
-
throw new Error(`Invalid OpenCode SDK adapter from "${config.sdkAdapterModule || moduleSpecifier}" export "${config.sdkAdapterExport}". ` +
|
|
133
|
-
"Expected an object with createSession(args) function.");
|
|
134
|
-
}
|
|
135
|
-
return candidate;
|
|
136
|
-
}
|
|
137
77
|
function respondError(respond, code, message) {
|
|
138
78
|
respond(false, undefined, {
|
|
139
79
|
code,
|
|
@@ -240,13 +180,21 @@ async function ensureBridge(api) {
|
|
|
240
180
|
}
|
|
241
181
|
if (!bridgePromise) {
|
|
242
182
|
bridgePromise = (async () => {
|
|
243
|
-
const sdk =
|
|
183
|
+
const sdk = createOpenCodeSdkAdapter({
|
|
184
|
+
baseUrl: config.opencodeBaseUrl,
|
|
185
|
+
directory: config.opencodeDirectory,
|
|
186
|
+
agent: config.opencodeAgent,
|
|
187
|
+
providerId: config.opencodeProviderId,
|
|
188
|
+
modelId: config.opencodeModelId,
|
|
189
|
+
systemPrompt: config.opencodeSystemPrompt,
|
|
190
|
+
logger: api.logger,
|
|
191
|
+
});
|
|
244
192
|
const bridge = new OpenClawOpenCodeBridge(sdk, {
|
|
245
193
|
sessionTtlMs: config.sessionTtlMs,
|
|
246
194
|
cleanupIntervalMs: config.cleanupIntervalMs,
|
|
247
195
|
});
|
|
248
196
|
bridgeInstance = bridge;
|
|
249
|
-
api.logger.info(`[${CHAT_SEND_METHOD}] bridge initialized`);
|
|
197
|
+
api.logger.info(`[${CHAT_SEND_METHOD}] bridge initialized (baseUrl=${config.opencodeBaseUrl}, directory=${config.opencodeDirectory ?? "<default>"})`);
|
|
250
198
|
return bridge;
|
|
251
199
|
})();
|
|
252
200
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OpenCodeSdkAdapter } from "./index.js";
|
|
2
|
+
type Logger = {
|
|
3
|
+
info?: (message: string) => void;
|
|
4
|
+
warn?: (message: string) => void;
|
|
5
|
+
error?: (message: string) => void;
|
|
6
|
+
debug?: (message: string) => void;
|
|
7
|
+
};
|
|
8
|
+
export interface OpenCodeSdkRuntimeConfig {
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
directory?: string;
|
|
11
|
+
agent?: string;
|
|
12
|
+
providerId?: string;
|
|
13
|
+
modelId?: string;
|
|
14
|
+
systemPrompt?: string;
|
|
15
|
+
logger?: Logger;
|
|
16
|
+
}
|
|
17
|
+
export declare function createOpenCodeSdkAdapter(config: OpenCodeSdkRuntimeConfig): OpenCodeSdkAdapter;
|
|
18
|
+
export default createOpenCodeSdkAdapter;
|
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
import { createOpencodeClient } from "@opencode-ai/sdk/v2";
|
|
2
|
+
function asObject(value) {
|
|
3
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
4
|
+
return null;
|
|
5
|
+
}
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
function asObjectArray(value) {
|
|
9
|
+
if (!Array.isArray(value)) {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const item of value) {
|
|
14
|
+
const obj = asObject(item);
|
|
15
|
+
if (obj) {
|
|
16
|
+
out.push(obj);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
function asStringArray(value) {
|
|
22
|
+
if (!Array.isArray(value)) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const item of value) {
|
|
27
|
+
if (typeof item !== "string") {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const trimmed = item.trim();
|
|
31
|
+
if (trimmed) {
|
|
32
|
+
out.push(trimmed);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
function readString(source, key) {
|
|
38
|
+
const value = source[key];
|
|
39
|
+
return typeof value === "string" ? value.trim() : "";
|
|
40
|
+
}
|
|
41
|
+
function optionIdFromLabel(label, index) {
|
|
42
|
+
const normalized = label
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
45
|
+
.replace(/^_+|_+$/g, "");
|
|
46
|
+
return normalized || `option_${index + 1}`;
|
|
47
|
+
}
|
|
48
|
+
function toErrorMessage(error) {
|
|
49
|
+
if (error instanceof Error) {
|
|
50
|
+
return error.message;
|
|
51
|
+
}
|
|
52
|
+
return String(error);
|
|
53
|
+
}
|
|
54
|
+
function formatSdkError(error) {
|
|
55
|
+
const obj = asObject(error);
|
|
56
|
+
if (!obj) {
|
|
57
|
+
return toErrorMessage(error);
|
|
58
|
+
}
|
|
59
|
+
const name = readString(obj, "name");
|
|
60
|
+
const message = readString(obj, "message");
|
|
61
|
+
const data = asObject(obj.data);
|
|
62
|
+
const dataMessage = data ? readString(data, "message") : "";
|
|
63
|
+
const text = dataMessage || message || JSON.stringify(obj);
|
|
64
|
+
if (name && text) {
|
|
65
|
+
return `${name}: ${text}`;
|
|
66
|
+
}
|
|
67
|
+
return text || "Unknown SDK error";
|
|
68
|
+
}
|
|
69
|
+
async function requestNoError(label, request) {
|
|
70
|
+
const result = asObject(await request);
|
|
71
|
+
if (!result) {
|
|
72
|
+
throw new Error(`${label} returned invalid response`);
|
|
73
|
+
}
|
|
74
|
+
if (result.error !== undefined) {
|
|
75
|
+
throw new Error(`${label} failed: ${formatSdkError(result.error)}`);
|
|
76
|
+
}
|
|
77
|
+
return result.data;
|
|
78
|
+
}
|
|
79
|
+
function getEventSessionId(eventType, properties) {
|
|
80
|
+
const direct = readString(properties, "sessionID");
|
|
81
|
+
if (direct) {
|
|
82
|
+
return direct;
|
|
83
|
+
}
|
|
84
|
+
const info = asObject(properties.info);
|
|
85
|
+
if (info) {
|
|
86
|
+
const fromInfo = readString(info, "sessionID");
|
|
87
|
+
if (fromInfo) {
|
|
88
|
+
return fromInfo;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const part = asObject(properties.part);
|
|
92
|
+
if (part) {
|
|
93
|
+
const fromPart = readString(part, "sessionID");
|
|
94
|
+
if (fromPart) {
|
|
95
|
+
return fromPart;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (eventType === "message.part.delta") {
|
|
99
|
+
const fromDelta = readString(properties, "sessionID");
|
|
100
|
+
if (fromDelta) {
|
|
101
|
+
return fromDelta;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
function resolveToolEvents(part) {
|
|
107
|
+
const tool = readString(part, "tool") || "tool";
|
|
108
|
+
const state = asObject(part.state);
|
|
109
|
+
if (!state) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
const status = readString(state, "status");
|
|
113
|
+
if (!status) {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
const input = asObject(state.input) ?? undefined;
|
|
117
|
+
if (status === "pending" || status === "running") {
|
|
118
|
+
return [
|
|
119
|
+
{
|
|
120
|
+
kind: "tool_called",
|
|
121
|
+
tool,
|
|
122
|
+
args: input,
|
|
123
|
+
},
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
if (status === "completed") {
|
|
127
|
+
const output = readString(state, "output") || readString(state, "title") || "Tool completed";
|
|
128
|
+
return [
|
|
129
|
+
{
|
|
130
|
+
kind: "tool_result",
|
|
131
|
+
tool,
|
|
132
|
+
ok: true,
|
|
133
|
+
result: output,
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
if (status === "error") {
|
|
138
|
+
const message = readString(state, "error") || "Tool failed";
|
|
139
|
+
return [
|
|
140
|
+
{
|
|
141
|
+
kind: "tool_result",
|
|
142
|
+
tool,
|
|
143
|
+
ok: false,
|
|
144
|
+
result: message,
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
function resolveTextDelta(part, runState) {
|
|
151
|
+
const partId = readString(part, "id");
|
|
152
|
+
const text = readString(part, "text");
|
|
153
|
+
if (!partId || !text) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
const previous = runState.partTextById.get(partId) ?? "";
|
|
157
|
+
runState.partTextById.set(partId, text);
|
|
158
|
+
const delta = text.startsWith(previous) ? text.slice(previous.length) : text;
|
|
159
|
+
if (!delta) {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
return [
|
|
163
|
+
{
|
|
164
|
+
kind: "assistant_delta",
|
|
165
|
+
delta,
|
|
166
|
+
text,
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
function resolvePatchEvents(part) {
|
|
171
|
+
const files = asStringArray(part.files);
|
|
172
|
+
const events = [];
|
|
173
|
+
for (const file of files) {
|
|
174
|
+
events.push({
|
|
175
|
+
kind: "file_changed",
|
|
176
|
+
path: file,
|
|
177
|
+
op: "update",
|
|
178
|
+
summary: "Patch generated by OpenCode",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return events;
|
|
182
|
+
}
|
|
183
|
+
function resolveQuestionSummary(questions) {
|
|
184
|
+
const lines = [];
|
|
185
|
+
const defaultAnswers = [];
|
|
186
|
+
const firstQuestionOptionById = {};
|
|
187
|
+
let firstQuestionOptions = [];
|
|
188
|
+
let firstQuestionDefaultOption;
|
|
189
|
+
questions.forEach((question, index) => {
|
|
190
|
+
const text = readString(question, "question") || readString(question, "header") || `Question ${index + 1}`;
|
|
191
|
+
lines.push(`${index + 1}. ${text}`);
|
|
192
|
+
const options = asObjectArray(question.options);
|
|
193
|
+
const labels = options
|
|
194
|
+
.map((opt) => readString(opt, "label"))
|
|
195
|
+
.filter((label) => label.length > 0);
|
|
196
|
+
defaultAnswers.push(labels[0] ? [labels[0]] : [""]);
|
|
197
|
+
if (index === 0) {
|
|
198
|
+
firstQuestionOptions = options.map((opt, optIndex) => {
|
|
199
|
+
const title = readString(opt, "label") || `Option ${optIndex + 1}`;
|
|
200
|
+
const id = optionIdFromLabel(title, optIndex);
|
|
201
|
+
firstQuestionOptionById[id] = title;
|
|
202
|
+
const description = readString(opt, "description") || undefined;
|
|
203
|
+
return {
|
|
204
|
+
id,
|
|
205
|
+
title,
|
|
206
|
+
description,
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
firstQuestionDefaultOption = firstQuestionOptions[0]?.id;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
return {
|
|
213
|
+
message: lines.join("\n"),
|
|
214
|
+
options: firstQuestionOptions,
|
|
215
|
+
defaultOption: firstQuestionDefaultOption,
|
|
216
|
+
defaultAnswers,
|
|
217
|
+
optionById: firstQuestionOptionById,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function resolveQuestionAnswers(payload, pending) {
|
|
221
|
+
const fallback = pending.defaultAnswers.map((row) => [...row]);
|
|
222
|
+
if (!payload) {
|
|
223
|
+
return fallback;
|
|
224
|
+
}
|
|
225
|
+
const explicitAnswers = payload.answers;
|
|
226
|
+
if (Array.isArray(explicitAnswers)) {
|
|
227
|
+
const rows = [];
|
|
228
|
+
for (const row of explicitAnswers) {
|
|
229
|
+
if (!Array.isArray(row)) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const values = row
|
|
233
|
+
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
|
234
|
+
.filter((item) => item.length > 0);
|
|
235
|
+
rows.push(values.length > 0 ? values : [""]);
|
|
236
|
+
}
|
|
237
|
+
if (rows.length > 0) {
|
|
238
|
+
return rows;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const answerValue = payload.answer;
|
|
242
|
+
if (typeof answerValue === "string" && answerValue.trim()) {
|
|
243
|
+
fallback[0] = [answerValue.trim()];
|
|
244
|
+
return fallback;
|
|
245
|
+
}
|
|
246
|
+
if (Array.isArray(answerValue)) {
|
|
247
|
+
const first = answerValue
|
|
248
|
+
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
|
249
|
+
.filter((item) => item.length > 0);
|
|
250
|
+
if (first.length > 0) {
|
|
251
|
+
fallback[0] = first;
|
|
252
|
+
return fallback;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
const optionId = typeof payload.optionId === "string" ? payload.optionId.trim() : "";
|
|
256
|
+
if (optionId && pending.firstQuestionOptionById[optionId]) {
|
|
257
|
+
fallback[0] = [pending.firstQuestionOptionById[optionId]];
|
|
258
|
+
return fallback;
|
|
259
|
+
}
|
|
260
|
+
return fallback;
|
|
261
|
+
}
|
|
262
|
+
function mapPermissionDecision(decision, payload) {
|
|
263
|
+
if (decision !== "approve") {
|
|
264
|
+
return "reject";
|
|
265
|
+
}
|
|
266
|
+
return payload?.always === true ? "always" : "once";
|
|
267
|
+
}
|
|
268
|
+
function extractNestedErrorMessage(value) {
|
|
269
|
+
const obj = asObject(value);
|
|
270
|
+
if (!obj) {
|
|
271
|
+
return toErrorMessage(value);
|
|
272
|
+
}
|
|
273
|
+
const data = asObject(obj.data);
|
|
274
|
+
const dataMessage = data ? readString(data, "message") : "";
|
|
275
|
+
const message = readString(obj, "message");
|
|
276
|
+
const name = readString(obj, "name");
|
|
277
|
+
const text = dataMessage || message || JSON.stringify(obj);
|
|
278
|
+
return name ? `${name}: ${text}` : text;
|
|
279
|
+
}
|
|
280
|
+
function mapSdkEvent(args) {
|
|
281
|
+
const event = asObject(args.rawEvent);
|
|
282
|
+
if (!event) {
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
const type = readString(event, "type");
|
|
286
|
+
if (!type) {
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
const properties = asObject(event.properties) ?? {};
|
|
290
|
+
const eventSessionId = getEventSessionId(type, properties);
|
|
291
|
+
if (eventSessionId && eventSessionId !== args.sessionState.sessionId) {
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
const out = [];
|
|
295
|
+
if (type === "message.part.delta") {
|
|
296
|
+
if (readString(properties, "field") === "text") {
|
|
297
|
+
const delta = readString(properties, "delta");
|
|
298
|
+
if (delta) {
|
|
299
|
+
out.push({
|
|
300
|
+
kind: "assistant_delta",
|
|
301
|
+
delta,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
307
|
+
if (type === "message.part.updated") {
|
|
308
|
+
const part = asObject(properties.part);
|
|
309
|
+
if (!part) {
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
const partType = readString(part, "type");
|
|
313
|
+
if (partType === "text") {
|
|
314
|
+
out.push(...resolveTextDelta(part, args.runState));
|
|
315
|
+
}
|
|
316
|
+
else if (partType === "tool") {
|
|
317
|
+
out.push(...resolveToolEvents(part));
|
|
318
|
+
}
|
|
319
|
+
else if (partType === "patch") {
|
|
320
|
+
out.push(...resolvePatchEvents(part));
|
|
321
|
+
}
|
|
322
|
+
return out;
|
|
323
|
+
}
|
|
324
|
+
if (type === "permission.asked") {
|
|
325
|
+
const interactionId = readString(properties, "id");
|
|
326
|
+
if (!interactionId) {
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
const permission = readString(properties, "permission") || "permission";
|
|
330
|
+
const patterns = asStringArray(properties.patterns);
|
|
331
|
+
const patternText = patterns.length > 0 ? patterns.join(", ") : "*";
|
|
332
|
+
args.sessionState.pendingInteractions.set(interactionId, {
|
|
333
|
+
kind: "permission",
|
|
334
|
+
requestId: interactionId,
|
|
335
|
+
});
|
|
336
|
+
out.push({
|
|
337
|
+
kind: "interaction_required",
|
|
338
|
+
interactionId,
|
|
339
|
+
interactionType: "permission",
|
|
340
|
+
title: "Permission required",
|
|
341
|
+
message: `${permission} on ${patternText}`,
|
|
342
|
+
options: [
|
|
343
|
+
{ id: "approve_once", title: "Approve once" },
|
|
344
|
+
{ id: "approve_always", title: "Approve always" },
|
|
345
|
+
{ id: "reject", title: "Reject" },
|
|
346
|
+
],
|
|
347
|
+
defaultOption: "approve_once",
|
|
348
|
+
});
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
if (type === "question.asked") {
|
|
352
|
+
const interactionId = readString(properties, "id");
|
|
353
|
+
if (!interactionId) {
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
const questions = asObjectArray(properties.questions);
|
|
357
|
+
const summary = resolveQuestionSummary(questions);
|
|
358
|
+
args.sessionState.pendingInteractions.set(interactionId, {
|
|
359
|
+
kind: "question",
|
|
360
|
+
requestId: interactionId,
|
|
361
|
+
defaultAnswers: summary.defaultAnswers,
|
|
362
|
+
firstQuestionOptionById: summary.optionById,
|
|
363
|
+
});
|
|
364
|
+
out.push({
|
|
365
|
+
kind: "interaction_required",
|
|
366
|
+
interactionId,
|
|
367
|
+
interactionType: "question",
|
|
368
|
+
title: "Question from OpenCode",
|
|
369
|
+
message: summary.message || "Answer is required.",
|
|
370
|
+
options: summary.options.length > 0
|
|
371
|
+
? summary.options
|
|
372
|
+
: [
|
|
373
|
+
{
|
|
374
|
+
id: "answer",
|
|
375
|
+
title: "Reply",
|
|
376
|
+
description: "Provide an answer in payload.answer or payload.answers",
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
defaultOption: summary.defaultOption,
|
|
380
|
+
});
|
|
381
|
+
return out;
|
|
382
|
+
}
|
|
383
|
+
if (type === "permission.replied") {
|
|
384
|
+
const interactionId = readString(properties, "requestID");
|
|
385
|
+
if (!interactionId) {
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
const reply = readString(properties, "reply");
|
|
389
|
+
args.sessionState.pendingInteractions.delete(interactionId);
|
|
390
|
+
out.push({
|
|
391
|
+
kind: "interaction_resolved",
|
|
392
|
+
interactionId,
|
|
393
|
+
decision: reply === "reject" ? "reject" : "approve",
|
|
394
|
+
status: "resolved",
|
|
395
|
+
source: "opencode",
|
|
396
|
+
});
|
|
397
|
+
return out;
|
|
398
|
+
}
|
|
399
|
+
if (type === "question.replied" || type === "question.rejected") {
|
|
400
|
+
const interactionId = readString(properties, "requestID");
|
|
401
|
+
if (!interactionId) {
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
args.sessionState.pendingInteractions.delete(interactionId);
|
|
405
|
+
out.push({
|
|
406
|
+
kind: "interaction_resolved",
|
|
407
|
+
interactionId,
|
|
408
|
+
decision: type === "question.rejected" ? "reject" : "approve",
|
|
409
|
+
status: "resolved",
|
|
410
|
+
source: "opencode",
|
|
411
|
+
});
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
if (type === "session.status") {
|
|
415
|
+
const status = asObject(properties.status);
|
|
416
|
+
const statusType = status ? readString(status, "type") : "";
|
|
417
|
+
if (!statusType) {
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
if (statusType === "busy") {
|
|
421
|
+
args.runState.seenBusy = true;
|
|
422
|
+
out.push({
|
|
423
|
+
kind: "progress",
|
|
424
|
+
stage: "session_busy",
|
|
425
|
+
message: "OpenCode is processing the request",
|
|
426
|
+
});
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
if (statusType === "retry") {
|
|
430
|
+
const attemptValue = status?.attempt;
|
|
431
|
+
const attempt = typeof attemptValue === "number" && Number.isFinite(attemptValue) ? Math.floor(attemptValue) : undefined;
|
|
432
|
+
const message = readString(status ?? {}, "message") || "OpenCode is retrying";
|
|
433
|
+
out.push({
|
|
434
|
+
kind: "progress",
|
|
435
|
+
stage: "session_retry",
|
|
436
|
+
message: attempt ? `${message} (attempt ${attempt})` : message,
|
|
437
|
+
});
|
|
438
|
+
return out;
|
|
439
|
+
}
|
|
440
|
+
if (statusType === "idle" && args.runState.seenBusy) {
|
|
441
|
+
args.runState.completed = true;
|
|
442
|
+
out.push({
|
|
443
|
+
kind: "run_completed",
|
|
444
|
+
status: "succeeded",
|
|
445
|
+
});
|
|
446
|
+
return out;
|
|
447
|
+
}
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
if (type === "session.idle" && args.runState.seenBusy) {
|
|
451
|
+
args.runState.completed = true;
|
|
452
|
+
out.push({
|
|
453
|
+
kind: "run_completed",
|
|
454
|
+
status: "succeeded",
|
|
455
|
+
});
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
if (type === "session.error") {
|
|
459
|
+
const errorMessage = extractNestedErrorMessage(properties.error ?? properties);
|
|
460
|
+
args.runState.completed = true;
|
|
461
|
+
args.runState.lastError = errorMessage;
|
|
462
|
+
out.push({
|
|
463
|
+
kind: "assistant_final",
|
|
464
|
+
text: errorMessage,
|
|
465
|
+
summary: "OpenCode run failed",
|
|
466
|
+
status: "failed",
|
|
467
|
+
});
|
|
468
|
+
out.push({
|
|
469
|
+
kind: "run_completed",
|
|
470
|
+
status: "failed",
|
|
471
|
+
error: errorMessage,
|
|
472
|
+
});
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
if (type === "session.diff") {
|
|
476
|
+
const diffs = asObjectArray(properties.diff);
|
|
477
|
+
for (const diff of diffs) {
|
|
478
|
+
const file = readString(diff, "file");
|
|
479
|
+
if (!file) {
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const status = readString(diff, "status");
|
|
483
|
+
const op = status === "added" ? "create" : status === "deleted" ? "delete" : "update";
|
|
484
|
+
const additions = typeof diff.additions === "number" ? diff.additions : 0;
|
|
485
|
+
const deletions = typeof diff.deletions === "number" ? diff.deletions : 0;
|
|
486
|
+
out.push({
|
|
487
|
+
kind: "file_changed",
|
|
488
|
+
path: file,
|
|
489
|
+
op,
|
|
490
|
+
summary: `Diff +${additions} -${deletions}`,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
if (type === "file.watcher.updated") {
|
|
496
|
+
const file = readString(properties, "file");
|
|
497
|
+
if (!file) {
|
|
498
|
+
return out;
|
|
499
|
+
}
|
|
500
|
+
const watcherEvent = readString(properties, "event");
|
|
501
|
+
const op = watcherEvent === "add" ? "create" : watcherEvent === "unlink" ? "delete" : "update";
|
|
502
|
+
out.push({
|
|
503
|
+
kind: "file_changed",
|
|
504
|
+
path: file,
|
|
505
|
+
op,
|
|
506
|
+
summary: `File watcher event: ${watcherEvent || "change"}`,
|
|
507
|
+
});
|
|
508
|
+
return out;
|
|
509
|
+
}
|
|
510
|
+
if (type === "file.edited") {
|
|
511
|
+
const file = readString(properties, "file");
|
|
512
|
+
if (!file) {
|
|
513
|
+
return out;
|
|
514
|
+
}
|
|
515
|
+
out.push({
|
|
516
|
+
kind: "file_changed",
|
|
517
|
+
path: file,
|
|
518
|
+
op: "update",
|
|
519
|
+
summary: "File edited",
|
|
520
|
+
});
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
return out;
|
|
524
|
+
}
|
|
525
|
+
class OpenCodeSessionRuntime {
|
|
526
|
+
client;
|
|
527
|
+
config;
|
|
528
|
+
state;
|
|
529
|
+
constructor(client, config, sessionId) {
|
|
530
|
+
this.client = client;
|
|
531
|
+
this.config = config;
|
|
532
|
+
this.state = {
|
|
533
|
+
sessionId,
|
|
534
|
+
pendingInteractions: new Map(),
|
|
535
|
+
activeRunAbort: null,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
async *sendMessage(args) {
|
|
539
|
+
if (this.state.activeRunAbort) {
|
|
540
|
+
throw new Error("A run is already active for this session. Wait for completion before sending another message.");
|
|
541
|
+
}
|
|
542
|
+
const runAbort = new AbortController();
|
|
543
|
+
this.state.activeRunAbort = runAbort;
|
|
544
|
+
const runState = {
|
|
545
|
+
seenBusy: false,
|
|
546
|
+
completed: false,
|
|
547
|
+
partTextById: new Map(),
|
|
548
|
+
};
|
|
549
|
+
try {
|
|
550
|
+
const stream = await this.client.event.subscribe({
|
|
551
|
+
directory: this.config.directory,
|
|
552
|
+
}, {
|
|
553
|
+
signal: runAbort.signal,
|
|
554
|
+
sseMaxRetryAttempts: 0,
|
|
555
|
+
});
|
|
556
|
+
const body = {
|
|
557
|
+
messageID: args.idempotencyKey,
|
|
558
|
+
parts: [
|
|
559
|
+
{
|
|
560
|
+
type: "text",
|
|
561
|
+
text: args.message,
|
|
562
|
+
},
|
|
563
|
+
],
|
|
564
|
+
};
|
|
565
|
+
if (this.config.agent) {
|
|
566
|
+
body.agent = this.config.agent;
|
|
567
|
+
}
|
|
568
|
+
if (this.config.systemPrompt) {
|
|
569
|
+
body.system = this.config.systemPrompt;
|
|
570
|
+
}
|
|
571
|
+
if (this.config.providerId && this.config.modelId) {
|
|
572
|
+
body.model = {
|
|
573
|
+
providerID: this.config.providerId,
|
|
574
|
+
modelID: this.config.modelId,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
await requestNoError("session.promptAsync", this.client.session.promptAsync({
|
|
578
|
+
sessionID: this.state.sessionId,
|
|
579
|
+
directory: this.config.directory,
|
|
580
|
+
...body,
|
|
581
|
+
}));
|
|
582
|
+
yield {
|
|
583
|
+
kind: "progress",
|
|
584
|
+
stage: "submitted",
|
|
585
|
+
message: "Message submitted to OpenCode",
|
|
586
|
+
};
|
|
587
|
+
for await (const rawEvent of stream.stream) {
|
|
588
|
+
const events = mapSdkEvent({
|
|
589
|
+
rawEvent,
|
|
590
|
+
sessionState: this.state,
|
|
591
|
+
runState,
|
|
592
|
+
});
|
|
593
|
+
for (const event of events) {
|
|
594
|
+
yield event;
|
|
595
|
+
}
|
|
596
|
+
if (runState.completed) {
|
|
597
|
+
runAbort.abort();
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
if (!runState.completed) {
|
|
602
|
+
const finalError = runState.lastError || "OpenCode event stream closed before completion";
|
|
603
|
+
yield {
|
|
604
|
+
kind: "run_completed",
|
|
605
|
+
status: "failed",
|
|
606
|
+
error: finalError,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
catch (error) {
|
|
611
|
+
const message = `OpenCode streaming failed: ${toErrorMessage(error)}`;
|
|
612
|
+
this.config.logger?.error?.(message);
|
|
613
|
+
yield {
|
|
614
|
+
kind: "assistant_final",
|
|
615
|
+
text: message,
|
|
616
|
+
summary: "OpenCode runtime error",
|
|
617
|
+
status: "failed",
|
|
618
|
+
};
|
|
619
|
+
yield {
|
|
620
|
+
kind: "run_completed",
|
|
621
|
+
status: "failed",
|
|
622
|
+
error: message,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
finally {
|
|
626
|
+
runAbort.abort();
|
|
627
|
+
if (this.state.activeRunAbort === runAbort) {
|
|
628
|
+
this.state.activeRunAbort = null;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
async *sendAction(args) {
|
|
633
|
+
if (args.action.type === "cancel_run") {
|
|
634
|
+
const reason = args.action.reason?.trim() || "Run cancelled by user";
|
|
635
|
+
if (this.state.activeRunAbort) {
|
|
636
|
+
this.state.activeRunAbort.abort();
|
|
637
|
+
this.state.activeRunAbort = null;
|
|
638
|
+
}
|
|
639
|
+
await requestNoError("session.abort", this.client.session.abort({
|
|
640
|
+
sessionID: this.state.sessionId,
|
|
641
|
+
directory: this.config.directory,
|
|
642
|
+
}));
|
|
643
|
+
yield {
|
|
644
|
+
kind: "progress",
|
|
645
|
+
stage: "cancelled",
|
|
646
|
+
message: reason,
|
|
647
|
+
};
|
|
648
|
+
yield {
|
|
649
|
+
kind: "run_completed",
|
|
650
|
+
status: "failed",
|
|
651
|
+
error: reason,
|
|
652
|
+
};
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const interactionId = args.action.interactionId;
|
|
656
|
+
const pending = this.state.pendingInteractions.get(interactionId);
|
|
657
|
+
if (!pending) {
|
|
658
|
+
throw new Error(`Unknown interactionId: ${interactionId}`);
|
|
659
|
+
}
|
|
660
|
+
const payload = asObject(args.action.payload);
|
|
661
|
+
if (pending.kind === "permission") {
|
|
662
|
+
const reply = mapPermissionDecision(args.action.decision, payload);
|
|
663
|
+
await requestNoError("permission.reply", this.client.permission.reply({
|
|
664
|
+
requestID: pending.requestId,
|
|
665
|
+
directory: this.config.directory,
|
|
666
|
+
reply,
|
|
667
|
+
message: payload ? readString(payload, "message") || undefined : undefined,
|
|
668
|
+
}));
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
if (args.action.decision === "reject" || args.action.decision === "cancel") {
|
|
672
|
+
await requestNoError("question.reject", this.client.question.reject({
|
|
673
|
+
requestID: pending.requestId,
|
|
674
|
+
directory: this.config.directory,
|
|
675
|
+
}));
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
const answers = resolveQuestionAnswers(payload, pending);
|
|
679
|
+
await requestNoError("question.reply", this.client.question.reply({
|
|
680
|
+
requestID: pending.requestId,
|
|
681
|
+
directory: this.config.directory,
|
|
682
|
+
answers,
|
|
683
|
+
}));
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
this.state.pendingInteractions.delete(interactionId);
|
|
687
|
+
yield {
|
|
688
|
+
kind: "interaction_resolved",
|
|
689
|
+
interactionId,
|
|
690
|
+
decision: args.action.decision,
|
|
691
|
+
status: "resolved",
|
|
692
|
+
source: args.action.source || "openclaw-opencode-bridge",
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
async close() {
|
|
696
|
+
if (this.state.activeRunAbort) {
|
|
697
|
+
this.state.activeRunAbort.abort();
|
|
698
|
+
this.state.activeRunAbort = null;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
async function createSessionId(client, config, sessionKey) {
|
|
703
|
+
const data = await requestNoError("session.create", client.session.create({
|
|
704
|
+
directory: config.directory,
|
|
705
|
+
title: `openclaw:${sessionKey}`,
|
|
706
|
+
}));
|
|
707
|
+
const session = data ? asObject(data) : null;
|
|
708
|
+
const sessionId = session ? readString(session, "id") : "";
|
|
709
|
+
if (!sessionId) {
|
|
710
|
+
throw new Error("session.create failed: missing session id");
|
|
711
|
+
}
|
|
712
|
+
return sessionId;
|
|
713
|
+
}
|
|
714
|
+
export function createOpenCodeSdkAdapter(config) {
|
|
715
|
+
const client = createOpencodeClient({
|
|
716
|
+
baseUrl: config.baseUrl,
|
|
717
|
+
directory: config.directory,
|
|
718
|
+
});
|
|
719
|
+
return {
|
|
720
|
+
async createSession(args) {
|
|
721
|
+
const sessionId = await createSessionId(client, config, args.sessionKey);
|
|
722
|
+
config.logger?.info?.(`[openclaw-opencode-bridge] OpenCode session created: key=${args.sessionKey} id=${sessionId}`);
|
|
723
|
+
return new OpenCodeSessionRuntime(client, config, sessionId);
|
|
724
|
+
},
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
export default createOpenCodeSdkAdapter;
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-opencode-bridge",
|
|
3
3
|
"name": "OpenCode Bridge",
|
|
4
|
-
"description": "Bridge OpenClaw gateway chat.send/chat.action with OpenCode SDK
|
|
4
|
+
"description": "Bridge OpenClaw gateway chat.send/chat.action with built-in OpenCode SDK runtime",
|
|
5
5
|
"configSchema": {
|
|
6
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"properties": {
|
|
9
|
-
"
|
|
9
|
+
"opencodeBaseUrl": {
|
|
10
10
|
"type": "string",
|
|
11
|
-
"description": "
|
|
11
|
+
"description": "OpenCode server base URL used by @opencode-ai/sdk (default: http://127.0.0.1:4096)"
|
|
12
12
|
},
|
|
13
|
-
"
|
|
13
|
+
"opencodeDirectory": {
|
|
14
14
|
"type": "string",
|
|
15
|
-
"description": "
|
|
15
|
+
"description": "Workspace directory passed to OpenCode SDK requests"
|
|
16
|
+
},
|
|
17
|
+
"opencodeAgent": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Optional OpenCode agent id used by session.promptAsync"
|
|
20
|
+
},
|
|
21
|
+
"opencodeProviderId": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Optional provider id for model override"
|
|
24
|
+
},
|
|
25
|
+
"opencodeModelId": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"description": "Optional model id for model override (requires opencodeProviderId)"
|
|
28
|
+
},
|
|
29
|
+
"opencodeSystemPrompt": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"description": "Optional system prompt sent with each chat message"
|
|
16
32
|
},
|
|
17
33
|
"sessionTtlMs": {
|
|
18
34
|
"type": "integer",
|
|
@@ -29,13 +45,29 @@
|
|
|
29
45
|
}
|
|
30
46
|
},
|
|
31
47
|
"uiHints": {
|
|
32
|
-
"
|
|
33
|
-
"label": "
|
|
34
|
-
"help": "
|
|
48
|
+
"opencodeBaseUrl": {
|
|
49
|
+
"label": "OpenCode Base URL",
|
|
50
|
+
"help": "Defaults to http://127.0.0.1:4096. You can also use env OPENCODE_BASE_URL."
|
|
51
|
+
},
|
|
52
|
+
"opencodeDirectory": {
|
|
53
|
+
"label": "OpenCode Directory",
|
|
54
|
+
"help": "You can also use env OPENCODE_WORKSPACE_DIR."
|
|
55
|
+
},
|
|
56
|
+
"opencodeAgent": {
|
|
57
|
+
"label": "OpenCode Agent",
|
|
58
|
+
"help": "You can also use env OPENCODE_AGENT."
|
|
59
|
+
},
|
|
60
|
+
"opencodeProviderId": {
|
|
61
|
+
"label": "OpenCode Provider",
|
|
62
|
+
"help": "You can also use env OPENCODE_PROVIDER_ID."
|
|
63
|
+
},
|
|
64
|
+
"opencodeModelId": {
|
|
65
|
+
"label": "OpenCode Model",
|
|
66
|
+
"help": "You can also use env OPENCODE_MODEL_ID."
|
|
35
67
|
},
|
|
36
|
-
"
|
|
37
|
-
"label": "
|
|
38
|
-
"help": "
|
|
68
|
+
"opencodeSystemPrompt": {
|
|
69
|
+
"label": "OpenCode System Prompt",
|
|
70
|
+
"help": "You can also use env OPENCODE_SYSTEM_PROMPT."
|
|
39
71
|
},
|
|
40
72
|
"sessionTtlMs": {
|
|
41
73
|
"label": "Session TTL (ms)"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sw-market/openclaw-opencode-bridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "OpenClaw plugin bridge for OpenCode realtime streaming and interaction actions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -29,6 +29,9 @@
|
|
|
29
29
|
"./dist/openclaw-extension.js"
|
|
30
30
|
]
|
|
31
31
|
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@opencode-ai/sdk": "^1.2.15"
|
|
34
|
+
},
|
|
32
35
|
"devDependencies": {
|
|
33
36
|
"@types/node": "^22.15.0",
|
|
34
37
|
"rimraf": "^6.0.1",
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import type { OpenCodeSdkAdapter } from "./index.js";
|
|
2
|
-
type Logger = {
|
|
3
|
-
info?: (message: string) => void;
|
|
4
|
-
warn?: (message: string) => void;
|
|
5
|
-
error?: (message: string) => void;
|
|
6
|
-
};
|
|
7
|
-
type CreateAdapterContext = {
|
|
8
|
-
api?: {
|
|
9
|
-
logger?: Logger;
|
|
10
|
-
};
|
|
11
|
-
};
|
|
12
|
-
export declare function createOpenCodeSdkAdapter(ctx?: CreateAdapterContext): OpenCodeSdkAdapter;
|
|
13
|
-
export default createOpenCodeSdkAdapter;
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
const DEFAULT_TEMPLATE_PATH = path.join(os.homedir(), ".openclaw", "opencode_sdk_adapter.mjs");
|
|
5
|
-
const ADAPTER_TEMPLATE = `// Auto-generated by @sw-market/openclaw-opencode-bridge.
|
|
6
|
-
// Fill this file with your real OpenCode SDK wiring.
|
|
7
|
-
// Then set in OpenClaw:
|
|
8
|
-
// plugins.entries["openclaw-opencode-bridge"].config.sdkAdapterModule = "<this file path>"
|
|
9
|
-
// or set env:
|
|
10
|
-
// OPENCODE_SDK_ADAPTER_MODULE=<this file path>
|
|
11
|
-
|
|
12
|
-
export function createOpenCodeSdkAdapter() {
|
|
13
|
-
return {
|
|
14
|
-
async createSession({ sessionKey }) {
|
|
15
|
-
return {
|
|
16
|
-
async *sendMessage({ runId, message, idempotencyKey }) {
|
|
17
|
-
// TODO: Replace this with real OpenCode SDK call + stream mapping.
|
|
18
|
-
yield {
|
|
19
|
-
kind: "assistant_final",
|
|
20
|
-
text:
|
|
21
|
-
"Template adapter loaded, but real SDK is not connected yet. " +
|
|
22
|
-
"Please implement sendMessage/sendAction in opencode_sdk_adapter.mjs.",
|
|
23
|
-
summary: "Template adapter loaded",
|
|
24
|
-
status: "failed",
|
|
25
|
-
};
|
|
26
|
-
yield {
|
|
27
|
-
kind: "run_completed",
|
|
28
|
-
status: "failed",
|
|
29
|
-
error: "Template adapter not implemented",
|
|
30
|
-
};
|
|
31
|
-
},
|
|
32
|
-
async *sendAction({ runId, action, idempotencyKey }) {
|
|
33
|
-
// TODO: Replace this with real action callback into OpenCode SDK.
|
|
34
|
-
if (action.type === "interaction.reply") {
|
|
35
|
-
yield {
|
|
36
|
-
kind: "interaction_resolved",
|
|
37
|
-
interactionId: action.interactionId,
|
|
38
|
-
decision: action.decision,
|
|
39
|
-
status: "resolved",
|
|
40
|
-
source: "template_adapter",
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
yield {
|
|
44
|
-
kind: "run_completed",
|
|
45
|
-
status: "failed",
|
|
46
|
-
error: "Template adapter not implemented",
|
|
47
|
-
};
|
|
48
|
-
},
|
|
49
|
-
};
|
|
50
|
-
},
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
|
-
`;
|
|
54
|
-
function toErrorMessage(error) {
|
|
55
|
-
if (error instanceof Error) {
|
|
56
|
-
return error.message;
|
|
57
|
-
}
|
|
58
|
-
return String(error);
|
|
59
|
-
}
|
|
60
|
-
function ensureTemplateFile(logger) {
|
|
61
|
-
try {
|
|
62
|
-
if (!existsSync(DEFAULT_TEMPLATE_PATH)) {
|
|
63
|
-
mkdirSync(path.dirname(DEFAULT_TEMPLATE_PATH), { recursive: true });
|
|
64
|
-
writeFileSync(DEFAULT_TEMPLATE_PATH, ADAPTER_TEMPLATE, "utf-8");
|
|
65
|
-
logger?.info?.(`[openclaw-opencode-bridge] generated SDK adapter template at ${DEFAULT_TEMPLATE_PATH}`);
|
|
66
|
-
}
|
|
67
|
-
return DEFAULT_TEMPLATE_PATH;
|
|
68
|
-
}
|
|
69
|
-
catch (error) {
|
|
70
|
-
logger?.warn?.(`[openclaw-opencode-bridge] failed to write adapter template: ${toErrorMessage(error)}`);
|
|
71
|
-
return undefined;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
function buildGuidanceText(templatePath) {
|
|
75
|
-
const lines = [
|
|
76
|
-
"OpenCode SDK adapter is not configured yet.",
|
|
77
|
-
"Set plugins.entries.openclaw-opencode-bridge.config.sdkAdapterModule",
|
|
78
|
-
"or set env OPENCODE_SDK_ADAPTER_MODULE, then restart openclaw-gateway.",
|
|
79
|
-
];
|
|
80
|
-
if (templatePath) {
|
|
81
|
-
lines.push(`Template generated at: ${templatePath}`);
|
|
82
|
-
}
|
|
83
|
-
return lines.join("\n");
|
|
84
|
-
}
|
|
85
|
-
function createGuidanceSession(guidanceText) {
|
|
86
|
-
return {
|
|
87
|
-
async *sendMessage() {
|
|
88
|
-
yield {
|
|
89
|
-
kind: "progress",
|
|
90
|
-
stage: "configuration_required",
|
|
91
|
-
message: "OpenCode SDK adapter module is required.",
|
|
92
|
-
};
|
|
93
|
-
yield {
|
|
94
|
-
kind: "assistant_final",
|
|
95
|
-
text: guidanceText,
|
|
96
|
-
summary: "SDK adapter not configured",
|
|
97
|
-
status: "failed",
|
|
98
|
-
};
|
|
99
|
-
yield {
|
|
100
|
-
kind: "run_completed",
|
|
101
|
-
status: "failed",
|
|
102
|
-
error: "OpenCode SDK adapter is not configured",
|
|
103
|
-
};
|
|
104
|
-
},
|
|
105
|
-
async *sendAction(args) {
|
|
106
|
-
if (args.action.type === "interaction.reply") {
|
|
107
|
-
yield {
|
|
108
|
-
kind: "interaction_resolved",
|
|
109
|
-
interactionId: args.action.interactionId,
|
|
110
|
-
decision: args.action.decision,
|
|
111
|
-
status: "resolved",
|
|
112
|
-
source: "openclaw-opencode-bridge",
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
yield {
|
|
116
|
-
kind: "run_completed",
|
|
117
|
-
status: "failed",
|
|
118
|
-
error: "OpenCode SDK adapter is not configured",
|
|
119
|
-
};
|
|
120
|
-
},
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
export function createOpenCodeSdkAdapter(ctx) {
|
|
124
|
-
const logger = ctx?.api?.logger;
|
|
125
|
-
const templatePath = ensureTemplateFile(logger);
|
|
126
|
-
const guidanceText = buildGuidanceText(templatePath);
|
|
127
|
-
return {
|
|
128
|
-
async createSession() {
|
|
129
|
-
return createGuidanceSession(guidanceText);
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
export default createOpenCodeSdkAdapter;
|