@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.
- package/LICENSE +21 -0
- package/README.md +207 -2
- package/README.zh.md +195 -0
- package/assets/icon.svg +47 -0
- package/cordis.patch.yml +41 -0
- package/lib/bridge/acp-bridge.js +905 -0
- package/lib/bridge/peer-connection.js +730 -0
- package/lib/client.js +2842 -0
- package/lib/core/acp-model-overlay.js +121 -0
- package/lib/core/approval-queue.js +216 -0
- package/lib/core/audit-log.js +322 -0
- package/lib/core/desktop-notify.js +173 -0
- package/lib/core/environment-prompt.js +76 -0
- package/lib/core/relay-supervisor.js +278 -0
- package/lib/core/review-engine.js +679 -0
- package/lib/core/sandbox-diagnosis.js +258 -0
- package/lib/core/sandbox-probe.js +310 -0
- package/lib/core/self-acp-spec.js +117 -0
- package/lib/core/selfcheck-runner.js +150 -0
- package/lib/core/settings-store.js +226 -0
- package/lib/core/shot-gc.js +334 -0
- package/lib/core/task-cwd.js +50 -0
- package/lib/core/task-memory.js +163 -0
- package/lib/core/task-registry.js +238 -0
- package/lib/core/toolcall-cache.js +206 -0
- package/lib/core/transfer-queue.js +58 -0
- package/lib/core/transfer.js +1144 -0
- package/lib/core/worker-arming.js +334 -0
- package/lib/index.js +2207 -0
- package/lib/provider/out-of-process-helpers.js +76 -0
- package/lib/provider/remote-provider.js +261 -0
- package/lib/tools/mediator-shim.mjs +180 -0
- package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
- package/lib/tools/remote-tools.js +680 -0
- package/lib/tools/selfcheck-fix-prompt.md +206 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
- package/slave/acp-profile-approval.patch.yml +66 -0
- package/slave/file-transfer.mjs +552 -0
- package/slave/integrity-probe.ps1 +99 -0
- package/slave/make-launch-spec.mjs +184 -0
- package/slave/net-address.mjs +138 -0
- package/slave/node_modules/.package-lock.json +26 -0
- package/slave/node_modules/ws/LICENSE +20 -0
- package/slave/node_modules/ws/README.md +548 -0
- package/slave/node_modules/ws/browser.js +8 -0
- package/slave/node_modules/ws/index.js +22 -0
- package/slave/node_modules/ws/lib/buffer-util.js +131 -0
- package/slave/node_modules/ws/lib/constants.js +19 -0
- package/slave/node_modules/ws/lib/event-target.js +292 -0
- package/slave/node_modules/ws/lib/extension.js +203 -0
- package/slave/node_modules/ws/lib/limiter.js +55 -0
- package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/slave/node_modules/ws/lib/receiver.js +743 -0
- package/slave/node_modules/ws/lib/sender.js +607 -0
- package/slave/node_modules/ws/lib/stream.js +161 -0
- package/slave/node_modules/ws/lib/subprotocol.js +62 -0
- package/slave/node_modules/ws/lib/validation.js +152 -0
- package/slave/node_modules/ws/lib/websocket-server.js +562 -0
- package/slave/node_modules/ws/lib/websocket.js +1421 -0
- package/slave/node_modules/ws/package.json +74 -0
- package/slave/node_modules/ws/wrapper.mjs +21 -0
- package/slave/pairing.mjs +175 -0
- package/slave/permission-pairing.mjs +240 -0
- package/slave/prune-shots.mjs +170 -0
- package/slave/relay.mjs +1351 -0
- package/slave/repair-workspace-integrity.ps1 +75 -0
- package/slave/screenshot.ps1 +114 -0
- package/slave/selfcheck.ps1 +350 -0
- package/slave/stage-app.ps1 +116 -0
- 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
|
+
}
|