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.
@@ -0,0 +1,503 @@
1
+ /**
2
+ * Gemini CLI 适配骨架(**ACP / JSON-RPC over stdio**,M2 第 5 周)。
3
+ *
4
+ * ## 为什么 ACP 这家最省事
5
+ *
6
+ * ACP 的 stdio 传输规定:**一条消息一行 JSON-RPC、行内不许有换行**(官方 transports 规范原文:
7
+ * "Messages are delimited by newlines (`\n`), and MUST NOT contain embedded newlines")。
8
+ * ⇒ 它和 Claude/Codex 的 jsonl 输出**同构**,可以**直接复用 `createJsonlDriver`**,只需要换一个 `mapLine`。
9
+ * 这是"2 个驱动覆盖 4 家"里 ACP 那半个驱动的最小落地。
10
+ *
11
+ * ## 依据来源(全部官方,2026-11 读取)
12
+ *
13
+ * | 依据 | 出处 |
14
+ * |---|---|
15
+ * | stdio 传输:"一行一条 JSON-RPC" | <https://agentclientprotocol.com/protocol/v1/transports> |
16
+ * | `session/update` 的全部 `sessionUpdate` 变体 | <https://agentclientprotocol.com/protocol/v1/prompt-turn> |
17
+ * | `agent_message_chunk` / `plan` / `notice` 的 JSON 样本 | 同上(测试夹具逐字摘录) |
18
+ * | `tool_call` / `tool_call_update` / `session/request_permission` 的 JSON 样本与字段 | <https://agentclientprotocol.com/protocol/v1/tool-calls> |
19
+ * | stopReason 闭集(end_turn / max_tokens / max_turn_requests / refusal / cancelled) | <https://agentclientprotocol.com/protocol/v1/prompt-turn#stop-reasons> |
20
+ * | 本机 `gemini --help` 实测 | `gemini 0.31.0`(Homebrew 安装):ACP 开关当前叫 **`--experimental-acp`**(不是文档里的 `--acp`) |
21
+ *
22
+ * ## 未核实项(**不要当事实**)
23
+ *
24
+ * 1. 本机 `gemini 0.31.0` 的 ACP 实现代际(文档同时有 ACP v1/v2)—— 本模块只按 **v1** 语义映射;
25
+ * 真机握手时以 `initialize` 的协议版本为准。
26
+ * 2. `usage_update` / `notice` / `session_info_update` 等较新变体的实际字段 —— 本模块对不认识的变体
27
+ * **忽略而不是报错**(`ok:true, events:[]`),避免被判成"未知行"。
28
+ * 3. `session/prompt` 的**失败**是"JSON-RPC error 响应"还是"stopReason"—— 本模块把 JSON-RPC error
29
+ * 映射成 `status(error)`;要精确成 `turn.end` 需要维护"哪个 id 是 prompt"的状态机(下一步做)。
30
+ * 4. 审批:ACP 的 `session/request_permission` 是**协议内的双向请求**(我们作为 client 必须回)。
31
+ * 本模块只把它映射成 `status(waiting_approval)`;真正的"回执写回"要接到 PermissionBridge/协调器(下一步)。
32
+ */
33
+
34
+ import fsSync from "node:fs";
35
+ import path from "node:path";
36
+ import { spawnSync as nodeSpawnSync, execFileSync as nodeExecFileSync } from "node:child_process";
37
+
38
+ import {
39
+ DEFAULT_MAX_LINE_BYTES,
40
+ DEFAULT_MAX_PREVIEW_CHARS,
41
+ EVENT_TYPES,
42
+ byteLength,
43
+ createJsonlDriver,
44
+ frameLine,
45
+ parseLineJson,
46
+ previewOf
47
+ } from "./events.mjs";
48
+ import { NPM_ROOT_ENV, probeCandidateVersion, probeNpmRoot, splitPathList } from "./detect.mjs";
49
+ import { formatVersion } from "./version.mjs";
50
+
51
+ /** 显式指定 gemini CLI。 */
52
+ export const GEMINI_BIN_ENV = "DSH_AGENT_GEMINI_BIN";
53
+ /** 显式指定 npm 全局根(不设则回落到通用 `DSH_AGENT_NPM_ROOT` / 自动探测)。 */
54
+ export const GEMINI_NPM_ROOT_ENV = "DSH_AGENT_GEMINI_NPM_ROOT";
55
+
56
+ /** ACP 的 `sessionUpdate` 变体(v1 文档全集;本模块只映射前 6 个,其余忽略)。 */
57
+ export const ACP_SESSION_UPDATE_VARIANTS = Object.freeze([
58
+ "user_message_chunk",
59
+ "agent_message_chunk",
60
+ "agent_thought_chunk",
61
+ "tool_call",
62
+ "tool_call_update",
63
+ "plan",
64
+ "available_commands_update",
65
+ "current_mode_update",
66
+ "config_option_update",
67
+ "session_info_update",
68
+ "usage_update",
69
+ "notice",
70
+ "compaction_update",
71
+ "compaction_summary_chunk"
72
+ ]);
73
+
74
+ /** ACP 工具分类词表(设计稿 §2.3 已直接沿用这套)。 */
75
+ export const ACP_TOOL_KINDS = Object.freeze([
76
+ "read", "edit", "delete", "move", "search", "execute", "think", "fetch", "switch_mode", "other"
77
+ ]);
78
+
79
+ /** ACP stopReason → 归一化 turn.end reason。 */
80
+ export function stopReasonOf(stopReason) {
81
+ switch (String(stopReason || "")) {
82
+ case "end_turn":
83
+ return "completed";
84
+ case "max_tokens":
85
+ return "max-tokens";
86
+ case "max_turn_requests":
87
+ return "max-turns";
88
+ case "refusal":
89
+ return "refused";
90
+ case "cancelled":
91
+ return "aborted";
92
+ default:
93
+ return "completed";
94
+ }
95
+ }
96
+
97
+ function mapToolKind(kind) {
98
+ const k = String(kind || "");
99
+ return ACP_TOOL_KINDS.includes(k) ? k : "other";
100
+ }
101
+
102
+ /**
103
+ * ACP content(单块或数组)→ 文本。
104
+ *
105
+ * 注意两种官方形状都要吃:
106
+ * · 消息块直接是 `{type:"text", text}`;
107
+ * · `tool_call_update.content` 是 `[{type:"content", content:{type:"text",text}}]`(嵌套一层)。
108
+ */
109
+ function contentText(content) {
110
+ const blocks = Array.isArray(content) ? content : content ? [content] : [];
111
+ return blocks
112
+ .map((c) => {
113
+ if (!c || typeof c !== "object") return "";
114
+ if (typeof c.text === "string") return c.text;
115
+ if (c.content && typeof c.content === "object" && typeof c.content.text === "string") return c.content.text;
116
+ if (typeof c.content === "string") return c.content;
117
+ return "";
118
+ })
119
+ .filter(Boolean)
120
+ .join("");
121
+ }
122
+
123
+ /**
124
+ * 一条 ACP 消息(已解析的 JSON-RPC 对象)→ 归一化事件。
125
+ *
126
+ * 返回 `{ok:true, events}` 或 `{ok:false, reason}`;永不抛错。
127
+ *
128
+ * @param {object} obj
129
+ * @param {{now?:()=>number, maxPreviewChars?:number}} [ctx]
130
+ */
131
+ export function mapGeminiObject(obj, {
132
+ now = () => Date.now(),
133
+ maxPreviewChars = DEFAULT_MAX_PREVIEW_CHARS
134
+ } = {}) {
135
+ if (!obj || typeof obj !== "object") return { ok: false, reason: "not-object" };
136
+ const at = now();
137
+ const base = (type) => ({ v: 1, type, at });
138
+
139
+ // ACP 的传输是 JSON-RPC 2.0;不是这个形状就不是 ACP(防止把 stderr 混进 stdout)
140
+ if (obj.jsonrpc !== "2.0") return { ok: false, reason: "not-jsonrpc" };
141
+
142
+ // ① Agent → Client 通知:session/update
143
+ if (obj.method === "session/update") {
144
+ const params = obj.params || {};
145
+ const update = params.update;
146
+ if (!update || typeof update !== "object") return { ok: false, reason: "update-without-payload" };
147
+ const variant = String(update.sessionUpdate || "");
148
+ switch (variant) {
149
+ case "agent_message_chunk": {
150
+ const text = contentText(update.content);
151
+ if (!text) return { ok: true, events: [] };
152
+ // ACP 的 chunk 是**流式增量**;完整消息由客户端按 messageId 拼
153
+ return { ok: true, events: [{ ...base(EVENT_TYPES.MESSAGE_DELTA), text, final: false }] };
154
+ }
155
+ case "agent_thought_chunk":
156
+ return {
157
+ ok: true,
158
+ events: [{
159
+ ...base(EVENT_TYPES.STATUS),
160
+ status: "thinking",
161
+ // 推理内容不落(同 Codex 的策略),只给字节数
162
+ textBytes: byteLength(contentText(update.content))
163
+ }]
164
+ };
165
+ case "user_message_chunk":
166
+ return { ok: true, events: [] };
167
+ case "tool_call": {
168
+ const raw = update.rawInput;
169
+ return {
170
+ ok: true,
171
+ events: [{
172
+ ...base(EVENT_TYPES.TOOL_CALL),
173
+ toolCallId: String(update.toolCallId || ""),
174
+ name: String(update.name || ""),
175
+ kind: mapToolKind(update.kind),
176
+ status: String(update.status || "pending"),
177
+ // title 是 Agent 自己写的文案,可能夹带命令 → 不落;只落输入字节数
178
+ inputBytes: raw === undefined ? 0 : byteLength(JSON.stringify(raw))
179
+ }]
180
+ };
181
+ }
182
+ case "tool_call_update": {
183
+ const status = String(update.status || "");
184
+ // 只有终态才产出 tool.result;in_progress/pending 的增量不产生事件(避免重复)
185
+ if (status !== "completed" && status !== "failed") return { ok: true, events: [] };
186
+ const text = contentText(update.content);
187
+ return {
188
+ ok: true,
189
+ events: [{
190
+ ...base(EVENT_TYPES.TOOL_RESULT),
191
+ toolCallId: String(update.toolCallId || ""),
192
+ isError: status === "failed",
193
+ outputBytes: byteLength(text),
194
+ outputPreview: previewOf(text, maxPreviewChars)
195
+ }]
196
+ };
197
+ }
198
+ case "plan": {
199
+ const entries = Array.isArray(update.entries) ? update.entries : [];
200
+ return {
201
+ ok: true,
202
+ events: [{
203
+ ...base(EVENT_TYPES.STATUS),
204
+ status: "plan",
205
+ planItems: entries.length,
206
+ planDone: entries.filter((e) => e && String(e.status || "") === "completed").length
207
+ }]
208
+ };
209
+ }
210
+ case "notice":
211
+ return {
212
+ ok: true,
213
+ events: [{
214
+ ...base(EVENT_TYPES.STATUS),
215
+ status: "notice",
216
+ text: previewOf(String(update.message ?? update.text ?? ""), maxPreviewChars)
217
+ }]
218
+ };
219
+ case "usage_update":
220
+ // 字段形状未核实(v1 文档只列了变体名)→ 只报"有更新",不猜字段
221
+ return { ok: true, events: [{ ...base(EVENT_TYPES.STATUS), status: "usage" }] };
222
+ default:
223
+ // available_commands_update / current_mode_update / config_option_update /
224
+ // session_info_update / compaction_* —— 与 UI 的 5 类事件无关,忽略而非报错
225
+ return { ok: true, events: [] };
226
+ }
227
+ }
228
+
229
+ // ② Agent → Client 双向请求:session/request_permission(审批)
230
+ if (obj.method === "session/request_permission") {
231
+ const params = obj.params || {};
232
+ const toolCall = params.toolCall || {};
233
+ const options = Array.isArray(params.options) ? params.options : [];
234
+ return {
235
+ ok: true,
236
+ events: [{
237
+ ...base(EVENT_TYPES.STATUS),
238
+ status: "waiting_approval",
239
+ // JSON-RPC 的 id 就是这条审批的回执 id(client 必须用它回 outcome)
240
+ requestId: String(obj.id ?? ""),
241
+ toolCallId: String(toolCall.toolCallId || ""),
242
+ toolKind: mapToolKind(toolCall.kind),
243
+ // 选项只暴露 kind(协议枚举),不暴露 agent 写的 name 文案
244
+ optionKinds: options.map((o) => String(o?.kind || "")).filter(Boolean)
245
+ }]
246
+ };
247
+ }
248
+
249
+ // ③ 响应:session/prompt 的结果(stopReason)
250
+ if (obj.result && typeof obj.result === "object" && "stopReason" in obj.result) {
251
+ const raw = String(obj.result.stopReason || "");
252
+ return {
253
+ ok: true,
254
+ events: [{
255
+ ...base(EVENT_TYPES.TURN_END),
256
+ reason: stopReasonOf(raw),
257
+ isError: false,
258
+ rawStopReason: raw
259
+ }]
260
+ };
261
+ }
262
+
263
+ // ④ JSON-RPC 错误响应(可能是 prompt 失败,也可能是某次请求失败;精确归属需要状态机,下一步)
264
+ if (obj.error) {
265
+ return {
266
+ ok: true,
267
+ events: [{
268
+ ...base(EVENT_TYPES.STATUS),
269
+ status: "error",
270
+ scope: "jsonrpc",
271
+ message: previewOf(String(obj.error?.message || JSON.stringify(obj.error)), maxPreviewChars)
272
+ }]
273
+ };
274
+ }
275
+
276
+ if (obj.method) return { ok: false, reason: `unknown-method:${String(obj.method)}` };
277
+ return { ok: false, reason: "unknown-shape" };
278
+ }
279
+
280
+ /** 一行(字符串)映射。 */
281
+ export function mapGeminiLine(line, {
282
+ now = () => Date.now(),
283
+ maxLineBytes = DEFAULT_MAX_LINE_BYTES,
284
+ maxPreviewChars = DEFAULT_MAX_PREVIEW_CHARS
285
+ } = {}) {
286
+ const framed = frameLine(line, { maxLineBytes });
287
+ if (!framed.ok) return framed;
288
+ const parsed = parseLineJson(framed.raw);
289
+ if (!parsed.ok) return parsed;
290
+ return mapGeminiObject(parsed.obj, { now, maxPreviewChars });
291
+ }
292
+
293
+ /** 现成的 Gemini 事件驱动(ACP stdio 的一行一条 JSON-RPC 直接喂进来)。 */
294
+ export function createGeminiDriver({
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: mapGeminiObject,
304
+ sink,
305
+ onEvent,
306
+ onUnknown,
307
+ now,
308
+ maxLineBytes,
309
+ maxPreviewChars,
310
+ driverName: "gemini-acp"
311
+ });
312
+ }
313
+
314
+ // ─────────────────────────────────────────────────────────────────────────────
315
+ // CLI 探测
316
+ // ─────────────────────────────────────────────────────────────────────────────
317
+
318
+ const DEFAULT_GEMINI_FS = {
319
+ existsSync: (p) => fsSync.existsSync(p),
320
+ statSync: (p) => fsSync.statSync(p),
321
+ realpathSync: (p) => fsSync.realpathSync(p)
322
+ };
323
+
324
+ /** ACP 能力声明(detectGemini 会按本机 help 实测覆盖)。 */
325
+ export const GEMINI_CAPABILITIES = Object.freeze({
326
+ acp: false, // 是否支持 ACP 模式(实测 help)
327
+ acpFlag: "", // 实际开关名(`--acp` 或 `--experimental-acp`)
328
+ jsonOutput: false, // `-p --output-format json` 一类的非交互输出【未核实】
329
+ externalApprovalHook: false, // ACP 不需要外部钩子:审批在协议内(session/request_permission)
330
+ acpApproval: false // 协议层支持审批(acp=true 时为 true;接线未做)
331
+ });
332
+
333
+ /**
334
+ * 候选:显式 env → PATH → npm 全局包(`@google/gemini-cli/bundle/gemini.js`)。
335
+ * 注意:Homebrew 装的 gemini 是 `Cellar/.../dist/index.js`(可执行 shebang),
336
+ * PATH 那一步 realpath 后就是它,能直接 spawn。
337
+ */
338
+ export function buildGeminiCandidates({
339
+ env = process.env,
340
+ platform = process.platform,
341
+ execPath = process.execPath,
342
+ fs = DEFAULT_GEMINI_FS,
343
+ npmRoot = "",
344
+ isExecutable,
345
+ realpath
346
+ } = {}) {
347
+ const out = [];
348
+ const seen = new Set();
349
+ const push = (c) => {
350
+ const key = `${c.command}:${(c.argsPrefix || []).join(",")}`;
351
+ if (seen.has(key)) return;
352
+ seen.add(key);
353
+ out.push(c);
354
+ };
355
+ const isExec =
356
+ typeof isExecutable === "function"
357
+ ? isExecutable
358
+ : (p) => {
359
+ try {
360
+ const st = fs.statSync(p);
361
+ if (!st.isFile()) return false;
362
+ if (platform === "win32") return /\.exe$/i.test(p);
363
+ return (Number(st.mode) & 0o111) !== 0;
364
+ } catch {
365
+ return false;
366
+ }
367
+ };
368
+ const real = typeof realpath === "function" ? realpath : (p) => {
369
+ try {
370
+ return fs.realpathSync(p);
371
+ } catch {
372
+ return p;
373
+ }
374
+ };
375
+
376
+ const explicit = String(env?.[GEMINI_BIN_ENV] ?? "").trim();
377
+ if (explicit) {
378
+ if (/\.(c|m)?js$/i.test(explicit)) push({ kind: "explicit-node", command: execPath, argsPrefix: [explicit], pathHint: explicit });
379
+ else push({ kind: "explicit", command: explicit, argsPrefix: [], pathHint: explicit });
380
+ }
381
+
382
+ for (const dir of splitPathList(env?.PATH, platform)) {
383
+ const names = platform === "win32" ? ["gemini.exe"] : ["gemini"];
384
+ for (const name of names) {
385
+ const p = path.join(dir, name);
386
+ if (!isExec(p)) continue;
387
+ push({ kind: "path", command: platform === "win32" ? p : real(p), argsPrefix: [], pathHint: p });
388
+ break;
389
+ }
390
+ }
391
+
392
+ if (npmRoot) {
393
+ const js = path.join(npmRoot, "@google", "gemini-cli", "bundle", "gemini.js");
394
+ try {
395
+ if (fs.statSync(js).isFile()) push({ kind: "npm-node", command: execPath, argsPrefix: [js], pathHint: js });
396
+ } catch {
397
+ /* 不存在就跳过 */
398
+ }
399
+ }
400
+ return out;
401
+ }
402
+
403
+ /** 探 ACP 能力位:跑一次 `--help`(**不启动模型**)。 */
404
+ export function probeGeminiCaps(candidate, { spawnSync = nodeSpawnSync, timeoutMs = 10_000 } = {}) {
405
+ const caps = { ...GEMINI_CAPABILITIES };
406
+ try {
407
+ const r = spawnSync(candidate.command, [...(candidate.argsPrefix || []), "--help"], {
408
+ encoding: "utf8",
409
+ timeout: timeoutMs,
410
+ windowsHide: true,
411
+ shell: false,
412
+ stdio: ["ignore", "pipe", "pipe"]
413
+ });
414
+ const text = `${r?.stdout ?? ""}${r?.stderr ?? ""}`;
415
+ if (r?.error || (typeof r?.status === "number" && r.status !== 0) || !text.trim()) {
416
+ return { ok: false, caps, evidence: { helpTextLength: text.length }, reason: `--help 失败(${r?.error?.code || r?.status || "空输出"})` };
417
+ }
418
+ const hasAcp = /--acp\b/.test(text);
419
+ const hasExperimental = /--experimental-acp\b/.test(text);
420
+ caps.acp = hasAcp || hasExperimental;
421
+ caps.acpFlag = hasAcp ? "--acp" : hasExperimental ? "--experimental-acp" : "";
422
+ caps.acpApproval = caps.acp;
423
+ caps.jsonOutput = /--output-format\b/.test(text);
424
+ return {
425
+ ok: true,
426
+ caps,
427
+ evidence: { helpTextLength: text.length, matched: { acp: hasAcp, experimentalAcp: hasExperimental, outputFormat: caps.jsonOutput } }
428
+ };
429
+ } catch (err) {
430
+ return { ok: false, caps, evidence: {}, reason: `能力探测异常: ${err?.code || err?.message || err}` };
431
+ }
432
+ }
433
+
434
+ /**
435
+ * 一站式探测:找 CLI → 版本 → ACP 能力位。**永不抛错**。
436
+ *
437
+ * mode:有 ACP → `"approval"`(协议内审批,接线待做);没有 → `"notify-only"`。
438
+ */
439
+ export function detectGemini({
440
+ env = process.env,
441
+ platform = process.platform,
442
+ execPath = process.execPath,
443
+ fs = DEFAULT_GEMINI_FS,
444
+ spawnSync = nodeSpawnSync,
445
+ execFileSync = nodeExecFileSync,
446
+ npmRoot,
447
+ probeTimeoutMs = 10_000,
448
+ probeCaps = true,
449
+ isExecutable,
450
+ realpath
451
+ } = {}) {
452
+ const warnings = [];
453
+ let root = npmRoot;
454
+ if (root === undefined) {
455
+ root =
456
+ String(env?.[GEMINI_NPM_ROOT_ENV] ?? "").trim() ||
457
+ String(env?.[NPM_ROOT_ENV] ?? "").trim() ||
458
+ probeNpmRoot({ execFileSync, platform, env, fs, execPath });
459
+ }
460
+
461
+ let candidates;
462
+ try {
463
+ candidates = buildGeminiCandidates({ env, platform, execPath, fs, npmRoot: root, isExecutable, realpath });
464
+ } catch (err) {
465
+ return { ok: false, status: "error", mode: "notify-only", reason: `构建候选异常: ${err?.message || err}`, caps: { ...GEMINI_CAPABILITIES } };
466
+ }
467
+
468
+ const attempts = [];
469
+ for (const c of candidates) {
470
+ const r = probeCandidateVersion(c, { spawnSync, timeoutMs: probeTimeoutMs });
471
+ attempts.push({ kind: c.kind, path: c.pathHint || c.command, ok: r.ok, reason: r.ok ? "" : r.reason });
472
+ if (!r.ok) continue;
473
+ const capsResult = probeCaps ? probeGeminiCaps(c, { spawnSync, timeoutMs: probeTimeoutMs }) : { ok: true, caps: { ...GEMINI_CAPABILITIES }, evidence: {} };
474
+ if (!capsResult.ok) warnings.push({ code: "caps-probe-failed", message: capsResult.reason });
475
+ if (capsResult.caps?.acpFlag === "--experimental-acp") {
476
+ warnings.push({ code: "acp-experimental", message: "本机 ACP 开关是 --experimental-acp(实验性),协议代际需实测握手确认" });
477
+ }
478
+ return {
479
+ ok: true,
480
+ status: "ok",
481
+ mode: capsResult.caps?.acp ? "approval" : "notify-only",
482
+ approvalChannel: capsResult.caps?.acp ? "acp-jsonrpc" : "",
483
+ cli: { kind: c.kind, path: c.pathHint || c.command, command: c.command, argsPrefix: c.argsPrefix || [] },
484
+ version: { ...r.version, text: formatVersion(r.version) },
485
+ caps: capsResult.caps,
486
+ capsEvidence: capsResult.evidence,
487
+ attempts,
488
+ warnings
489
+ };
490
+ }
491
+
492
+ return {
493
+ ok: false,
494
+ status: candidates.length ? "version-unknown" : "cli-missing",
495
+ mode: "notify-only",
496
+ reason: candidates.length
497
+ ? "找到了 gemini 候选但都拿不到版本"
498
+ : "没有找到 Gemini CLI(未安装,或不在 PATH / npm 全局目录里)",
499
+ caps: { ...GEMINI_CAPABILITIES },
500
+ attempts,
501
+ warnings
502
+ };
503
+ }