@mrc2204/opencode-bridge 0.1.1
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.en.md +115 -0
- package/README.md +117 -0
- package/dist/chunk-6NIQKNRA.js +176 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +814 -0
- package/dist/observability.d.ts +52 -0
- package/dist/observability.js +16 -0
- package/openclaw.plugin.json +56 -0
- package/package.json +49 -0
- package/skills/opencode-orchestration/SKILL.md +658 -0
package/README.en.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# @mrc2204/opencode-bridge
|
|
2
|
+
|
|
3
|
+
OpenClaw ↔ OpenCode bridge plugin for multi-agent orchestration, callback routing, SSE probing, run observability, and runtime serve management.
|
|
4
|
+
|
|
5
|
+
## Goals
|
|
6
|
+
- Standardize callback flow from OpenCode back into OpenClaw via `/hooks/agent`
|
|
7
|
+
- Standardize `sessionKey` and routing envelope
|
|
8
|
+
- Support **1 project = 1 OpenCode serve instance**
|
|
9
|
+
- Provide runtime-ops baseline for:
|
|
10
|
+
- SSE probe / event normalization
|
|
11
|
+
- run status / run events / session tail
|
|
12
|
+
- serve registry / spawn / reuse / shutdown / idle evaluation
|
|
13
|
+
|
|
14
|
+
## Install from npm
|
|
15
|
+
```bash
|
|
16
|
+
openclaw plugins install @mrc2204/openclaw-opencode-bridge
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Repository structure (build-based)
|
|
20
|
+
```text
|
|
21
|
+
openclaw-opencode-bridge/
|
|
22
|
+
├── openclaw.plugin.json
|
|
23
|
+
├── package.json
|
|
24
|
+
├── tsconfig.json
|
|
25
|
+
├── src/
|
|
26
|
+
│ ├── index.ts
|
|
27
|
+
│ ├── observability.ts
|
|
28
|
+
│ └── types.ts
|
|
29
|
+
├── dist/ # generated by npm run build
|
|
30
|
+
├── test/
|
|
31
|
+
│ ├── run-tests.ts
|
|
32
|
+
│ └── observability.test.ts
|
|
33
|
+
├── skills/
|
|
34
|
+
│ └── opencode-orchestration/
|
|
35
|
+
│ └── SKILL.md
|
|
36
|
+
├── README.md
|
|
37
|
+
└── README.en.md
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Available tools
|
|
41
|
+
- `opencode_status`
|
|
42
|
+
- `opencode_resolve_project`
|
|
43
|
+
- `opencode_build_envelope`
|
|
44
|
+
- `opencode_check_hook_policy`
|
|
45
|
+
- `opencode_evaluate_lifecycle`
|
|
46
|
+
- `opencode_run_status`
|
|
47
|
+
- `opencode_run_events`
|
|
48
|
+
- `opencode_session_tail`
|
|
49
|
+
- `opencode_serve_spawn`
|
|
50
|
+
- `opencode_registry_get`
|
|
51
|
+
- `opencode_registry_upsert`
|
|
52
|
+
- `opencode_registry_cleanup`
|
|
53
|
+
- `opencode_serve_shutdown`
|
|
54
|
+
- `opencode_serve_idle_check`
|
|
55
|
+
|
|
56
|
+
## Current assumptions
|
|
57
|
+
- `1 project = 1 opencode serve instance`
|
|
58
|
+
- callback primary = `/hooks/agent`
|
|
59
|
+
- `sessionKey` convention = `hook:opencode:<agentId>:<taskId>`
|
|
60
|
+
- `opencode_server_url` is required in practical routing envelopes
|
|
61
|
+
|
|
62
|
+
## Build & test
|
|
63
|
+
```bash
|
|
64
|
+
npm install
|
|
65
|
+
npm run build
|
|
66
|
+
npm run typecheck
|
|
67
|
+
npm run test
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
After `npm run build`, runtime entrypoints point to built artifacts in `dist/`:
|
|
71
|
+
- `main`: `./dist/index.js`
|
|
72
|
+
- `types`: `./dist/index.d.ts`
|
|
73
|
+
- `openclaw.extensions`: `./dist/index.js`
|
|
74
|
+
|
|
75
|
+
## Publish flow (safe)
|
|
76
|
+
```bash
|
|
77
|
+
npm run build
|
|
78
|
+
npm run test
|
|
79
|
+
npm pack --dry-run
|
|
80
|
+
# if all good:
|
|
81
|
+
# npm publish
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Use `npm pack --dry-run` to confirm package contents are build artifacts (`dist/`, `skills/`, readmes, plugin manifest), not raw TypeScript entrypoints.
|
|
85
|
+
|
|
86
|
+
## Runtime config
|
|
87
|
+
Plugin-owned config path:
|
|
88
|
+
```text
|
|
89
|
+
~/.openclaw/opencode-bridge/config.json
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
```json
|
|
94
|
+
{
|
|
95
|
+
"opencodeServerUrl": "http://127.0.0.1:4096",
|
|
96
|
+
"hookBaseUrl": "http://127.0.0.1:18789",
|
|
97
|
+
"hookToken": "<OPENCLAW_HOOK_TOKEN>",
|
|
98
|
+
"projectRegistry": [
|
|
99
|
+
{
|
|
100
|
+
"projectId": "agent-smart-memo",
|
|
101
|
+
"repoRoot": "/Users/me/Work/projects/agent-smart-memo",
|
|
102
|
+
"serverUrl": "http://127.0.0.1:4096",
|
|
103
|
+
"idleTimeoutMs": 900000
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Local development install
|
|
110
|
+
```bash
|
|
111
|
+
openclaw plugins install -l ~/Work/projects/opencode-bridge
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
MIT
|
package/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# @mrc2204/opencode-bridge
|
|
2
|
+
|
|
3
|
+
> English README: [README.en.md](./README.en.md)
|
|
4
|
+
|
|
5
|
+
Plugin bridge giữa **OpenClaw** và **OpenCode** cho orchestration multi-agent, callback routing, SSE probing, run observability và serve runtime management.
|
|
6
|
+
|
|
7
|
+
## Mục tiêu
|
|
8
|
+
- Chuẩn hóa callback path từ OpenCode về OpenClaw qua `/hooks/agent`
|
|
9
|
+
- Chuẩn hóa `sessionKey` / `routing envelope`
|
|
10
|
+
- Hỗ trợ mô hình vận hành: **1 project = 1 OpenCode serve instance**
|
|
11
|
+
- Cung cấp runtime-ops baseline:
|
|
12
|
+
- SSE probe / event normalize
|
|
13
|
+
- run status / run events / session tail
|
|
14
|
+
- serve registry / spawn / reuse / shutdown / idle evaluation
|
|
15
|
+
|
|
16
|
+
## Cài package từ npm
|
|
17
|
+
```bash
|
|
18
|
+
openclaw plugins install @mrc2204/openclaw-opencode-bridge
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Cấu trúc repo (build-based)
|
|
22
|
+
```text
|
|
23
|
+
openclaw-opencode-bridge/
|
|
24
|
+
├── openclaw.plugin.json
|
|
25
|
+
├── package.json
|
|
26
|
+
├── tsconfig.json
|
|
27
|
+
├── src/
|
|
28
|
+
│ ├── index.ts
|
|
29
|
+
│ ├── observability.ts
|
|
30
|
+
│ └── types.ts
|
|
31
|
+
├── dist/ # artifact tạo bởi npm run build
|
|
32
|
+
├── test/
|
|
33
|
+
│ ├── run-tests.ts
|
|
34
|
+
│ └── observability.test.ts
|
|
35
|
+
├── skills/
|
|
36
|
+
│ └── opencode-orchestration/
|
|
37
|
+
│ └── SKILL.md
|
|
38
|
+
├── README.md
|
|
39
|
+
└── README.en.md
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Tool hiện có
|
|
43
|
+
- `opencode_status`
|
|
44
|
+
- `opencode_resolve_project`
|
|
45
|
+
- `opencode_build_envelope`
|
|
46
|
+
- `opencode_check_hook_policy`
|
|
47
|
+
- `opencode_evaluate_lifecycle`
|
|
48
|
+
- `opencode_run_status`
|
|
49
|
+
- `opencode_run_events`
|
|
50
|
+
- `opencode_session_tail`
|
|
51
|
+
- `opencode_serve_spawn`
|
|
52
|
+
- `opencode_registry_get`
|
|
53
|
+
- `opencode_registry_upsert`
|
|
54
|
+
- `opencode_registry_cleanup`
|
|
55
|
+
- `opencode_serve_shutdown`
|
|
56
|
+
- `opencode_serve_idle_check`
|
|
57
|
+
|
|
58
|
+
## Assumptions hiện tại
|
|
59
|
+
- `1 project = 1 opencode serve instance`
|
|
60
|
+
- callback primary = `/hooks/agent`
|
|
61
|
+
- `sessionKey` convention = `hook:opencode:<agentId>:<taskId>`
|
|
62
|
+
- `opencode_server_url` là field bắt buộc trong envelope routing thực tế
|
|
63
|
+
|
|
64
|
+
## Build/Test
|
|
65
|
+
```bash
|
|
66
|
+
npm install
|
|
67
|
+
npm run build
|
|
68
|
+
npm run typecheck
|
|
69
|
+
npm run test
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Sau `npm run build`, entrypoint runtime dùng artifact trong `dist/`:
|
|
73
|
+
- `main`: `./dist/index.js`
|
|
74
|
+
- `types`: `./dist/index.d.ts`
|
|
75
|
+
- `openclaw.extensions`: `./dist/index.js`
|
|
76
|
+
|
|
77
|
+
## Publish flow (safe)
|
|
78
|
+
```bash
|
|
79
|
+
npm run build
|
|
80
|
+
npm run test
|
|
81
|
+
npm pack --dry-run
|
|
82
|
+
# nếu OK:
|
|
83
|
+
# npm publish
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`npm pack --dry-run` dùng để kiểm tra package chỉ publish artifact cần thiết (`dist/`, `skills/`, readme, plugin manifest), không trỏ trực tiếp vào source TypeScript.
|
|
87
|
+
|
|
88
|
+
## Config runtime
|
|
89
|
+
Plugin dùng plugin-owned config tại:
|
|
90
|
+
```text
|
|
91
|
+
~/.openclaw/opencode-bridge/config.json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Ví dụ:
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"opencodeServerUrl": "http://127.0.0.1:4096",
|
|
98
|
+
"hookBaseUrl": "http://127.0.0.1:18789",
|
|
99
|
+
"hookToken": "<OPENCLAW_HOOK_TOKEN>",
|
|
100
|
+
"projectRegistry": [
|
|
101
|
+
{
|
|
102
|
+
"projectId": "agent-smart-memo",
|
|
103
|
+
"repoRoot": "/Users/me/Work/projects/agent-smart-memo",
|
|
104
|
+
"serverUrl": "http://127.0.0.1:4096",
|
|
105
|
+
"idleTimeoutMs": 900000
|
|
106
|
+
}
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Local dev plugin install
|
|
112
|
+
```bash
|
|
113
|
+
openclaw plugins install -l ~/Work/projects/opencode-bridge
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
MIT
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// src/observability.ts
|
|
2
|
+
function parseSseFramesFromBuffer(input) {
|
|
3
|
+
const normalized = input.replace(/\r\n/g, "\n");
|
|
4
|
+
const parts = normalized.split("\n\n");
|
|
5
|
+
const remainder = parts.pop() ?? "";
|
|
6
|
+
const frames = [];
|
|
7
|
+
for (const rawFrame of parts) {
|
|
8
|
+
const lines = rawFrame.split("\n");
|
|
9
|
+
let event;
|
|
10
|
+
let id;
|
|
11
|
+
let retry;
|
|
12
|
+
const dataLines = [];
|
|
13
|
+
for (const line of lines) {
|
|
14
|
+
if (!line || line.startsWith(":")) continue;
|
|
15
|
+
const idx = line.indexOf(":");
|
|
16
|
+
const field = idx >= 0 ? line.slice(0, idx).trim() : line.trim();
|
|
17
|
+
const value = idx >= 0 ? line.slice(idx + 1).replace(/^\s/, "") : "";
|
|
18
|
+
if (field === "event") event = value;
|
|
19
|
+
else if (field === "id") id = value;
|
|
20
|
+
else if (field === "retry") {
|
|
21
|
+
const n = Number(value);
|
|
22
|
+
if (Number.isFinite(n)) retry = n;
|
|
23
|
+
} else if (field === "data") dataLines.push(value);
|
|
24
|
+
}
|
|
25
|
+
if (dataLines.length <= 0) continue;
|
|
26
|
+
frames.push({
|
|
27
|
+
event,
|
|
28
|
+
id,
|
|
29
|
+
retry,
|
|
30
|
+
data: dataLines.join("\n"),
|
|
31
|
+
raw: rawFrame
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return { frames, remainder };
|
|
35
|
+
}
|
|
36
|
+
function parseSseData(data) {
|
|
37
|
+
const trimmed = data.trim();
|
|
38
|
+
if (!trimmed) return null;
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(trimmed);
|
|
41
|
+
} catch {
|
|
42
|
+
return { raw: trimmed };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function unwrapGlobalPayload(raw) {
|
|
46
|
+
let current = raw;
|
|
47
|
+
const wrappers = [];
|
|
48
|
+
for (let i = 0; i < 3; i += 1) {
|
|
49
|
+
if (!current || typeof current !== "object") break;
|
|
50
|
+
if ("payload" in current && current.payload !== void 0) {
|
|
51
|
+
wrappers.push("payload");
|
|
52
|
+
current = current.payload;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if ("data" in current && current.data !== void 0 && Object.keys(current).length <= 4) {
|
|
56
|
+
wrappers.push("data");
|
|
57
|
+
current = current.data;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
return { payload: current, wrappers };
|
|
63
|
+
}
|
|
64
|
+
function pickFirstString(obj, keys) {
|
|
65
|
+
if (!obj || typeof obj !== "object") return void 0;
|
|
66
|
+
for (const key of keys) {
|
|
67
|
+
const value = obj[key];
|
|
68
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
69
|
+
}
|
|
70
|
+
return void 0;
|
|
71
|
+
}
|
|
72
|
+
function normalizeOpenCodeEvent(raw) {
|
|
73
|
+
const text = JSON.stringify(raw || {}).toLowerCase();
|
|
74
|
+
if (text.includes("permission") || text.includes("question.asked")) {
|
|
75
|
+
return { kind: "permission.requested", summary: "OpenCode \u0111ang ch\u1EDD permission/user input", raw };
|
|
76
|
+
}
|
|
77
|
+
if (text.includes("error") || text.includes("failed")) {
|
|
78
|
+
return { kind: "task.failed", summary: "OpenCode task failed", raw };
|
|
79
|
+
}
|
|
80
|
+
if (text.includes("stalled")) {
|
|
81
|
+
return { kind: "task.stalled", summary: "OpenCode task stalled", raw };
|
|
82
|
+
}
|
|
83
|
+
if (text.includes("complete") || text.includes("completed") || text.includes("done")) {
|
|
84
|
+
return { kind: "task.completed", summary: "OpenCode task completed", raw };
|
|
85
|
+
}
|
|
86
|
+
if (text.includes("progress") || text.includes("delta") || text.includes("assistant")) {
|
|
87
|
+
return { kind: "task.progress", summary: "OpenCode task made progress", raw };
|
|
88
|
+
}
|
|
89
|
+
if (text.includes("start") || text.includes("created") || text.includes("connected")) {
|
|
90
|
+
return { kind: "task.started", summary: "OpenCode task/server started", raw };
|
|
91
|
+
}
|
|
92
|
+
return { kind: null, raw };
|
|
93
|
+
}
|
|
94
|
+
function normalizeTypedEventV1(frame, scope) {
|
|
95
|
+
const parsed = parseSseData(frame.data);
|
|
96
|
+
const unwrapped = unwrapGlobalPayload(parsed);
|
|
97
|
+
const normalized = normalizeOpenCodeEvent(unwrapped.payload);
|
|
98
|
+
return {
|
|
99
|
+
schema: "opencode.event.v1",
|
|
100
|
+
scope,
|
|
101
|
+
eventName: frame.event,
|
|
102
|
+
eventId: frame.id,
|
|
103
|
+
kind: normalized.kind,
|
|
104
|
+
summary: normalized.summary,
|
|
105
|
+
runId: pickFirstString(unwrapped.payload, ["run_id", "runId"]),
|
|
106
|
+
taskId: pickFirstString(unwrapped.payload, ["task_id", "taskId"]),
|
|
107
|
+
sessionId: pickFirstString(unwrapped.payload, ["session_id", "sessionId", "session"]),
|
|
108
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
109
|
+
wrappers: unwrapped.wrappers,
|
|
110
|
+
payload: unwrapped.payload
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function asString(value) {
|
|
114
|
+
return typeof value === "string" && value.trim() ? value.trim() : void 0;
|
|
115
|
+
}
|
|
116
|
+
function scoreSessionCandidate(candidate, ctx) {
|
|
117
|
+
const id = asString(candidate?.id) || "";
|
|
118
|
+
const candidateText = JSON.stringify(candidate || {}).toLowerCase();
|
|
119
|
+
const runId = ctx.runId?.toLowerCase();
|
|
120
|
+
const taskId = ctx.taskId?.toLowerCase();
|
|
121
|
+
const sessionKey = ctx.sessionKey?.toLowerCase();
|
|
122
|
+
const artifactSessionId = ctx.artifactSessionId;
|
|
123
|
+
let score = 0;
|
|
124
|
+
if (artifactSessionId && id === artifactSessionId) score += 1e3;
|
|
125
|
+
if (runId && candidateText.includes(runId)) score += 120;
|
|
126
|
+
if (taskId && candidateText.includes(taskId)) score += 70;
|
|
127
|
+
if (sessionKey && candidateText.includes(sessionKey)) score += 60;
|
|
128
|
+
if (runId && id.toLowerCase().includes(runId)) score += 30;
|
|
129
|
+
if (taskId && id.toLowerCase().includes(taskId)) score += 20;
|
|
130
|
+
score += Math.max(0, 20 - ctx.recencyRank);
|
|
131
|
+
return score;
|
|
132
|
+
}
|
|
133
|
+
function resolveSessionId(input) {
|
|
134
|
+
if (input.explicitSessionId) {
|
|
135
|
+
return { sessionId: input.explicitSessionId, strategy: "explicit", score: 9999 };
|
|
136
|
+
}
|
|
137
|
+
const list = Array.isArray(input.sessionList) ? input.sessionList : [];
|
|
138
|
+
const withRecency = [...list].map((item, idx) => {
|
|
139
|
+
const updated = Number(item?.time?.updated || 0);
|
|
140
|
+
return { item, idx, updated };
|
|
141
|
+
}).sort((a, b) => b.updated - a.updated).map((x, rank) => ({ ...x, rank }));
|
|
142
|
+
if (input.artifactSessionId) {
|
|
143
|
+
const direct = withRecency.find((x) => asString(x.item?.id) === input.artifactSessionId);
|
|
144
|
+
if (direct) {
|
|
145
|
+
return { sessionId: input.artifactSessionId, strategy: "artifact", score: 1e3 };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
let best = { score: -1 };
|
|
149
|
+
for (const candidate of withRecency) {
|
|
150
|
+
const id = asString(candidate.item?.id);
|
|
151
|
+
if (!id) continue;
|
|
152
|
+
const score = scoreSessionCandidate(candidate.item, {
|
|
153
|
+
runId: input.runId,
|
|
154
|
+
taskId: input.taskId,
|
|
155
|
+
sessionKey: input.sessionKey,
|
|
156
|
+
artifactSessionId: input.artifactSessionId,
|
|
157
|
+
recencyRank: candidate.rank
|
|
158
|
+
});
|
|
159
|
+
if (score > best.score) best = { id, score };
|
|
160
|
+
}
|
|
161
|
+
if (best.id && best.score > 0) {
|
|
162
|
+
return { sessionId: best.id, strategy: "scored_fallback", score: best.score };
|
|
163
|
+
}
|
|
164
|
+
const latest = withRecency.find((x) => asString(x.item?.id));
|
|
165
|
+
if (latest) return { sessionId: asString(latest.item?.id), strategy: "latest", score: 0 };
|
|
166
|
+
return { strategy: "none", score: -1 };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export {
|
|
170
|
+
parseSseFramesFromBuffer,
|
|
171
|
+
parseSseData,
|
|
172
|
+
unwrapGlobalPayload,
|
|
173
|
+
normalizeOpenCodeEvent,
|
|
174
|
+
normalizeTypedEventV1,
|
|
175
|
+
resolveSessionId
|
|
176
|
+
};
|