dsh-remote-web 0.7.9 → 0.7.10-beta.10
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/lib/client.js +350 -8
- package/lib/index.js +26 -0
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/agent/README.md +412 -0
- package/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
- package/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
- package/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
- package/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
- package/runtime/clients/dsh-remote/agent/events.mjs +511 -0
- package/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
- package/runtime/clients/dsh-remote/agent/index.mjs +339 -0
- package/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
- package/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
- package/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
- package/runtime/clients/dsh-remote/agent/version.mjs +148 -0
- package/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
- package/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +208 -1
- package/runtime/clients/dsh-remote/mobile-adapter.mjs +775 -11
- package/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
- package/runtime/dsh-setup.mjs +5 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode 适配骨架(**HTTP server + SSE 事件流**,M2 第 5 周)。
|
|
3
|
+
*
|
|
4
|
+
* ## 和 jsonl 两家最大的不同:事件是 **SSE**,不是 stdout 行
|
|
5
|
+
*
|
|
6
|
+
* `opencode serve` 把它的事件挂在 `GET /event`(Server-Sent Events)上;回合结束、工具状态、
|
|
7
|
+
* 审批请求都从这条流里来。**不能直接喂 `createJsonlDriver`** —— SSE 有自己的分帧(`data: {...}\n\n`、
|
|
8
|
+
* 注释行心跳、多行 data)。所以本模块提供三层:
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* HTTP 响应流(SSE) ──createSseParser──▶ {event, data} ──mapOpenCodeObject──▶ 5 类事件
|
|
12
|
+
* ↑ SSE 分帧/心跳/坏 JSON 兜底 ↑ 一家一份
|
|
13
|
+
* attachOpenCodeSse({stream, driver}) ← 把手接起来的薄胶水
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* **为什么要桥接而不是直接改驱动**:通用驱动(`events.mjs`)吃的是"一行一个 JSON",
|
|
17
|
+
* 它的假设(行=对象、坏 JSON 计数)正好等于 SSE 的 *data 载荷*。因此桥接层只负责"SSE → 一行 JSON",
|
|
18
|
+
* 之后仍然复用同一个驱动(seq/统计/sink/未知兜底全部不改)——这正是"2 个驱动 4 家"的中间形态。
|
|
19
|
+
*
|
|
20
|
+
* ## 依据来源(官方,2026-11 读取)
|
|
21
|
+
*
|
|
22
|
+
* | 依据 | 出处 |
|
|
23
|
+
* |---|---|
|
|
24
|
+
* | Server API(`GET /event` SSE、`POST /session/:id/permissions/:permissionID`、`prompt_async`、`session.idle` 语义) | <https://opencode.ai/docs/server/> |
|
|
25
|
+
* | 事件与 Part 的**权威字段定义**(`Event` 联合、`message.part.updated`、`permission.updated`、`ToolState`…) | npm 包 `@opencode-ai/sdk@1.19.5` 的 `dist/gen/types.gen.d.ts`(官方发布产物,逐字对照) |
|
|
26
|
+
* | 官方文档里该类型的链接 | 同 docs/server 指向 `packages/sdk/js/src/gen/types.gen.ts`(v2 仓库重构后该路径已变,故改用 npm 产物) |
|
|
27
|
+
*
|
|
28
|
+
* ## 未核实项(**不要当事实**)
|
|
29
|
+
*
|
|
30
|
+
* 1. 本机**未安装** OpenCode(探测会 SKIP);以下都是"按官方类型定义映射",**没有真实事件流校准**。
|
|
31
|
+
* 2. SSE 分帧里 OpenCode 是否带 `event:` 行(本模块两种都吃:有 event 名则忽略,只认 data)。
|
|
32
|
+
* 3. `message.part.updated` 是否既发"全量 text"又发 `delta` —— 本模块优先用 `delta`(增量),
|
|
33
|
+
* 否则把 `text` 当完整段落(final=true),下游需要按 `part.id` 去重(下一步做)。
|
|
34
|
+
* 4. `session.idle` 是否等价于"回合结束"(文档语义是,但未实测多 agent/子会话场景)。
|
|
35
|
+
* 5. `permission.updated` 的 `pattern` 可能含命令片段 → **不落**;`title` 是 agent 文案 → 也**不落**。
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import fsSync from "node:fs";
|
|
39
|
+
import path from "node:path";
|
|
40
|
+
import { spawnSync as nodeSpawnSync, execFileSync as nodeExecFileSync } from "node:child_process";
|
|
41
|
+
|
|
42
|
+
import {
|
|
43
|
+
DEFAULT_MAX_LINE_BYTES,
|
|
44
|
+
DEFAULT_MAX_PREVIEW_CHARS,
|
|
45
|
+
EVENT_TYPES,
|
|
46
|
+
byteLength,
|
|
47
|
+
createJsonlDriver,
|
|
48
|
+
previewOf
|
|
49
|
+
} from "./events.mjs";
|
|
50
|
+
import { NPM_ROOT_ENV, probeCandidateVersion, probeNpmRoot, splitPathList } from "./detect.mjs";
|
|
51
|
+
import { formatVersion } from "./version.mjs";
|
|
52
|
+
|
|
53
|
+
/** 显式指定 opencode CLI。 */
|
|
54
|
+
export const OPENCODE_BIN_ENV = "DSH_AGENT_OPENCODE_BIN";
|
|
55
|
+
/** 显式指定 npm 全局根。 */
|
|
56
|
+
export const OPENCODE_NPM_ROOT_ENV = "DSH_AGENT_OPENCODE_NPM_ROOT";
|
|
57
|
+
|
|
58
|
+
/** 我们认识的顶层事件(其余按"忽略"处理,未知形状才计 unknown)。 */
|
|
59
|
+
export const OPENCODE_EVENTS = Object.freeze([
|
|
60
|
+
"server.connected",
|
|
61
|
+
"session.status",
|
|
62
|
+
"session.idle",
|
|
63
|
+
"session.error",
|
|
64
|
+
"message.part.updated",
|
|
65
|
+
"permission.updated",
|
|
66
|
+
"permission.replied"
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
/** 明确"认识但与本层无关"的事件(忽略而不是报 unknown,避免日志噪声)。 */
|
|
70
|
+
export const OPENCODE_IGNORED_EVENTS = Object.freeze([
|
|
71
|
+
"server.instance.disposed",
|
|
72
|
+
"installation.updated",
|
|
73
|
+
"installation.update.available",
|
|
74
|
+
"lsp.client.diagnostics",
|
|
75
|
+
"lsp.updated",
|
|
76
|
+
"message.updated",
|
|
77
|
+
"message.removed",
|
|
78
|
+
"message.part.removed",
|
|
79
|
+
"session.compacted",
|
|
80
|
+
"session.created",
|
|
81
|
+
"session.updated",
|
|
82
|
+
"session.deleted",
|
|
83
|
+
"session.diff",
|
|
84
|
+
"file.edited",
|
|
85
|
+
"file.watcher.updated",
|
|
86
|
+
"todo.updated",
|
|
87
|
+
"command.executed",
|
|
88
|
+
"vcs.branch.updated",
|
|
89
|
+
"tui.prompt.append",
|
|
90
|
+
"tui.command.execute",
|
|
91
|
+
"tui.toast.show",
|
|
92
|
+
"pty.created",
|
|
93
|
+
"pty.updated",
|
|
94
|
+
"pty.exited",
|
|
95
|
+
"pty.deleted"
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
/** OpenCode 工具名 → 归一化 kind(工具名是小写 snake)。 */
|
|
99
|
+
const TOOL_KIND = Object.freeze({
|
|
100
|
+
bash: "execute",
|
|
101
|
+
shell: "execute",
|
|
102
|
+
edit: "edit",
|
|
103
|
+
write: "edit",
|
|
104
|
+
multiedit: "edit",
|
|
105
|
+
patch: "edit",
|
|
106
|
+
apply_patch: "edit",
|
|
107
|
+
read: "read",
|
|
108
|
+
grep: "search",
|
|
109
|
+
glob: "search",
|
|
110
|
+
list: "search",
|
|
111
|
+
webfetch: "fetch",
|
|
112
|
+
websearch: "fetch",
|
|
113
|
+
task: "other",
|
|
114
|
+
todowrite: "other",
|
|
115
|
+
todoread: "other"
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
function kindOfTool(tool) {
|
|
119
|
+
return TOOL_KIND[String(tool || "").toLowerCase()] || "other";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 取出事件体:`/global/event` 是 `{directory, payload}`,`/event` 是事件本体。 */
|
|
123
|
+
export function unwrapOpenCodeEvent(obj) {
|
|
124
|
+
if (!obj || typeof obj !== "object") return null;
|
|
125
|
+
if (obj.payload && typeof obj.payload === "object" && typeof obj.payload.type === "string") return obj.payload;
|
|
126
|
+
if (typeof obj.type === "string") return obj;
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 一个 OpenCode 事件对象 → 归一化事件。
|
|
132
|
+
*
|
|
133
|
+
* @param {object} obj `/event` 的事件本体,或 `/global/event` 的 `{directory,payload}`
|
|
134
|
+
* @param {{now?:()=>number, maxPreviewChars?:number}} [ctx]
|
|
135
|
+
*/
|
|
136
|
+
export function mapOpenCodeObject(obj, {
|
|
137
|
+
now = () => Date.now(),
|
|
138
|
+
maxPreviewChars = DEFAULT_MAX_PREVIEW_CHARS
|
|
139
|
+
} = {}) {
|
|
140
|
+
const ev = unwrapOpenCodeEvent(obj);
|
|
141
|
+
if (!ev) return { ok: false, reason: "not-event" };
|
|
142
|
+
const at = now();
|
|
143
|
+
const base = (type) => ({ v: 1, type, at });
|
|
144
|
+
const props = ev.properties && typeof ev.properties === "object" ? ev.properties : {};
|
|
145
|
+
|
|
146
|
+
switch (ev.type) {
|
|
147
|
+
case "server.connected":
|
|
148
|
+
return { ok: true, events: [{ ...base(EVENT_TYPES.STATUS), status: "connected" }] };
|
|
149
|
+
|
|
150
|
+
case "session.status": {
|
|
151
|
+
const st = props.status || {};
|
|
152
|
+
const state = String(st.type || "");
|
|
153
|
+
if (state === "idle") return { ok: true, events: [{ ...base(EVENT_TYPES.STATUS), status: "idle" }] };
|
|
154
|
+
if (state === "busy") return { ok: true, events: [{ ...base(EVENT_TYPES.STATUS), status: "working" }] };
|
|
155
|
+
if (state === "retry") {
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
events: [{
|
|
159
|
+
...base(EVENT_TYPES.STATUS),
|
|
160
|
+
status: "retrying",
|
|
161
|
+
attempt: Number(st.attempt) || 0,
|
|
162
|
+
retryDelayMs: Number(st.next) || 0
|
|
163
|
+
}]
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return { ok: true, events: [] };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
case "session.idle":
|
|
170
|
+
// 文档语义:会话空闲 = 回合结束
|
|
171
|
+
return { ok: true, events: [{ ...base(EVENT_TYPES.TURN_END), reason: "completed", isError: false }] };
|
|
172
|
+
|
|
173
|
+
case "session.error": {
|
|
174
|
+
const err = props.error || {};
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
events: [{
|
|
178
|
+
...base(EVENT_TYPES.TURN_END),
|
|
179
|
+
reason: "error",
|
|
180
|
+
isError: true,
|
|
181
|
+
// error 是 ProviderAuthError / UnknownError / ApiError 等联合;只取 name + message 预览
|
|
182
|
+
errorName: String(err.name || ""),
|
|
183
|
+
errorMessage: previewOf(String(err?.data?.message || err?.message || ""), maxPreviewChars)
|
|
184
|
+
}]
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
case "message.part.updated": {
|
|
189
|
+
const part = props.part || {};
|
|
190
|
+
const type = String(part.type || "");
|
|
191
|
+
if (type === "text") {
|
|
192
|
+
const delta = typeof props.delta === "string" ? props.delta : "";
|
|
193
|
+
if (delta) return { ok: true, events: [{ ...base(EVENT_TYPES.MESSAGE_DELTA), text: delta, final: false }] };
|
|
194
|
+
const text = String(part.text || "");
|
|
195
|
+
if (!text) return { ok: true, events: [] };
|
|
196
|
+
// 没给 delta 时把 text 当完整段落;同一个 part 会多次到达,下游需按 part.id 去重【未核实】
|
|
197
|
+
return { ok: true, events: [{ ...base(EVENT_TYPES.MESSAGE_DELTA), text, final: true }] };
|
|
198
|
+
}
|
|
199
|
+
if (type === "reasoning") {
|
|
200
|
+
return {
|
|
201
|
+
ok: true,
|
|
202
|
+
events: [{ ...base(EVENT_TYPES.STATUS), status: "thinking", textBytes: byteLength(part.text ?? "") }]
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (type === "tool") {
|
|
206
|
+
const state = part.state || {};
|
|
207
|
+
const status = String(state.status || "");
|
|
208
|
+
const toolCallId = String(part.callID || part.id || "");
|
|
209
|
+
if (status === "pending" || status === "running" || !status) {
|
|
210
|
+
return {
|
|
211
|
+
ok: true,
|
|
212
|
+
events: [{
|
|
213
|
+
...base(EVENT_TYPES.TOOL_CALL),
|
|
214
|
+
toolCallId,
|
|
215
|
+
name: String(part.tool || ""),
|
|
216
|
+
kind: kindOfTool(part.tool),
|
|
217
|
+
status: status || "pending",
|
|
218
|
+
// input 只算字节数,不落内容(命令原文不进日志)
|
|
219
|
+
inputBytes: state.input === undefined ? 0 : byteLength(JSON.stringify(state.input))
|
|
220
|
+
}]
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (status === "completed" || status === "error") {
|
|
224
|
+
const output = status === "completed" ? String(state.output ?? "") : String(state.error ?? "");
|
|
225
|
+
return {
|
|
226
|
+
ok: true,
|
|
227
|
+
events: [{
|
|
228
|
+
...base(EVENT_TYPES.TOOL_RESULT),
|
|
229
|
+
toolCallId,
|
|
230
|
+
isError: status === "error",
|
|
231
|
+
outputBytes: byteLength(output),
|
|
232
|
+
outputPreview: previewOf(output, maxPreviewChars)
|
|
233
|
+
}]
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, events: [] };
|
|
237
|
+
}
|
|
238
|
+
// step-start / step-finish / snapshot / patch / file / agent / subtask / retry / compaction
|
|
239
|
+
return { ok: true, events: [] };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
case "permission.updated": {
|
|
243
|
+
// 审批请求:pattern/title 可能含命令片段 → 不落;只给 id/类型/工具关联
|
|
244
|
+
return {
|
|
245
|
+
ok: true,
|
|
246
|
+
events: [{
|
|
247
|
+
...base(EVENT_TYPES.STATUS),
|
|
248
|
+
status: "waiting_approval",
|
|
249
|
+
requestId: String(props.id || ""),
|
|
250
|
+
permType: String(props.type || ""),
|
|
251
|
+
toolCallId: String(props.callID || "")
|
|
252
|
+
// 注意:真正的审批详情/回执走 PermissionBridge + HTTP(POST /session/:id/permissions/:id),
|
|
253
|
+
// 不走事件流;事件流这里只负责"有一条待审批"的提示。
|
|
254
|
+
}]
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case "permission.replied":
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
events: [{
|
|
262
|
+
...base(EVENT_TYPES.STATUS),
|
|
263
|
+
status: "approval_replied",
|
|
264
|
+
requestId: String(props.permissionID || ""),
|
|
265
|
+
response: String(props.response || "")
|
|
266
|
+
}]
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
default:
|
|
270
|
+
if (OPENCODE_IGNORED_EVENTS.includes(ev.type)) return { ok: true, events: [] };
|
|
271
|
+
return { ok: false, reason: `unknown-type:${String(ev.type || "?")}` };
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 一行 JSON(SSE data 或测试夹具)映射。 */
|
|
276
|
+
export function mapOpenCodeLine(line, {
|
|
277
|
+
now = () => Date.now(),
|
|
278
|
+
maxLineBytes = DEFAULT_MAX_LINE_BYTES,
|
|
279
|
+
maxPreviewChars = DEFAULT_MAX_PREVIEW_CHARS
|
|
280
|
+
} = {}) {
|
|
281
|
+
const raw = String(line ?? "").trim();
|
|
282
|
+
if (!raw) return { ok: false, reason: "empty" };
|
|
283
|
+
if (byteLength(raw) > maxLineBytes) return { ok: false, reason: "too-long" };
|
|
284
|
+
let obj;
|
|
285
|
+
try {
|
|
286
|
+
obj = JSON.parse(raw);
|
|
287
|
+
} catch {
|
|
288
|
+
return { ok: false, reason: "bad-json" };
|
|
289
|
+
}
|
|
290
|
+
return mapOpenCodeObject(obj, { now, maxPreviewChars });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** 现成的 OpenCode 事件驱动(喂 SSE 的 data 载荷行)。 */
|
|
294
|
+
export function createOpenCodeDriver({
|
|
295
|
+
sink = null,
|
|
296
|
+
onEvent,
|
|
297
|
+
onUnknown,
|
|
298
|
+
now = () => Date.now(),
|
|
299
|
+
maxLineBytes = DEFAULT_MAX_LINE_BYTES,
|
|
300
|
+
maxPreviewChars = DEFAULT_MAX_PREVIEW_CHARS
|
|
301
|
+
} = {}) {
|
|
302
|
+
return createJsonlDriver({
|
|
303
|
+
mapLine: mapOpenCodeObject,
|
|
304
|
+
sink,
|
|
305
|
+
onEvent,
|
|
306
|
+
onUnknown,
|
|
307
|
+
now,
|
|
308
|
+
maxLineBytes,
|
|
309
|
+
maxPreviewChars,
|
|
310
|
+
driverName: "opencode-sse"
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
315
|
+
// SSE → 事件(真正的"桥接"就在这里)
|
|
316
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 解析一个 SSE block(`event:` / `data:` / 注释心跳)。坏 JSON 返回 `{data:null,raw}`,不抛。
|
|
320
|
+
* @param {string} raw
|
|
321
|
+
*/
|
|
322
|
+
export function parseSseBlock(raw) {
|
|
323
|
+
const lines = String(raw ?? "").split(/\r?\n/);
|
|
324
|
+
const dataLines = [];
|
|
325
|
+
let eventName = "";
|
|
326
|
+
for (const line of lines) {
|
|
327
|
+
if (!line) continue;
|
|
328
|
+
if (line.startsWith(":")) continue; // 注释/心跳(OpenCode 会发 `: connected` 之类)
|
|
329
|
+
const i = line.indexOf(":");
|
|
330
|
+
const field = i === -1 ? line : line.slice(0, i);
|
|
331
|
+
let value = i === -1 ? "" : line.slice(i + 1);
|
|
332
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
333
|
+
if (field === "data") dataLines.push(value);
|
|
334
|
+
else if (field === "event") eventName = value;
|
|
335
|
+
}
|
|
336
|
+
if (!dataLines.length) return null;
|
|
337
|
+
const dataText = dataLines.join("\n");
|
|
338
|
+
try {
|
|
339
|
+
return { event: eventName, data: JSON.parse(dataText) };
|
|
340
|
+
} catch {
|
|
341
|
+
return { event: eventName, data: null, raw: dataText };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 增量 SSE 解析器:`push(chunk)` 返回本次凑齐的消息;跨 chunk 的半截数据留在缓冲区。
|
|
347
|
+
* @param {{onMessage?:(m:{event:string,data:any,raw?:string})=>void}} [opts]
|
|
348
|
+
*/
|
|
349
|
+
export function createSseParser({ onMessage } = {}) {
|
|
350
|
+
let buffer = "";
|
|
351
|
+
const emit = (msg) => {
|
|
352
|
+
if (!msg) return null;
|
|
353
|
+
if (typeof onMessage === "function") {
|
|
354
|
+
try {
|
|
355
|
+
onMessage(msg);
|
|
356
|
+
} catch {
|
|
357
|
+
/* 回调抛错不影响解析 */
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return msg;
|
|
361
|
+
};
|
|
362
|
+
return {
|
|
363
|
+
push(chunk) {
|
|
364
|
+
buffer += String(chunk ?? "");
|
|
365
|
+
const out = [];
|
|
366
|
+
let idx;
|
|
367
|
+
// SSE 事件以空行分隔;兼容 \r\n
|
|
368
|
+
while ((idx = buffer.search(/\r?\n\r?\n/)) !== -1) {
|
|
369
|
+
const raw = buffer.slice(0, idx);
|
|
370
|
+
const match = /\r?\n\r?\n/.exec(buffer.slice(idx));
|
|
371
|
+
buffer = buffer.slice(idx + (match ? match[0].length : 2));
|
|
372
|
+
const msg = emit(parseSseBlock(raw));
|
|
373
|
+
if (msg) out.push(msg);
|
|
374
|
+
}
|
|
375
|
+
return out;
|
|
376
|
+
},
|
|
377
|
+
/** 流结束:把缓冲区里最后一个没有空行结尾的 block 也解析掉。 */
|
|
378
|
+
flush() {
|
|
379
|
+
const msg = buffer.trim() ? emit(parseSseBlock(buffer)) : null;
|
|
380
|
+
buffer = "";
|
|
381
|
+
return msg ? [msg] : [];
|
|
382
|
+
},
|
|
383
|
+
buffered() {
|
|
384
|
+
return buffer;
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 把 `GET /event` 的响应流接到驱动上 —— **SSE 桥接的具体做法**(3 行核心逻辑):
|
|
391
|
+
* ① `stream.on("data")` → `parser.push(chunk)`(分帧);
|
|
392
|
+
* ② 每条消息的 `data` 对象 → `driver.writeLine(JSON.stringify(data))`(复用通用驱动);
|
|
393
|
+
* ③ 坏 JSON/未知对象 → 驱动自己的 `unknown` 计数(不吞、不崩)。
|
|
394
|
+
*
|
|
395
|
+
* 用法(未来接到 index/bridge):
|
|
396
|
+
* ```js
|
|
397
|
+
* const driver = createOpenCodeDriver({ sink: createJsonlSink({ filePath }), onEvent });
|
|
398
|
+
* const sse = attachOpenCodeSse({ stream: res, driver, log });
|
|
399
|
+
* // 结束时:sse.detach(); sse.parser.flush();
|
|
400
|
+
* ```
|
|
401
|
+
*
|
|
402
|
+
* @param {{stream:NodeJS.ReadableStream, driver:object, log?:(e:object)=>void}} opts
|
|
403
|
+
* @returns {{detach:()=>void, parser:object, stats:()=>({chunks:number, badBlocks:number})}}
|
|
404
|
+
*/
|
|
405
|
+
export function attachOpenCodeSse({ stream, driver, log } = {}) {
|
|
406
|
+
if (!stream || typeof stream.on !== "function") throw new Error("attachOpenCodeSse: 缺少可读流");
|
|
407
|
+
if (!driver || typeof driver.writeLine !== "function") throw new Error("attachOpenCodeSse: 缺少 driver");
|
|
408
|
+
const logger = typeof log === "function" ? log : () => {};
|
|
409
|
+
const counts = { chunks: 0, badBlocks: 0 };
|
|
410
|
+
const parser = createSseParser({
|
|
411
|
+
onMessage(msg) {
|
|
412
|
+
if (!msg) return;
|
|
413
|
+
if (msg.data == null) {
|
|
414
|
+
counts.badBlocks += 1;
|
|
415
|
+
try {
|
|
416
|
+
logger({ level: "warn", event: "sse.bad-json", rawBytes: byteLength(msg.raw || "") });
|
|
417
|
+
} catch {
|
|
418
|
+
/* ignore */
|
|
419
|
+
}
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
driver.writeLine(JSON.stringify(msg.data));
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
const onData = (chunk) => {
|
|
426
|
+
counts.chunks += 1;
|
|
427
|
+
parser.push(chunk);
|
|
428
|
+
};
|
|
429
|
+
stream.on("data", onData);
|
|
430
|
+
const onEnd = () => parser.flush();
|
|
431
|
+
if (typeof stream.once === "function") stream.once("end", onEnd);
|
|
432
|
+
return {
|
|
433
|
+
parser,
|
|
434
|
+
detach() {
|
|
435
|
+
if (typeof stream.off === "function") stream.off("data", onData);
|
|
436
|
+
else if (typeof stream.removeListener === "function") stream.removeListener("data", onData);
|
|
437
|
+
},
|
|
438
|
+
stats: () => ({ ...counts })
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
443
|
+
// CLI 探测
|
|
444
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
const DEFAULT_OPENCODE_FS = {
|
|
447
|
+
existsSync: (p) => fsSync.existsSync(p),
|
|
448
|
+
statSync: (p) => fsSync.statSync(p),
|
|
449
|
+
realpathSync: (p) => fsSync.realpathSync(p),
|
|
450
|
+
readdirSync: (p) => fsSync.readdirSync(p)
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
/** 能力声明(server API 来自官方文档;serve 开关实测 help)。 */
|
|
454
|
+
export const OPENCODE_CAPABILITIES = Object.freeze({
|
|
455
|
+
serverApi: false, // `opencode serve`(实测 help)
|
|
456
|
+
sse: true, // `GET /event`(官方 server 文档)
|
|
457
|
+
permissionReply: true, // `POST /session/:id/permissions/:permissionID`(官方 server 文档)
|
|
458
|
+
externalApprovalHook: false, // 审批走 HTTP 端点(我们直接答),不需要外部钩子
|
|
459
|
+
approvalChannel: "http" // 审批通道形态
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* 候选:显式 env → PATH → npm 全局包 `opencode-ai`(目录里找 `opencode*` 可执行/JS 入口)。
|
|
464
|
+
* ⚠️ npm 包里二进制命名跨平台不一致(`bin/opencode.exe` 等)——这里按目录扫描,找不到就跳过,
|
|
465
|
+
* 并在 README 里标注"npm 布局未核实"。原生安装器装到 PATH 的那条永远优先。
|
|
466
|
+
*/
|
|
467
|
+
export function buildOpenCodeCandidates({
|
|
468
|
+
env = process.env,
|
|
469
|
+
platform = process.platform,
|
|
470
|
+
execPath = process.execPath,
|
|
471
|
+
fs = DEFAULT_OPENCODE_FS,
|
|
472
|
+
npmRoot = "",
|
|
473
|
+
isExecutable,
|
|
474
|
+
realpath
|
|
475
|
+
} = {}) {
|
|
476
|
+
const out = [];
|
|
477
|
+
const seen = new Set();
|
|
478
|
+
const push = (c) => {
|
|
479
|
+
const key = `${c.command}:${(c.argsPrefix || []).join(",")}`;
|
|
480
|
+
if (seen.has(key)) return;
|
|
481
|
+
seen.add(key);
|
|
482
|
+
out.push(c);
|
|
483
|
+
};
|
|
484
|
+
const isExec =
|
|
485
|
+
typeof isExecutable === "function"
|
|
486
|
+
? isExecutable
|
|
487
|
+
: (p) => {
|
|
488
|
+
try {
|
|
489
|
+
const st = fs.statSync(p);
|
|
490
|
+
if (!st.isFile()) return false;
|
|
491
|
+
if (platform === "win32") return /\.exe$/i.test(p);
|
|
492
|
+
return (Number(st.mode) & 0o111) !== 0;
|
|
493
|
+
} catch {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
const real = typeof realpath === "function" ? realpath : (p) => {
|
|
498
|
+
try {
|
|
499
|
+
return fs.realpathSync(p);
|
|
500
|
+
} catch {
|
|
501
|
+
return p;
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const explicit = String(env?.[OPENCODE_BIN_ENV] ?? "").trim();
|
|
506
|
+
if (explicit) {
|
|
507
|
+
if (/\.(c|m)?js$/i.test(explicit)) push({ kind: "explicit-node", command: execPath, argsPrefix: [explicit], pathHint: explicit });
|
|
508
|
+
else push({ kind: "explicit", command: explicit, argsPrefix: [], pathHint: explicit });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
for (const dir of splitPathList(env?.PATH, platform)) {
|
|
512
|
+
const names = platform === "win32" ? ["opencode.exe"] : ["opencode"];
|
|
513
|
+
for (const name of names) {
|
|
514
|
+
const p = path.join(dir, name);
|
|
515
|
+
if (!isExec(p)) continue;
|
|
516
|
+
push({ kind: "path", command: platform === "win32" ? p : real(p), argsPrefix: [], pathHint: p });
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (npmRoot) {
|
|
522
|
+
const binDir = path.join(npmRoot, "opencode-ai", "bin");
|
|
523
|
+
try {
|
|
524
|
+
for (const name of fs.readdirSync(binDir)) {
|
|
525
|
+
if (!/^opencode/i.test(name)) continue;
|
|
526
|
+
const p = path.join(binDir, name);
|
|
527
|
+
if (/\.(c|m)?js$/i.test(name)) push({ kind: "npm-node", command: execPath, argsPrefix: [p], pathHint: p });
|
|
528
|
+
else if (isExec(p)) push({ kind: "npm-native", command: p, argsPrefix: [], pathHint: p });
|
|
529
|
+
}
|
|
530
|
+
} catch {
|
|
531
|
+
/* 没装 npm 包就跳过 */
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return out;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** 探 serve 能力位:`opencode serve --help`(不启动服务、不调模型)。 */
|
|
538
|
+
export function probeOpenCodeCaps(candidate, { spawnSync = nodeSpawnSync, timeoutMs = 10_000 } = {}) {
|
|
539
|
+
const caps = { ...OPENCODE_CAPABILITIES, serverApi: false };
|
|
540
|
+
try {
|
|
541
|
+
const r = spawnSync(candidate.command, [...(candidate.argsPrefix || []), "serve", "--help"], {
|
|
542
|
+
encoding: "utf8",
|
|
543
|
+
timeout: timeoutMs,
|
|
544
|
+
windowsHide: true,
|
|
545
|
+
shell: false,
|
|
546
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
547
|
+
});
|
|
548
|
+
const text = `${r?.stdout ?? ""}${r?.stderr ?? ""}`;
|
|
549
|
+
if (r?.error || (typeof r?.status === "number" && r.status !== 0) || !text.trim()) {
|
|
550
|
+
return { ok: false, caps, evidence: {}, reason: `serve --help 失败(${r?.error?.code || r?.status || "空输出"})` };
|
|
551
|
+
}
|
|
552
|
+
caps.serverApi = /--port\b/.test(text) || /--hostname\b/.test(text);
|
|
553
|
+
return {
|
|
554
|
+
ok: true,
|
|
555
|
+
caps,
|
|
556
|
+
evidence: {
|
|
557
|
+
helpTextLength: text.length,
|
|
558
|
+
matched: { port: /--port\b/.test(text), hostname: /--hostname\b/.test(text), cors: /--cors\b/.test(text) }
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
} catch (err) {
|
|
562
|
+
return { ok: false, caps, evidence: {}, reason: `能力探测异常: ${err?.code || err?.message || err}` };
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* 一站式探测。**永不抛错**。mode:有 server API → `"approval"`(HTTP 审批端点);否则 `"notify-only"`。
|
|
568
|
+
*/
|
|
569
|
+
export function detectOpenCode({
|
|
570
|
+
env = process.env,
|
|
571
|
+
platform = process.platform,
|
|
572
|
+
execPath = process.execPath,
|
|
573
|
+
fs = DEFAULT_OPENCODE_FS,
|
|
574
|
+
spawnSync = nodeSpawnSync,
|
|
575
|
+
execFileSync = nodeExecFileSync,
|
|
576
|
+
npmRoot,
|
|
577
|
+
probeTimeoutMs = 10_000,
|
|
578
|
+
probeCaps = true,
|
|
579
|
+
isExecutable,
|
|
580
|
+
realpath
|
|
581
|
+
} = {}) {
|
|
582
|
+
const warnings = [];
|
|
583
|
+
let root = npmRoot;
|
|
584
|
+
if (root === undefined) {
|
|
585
|
+
root =
|
|
586
|
+
String(env?.[OPENCODE_NPM_ROOT_ENV] ?? "").trim() ||
|
|
587
|
+
String(env?.[NPM_ROOT_ENV] ?? "").trim() ||
|
|
588
|
+
probeNpmRoot({ execFileSync, platform, env, fs, execPath });
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
let candidates;
|
|
592
|
+
try {
|
|
593
|
+
candidates = buildOpenCodeCandidates({ env, platform, execPath, fs, npmRoot: root, isExecutable, realpath });
|
|
594
|
+
} catch (err) {
|
|
595
|
+
return { ok: false, status: "error", mode: "notify-only", reason: `构建候选异常: ${err?.message || err}`, caps: { ...OPENCODE_CAPABILITIES } };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const attempts = [];
|
|
599
|
+
for (const c of candidates) {
|
|
600
|
+
const r = probeCandidateVersion(c, { spawnSync, timeoutMs: probeTimeoutMs });
|
|
601
|
+
attempts.push({ kind: c.kind, path: c.pathHint || c.command, ok: r.ok, reason: r.ok ? "" : r.reason });
|
|
602
|
+
if (!r.ok) continue;
|
|
603
|
+
const capsResult = probeCaps ? probeOpenCodeCaps(c, { spawnSync, timeoutMs: probeTimeoutMs }) : { ok: true, caps: { ...OPENCODE_CAPABILITIES, serverApi: true }, evidence: {} };
|
|
604
|
+
if (!capsResult.ok) warnings.push({ code: "caps-probe-failed", message: capsResult.reason });
|
|
605
|
+
return {
|
|
606
|
+
ok: true,
|
|
607
|
+
status: "ok",
|
|
608
|
+
mode: capsResult.caps?.serverApi ? "approval" : "notify-only",
|
|
609
|
+
approvalChannel: capsResult.caps?.serverApi ? "http" : "",
|
|
610
|
+
cli: { kind: c.kind, path: c.pathHint || c.command, command: c.command, argsPrefix: c.argsPrefix || [] },
|
|
611
|
+
version: { ...r.version, text: formatVersion(r.version) },
|
|
612
|
+
caps: capsResult.caps,
|
|
613
|
+
capsEvidence: capsResult.evidence,
|
|
614
|
+
attempts,
|
|
615
|
+
warnings
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
ok: false,
|
|
621
|
+
status: candidates.length ? "version-unknown" : "cli-missing",
|
|
622
|
+
mode: "notify-only",
|
|
623
|
+
reason: candidates.length ? "找到了 opencode 候选但都拿不到版本" : "没有找到 OpenCode CLI(未安装,或不在 PATH / npm 全局目录里)",
|
|
624
|
+
caps: { ...OPENCODE_CAPABILITIES },
|
|
625
|
+
attempts,
|
|
626
|
+
warnings
|
|
627
|
+
};
|
|
628
|
+
}
|