@hope_phenom/dsh-plugin-remote-use 0.0.0-stage → 0.2.24

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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +207 -2
  3. package/README.zh.md +195 -0
  4. package/assets/icon.svg +47 -0
  5. package/cordis.patch.yml +41 -0
  6. package/lib/bridge/acp-bridge.js +905 -0
  7. package/lib/bridge/peer-connection.js +730 -0
  8. package/lib/client.js +2842 -0
  9. package/lib/core/acp-model-overlay.js +121 -0
  10. package/lib/core/approval-queue.js +216 -0
  11. package/lib/core/audit-log.js +322 -0
  12. package/lib/core/desktop-notify.js +173 -0
  13. package/lib/core/environment-prompt.js +76 -0
  14. package/lib/core/relay-supervisor.js +278 -0
  15. package/lib/core/review-engine.js +679 -0
  16. package/lib/core/sandbox-diagnosis.js +258 -0
  17. package/lib/core/sandbox-probe.js +310 -0
  18. package/lib/core/self-acp-spec.js +117 -0
  19. package/lib/core/selfcheck-runner.js +150 -0
  20. package/lib/core/settings-store.js +226 -0
  21. package/lib/core/shot-gc.js +334 -0
  22. package/lib/core/task-cwd.js +50 -0
  23. package/lib/core/task-memory.js +163 -0
  24. package/lib/core/task-registry.js +238 -0
  25. package/lib/core/toolcall-cache.js +206 -0
  26. package/lib/core/transfer-queue.js +58 -0
  27. package/lib/core/transfer.js +1144 -0
  28. package/lib/core/worker-arming.js +334 -0
  29. package/lib/index.js +2207 -0
  30. package/lib/provider/out-of-process-helpers.js +76 -0
  31. package/lib/provider/remote-provider.js +261 -0
  32. package/lib/tools/mediator-shim.mjs +180 -0
  33. package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
  34. package/lib/tools/remote-tools.js +680 -0
  35. package/lib/tools/selfcheck-fix-prompt.md +206 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +72 -4
  39. package/slave/acp-profile-approval.patch.yml +66 -0
  40. package/slave/file-transfer.mjs +552 -0
  41. package/slave/integrity-probe.ps1 +99 -0
  42. package/slave/make-launch-spec.mjs +184 -0
  43. package/slave/net-address.mjs +138 -0
  44. package/slave/node_modules/.package-lock.json +26 -0
  45. package/slave/node_modules/ws/LICENSE +20 -0
  46. package/slave/node_modules/ws/README.md +548 -0
  47. package/slave/node_modules/ws/browser.js +8 -0
  48. package/slave/node_modules/ws/index.js +22 -0
  49. package/slave/node_modules/ws/lib/buffer-util.js +131 -0
  50. package/slave/node_modules/ws/lib/constants.js +19 -0
  51. package/slave/node_modules/ws/lib/event-target.js +292 -0
  52. package/slave/node_modules/ws/lib/extension.js +203 -0
  53. package/slave/node_modules/ws/lib/limiter.js +55 -0
  54. package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
  55. package/slave/node_modules/ws/lib/receiver.js +743 -0
  56. package/slave/node_modules/ws/lib/sender.js +607 -0
  57. package/slave/node_modules/ws/lib/stream.js +161 -0
  58. package/slave/node_modules/ws/lib/subprotocol.js +62 -0
  59. package/slave/node_modules/ws/lib/validation.js +152 -0
  60. package/slave/node_modules/ws/lib/websocket-server.js +562 -0
  61. package/slave/node_modules/ws/lib/websocket.js +1421 -0
  62. package/slave/node_modules/ws/package.json +74 -0
  63. package/slave/node_modules/ws/wrapper.mjs +21 -0
  64. package/slave/pairing.mjs +175 -0
  65. package/slave/permission-pairing.mjs +240 -0
  66. package/slave/prune-shots.mjs +170 -0
  67. package/slave/relay.mjs +1351 -0
  68. package/slave/repair-workspace-integrity.ps1 +75 -0
  69. package/slave/screenshot.ps1 +114 -0
  70. package/slave/selfcheck.ps1 +350 -0
  71. package/slave/stage-app.ps1 +116 -0
  72. package/slave/workspace-integrity.mjs +157 -0
@@ -0,0 +1,905 @@
1
+ /**
2
+ * 【B】ACP over WebSocket 客户端桥。
3
+ *
4
+ * 契约来源:`docs/INTERFACES.md` 的【B】节 + `docs/DESIGN.md` §13.1–13.6 / §8.4。
5
+ * 签名不得改动;本文件只做增量(见文件末尾注释与交付说明)。
6
+ *
7
+ * 三个硬行为(INTERFACES【B】):
8
+ * 1. 停止原因映射:prompt 的 JSON-RPC reject → 'error'(diagnostic 取其 message,
9
+ * 除非本 turn 收到过本地 cancel() → 'aborted',见下);cancelled → 'aborted';
10
+ * refusal → 'refusal';max_tokens → 'max-tokens';否则 'completed'。
11
+ * 2. 审批超时兜底:approvalTimeoutMs 到点自己回 reject-once,不依赖调用方守约,
12
+ * 且绝不抛错打断 turn(DESIGN §8.4)。
13
+ * 3. 缓存未命中可见:onApproval 收到的 toolName/rawInput 可能是 undefined,照样上报,
14
+ * 决策留给 review-engine,bridge 不做决策。
15
+ *
16
+ * 取消语义(硬行为 1 的补充,Lead 裁决):
17
+ * 一次 turn 内收到过本地 `cancel()` 后,如果 prompt 以 JSON-RPC reject 结束(不同实现
18
+ * 可能用不同 error code,例如 -32800),映射为 `'aborted'` 而不是 `'error'`,原 message
19
+ * 仍保留在 diagnostic 里。判据是「本 turn 是否收到过本地 cancel()」,**不看 code**;
20
+ * `remote_interrupt` 依赖这个区分,否则模型会把「我们自己取消的」误当失败去重试。
21
+ *
22
+ * 回调归属(集成缝修正,INTERFACES【B】的增量):
23
+ * `startTask({ cwd, prompt, onUpdate, onApproval })` 可按任务覆盖构造级默认回调
24
+ * (`onUpdate`/`onApproval` 仍在构造函数里可用,是 INTERFACES 原契约)。
25
+ * 两者在每次 startTask 里解析一次并存进当前任务记录,事件与审批分发**只读任务记录**,
26
+ * 绝不跨任务串台;任务结束(含 throw)即随任务记录一起丢弃,下一个任务必定重新解析。
27
+ * 两者都不给时行为不变:没有 onApproval → fail closed 回 reject-once。
28
+ * 注:任务结束后到达的迟到 update 没有归属者,直接丢弃(turn 已结束、TaskResult 已快照)。
29
+ *
30
+ * 已知代价(U6):每个任务结束都 best-effort 发一次 `session/close`(失败静默),
31
+ * 因此 V1 **没有 session 复用** —— 每任务冷启动约 1–2s。这是有意取舍(不泄漏 session),
32
+ * 不是遗漏;要复用需配合 `session/resume`。
33
+ *
34
+ * 实测事实(DESIGN §13.3–13.5):
35
+ * - `session/prompt` 的 result 只有 `{stopReason}`;正文只能从
36
+ * `session/update` 的 `agent_message_chunk` 累积。
37
+ * - `tool_call` 的 `title` 就是工具名,`rawInput` 是完整参数。
38
+ * - `session/request_permission` 的 params 只带 `{sessionId, toolCall:{toolCallId}, options}`。
39
+ */
40
+ import * as acp from '@agentclientprotocol/sdk'
41
+ import { createWebSocketStream } from '@agentclientprotocol/sdk/experimental/ws-client'
42
+ import { WebSocket as WsWebSocket } from 'ws'
43
+
44
+ /**
45
+ * 默认审批超时(DESIGN §8.4 硬性要求)。
46
+ *
47
+ * 180 秒而不是 90 秒:2026-10-14 实测,这个窗口是"人能不能看见"的唯一约束,而 88 秒
48
+ * 里没人盯屏幕就等于静默拒绝(审计里连着六次 `by=timeout`)。加长窗口不放松任何东西 ——
49
+ * 超时依然是拒绝,它只是让"看见"这件事来得及。
50
+ */
51
+ export const DEFAULT_APPROVAL_TIMEOUT_MS = 180_000
52
+
53
+ /** ACP 审批应答的 optionId(DESIGN §2.3,ACP 硬编码两个值)。 */
54
+ export const OPTION_ALLOW_ONCE = 'allow-once'
55
+ export const OPTION_REJECT_ONCE = 'reject-once'
56
+
57
+ /** 连接名,仅用于 SDK 诊断。 */
58
+ const DEFAULT_CLIENT_NAME = 'dsh-plugin-remote-use'
59
+
60
+ /**
61
+ * 插件自定义 RPC 的命名空间。
62
+ *
63
+ * 从机中继在把帧转发给 `dsh --profile acp` 之前先按这个前缀拦截,所以这些方法
64
+ * **不经过 ACP agent**,也不需要扩 ACP 词汇表。前缀与写入权限一起构成边界:
65
+ * 未知的 `dsh-remote/*` 方法由中继显式回 `-32601`,绝不静默转发
66
+ * (静默转发会让未来的协议新增看起来像 no-op)。
67
+ */
68
+ export const PLUGIN_RPC_PREFIX = 'dsh-remote/'
69
+
70
+ /** 插件 RPC 的默认超时。自检单独放宽,见下。 */
71
+ const DEFAULT_PLUGIN_RPC_TIMEOUT_MS = 15_000
72
+
73
+ /** 自检要跑 PowerShell 并做 P/Invoke 探测(实测约 1.5–2.5s),给更宽的预算。 */
74
+ const DEFAULT_SELFCHECK_TIMEOUT_MS = 60_000
75
+
76
+ /**
77
+ * 停止原因映射(硬行为 1)。
78
+ *
79
+ * 注意:DSH 把 `blocked`/`error` 都映射成 `end_turn`(DESIGN §13.5),
80
+ * 所以失败判定必须靠传输层 —— prompt 的 JSON-RPC reject 由 {@link AcpBridge.startTask}
81
+ * 映射成 `'error'`,不经过本函数。
82
+ *
83
+ * @param {unknown} stopReason ACP `stopReason`
84
+ * @returns {'completed'|'error'|'aborted'|'refusal'|'max-tokens'}
85
+ */
86
+ export function mapStopReason(stopReason) {
87
+ switch (stopReason) {
88
+ case 'cancelled':
89
+ return 'aborted'
90
+ case 'refusal':
91
+ return 'refusal'
92
+ case 'max_tokens':
93
+ return 'max-tokens'
94
+ // end_turn / max_turn_requests / 未知 / 缺失 → completed(INTERFACES 明文「否则」)
95
+ default:
96
+ return 'completed'
97
+ }
98
+ }
99
+
100
+ /**
101
+ * bridge 内部最小版 toolCall 缓存(等价物)。
102
+ *
103
+ * 刻意不 import `lib/core/toolcall-cache.js`(并行实现,写入范围互斥)。
104
+ * 只服务两件事:审批时的 `toolName`/`rawInput` 回填,以及 TaskResult.toolCalls 清单。
105
+ */
106
+ export class BridgeToolCallCache {
107
+ /** @type {Map<string, {toolCallId: string, toolName: string|undefined, rawInput: unknown, status: string|undefined}>} */
108
+ #entries = new Map()
109
+ #misses = 0
110
+ #evictions = 0
111
+ #maxEntries
112
+
113
+ /** @param {{ maxEntries?: number }} [opts] */
114
+ constructor(opts = {}) {
115
+ const maxEntries = opts?.maxEntries ?? 512
116
+ this.#maxEntries = Number.isFinite(maxEntries) && maxEntries > 0 ? Math.floor(maxEntries) : 512
117
+ }
118
+
119
+ /**
120
+ * 记录 `tool_call` 通知。`title` 即工具名(DESIGN §13.4)。
121
+ * 同 id 重复 note 时以本次给出的字段为准,缺省字段保留旧值。
122
+ */
123
+ note(toolCallId, { title, rawInput, status } = {}) {
124
+ this.#merge(toolCallId, { title, rawInput, status })
125
+ }
126
+
127
+ /** 记录 `tool_call_update`;不覆盖未显式给出的字段。 */
128
+ update(toolCallId, { status, title, rawInput } = {}) {
129
+ this.#merge(toolCallId, { title, rawInput, status })
130
+ }
131
+
132
+ /** @returns {{ toolCallId: string, toolName: string|undefined, rawInput: unknown, status: string|undefined }|undefined} */
133
+ get(toolCallId) {
134
+ return this.#entries.get(toolCallId)
135
+ }
136
+
137
+ /** 审批时缓存未命中的次数(审计用:命中率低 = 无法判断档位)。 */
138
+ miss() {
139
+ this.#misses += 1
140
+ }
141
+
142
+ get misses() {
143
+ return this.#misses
144
+ }
145
+
146
+ get evictions() {
147
+ return this.#evictions
148
+ }
149
+
150
+ get size() {
151
+ return this.#entries.size
152
+ }
153
+
154
+ /** 按首次出现顺序返回全部条目。 */
155
+ values() {
156
+ return [...this.#entries.values()]
157
+ }
158
+
159
+ clear() {
160
+ this.#entries.clear()
161
+ }
162
+
163
+ #merge(toolCallId, patch) {
164
+ if (typeof toolCallId !== 'string' || toolCallId.length === 0) {
165
+ return
166
+ }
167
+ const prev = this.#entries.get(toolCallId)
168
+ const entry = {
169
+ toolCallId,
170
+ toolName: patch.title ?? prev?.toolName,
171
+ rawInput: patch.rawInput === undefined ? prev?.rawInput : patch.rawInput,
172
+ status: patch.status ?? prev?.status,
173
+ }
174
+ // 保持「首次出现」顺序:重复 id 走 set 不改变 Map 的插入顺序。
175
+ this.#entries.set(toolCallId, entry)
176
+ while (this.#entries.size > this.#maxEntries) {
177
+ const oldest = this.#entries.keys().next()
178
+ if (oldest.done) {
179
+ break
180
+ }
181
+ this.#entries.delete(oldest.value)
182
+ this.#evictions += 1
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * ACP over WebSocket 客户端。
189
+ *
190
+ * @typedef {object} UpdateEvent
191
+ * @property {'message'|'thought'|'tool_call'|'tool_call_update'|'usage'|'config'|'other'} kind
192
+ * @property {string} [text]
193
+ * @property {{toolCallId: string, toolName: string|undefined, rawInput: unknown, status: string}} [tool]
194
+ * @property {{used: number, size: number}} [usage]
195
+ *
196
+ * @typedef {object} ApprovalAsk
197
+ * @property {string} toolCallId
198
+ * @property {string|undefined} toolName
199
+ * @property {unknown} rawInput
200
+ *
201
+ * @typedef {object} TaskCallbacks
202
+ * @property {(evt: UpdateEvent) => void} [onUpdate]
203
+ * @property {(req: ApprovalAsk) => Promise<'allow-once'|'reject-once'>} [onApproval]
204
+ *
205
+ * @typedef {object} TaskResult
206
+ * @property {'completed'|'error'|'aborted'|'refusal'|'max-tokens'} stopReason
207
+ * @property {string} output
208
+ * @property {string} [diagnostic]
209
+ * @property {Array<{toolCallId: string, toolName: string|undefined, status: string}>} toolCalls
210
+ * @property {{used: number, size: number}|undefined} usage
211
+ */
212
+ export class AcpBridge {
213
+ #url
214
+ #token
215
+ #approvalTimeoutMs
216
+ /** 构造级默认回调(INTERFACES 原契约);可被 startTask 的 per-call 回调覆盖。 */
217
+ #defaultOnUpdate
218
+ #defaultOnApproval
219
+ #streamOption
220
+ #streamFactory
221
+ #WebSocketCtor
222
+
223
+ /** ClientConnectionHandle(SDK 私有类型,只用公开的 close/signal/closed/agent) */
224
+ #handle
225
+ /** @type {any} ClientContext */
226
+ #ctx
227
+ #toolCalls = new BridgeToolCallCache()
228
+ #task
229
+ #activeSessionId
230
+ /**
231
+ * 本 turn 是否收到过本地 `cancel()`。
232
+ * 只由 `cancel()` 置位、只作用于当前 turn:`startTask` 占位时清零,任务结束/teardown 再清零。
233
+ * @type {boolean}
234
+ */
235
+ #cancelRequested = false
236
+ #disposed = false
237
+ #connected = false
238
+ #initResult
239
+ #pendingTimers = new Set()
240
+ #stats = {
241
+ approvals: 0,
242
+ approvalTimeouts: 0,
243
+ approvalErrors: 0,
244
+ toolCallMisses: 0,
245
+ updates: 0,
246
+ }
247
+
248
+ /**
249
+ * @param {object} opts
250
+ * @param {string} [opts.url] ws://host:port
251
+ * @param {string} [opts.token] Bearer
252
+ * @param {number} [opts.approvalTimeoutMs] 默认 90_000
253
+ * @param {(evt: UpdateEvent) => void} [opts.onUpdate]
254
+ * 构造级默认回调;可被 `startTask({ onUpdate })` 按任务覆盖
255
+ * @param {(req: ApprovalAsk) => Promise<'allow-once'|'reject-once'>} [opts.onApproval]
256
+ * 构造级默认回调;可被 `startTask({ onApproval })` 按任务覆盖
257
+ * @param {{readable: ReadableStream, writable: WritableStream}} [opts.stream]
258
+ * 增量:注入已建好的 ACP stream(测试用内存流;生产可不传)
259
+ * @param {() => any} [opts.streamFactory]
260
+ * 增量:每次 connect() 调用一次,返回 stream(或 Promise<stream>)
261
+ * @param {typeof WsWebSocket} [opts.WebSocket]
262
+ * 增量:覆盖 WS 构造器(默认 `ws` 的 WebSocket)
263
+ */
264
+ constructor(opts = {}) {
265
+ if (opts === null || typeof opts !== 'object') {
266
+ throw new TypeError('AcpBridge: opts must be an object')
267
+ }
268
+ const {
269
+ url,
270
+ token,
271
+ approvalTimeoutMs = DEFAULT_APPROVAL_TIMEOUT_MS,
272
+ onUpdate,
273
+ onApproval,
274
+ stream,
275
+ streamFactory,
276
+ WebSocket,
277
+ } = opts
278
+
279
+ if (stream === undefined && streamFactory === undefined) {
280
+ if (typeof url !== 'string' || url.length === 0) {
281
+ throw new TypeError(
282
+ 'AcpBridge: opts.url (ws://host:port) is required unless opts.stream/opts.streamFactory is given',
283
+ )
284
+ }
285
+ }
286
+ if (typeof approvalTimeoutMs !== 'number' || !Number.isFinite(approvalTimeoutMs) || approvalTimeoutMs < 0) {
287
+ throw new TypeError('AcpBridge: opts.approvalTimeoutMs must be a finite number >= 0')
288
+ }
289
+ if (onUpdate !== undefined && typeof onUpdate !== 'function') {
290
+ throw new TypeError('AcpBridge: opts.onUpdate must be a function')
291
+ }
292
+ if (onApproval !== undefined && typeof onApproval !== 'function') {
293
+ throw new TypeError('AcpBridge: opts.onApproval must be a function')
294
+ }
295
+ if (token !== undefined && typeof token !== 'string') {
296
+ throw new TypeError('AcpBridge: opts.token must be a string')
297
+ }
298
+
299
+ this.#url = url
300
+ this.#token = token
301
+ this.#approvalTimeoutMs = approvalTimeoutMs
302
+ this.#defaultOnUpdate = onUpdate
303
+ this.#defaultOnApproval = onApproval
304
+ this.#streamOption = stream
305
+ this.#streamFactory = streamFactory
306
+ this.#WebSocketCtor = WebSocket ?? WsWebSocket
307
+ }
308
+
309
+ /** 是否已完成 initialize。 */
310
+ get connected() {
311
+ return this.#connected
312
+ }
313
+
314
+ /** initialize 的原始 result(诊断用)。 */
315
+ get initResult() {
316
+ return this.#initResult
317
+ }
318
+
319
+ /** 当前 session id(无任务时为 undefined)。 */
320
+ get sessionId() {
321
+ return this.#activeSessionId
322
+ }
323
+
324
+ /** 本桥内的 toolCall 缓存(只读观察,含 misses/evictions/size)。 */
325
+ get toolCalls() {
326
+ return this.#toolCalls
327
+ }
328
+
329
+ /** 可观测计数(审计 / §8.6 通知用)。 */
330
+ get stats() {
331
+ return { ...this.#stats, toolCallMisses: this.#toolCalls.misses }
332
+ }
333
+
334
+ /**
335
+ * 调用插件自定义 RPC(`dsh-remote/*`)。
336
+ *
337
+ * 这些方法由从机中继**本地**处理,不经过 ACP agent —— 中继在把帧转发给
338
+ * `dsh --profile acp` 之前先拦截 `dsh-remote/` 前缀。SDK 的 `request()` 只把
339
+ * method 当字符串发出去,不校验它是否属于 ACP 词汇表(已实测),所以不需要
340
+ * 额外的传输层。
341
+ *
342
+ * @param {string} method 必须以 `dsh-remote/` 开头
343
+ * @param {object} [params]
344
+ * @param {{ timeoutMs?: number }} [opts]
345
+ * @returns {Promise<unknown>}
346
+ */
347
+ async callPlugin(method, params = {}, opts = {}) {
348
+ if (this.#disposed || !this.#ctx) {
349
+ throw new Error('AcpBridge.callPlugin: not connected')
350
+ }
351
+ if (typeof method !== 'string' || !method.startsWith(PLUGIN_RPC_PREFIX)) {
352
+ throw new Error(`AcpBridge.callPlugin: method must start with "${PLUGIN_RPC_PREFIX}", got ${String(method)}`)
353
+ }
354
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? Math.max(1, Number(opts.timeoutMs)) : DEFAULT_PLUGIN_RPC_TIMEOUT_MS
355
+ return await this.#withTimeout(
356
+ this.#ctx.request(method, params),
357
+ timeoutMs,
358
+ `plugin RPC "${method}" did not answer within ${timeoutMs}ms`,
359
+ )
360
+ }
361
+
362
+ /**
363
+ * 向从机索取就绪报告(派单门禁与配置页共用)。
364
+ *
365
+ * `maxAgeMs: 0` 强制重跑;默认取中继的缓存(`S8` 会真的移动鼠标,不能每单都跑)。
366
+ * @param {{ maxAgeMs?: number, timeoutMs?: number }} [opts]
367
+ * @returns {Promise<object>}
368
+ */
369
+ async selfcheck(opts = {}) {
370
+ const params = Number.isFinite(opts.maxAgeMs) ? { maxAgeMs: Number(opts.maxAgeMs) } : {}
371
+ return await this.callPlugin(
372
+ `${PLUGIN_RPC_PREFIX}selfcheck`,
373
+ params,
374
+ // 自检要跑 PowerShell 并做 P/Invoke 探测,比普通 RPC 慢;给它更宽的预算。
375
+ { timeoutMs: opts.timeoutMs ?? DEFAULT_SELFCHECK_TIMEOUT_MS },
376
+ )
377
+ }
378
+
379
+ /**
380
+ * `Promise.race` 形式的超时包装:只有被包裹的操作自身无法取消(SDK request 不接
381
+ * signal),所以超时后原 promise 仍会挂着,但已经不再影响调用方。
382
+ * @template T
383
+ * @param {Promise<T>} promise
384
+ * @param {number} timeoutMs
385
+ * @param {string} message
386
+ * @returns {Promise<T>}
387
+ */
388
+ #withTimeout(promise, timeoutMs, message) {
389
+ let timer
390
+ const timeout = new Promise((_resolve, reject) => {
391
+ timer = setTimeout(() => reject(new Error(message)), timeoutMs)
392
+ if (typeof timer.unref === 'function') timer.unref()
393
+ })
394
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
395
+ }
396
+
397
+ /**
398
+ * 建立连接 + initialize。
399
+ * @returns {Promise<{agentInfo: object, capabilities: object}>}
400
+ */
401
+ async connect() {
402
+ if (this.#disposed) {
403
+ throw new Error('AcpBridge.connect: bridge is disposed')
404
+ }
405
+ if (this.#ctx) {
406
+ throw new Error('AcpBridge.connect: already connected')
407
+ }
408
+
409
+ const stream = await this.#resolveStream()
410
+ const app = acp
411
+ .client({ name: DEFAULT_CLIENT_NAME })
412
+ .onRequest(acp.methods.client.session.requestPermission, (ctx) =>
413
+ this.#handlePermissionRequest(ctx.params),
414
+ )
415
+ .onNotification(acp.methods.client.session.update, (ctx) => this.#handleSessionUpdate(ctx.params))
416
+
417
+ // 用 connect()(而非 connectWith)保持长连接:connectWith 会在 op 返回后关闭连接,
418
+ // 而本桥的一次连接要跨多个 startTask。
419
+ const handle = app.connect(stream)
420
+ this.#handle = handle
421
+ this.#ctx = handle.agent
422
+
423
+ try {
424
+ const result = await this.#ctx.request(acp.methods.agent.initialize, {
425
+ protocolVersion: acp.PROTOCOL_VERSION,
426
+ clientCapabilities: {},
427
+ })
428
+ this.#initResult = result
429
+ this.#connected = true
430
+ return {
431
+ agentInfo: result?.agentInfo ?? {},
432
+ capabilities: result?.agentCapabilities ?? {},
433
+ }
434
+ } catch (error) {
435
+ await this.#teardown()
436
+ throw error
437
+ }
438
+ }
439
+
440
+ /**
441
+ * 新建 session 并发送 prompt,阻塞到 turn 结束。
442
+ *
443
+ * 前置失败(未连接 / session/new 失败)会 throw;prompt 阶段的 JSON-RPC error
444
+ * 会被映射成 `{ stopReason: 'error', diagnostic }` 而不是 throw(硬行为 1)。
445
+ *
446
+ * 回调按任务解析(INTERFACES【B】的增量):`req.onUpdate` / `req.onApproval` 覆盖
447
+ * 构造级默认值;两者都不给时行为与只传构造级回调完全一致(无 onApproval → fail closed)。
448
+ * 解析结果存进 `#task`,本 turn 的所有事件/审批都从那里取,绝不跨任务串台。
449
+ *
450
+ * @param {{ cwd: string, prompt: string } & TaskCallbacks} req
451
+ * @returns {Promise<TaskResult>}
452
+ */
453
+ async startTask(req) {
454
+ this.#assertConnected('startTask')
455
+ if (this.#task) {
456
+ throw new Error('AcpBridge.startTask: a task is already in progress on this bridge')
457
+ }
458
+ const cwd = req?.cwd
459
+ const promptText = req?.prompt
460
+ const perCallOnUpdate = req?.onUpdate
461
+ const perCallOnApproval = req?.onApproval
462
+ if (typeof cwd !== 'string' || cwd.length === 0) {
463
+ throw new TypeError('AcpBridge.startTask: req.cwd must be a non-empty path string')
464
+ }
465
+ if (typeof promptText !== 'string') {
466
+ throw new TypeError('AcpBridge.startTask: req.prompt must be a string')
467
+ }
468
+ if (perCallOnUpdate !== undefined && typeof perCallOnUpdate !== 'function') {
469
+ throw new TypeError('AcpBridge.startTask: req.onUpdate must be a function')
470
+ }
471
+ if (perCallOnApproval !== undefined && typeof perCallOnApproval !== 'function') {
472
+ throw new TypeError('AcpBridge.startTask: req.onApproval must be a function')
473
+ }
474
+
475
+ // 每个任务独立解析一次:显式入参 > 构造默认(undefined 的 per-call 不算覆盖)。
476
+ const emitUpdate = perCallOnUpdate ?? this.#defaultOnUpdate
477
+ const askApproval = perCallOnApproval ?? this.#defaultOnApproval
478
+
479
+ // 先占位再发 session/new:让单任务守卫是同步的(并发 startTask 会立刻被拒)。
480
+ // 注意:清 cancel 标志必须在并发守卫**之后**,否则第二个 startTask 会清掉在跑那个 turn 的取消状态。
481
+ /**
482
+ * @type {{
483
+ * sessionId: string|undefined,
484
+ * output: string,
485
+ * toolCallIds: string[],
486
+ * usage: {used:number,size:number}|undefined,
487
+ * emitUpdate: ((evt: UpdateEvent) => void)|undefined,
488
+ * askApproval: ((req: ApprovalAsk) => Promise<'allow-once'|'reject-once'>)|undefined,
489
+ * }}
490
+ */
491
+ const task = { sessionId: undefined, output: '', toolCallIds: [], usage: undefined, emitUpdate, askApproval }
492
+ this.#task = task
493
+ this.#cancelRequested = false
494
+ let sessionId
495
+ try {
496
+ const created = await this.#ctx.request(acp.methods.agent.session.new, { cwd, mcpServers: [] })
497
+ sessionId = created?.sessionId
498
+ task.sessionId = sessionId
499
+ this.#activeSessionId = sessionId
500
+
501
+ try {
502
+ const response = await this.#ctx.request(acp.methods.agent.session.prompt, {
503
+ sessionId,
504
+ prompt: [{ type: 'text', text: promptText }],
505
+ })
506
+ await drain()
507
+ return {
508
+ stopReason: mapStopReason(response?.stopReason),
509
+ output: task.output,
510
+ toolCalls: this.#toolCallsOf(task),
511
+ usage: task.usage,
512
+ }
513
+ } catch (error) {
514
+ await drain()
515
+ // 本地取消后对端以 JSON-RPC error 结束 → 'aborted'(不看 code,只看我们是否发过 cancel)。
516
+ // 未发 cancel 时维持 'error',避免把真失败误判成取消。
517
+ const cancelled = this.#cancelRequested
518
+ return {
519
+ stopReason: cancelled ? 'aborted' : 'error',
520
+ diagnostic: errorMessage(error),
521
+ output: task.output,
522
+ toolCalls: this.#toolCallsOf(task),
523
+ usage: task.usage,
524
+ }
525
+ }
526
+ } finally {
527
+ this.#task = undefined
528
+ this.#activeSessionId = undefined
529
+ this.#cancelRequested = false
530
+ if (sessionId !== undefined) {
531
+ await this.#closeSession(sessionId)
532
+ }
533
+ }
534
+ }
535
+
536
+ /**
537
+ * 上层取消:对当前 session 发 `session/cancel`(不等待 quiescence)。
538
+ * 未连接 / 无进行中任务时是 no-op(此时不置取消标志,避免污染下一个 turn)。
539
+ */
540
+ async cancel() {
541
+ if (this.#disposed || !this.#ctx) {
542
+ return
543
+ }
544
+ const sessionId = this.#activeSessionId
545
+ if (sessionId === undefined) {
546
+ return
547
+ }
548
+ // 置位在 notify 之前:即使 notify 本身失败,也算「我们发过取消意图」,
549
+ // 后续 prompt 以 error 结束时仍按 'aborted' 上报。
550
+ this.#cancelRequested = true
551
+ await this.#ctx.notify(acp.methods.agent.session.cancel, { sessionId })
552
+ }
553
+
554
+ /** 关闭 WS 与所有 session。幂等。 */
555
+ async dispose() {
556
+ if (this.#disposed) {
557
+ return
558
+ }
559
+ this.#disposed = true
560
+ await this.#teardown()
561
+ }
562
+
563
+ // ---------------------------------------------------------------- 内部
564
+
565
+ async #resolveStream() {
566
+ if (this.#streamOption !== undefined) {
567
+ if (!isStream(this.#streamOption)) {
568
+ throw new TypeError('AcpBridge: opts.stream must be an ACP Stream ({ readable, writable })')
569
+ }
570
+ return this.#streamOption
571
+ }
572
+ if (this.#streamFactory !== undefined) {
573
+ const stream = await this.#streamFactory()
574
+ if (!isStream(stream)) {
575
+ throw new TypeError('AcpBridge: opts.streamFactory must return an ACP Stream ({ readable, writable })')
576
+ }
577
+ return stream
578
+ }
579
+ return createWebSocketStream(this.#url, {
580
+ WebSocket: this.#WebSocketCtor,
581
+ ...(this.#token ? { headers: { Authorization: `Bearer ${this.#token}` } } : {}),
582
+ })
583
+ }
584
+
585
+ #assertConnected(op) {
586
+ if (this.#disposed) {
587
+ throw new Error(`AcpBridge.${op}: bridge is disposed`)
588
+ }
589
+ if (!this.#ctx) {
590
+ throw new Error(`AcpBridge.${op}: not connected; call connect() first`)
591
+ }
592
+ }
593
+
594
+ async #teardown() {
595
+ for (const timer of this.#pendingTimers) {
596
+ clearTimeout(timer)
597
+ }
598
+ this.#pendingTimers.clear()
599
+
600
+ const handle = this.#handle
601
+ this.#handle = undefined
602
+ this.#ctx = undefined
603
+ this.#connected = false
604
+ this.#task = undefined
605
+ this.#activeSessionId = undefined
606
+ this.#cancelRequested = false
607
+ this.#toolCalls.clear()
608
+
609
+ if (handle) {
610
+ try {
611
+ handle.close()
612
+ } catch {
613
+ // 关闭失败不影响 dispose 幂等语义。
614
+ }
615
+ }
616
+ }
617
+
618
+ async #closeSession(sessionId) {
619
+ if (sessionId === undefined || !this.#ctx) {
620
+ return
621
+ }
622
+ try {
623
+ // session/close 是可选能力(DESIGN §13.3 第 6 步):best-effort,失败不影响结果。
624
+ await this.#ctx.request(acp.methods.agent.session.close, { sessionId })
625
+ } catch {
626
+ // ignore
627
+ }
628
+ }
629
+
630
+ /** @returns {Array<{toolCallId: string, toolName: string|undefined, status: string}>} */
631
+ #toolCallsOf(task) {
632
+ return task.toolCallIds.map((toolCallId) => {
633
+ const entry = this.#toolCalls.get(toolCallId)
634
+ return {
635
+ toolCallId,
636
+ toolName: entry?.toolName,
637
+ status: entry?.status ?? 'unknown',
638
+ }
639
+ })
640
+ }
641
+
642
+ /**
643
+ * `session/update` 通知(DESIGN §13.4 的 6 种变体 + 未知一律 'other')。
644
+ */
645
+ #handleSessionUpdate(params) {
646
+ if (params === null || typeof params !== 'object') {
647
+ return
648
+ }
649
+ const { sessionId, update } = params
650
+ if (update === null || typeof update !== 'object') {
651
+ return
652
+ }
653
+ const task = this.#task
654
+ if (task && sessionId !== undefined && task.sessionId !== undefined && sessionId !== task.sessionId) {
655
+ return
656
+ }
657
+ this.#stats.updates += 1
658
+
659
+ switch (update.sessionUpdate) {
660
+ case 'agent_message_chunk': {
661
+ const text = textOf(update.content)
662
+ if (text) {
663
+ if (task) {
664
+ task.output += text
665
+ }
666
+ this.#emit({ kind: 'message', text })
667
+ }
668
+ return
669
+ }
670
+ case 'agent_thought_chunk': {
671
+ const text = textOf(update.content)
672
+ if (text) {
673
+ this.#emit({ kind: 'thought', text })
674
+ }
675
+ return
676
+ }
677
+ case 'tool_call': {
678
+ const id = update.toolCallId
679
+ this.#toolCalls.note(id, {
680
+ title: update.title ?? update.name,
681
+ rawInput: update.rawInput,
682
+ status: update.status,
683
+ })
684
+ this.#trackToolCall(task, id)
685
+ this.#emit({ kind: 'tool_call', tool: this.#toolView(id, update) })
686
+ return
687
+ }
688
+ case 'tool_call_update': {
689
+ const id = update.toolCallId
690
+ this.#toolCalls.update(id, {
691
+ title: update.title ?? update.name,
692
+ rawInput: update.rawInput,
693
+ status: update.status,
694
+ })
695
+ this.#trackToolCall(task, id)
696
+ this.#emit({ kind: 'tool_call_update', tool: this.#toolView(id, update) })
697
+ return
698
+ }
699
+ case 'usage_update': {
700
+ const usage = { used: toNumber(update.used), size: toNumber(update.size) }
701
+ if (task) {
702
+ task.usage = usage
703
+ }
704
+ this.#emit({ kind: 'usage', usage })
705
+ return
706
+ }
707
+ case 'config_option_update': {
708
+ this.#emit({ kind: 'config' })
709
+ return
710
+ }
711
+ default: {
712
+ this.#emit({ kind: 'other' })
713
+ return
714
+ }
715
+ }
716
+ }
717
+
718
+ #trackToolCall(task, toolCallId) {
719
+ if (!task || typeof toolCallId !== 'string' || toolCallId.length === 0) {
720
+ return
721
+ }
722
+ if (!task.toolCallIds.includes(toolCallId)) {
723
+ task.toolCallIds.push(toolCallId)
724
+ }
725
+ }
726
+
727
+ #toolView(toolCallId, update) {
728
+ const cached = this.#toolCalls.get(toolCallId)
729
+ return {
730
+ toolCallId,
731
+ toolName: cached?.toolName ?? update?.title ?? update?.name,
732
+ rawInput: cached?.rawInput ?? update?.rawInput,
733
+ status: cached?.status ?? update?.status ?? 'unknown',
734
+ }
735
+ }
736
+
737
+ /**
738
+ * 事件只分发给**当前任务**解析出来的回调(`#task.emitUpdate`)。
739
+ *
740
+ * 没有进行中任务时没有归属者,事件直接丢弃:turn 已结束(TaskResult 已快照),
741
+ * 迟到的 update 不属于任何任务,绝不能落到别的任务的回调上。
742
+ */
743
+ #emit(event) {
744
+ const emitUpdate = this.#task?.emitUpdate
745
+ if (typeof emitUpdate !== 'function') {
746
+ return
747
+ }
748
+ try {
749
+ emitUpdate(event)
750
+ } catch {
751
+ // onUpdate 抛错不得打断 turn。
752
+ }
753
+ }
754
+
755
+ /**
756
+ * `session/request_permission`(硬行为 2 + 3)。
757
+ *
758
+ * params 只带 toolCallId(DESIGN §13.3 第 5 步),所以 toolName/rawInput 只能从
759
+ * toolCall 缓存回填;未命中就照样上报 undefined,由 review-engine 拒绝。
760
+ */
761
+ async #handlePermissionRequest(params) {
762
+ const options = Array.isArray(params?.options) ? params.options : []
763
+ const toolCallId = params?.toolCall?.toolCallId
764
+ const cached = typeof toolCallId === 'string' ? this.#toolCalls.get(toolCallId) : undefined
765
+ if (typeof toolCallId === 'string' && cached === undefined) {
766
+ this.#toolCalls.miss()
767
+ }
768
+
769
+ /** @type {ApprovalAsk} */
770
+ const ask = {
771
+ toolCallId,
772
+ toolName: cached?.toolName,
773
+ rawInput: cached?.rawInput,
774
+ }
775
+ const rejectOptionId = pickRejectOptionId(options)
776
+
777
+ // 只认当前任务解析出来的决策者(per-call 覆盖构造默认)。
778
+ const askApproval = this.#task?.askApproval
779
+ let answered
780
+ if (typeof askApproval !== 'function') {
781
+ // 没有决策者时 fail closed,绝不默认放行(DESIGN §5.2 / §8.3)。
782
+ answered = rejectOptionId
783
+ } else {
784
+ this.#stats.approvals += 1
785
+ answered = await this.#askApprovalWithTimeout(askApproval, ask, rejectOptionId)
786
+ }
787
+
788
+ const optionId = normalizeSelected(answered, options, rejectOptionId)
789
+ return { outcome: { outcome: 'selected', optionId } }
790
+ }
791
+
792
+ /**
793
+ * 硬行为 2:超时/异常一律回落到 reject-once,且绝不抛错打断 turn。
794
+ * @param {(req: ApprovalAsk) => Promise<'allow-once'|'reject-once'>} askApproval 本任务解析出的决策者
795
+ * @returns {Promise<string>} 最终 optionId
796
+ */
797
+ async #askApprovalWithTimeout(askApproval, ask, fallbackOptionId) {
798
+ let timer
799
+ const timeout = new Promise((resolve) => {
800
+ timer = setTimeout(() => {
801
+ this.#stats.approvalTimeouts += 1
802
+ resolve(fallbackOptionId)
803
+ }, this.#approvalTimeoutMs)
804
+ this.#pendingTimers.add(timer)
805
+ })
806
+
807
+ const answered = Promise.resolve()
808
+ .then(() => askApproval(ask))
809
+ .then(
810
+ (optionId) => optionId,
811
+ () => {
812
+ this.#stats.approvalErrors += 1
813
+ return fallbackOptionId
814
+ },
815
+ )
816
+
817
+ try {
818
+ return await Promise.race([answered, timeout])
819
+ } finally {
820
+ clearTimeout(timer)
821
+ this.#pendingTimers.delete(timer)
822
+ }
823
+ }
824
+ }
825
+
826
+ // ------------------------------------------------------------------ helpers
827
+
828
+ function isStream(value) {
829
+ return (
830
+ typeof value === 'object' &&
831
+ value !== null &&
832
+ typeof value.readable === 'object' &&
833
+ value.readable !== null &&
834
+ typeof value.writable === 'object' &&
835
+ value.writable !== null
836
+ )
837
+ }
838
+
839
+ /** 从 content block(实测 `{type:'text',text}`)取文本,容错 null/非文本。 */
840
+ function textOf(content) {
841
+ if (content === null || typeof content !== 'object') {
842
+ return undefined
843
+ }
844
+ return typeof content.text === 'string' ? content.text : undefined
845
+ }
846
+
847
+ function toNumber(value) {
848
+ const n = typeof value === 'number' ? value : Number(value)
849
+ return Number.isFinite(n) ? n : 0
850
+ }
851
+
852
+ /**
853
+ * 从 options 里挑「拒绝」用的 optionId(ACP:任何非 allow-once 的都算拒绝)。
854
+ * 实测 DSH 只给 allow-once / reject-once,但按 kind 兜底。
855
+ */
856
+ function pickRejectOptionId(options) {
857
+ for (const option of options) {
858
+ if (option?.optionId === OPTION_REJECT_ONCE) {
859
+ return OPTION_REJECT_ONCE
860
+ }
861
+ }
862
+ for (const option of options) {
863
+ if (typeof option?.kind === 'string' && option.kind.startsWith('reject')) {
864
+ return option.optionId
865
+ }
866
+ }
867
+ for (const option of options) {
868
+ if (option?.optionId !== OPTION_ALLOW_ONCE && typeof option?.optionId === 'string') {
869
+ return option.optionId
870
+ }
871
+ }
872
+ return OPTION_REJECT_ONCE
873
+ }
874
+
875
+ /** 只接受 allow-once;其余(含 undefined/异常值)一律回落到拒绝。 */
876
+ function normalizeSelected(answered, options, rejectOptionId) {
877
+ if (answered === OPTION_ALLOW_ONCE && options.some((option) => option?.optionId === OPTION_ALLOW_ONCE)) {
878
+ return OPTION_ALLOW_ONCE
879
+ }
880
+ return rejectOptionId
881
+ }
882
+
883
+ function errorMessage(error) {
884
+ if (error instanceof Error && typeof error.message === 'string' && error.message.length > 0) {
885
+ return error.message
886
+ }
887
+ if (error !== null && typeof error === 'object' && typeof error.message === 'string') {
888
+ return error.message
889
+ }
890
+ return String(error)
891
+ }
892
+
893
+ /**
894
+ * 让传输层的 `session/update` 通知处理先于结果快照跑完。
895
+ *
896
+ * 一次 prompt 里,通知与 prompt response 走在同一条有序流上,SDK 的 receive 循环
897
+ * 对通知是「启动即返回」的(`jsonrpc.js` 的 `receive()` 不 await 通知处理)。
898
+ * 因此这里让出一个宏任务,保证所有已到达通知的 handler 主体(同步累加 output /
899
+ * 写 toolCall 缓存)都已执行,再快照 TaskResult,避免拿到半截 output。
900
+ */
901
+ function drain() {
902
+ return new Promise((resolve) => {
903
+ setImmediate(resolve)
904
+ })
905
+ }