@dipoleml/dcode-cli 1.2.1 → 1.2.3

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 (31) hide show
  1. package/dist/dcode.cjs +2214 -1891
  2. package/dist/packages/browser-use-plugin/.dcode-plugin/plugin.json +8 -8
  3. package/dist/packages/dcode-cua-plugin/.dcode-plugin/plugin.json +10 -0
  4. package/dist/packages/dcode-cua-plugin/docs/computer-use.md +470 -0
  5. package/dist/packages/dcode-cua-plugin/package.json +12 -0
  6. package/dist/packages/dcode-cua-plugin/scripts/bump-dcode-cua-producer.mjs +305 -0
  7. package/dist/packages/dcode-cua-plugin/scripts/check-cua-baseline.mjs +155 -0
  8. package/dist/packages/dcode-cua-plugin/scripts/check-sdk.mjs +4 -0
  9. package/dist/packages/dcode-cua-plugin/scripts/check-version-coherence.mjs +478 -0
  10. package/dist/packages/dcode-cua-plugin/scripts/computer-use-client.mjs +1208 -0
  11. package/dist/packages/dcode-cua-plugin/scripts/resolve-pnpm-invocation.mjs +26 -0
  12. package/dist/packages/dcode-cua-plugin/scripts/sync-cache.mjs +116 -0
  13. package/dist/packages/dcode-cua-plugin/scripts/sync-skill-from-dcode-cua.mjs +115 -0
  14. package/dist/packages/dcode-cua-plugin/skills/computer-use/SKILL.md +306 -0
  15. package/dist/packages/node-repl-host/dist/mcp/server.js +3 -3
  16. package/dist/packages/node-repl-host/package.json +1 -1
  17. package/dist/provider/dcode-builtin.json +6748 -6748
  18. package/node_modules/@dcode/contracts/dist/events/event-reducer-helpers.d.ts +1 -1
  19. package/node_modules/@dcode/contracts/dist/events/event-reducer-helpers.js +5 -1
  20. package/node_modules/@dcode/contracts/dist/events/event-reducer.js +26 -5
  21. package/node_modules/@dcode/contracts/dist/interfaces/session.port.d.ts +5 -1
  22. package/node_modules/@dcode/contracts/dist/tools/bash.js +4 -1
  23. package/node_modules/@dcode/i18n/dist/locales/en-US.js +11 -23
  24. package/node_modules/@dcode/i18n/dist/locales/fr-CA.js +9 -21
  25. package/node_modules/@dcode/i18n/dist/types.d.ts +0 -12
  26. package/node_modules/@dcode/model-option-map/package.json +1 -1
  27. package/node_modules/@dcode/shared/package.json +1 -1
  28. package/node_modules/@dcode/tui/dist/app-model-ref.js +9 -2
  29. package/node_modules/@dcode/tui/dist/index.js +57 -65
  30. package/node_modules/@dcode/tui/dist/theme/defaults.js +21 -15
  31. package/package.json +3 -3
@@ -0,0 +1,1208 @@
1
+ /**
2
+ * Computer Use SDK —— 模型可见面与 Codex 的 `cua` 逐字同构(去掉 browser 半边)。
3
+ *
4
+ * 设计文档:dcode-cua/docs/refactor-port-specs/2026-09-10-cua-sdk-v3-api.md
5
+ * 逆向基线:@oai/cua@0.2.4 的 tinysky_alt/types.d.ts 与 docs/tinysky-alt-core-node-repl.md
6
+ *
7
+ * 三条原则(文档 §1):
8
+ * R1 同名同签 —— Codex 有的方法,名字、位置参数顺序、选项键名、返回类型逐字一致。
9
+ * R2 Codex 没有的能力先问能不能删;留下的只能出现在可选选项键、尾部可选参数,
10
+ * 或 `cua.computer` 逃逸口里。`Target` 上的附加成员数必须为 0。
11
+ * R3 安全语义只藏不删 —— state_id 强校验、frame 精确栅格、possibly_sent 防重放、
12
+ * controller lease、kill switch 全部保留,改为内部字段或类型化错误。
13
+ *
14
+ * 与 Codex 的两处不可对齐(文档 §5):
15
+ * 1. node_repl 的 Worker 每次 `js` 调用都是全新的,`const app` 活不到下一个 cell。
16
+ * stateId / frameId / diff 基线由 shared host 的 runtime session 持有,所以下一个
17
+ * cell 里 `getApp` 是重新绑定而非重新观察。
18
+ * 2. 动作失败抛 ComputerUseError 并带 actionSent —— Codex 的动作全是 Promise<void>,
19
+ * 把「可能已下发」的信息扔了;DCode 这条语义是事故驱动的,必须保留。
20
+ *
21
+ * 不融合 Browser Use:这里不存在 browsers / getBrowser / createBrowserTab / getTab;
22
+ * `State` 没有 `browsers` 键;`Target` 只被 App 实现,不与 browser 的 Tab 共享类型。
23
+ */
24
+
25
+ import { readFile } from "node:fs/promises";
26
+ import { join } from "node:path";
27
+
28
+ const BRIDGE_SYMBOL = Symbol.for("dcode.node-repl.computer-use-bridge");
29
+
30
+ /** 存活的工具面(= `cua.computer`)。协议层的事实来源在 dcode-cua/src/tools/manifest.ts。 */
31
+ const COMPUTER_METHOD_NAMES = Object.freeze([
32
+ "list_apps",
33
+ "list_windows",
34
+ "get_app_state",
35
+ "left_click",
36
+ "scroll",
37
+ "left_click_drag",
38
+ "type",
39
+ "set_value",
40
+ "select_text",
41
+ "key",
42
+ "perform_action",
43
+ "paste",
44
+ "request_access",
45
+ "stop_computer_control",
46
+ ]);
47
+
48
+ /**
49
+ * 平台面收缩:某些工具在某些平台上**不存在**。事实来源同为 dcode-cua/src/tools/manifest.ts
50
+ * 的 TOOL_PLATFORM_EXCLUSIONS —— 那边不注册 handler,这边不挂到 `cua.computer` 上,
51
+ * 两侧必须一致,否则模型看得见一个调用即报错的方法,又会去猜替代写法。
52
+ *
53
+ * 2026-09-16:`open_application` 已在 producer 侧**整体删除**(不再只是 darwin 排除)——
54
+ * 启动与激活并入 get_app_state 的透明拉起(见 dcode-cua/src/tools/manifest.ts 的说明),
55
+ * 与 codex 一致(其 `targets/mac` 本来就没有启动/激活原语)。于是这张表暂时为空:
56
+ * 留着它是因为「按平台收缩」这个机制本身还需要,下一个平台专属工具直接往里加。
57
+ */
58
+ const PLATFORM_EXCLUDED_METHODS = Object.freeze({});
59
+
60
+ /** 会改变 UI 的方法。成功即 void,失败抛错;这一集合决定 possibly_sent 的判定路径。 */
61
+ const MUTATING_METHODS = new Set([
62
+ "left_click",
63
+ "scroll",
64
+ "left_click_drag",
65
+ "type",
66
+ "set_value",
67
+ "select_text",
68
+ "key",
69
+ "perform_action",
70
+ "paste",
71
+ "stop_computer_control",
72
+ ]);
73
+
74
+ // ─────────────────────────────────────────────────────────── 错误
75
+
76
+ /** broker 错误码 → SDK 错误码。未知码归 INTERNAL,绝不静默成功。 */
77
+ const ERROR_CODE_BY_BROKER = Object.freeze({
78
+ permission_denied: "PERMISSION_DENIED",
79
+ not_authorized: "NOT_AUTHORIZED",
80
+ launch_failed: "LAUNCH_FAILED",
81
+ invalid_request: "INVALID_APP",
82
+ element_unavailable: "ELEMENT_UNAVAILABLE",
83
+ not_settable: "NOT_SETTABLE",
84
+ not_selectable: "NOT_SELECTABLE",
85
+ action_unavailable: "ACTION_UNAVAILABLE",
86
+ foreground_required: "FOREGROUND_REQUIRED",
87
+ controller_busy: "CONTROLLER_BUSY",
88
+ broker_unavailable: "HELPER_UNAVAILABLE",
89
+ version_mismatch: "VERSION_MISMATCH",
90
+ stale_socket: "HELPER_UNAVAILABLE",
91
+ timeout: "TIMEOUT",
92
+ unimplemented: "ACTION_UNAVAILABLE",
93
+ method_not_found: "INTERNAL",
94
+ internal: "INTERNAL",
95
+ });
96
+
97
+ /** 只有这些码在重试同一个动作之前必须先重新观察。 */
98
+ const REOBSERVE_CODES = new Set([
99
+ "ELEMENT_UNAVAILABLE",
100
+ "STALE_STATE",
101
+ "STRUCTURED_STATE_UNAVAILABLE",
102
+ ]);
103
+ const NEVER_RETRY_CODES = new Set([
104
+ "CONTROLLER_BUSY",
105
+ "CONTROL_STOPPED",
106
+ "PERMISSION_DENIED",
107
+ "NOT_AUTHORIZED",
108
+ "VERSION_MISMATCH",
109
+ "ACTION_UNAVAILABLE",
110
+ "NOT_SETTABLE",
111
+ "NOT_SELECTABLE",
112
+ ]);
113
+
114
+ class ComputerUseError extends Error {
115
+ constructor(message, { code, actionSent, dispatchStatus, details } = {}) {
116
+ super(message);
117
+ this.name = "ComputerUseError";
118
+ this.code = code ?? "INTERNAL";
119
+ // 默认 false 是**故意**的保守方向:只有在收据明确说下发过时才置 true。
120
+ // 反过来(默认 true)会让模型对一个从未下发的动作放弃重试。
121
+ this.actionSent = actionSent === true;
122
+ if (dispatchStatus) this.dispatchStatus = dispatchStatus;
123
+ this.details = Object.freeze({ ...(details ?? {}) });
124
+ this.retry = this.actionSent
125
+ ? "reobserve"
126
+ : NEVER_RETRY_CODES.has(this.code)
127
+ ? "never"
128
+ : REOBSERVE_CODES.has(this.code)
129
+ ? "reobserve"
130
+ : "retry";
131
+ }
132
+ }
133
+
134
+ function parseJsonRecord(text) {
135
+ if (typeof text !== "string") return undefined;
136
+ try {
137
+ const parsed = JSON.parse(text);
138
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
139
+ } catch {
140
+ return undefined;
141
+ }
142
+ }
143
+
144
+ /**
145
+ * 同上,但不排除数组。
146
+ *
147
+ * Bug 原因(2026-09-11 真机):list_apps 的 envelope 是一个**裸 JSON 数组**,而
148
+ * inventory() 用 parseJsonRecord 去解析它 —— 那个函数对数组显式返回 undefined,
149
+ * 于是紧随其后的 `else if (Array.isArray(parsed))` 是永远进不去的死分支,
150
+ * listApps() 恒返回 []。模型据此判定「应用列表是空的,可能需要先初始化/请求权限」,
151
+ * 白绕了三个 cell。空数组和「拿不到列表」在这里必须能被区分,所以解析要保真。
152
+ */
153
+ function parseJsonValue(text) {
154
+ if (typeof text !== "string") return undefined;
155
+ try {
156
+ return JSON.parse(text);
157
+ } catch {
158
+ return undefined;
159
+ }
160
+ }
161
+
162
+ function textBlocks(result) {
163
+ const content = Array.isArray(result?.content) ? result.content : [];
164
+ return content
165
+ .filter((block) => block?.type === "text" && typeof block.text === "string")
166
+ .map((block) => block.text);
167
+ }
168
+
169
+ /** 从 MCP envelope 里挖出结构化收据(顶层、structuredContent 或 action_outcome)。 */
170
+ function receiptOf(result) {
171
+ const candidates = [];
172
+ if (result && typeof result === "object") candidates.push(result);
173
+ if (result?.structuredContent && typeof result.structuredContent === "object") {
174
+ candidates.push(result.structuredContent);
175
+ }
176
+ for (const text of textBlocks(result)) {
177
+ const parsed = parseJsonRecord(text);
178
+ if (!parsed) continue;
179
+ candidates.push(parsed);
180
+ if (parsed.action_outcome && typeof parsed.action_outcome === "object") {
181
+ candidates.push(parsed.action_outcome);
182
+ }
183
+ }
184
+ const merged = {};
185
+ for (const candidate of candidates) {
186
+ for (const key of [
187
+ "state_id",
188
+ "frame_id",
189
+ "action_sent",
190
+ "dispatch_status",
191
+ "state_sync_status",
192
+ "code",
193
+ "reason",
194
+ "snapshot_mode",
195
+ "base_state_id",
196
+ ]) {
197
+ if (merged[key] === undefined && candidate[key] !== undefined) {
198
+ merged[key] = candidate[key];
199
+ }
200
+ }
201
+ }
202
+ return merged;
203
+ }
204
+
205
+ /**
206
+ * frame_id 的真实位置:官方 CUA 把它放在图片相邻的 `image_ref` 文本块里
207
+ * (`{"image_ref":{"frame_id":...}}`),而不是收据顶层。SDK 两处都读,
208
+ * 因为 image_ref 才是精确栅格契约的签发载体。
209
+ */
210
+ function frameIdOf(result) {
211
+ const receipt = receiptOf(result);
212
+ if (typeof receipt.frame_id === "string") return receipt.frame_id;
213
+ for (const text of textBlocks(result)) {
214
+ const parsed = parseJsonRecord(text);
215
+ const id = parsed?.image_ref?.frame_id;
216
+ if (typeof id === "string") return id;
217
+ }
218
+ return undefined;
219
+ }
220
+
221
+ function brokerErrorCodeOf(result) {
222
+ for (const text of textBlocks(result)) {
223
+ const parsed = parseJsonRecord(text);
224
+ const code = parsed?.code ?? parsed?.error?.code;
225
+ if (typeof code === "string") return code;
226
+ }
227
+ const receipt = receiptOf(result);
228
+ return typeof receipt.code === "string" ? receipt.code : undefined;
229
+ }
230
+
231
+ function messageOf(result, fallback) {
232
+ const texts = textBlocks(result);
233
+ for (const text of texts) {
234
+ const parsed = parseJsonRecord(text);
235
+ if (typeof parsed?.message === "string" && parsed.message) return parsed.message;
236
+ }
237
+ const plain = texts.find((text) => text && !parseJsonRecord(text));
238
+ return plain ?? texts[0] ?? fallback;
239
+ }
240
+
241
+ /**
242
+ * 把工具结果归一成「成功 void / 失败抛错」。
243
+ *
244
+ * possibly_sent 的映射是这里唯一需要小心的地方(文档 §5.2):v1 把它作为
245
+ * isError=false 加一段文本提示,模型经常读漏;v3 变成 reject 且 actionSent=true,
246
+ * 迫使模型先观察再决定,而不是盲重试一个可能已经落地的非幂等动作。
247
+ */
248
+ /**
249
+ * Helper 冷启动的 not-ready 信封。
250
+ *
251
+ * Bug 原因(2026-09-11 真机):Helper 是懒启动的 —— 第一次调用触发拉起,同时工具层
252
+ * 返回一个**非 error** 结果,单个文本块装着
253
+ * {kind:"CUA_NOT_READY", reasonCode:"broker_not_accepting", retryable:true,
254
+ * message:"...is starting up... Retry the same tool call after a brief wait."}
255
+ * 这是 producer 刻意设计的机器可读契约(possibly_sent ⇒ retryable=false,绝不重放;
256
+ * marker 在途 ⇒ 可重试),目的正是"宿主不必解析错误字符串"。
257
+ *
258
+ * 而 SDK 此前对它一无所知:appStateOf 只从 JSON 里挑 state_id/app/window/elements,
259
+ * not-ready 载荷一个键都不匹配,于是整条信号被丢掉,模型收到的是
260
+ * 「missing state_id, elements, app, window」。加上 node_repl 每个 cell 都是全新
261
+ * Worker,每次尝试都停在同一刻冷启动上,模型永远走不过去(真机上它据此去 request_access
262
+ * 并用 shell 排查环境)。
263
+ */
264
+ const NOT_READY_KIND = "CUA_NOT_READY";
265
+ const NOT_READY_MAX_ATTEMPTS = 6;
266
+ const NOT_READY_BACKOFF_MS = [250, 500, 750, 1000, 1500];
267
+
268
+ function notReadyEnvelopeOf(result) {
269
+ if (result?.isError === true) return undefined;
270
+ for (const text of textBlocks(result)) {
271
+ const parsed = parseJsonRecord(text);
272
+ if (parsed?.kind === NOT_READY_KIND) return parsed;
273
+ }
274
+ return undefined;
275
+ }
276
+
277
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
278
+
279
+ function assertOk(methodName, result) {
280
+ const receipt = receiptOf(result);
281
+ const isError = result?.isError === true;
282
+ const dispatch = receipt.dispatch_status;
283
+ const actionSent = receipt.action_sent === true;
284
+ if (!isError && dispatch !== "possibly_sent") return receipt;
285
+ const brokerCode = brokerErrorCodeOf(result);
286
+ const code = isError
287
+ ? (ERROR_CODE_BY_BROKER[brokerCode] ?? "INTERNAL")
288
+ : "TIMEOUT";
289
+ throw new ComputerUseError(
290
+ messageOf(result, `${methodName} failed`),
291
+ {
292
+ code,
293
+ actionSent: actionSent || dispatch === "possibly_sent",
294
+ dispatchStatus: dispatch,
295
+ details: { method: methodName, ...(brokerCode ? { brokerCode } : {}) },
296
+ },
297
+ );
298
+ }
299
+
300
+ // ─────────────────────────────────────────────────────────── 目标绑定
301
+
302
+ /**
303
+ * number → 元素索引,[x,y] → 当前栅格像素。
304
+ *
305
+ * 索引按**该 app 最新一次观察**解析(2026-09-12 对齐 codex:它的动作形如
306
+ * `set_value({app, elementIndex, value})`,索引身份只有 app + index,wire 上没有任何
307
+ * 「这个索引属于哪次观察」的凭证)。所以这里只发 `{type:"element", index}`,
308
+ * 由动作自带的 app_ref 说明作用域;坐标用当前 frameId,模型永远不需要碰 frame_id。
309
+ *
310
+ * 仍然要求先有一次观察:没有观察就没有索引可言,自动补观察会把「模型以为点 A、
311
+ * 实际点到 B」变成静默错误。
312
+ */
313
+ function bindTarget(binding, target, what) {
314
+ if (typeof target === "number") {
315
+ if (!Number.isInteger(target) || target < 0) {
316
+ throw new ComputerUseError(
317
+ `${what} element index must be a non-negative integer (got ${target})`,
318
+ { code: "INTERNAL" },
319
+ );
320
+ }
321
+ if (!binding.stateId) {
322
+ throw new ComputerUseError(
323
+ // Bug 原因(2026-09-12 真机):这里原先把 binding.label 拼进代码位置 ——
324
+ // `Call await ${binding.label}.getAXState()`。label 是**显示名**,模型用
325
+ // getApp({pid: 2697}) 绑定时它就是 "2697",于是给出的是 `await 2697.getAXState()`
326
+ // 这种语法都不合法的代码;绑定中文名时同样如此(`await 地图.getAXState()`)。
327
+ // 报错要么给可直接粘贴的代码,要么就用散文描述,不能给一句假代码。
328
+ `${what} needs a fresh accessibility state before an element index can be resolved. ` +
329
+ `Call getAXState() on the bound app first (the object getApp(...) returned), then act in the same cell.`,
330
+ { code: "STALE_STATE", details: { need: "getAXState" } },
331
+ );
332
+ }
333
+ return { type: "element", index: target };
334
+ }
335
+ if (Array.isArray(target) && target.length === 2) {
336
+ const [x, y] = target;
337
+ if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0) {
338
+ throw new ComputerUseError(
339
+ `${what} coordinate must be two non-negative integer pixels of the latest raster (got [${x}, ${y}])`,
340
+ { code: "INTERNAL" },
341
+ );
342
+ }
343
+ // 本 cell 没有取过栅格时不再拒绝。
344
+ //
345
+ // 工具层的坐标目标本来就允许省略 frame_id —— schemas/parseTarget.ts 的
346
+ // `CoordinateTarget.frame_id` 注释写着「omitted targets bind inside the current
347
+ // transport」,frame-pixel-resolver 的 `bindFramePixelTarget` 也早有 explicit /
348
+ // implicit 两条路,implicit 走 `resolveLatestForAction()`(绑该会话最近一次可动作
349
+ // 栅格)。所以"必须与截图同 cell"只是这一处 throw 加出来的限制,broker 侧从来不需要。
350
+ //
351
+ // 代价(真机 2026-09-14):node_repl 每个 cell 都是新 Worker,binding 不跨 cell,于是
352
+ // 每个要点坐标的 cell 都得先截一次图。模型照做前先撞一次这个错误、再补截图重来,
353
+ // 白花一轮。codex 的 mac 面根本没有帧凭证概念:坐标就是"最近一张 app 窗口截图的像素",
354
+ // 只要会话已激活就能直接点。
355
+ //
356
+ // 放宽之后安全性不变,且仍比 codex 严:帧过期 / 被替换 / 非可动作,以及 app_ref 与该帧
357
+ // 真实 owner 不一致(`frame_dispatch_identity_mismatch`),都仍然 fail-closed;一次栅格
358
+ // 都没有过时,broker 会给出「no actionable frame is available in this transport」。
359
+ return binding.frameId
360
+ ? { type: "coordinate", frame_id: binding.frameId, x, y }
361
+ : { type: "coordinate", x, y };
362
+ }
363
+ throw new ComputerUseError(
364
+ `${what} target must be an element index (number) or a raster pixel ([x, y])`,
365
+ { code: "INTERNAL" },
366
+ );
367
+ }
368
+
369
+ /**
370
+ * 一个字符串该当成 bundle id 还是 display name。
371
+ *
372
+ * Bug 原因(2026-09-11 真机):工具层的 `app_ref` 把**裸字符串一律当成 bundle_id**
373
+ * (`schemas/appRef.ts` 的 transform),所以 `getApp("Notes")` 变成
374
+ * `{bundle_id:"Notes"}` —— 即使 Notes 正在运行、`list_apps` 也把它的 `name` 报成
375
+ * "Notes",依然报「app 未运行且无法后台启动」。而 `{name:"Notes"}` 是通的。
376
+ * 模型按 SKILL 的指引传 display name,于是每次都撞在这里。
377
+ *
378
+ * bundle id 的判据:含点、不含空格和斜杠(`com.apple.Notes`)。其余按 display name。
379
+ * 猜错时调用方会用 {@link alternateAppRef} 换字段重试一次,所以判据只影响首选顺序,
380
+ * 不影响能不能成。
381
+ */
382
+ function looksLikeBundleId(target) {
383
+ return target.includes(".") && !/[\s/]/u.test(target);
384
+ }
385
+
386
+ function appRefFor(target, windowId) {
387
+ const field = looksLikeBundleId(target) ? "bundle_id" : "name";
388
+ return { [field]: target, ...(windowId === undefined ? {} : { window_id: windowId }) };
389
+ }
390
+
391
+ function alternateAppRef(target, windowId) {
392
+ const field = looksLikeBundleId(target) ? "name" : "bundle_id";
393
+ return { [field]: target, ...(windowId === undefined ? {} : { window_id: windowId }) };
394
+ }
395
+
396
+ /** 工具层的「未运行且无法后台启动」是 app 解析失败的信号,不是别的故障。 */
397
+ /**
398
+ * 只匹配语义核心「target app is not running」,不匹配后半句。
399
+ *
400
+ * 根因(2026-09-15,DCodeArena 151 例批次):原本匹配的是
401
+ * `target app is not running and could not be launched`,而 producer 的文案早已改成
402
+ * `target app is not running, and no installed application matched the ...`
403
+ * (dcode-cua 的 nativeAxSource.ts)。一个逗号加改写就让下面的 alternateAppRef 备用查询
404
+ * **一次都没执行过** —— 批次里 App 无法解析报错 47 次 / 23 例,模型只发了一次 name 查询就放弃。
405
+ * 与 #29 同类:字符串在两个仓库各写一遍,一侧改了另一侧不知道。
406
+ *
407
+ * 前缀是这条错误的语义核心("目标 app 没在运行"),后半句是随可执行建议演化的部分。
408
+ * dcode 侧的 sdk 测试钉住了 producer 的现行文案必须仍被这里命中。
409
+ */
410
+ function isAppNotFound(result) {
411
+ if (!result?.isError) return false;
412
+ return textBlocks(result).some((text) =>
413
+ /target app is not running/u.test(text),
414
+ );
415
+ }
416
+
417
+ const DIRECTIONS = new Set(["up", "down", "left", "right", "u", "d", "l", "r"]);
418
+ const LONG_DIRECTION = { u: "up", d: "down", l: "left", r: "right" };
419
+ const MOUSE_BUTTONS = { left: "left", right: "right", middle: "middle", l: "left", r: "right", m: "middle" };
420
+
421
+ // ─────────────────────────────────────────────────────────── 展示
422
+
423
+ /**
424
+ * 观察方法自行展示结果(对齐 Codex)。
425
+ *
426
+ * v1 把这件事写成 SKILL 里的一条纪律(「每个动作 cell 末尾必须显式
427
+ * nodeRepl.write(state.text)」),模型忘记就整段观察丢失。纪律本该由 SDK 承担。
428
+ * `{emit:false}` 留给「用代码判断但不想污染上下文」的场合。
429
+ */
430
+ function emitText(globals, text, options) {
431
+ if (options?.emit === false) return;
432
+ try {
433
+ globals.nodeRepl?.write?.(text);
434
+ } catch {
435
+ // 展示失败不能让功能调用失败。
436
+ }
437
+ }
438
+
439
+ function isFrameAuthorityText(value) {
440
+ const parsed = parseJsonRecord(value);
441
+ return Boolean(
442
+ parsed && Object.keys(parsed).length === 1 && Object.hasOwn(parsed, "image_ref"),
443
+ );
444
+ }
445
+
446
+ /**
447
+ * 图片与 frame authority 走 host 的 structured sink,文本走 write。
448
+ * 成功的纯文本动作不产生任何正文 —— 否则会和观察结果拼成两段输出。
449
+ */
450
+ function projectToHost(globals, result) {
451
+ const sink = globals.nodeRepl?.emitStructuredResult;
452
+ if (typeof sink !== "function" || !result || !Array.isArray(result.content)) return;
453
+ const media = result.content.filter(
454
+ (block) => block?.type === "image" || (block?.type === "text" && isFrameAuthorityText(block.text)),
455
+ );
456
+ if (media.length > 0) {
457
+ // 带图的观察必须**保留**自己的 structuredContent(投影后)。
458
+ //
459
+ // Bug 根因(2026-09-17 真机,会话 sess_8ec5bb22):这里原先无条件丢弃它,于是
460
+ // getAXStateAndScreenshot() 那次的元数据一个字都不显示,模型看到的 `Structured
461
+ // content` 是同 cell 里**没有图**的另一次调用 —— 也就是 getApp() 绑定时那次隐藏的
462
+ // 全量观察。可观测后果:请求了像素、图也确实附上了,`has_image` 却恒为 false
463
+ // (6 次调用全中),而 SKILL 里写着 "has_image: false 只表示这次没要像素",两边对不上。
464
+ // 更危险的是 `window` 与 `state_id` 也是探针那次的:探针早于本 cell 的动作执行,
465
+ // 弹窗在动作后才出现时,模型读到的是**漂移前**的窗口,据此判断"对话框没出现"。
466
+ //
467
+ // 丢弃的原意是防元素全表泄漏(见 forDisplay 的注释),但那件事 forDisplay 已经做了,
468
+ // 不需要整块丢。这里走同一条投影,所以元素表照样不会外泄。
469
+ const { structuredContent: raw, ...rest } = result;
470
+ const structured = raw && typeof raw === "object" ? forDisplay(raw) : undefined;
471
+ sink({ ...rest, content: media, ...(structured ? { structuredContent: structured } : {}) });
472
+ return;
473
+ }
474
+ if (result.isError === true) {
475
+ sink(result);
476
+ return;
477
+ }
478
+ const meta = result._meta && typeof result._meta === "object" ? result._meta : undefined;
479
+ const structured =
480
+ result.structuredContent && typeof result.structuredContent === "object"
481
+ ? result.structuredContent
482
+ : undefined;
483
+ if (!meta && !structured) return;
484
+ sink({
485
+ content: [],
486
+ ...(structured ? { structuredContent: forDisplay(structured) } : {}),
487
+ ...(meta ? { _meta: meta } : {}),
488
+ });
489
+ }
490
+
491
+ /**
492
+ * 投给宿主的结构化负载:去掉元素全表。
493
+ *
494
+ * Bug 原因(2026-09-11 真机):观察结果的 `structuredContent` 里带着整棵 AX 树的
495
+ * 元素数组,宿主把它挂到 MCP 结果上,agent 再把它序列化进**模型可见**的工具消息。
496
+ * 打开 Notes(26 条笔记、180+ 元素)时单次 node_repl 输出 140KB 被迫落盘,模型只能
497
+ * 反过来用 shell 去 grep 自己的工具结果。而元素表对模型是纯重复:它读的是同一次
498
+ * 观察里已经渲染好的树文本,结构化视图只在 Worker 内部给 SDK 用。
499
+ *
500
+ * 宿主侧唯一的消费者是工具结果展示面板(core 的 result-display,32KB 上限),
501
+ * 所以这里保留能标识状态的小字段,只把 `elements` 换成计数、`text` 丢掉(它已在
502
+ * content 里)。非观察类结果(动作收据等)没有 elements,原样透传。
503
+ */
504
+ function forDisplay(structured) {
505
+ if (!Array.isArray(structured.elements)) return structured;
506
+ const { elements, text: _text, ...rest } = structured;
507
+ return { ...rest, element_count: elements.length };
508
+ }
509
+
510
+ function imageBytesOf(result) {
511
+ const content = Array.isArray(result?.content) ? result.content : [];
512
+ const image = content.find((block) => block?.type === "image" && typeof block.data === "string");
513
+ return image ? Uint8Array.from(Buffer.from(image.data, "base64")) : undefined;
514
+ }
515
+
516
+ /** AppState 的结构化事实来源。缺任何一项都 fail closed,不让模型拿到 undefined 去猜。 */
517
+ function appStateOf(methodName, result) {
518
+ const structured =
519
+ result?.structuredContent && typeof result.structuredContent === "object"
520
+ ? result.structuredContent
521
+ : {};
522
+ const merged = { ...structured };
523
+ for (const text of textBlocks(result)) {
524
+ const parsed = parseJsonRecord(text);
525
+ if (!parsed) continue;
526
+ for (const key of ["state_id", "app", "window", "elements", "text", "snapshot_mode", "base_state_id"]) {
527
+ if (merged[key] === undefined && parsed[key] !== undefined) merged[key] = parsed[key];
528
+ }
529
+ }
530
+ if (merged.text === undefined) {
531
+ const tree = textBlocks(result).find((text) => !parseJsonRecord(text));
532
+ if (tree) merged.text = tree;
533
+ }
534
+ // 诊断性(2026-09-11 真机):这条过去只说「Re-run the bootstrap and observe again」。
535
+ // 模型照做了两次、次次失败,然后升级去 request_access 并用 shell 排查环境 —— 而事后
536
+ // 从转录本也无法回溯究竟缺了哪个字段,因为结果里只剩这句话。缺什么必须写进消息,
537
+ // 否则同一个故障既不可自愈也不可诊断。
538
+ const missing = [
539
+ typeof merged.state_id !== "string" ? "state_id" : null,
540
+ Array.isArray(merged.elements) ? null : "elements",
541
+ merged.app ? null : "app",
542
+ merged.window ? null : "window",
543
+ ].filter(Boolean);
544
+ if (missing.length > 0) {
545
+ throw new ComputerUseError(
546
+ `${methodName} returned no usable accessibility state: missing ${missing.join(", ")}. ` +
547
+ "A hidden app with no open window can produce this, and re-observing will not change it. " +
548
+ "Check listApps() for the app's state, or ask the user to open a window. " +
549
+ "Do not infer a state_id from prose.",
550
+ { code: "STRUCTURED_STATE_UNAVAILABLE", details: { method: methodName, missing } },
551
+ );
552
+ }
553
+ return merged;
554
+ }
555
+
556
+ /**
557
+ * Producer 随观察附带的**告知性文本块**(`[effect_evidence unchanged]`、
558
+ * `[screenshot_blank]` 等),树文本本身除外。
559
+ *
560
+ * Bug 根因(2026-09-17 真机,会话 sess_8ec5bb22):producer 为「AX 受理了但界面逐字节
561
+ * 未变」专门做了一个与控件类型无关的证据位,观察响应里以独立 text block 下发。而
562
+ * node_repl 这条链路把它整个丢了:`getAXState()` 只返回 `state.text`(= 树),
563
+ * `emitText` 也只写这一段,`projectToHost` 的非 media 分支又把 `content` 清成 `[]`。
564
+ * 于是模型对"点了没反应"零感知 —— 该会话里 3 次坐标点击全部静默无效、模型只能靠自己
565
+ * diff 两棵树,最终连点 3 次同一条失效路径。
566
+ *
567
+ * 挂在树文本后面而不是另开通道:模型常写 `{ emit: false }` 再自行过滤,附加在返回值里
568
+ * 两条路都覆盖到。同一个做法 getAXStateAndScreenshot 早就在用(`[screenshot unavailable: …]`)。
569
+ */
570
+ function advisoryTextOf(result, treeText) {
571
+ const blocks = textBlocks(result).filter(
572
+ (text) => text && text !== treeText && !parseJsonRecord(text) && !isFrameAuthorityText(text),
573
+ );
574
+ return blocks.length > 0 ? `\n${blocks.join("\n")}` : "";
575
+ }
576
+
577
+ // ─────────────────────────────────────────────────────────── 首用文档
578
+
579
+ // ─────────────────────────────────────────────────────────── Target
580
+
581
+ /**
582
+ * App / Window 共享的交互面 —— Codex `Target` 的 12 个成员,逐字同签。
583
+ * 附加成员数为 0(zoom 已在评审中删除);DCode 的附加能力只出现在选项袋里。
584
+ */
585
+ function createTarget(ctx, binding) {
586
+ const { call, globals } = ctx;
587
+
588
+ const observe = async (options, includeScreenshot, yieldsTree) => {
589
+ // 默认拿 delta(Helper 的 snapshot_mode),显式要整树时转发 `disable_diffing`,
590
+ // 工具层再把它翻成 broker 的 `force_full`。字段名必须和 get_app_state 的 schema
591
+ // 一致:它是 `.strict()` 的,凭空发明的键会让每一次观察都被 unrecognized_keys
592
+ // 打回(2026-09-11 真机就是这么炸的)。
593
+ const args = { app_ref: binding.appRef, include_screenshot: includeScreenshot };
594
+ // 不变量:delta 只在基线是模型**看过**的树时才允许。
595
+ //
596
+ // Bug 根因(2026-09-13 真机,会话 sess_04f97297):Helper 的 diff 基线是
597
+ // `snapshotCache.get(pid, window)` —— "该 pid+窗口的上一次 capture",不管发起者是谁、
598
+ // 模型有没有看见。而 getApp() 绑定时会做一次**全量但不展示**的观察(身份解析、窗口校验、
599
+ // 索引基线都要它),那次 capture 就成了基线。于是模型第一次 getAXState() 是对着一棵它
600
+ // 从未见过的树做增量:飞书 930 个元素只吐出 8 行 AXMenuItem enabled 抖动,模型只能
601
+ // 每次都显式传 disableDiffing 拿整树。node_repl 每个 cell 都是全新 Worker、每个 cell
602
+ // 都要重新 getApp,所以这不是"首次观察"的个例,而是**每个 cell 都发生**。
603
+ //
604
+ // 只截图的观察同样移动了 Helper 的基线却不给树文本,所以它把 treeSeen 清掉 ——
605
+ // 这和 codex 文档那条 "After a screenshot-only observation, request a full tree before
606
+ // relying on accessibility indexes again" 是同一条规则,只是这里强制执行而非靠模型记住。
607
+ if (yieldsTree === true && binding.treeSeen !== true) args.disable_diffing = true;
608
+ if (options?.disableDiffing === true) args.disable_diffing = true;
609
+ // yieldsTree=false(getScreenshot)不给树文本 —— 上面 binding.treeSeen 的注释里本来就写着
610
+ // 「也不能把自己算成基线」。同一个道理对 producer 侧的位移台账成立:没展示给模型的树
611
+ // 不能被算作「模型看到过的」,否则索引位移校验变成当前树跟当前树自比(2026-09-15 探针实测)。
612
+ if (yieldsTree !== true) args.tree_shown_to_model = false;
613
+ const result = await call("get_app_state", args);
614
+ assertOk("get_app_state", result);
615
+ const state = appStateOf("get_app_state", result);
616
+ binding.stateId = state.state_id;
617
+ binding.treeSeen = yieldsTree === true;
618
+ const frameId = frameIdOf(result);
619
+ if (typeof frameId === "string") binding.frameId = frameId;
620
+ return { state, result };
621
+ };
622
+
623
+ const act = async (methodName, args) => {
624
+ const result = await call(methodName, args);
625
+ assertOk(methodName, result);
626
+ // 这里原先动作后无条件 `binding.stateId = undefined`,逼下一次索引解析先重新观察。
627
+ //
628
+ // 为什么去掉(2026-09-12,逆向 codex 后):
629
+ // 1. 与我们自己的文档打架。参考文档原样抄了 codex 的动作目录块(标题「Perform one or
630
+ // more actions, and then fetch the latest state」,里面就是 click(42) 紧跟 setValue(42)),
631
+ // 而这条守卫让那种写法必然失败。真机上模型正是照文档写 click(9); setValue(9, ...),
632
+ // 然后被拒,白烧两个 cell(一个重新观察、一个重做)。
633
+ // 2. codex 没有任何等价物。它的模型面里 stateId/stale/generation 出现 0 次,
634
+ // set_value({app, elementIndex, value}) 不带任何新鲜度凭证;失效判定在原生服务,
635
+ // 而且是被动的 —— UIElementTreeInvalidationMonitor 先尝试按特征重取元素,
636
+ // 只有重取歧义/失败才报 "The element ID is no longer valid"。
637
+ // 3. 这条守卫没有信息支撑:SDK 只知道"发生过动作",不知道 UI 是否变化、也不知道那个
638
+ // 元素还在不在。真正知道的是 Helper —— 索引在观察时就已冻结成 native token,
639
+ // 元素消失时它本来就 fail-closed。
640
+ //
641
+ // 所以保留冻结映射 + Helper 侧校验(与 codex 同档),不再在客户端提前否决。
642
+ };
643
+
644
+ return {
645
+ async getAXState(options) {
646
+ const { state, result } = await observe(options, false, true);
647
+ // producer 的告知性块(effect_evidence / screenshot_blank)必须跟着树一起走,
648
+ // 否则这条链路会把它们丢光 —— 见 advisoryTextOf 的根因注释。
649
+ const text = `${state.text}${advisoryTextOf(result, state.text)}`;
650
+ emitText(globals, text, options);
651
+ return text;
652
+ },
653
+
654
+ async getScreenshot(options) {
655
+ // yieldsTree=false:只截图不给树文本,所以它不强制整树,也不能把自己算成基线。
656
+ const { state, result } = await observe(options, true, false);
657
+ const bytes = imageBytesOf(result);
658
+ if (!bytes) {
659
+ throw new ComputerUseError(
660
+ // 措辞纪律(2026-09-11 真机):这里过去写「raise the window and retry」,
661
+ // 那是直接教模型抢用户焦点 —— 后台截图本来就能拿到(负坐标副屏窗口实测可用)。
662
+ //
663
+ // 而真正常见的成因是应用处于 hidden(⌘H):macOS 不渲染隐藏窗口,Helper 的前后
664
+ // surface 指纹必然不一致,栅格 fail-closed。此时重试永远不会成功,模型如果只看到
665
+ // 一句无信息的 "Screenshot unavailable" 就会绕圈并升级(历史上升级成抢焦点,
666
+ // 后来升级成 request_access + shell 排查)。所以把 producer 给出的原因带上,
667
+ // 并指回真正可走的 AX 路径 —— 隐藏窗口的 accessibility 完全不受影响。
668
+ `Screenshot unavailable for ${binding.label}` +
669
+ (state.non_actionable_reason ? ` (${state.non_actionable_reason})` : "") +
670
+ (state.screenshot_blank ? " (the captured raster was blank)" : "") +
671
+ ". The accessibility tree still works on a hidden or unrendered window: use " +
672
+ "getAXState() and act on element indices. Do not activate the app; ask the user " +
673
+ "to unhide the window if pixels are genuinely required.",
674
+ { code: "ELEMENT_UNAVAILABLE", details: { app: binding.label } },
675
+ );
676
+ }
677
+ return bytes;
678
+ },
679
+
680
+ async getAXStateAndScreenshot(options) {
681
+ const { state, result } = await observe(options, true, true);
682
+ const screenshot = imageBytesOf(result);
683
+ // 静默丢图是本轮最后一条同类缺陷(codex 这条路径同样静默:screenshot===null 时
684
+ // 直接 return {state},一句话不给)。真机上模型正是从"没收到图"推断出「截图没有
685
+ // 回传(窗口在副显示器上,坐标为负)」,然后去抢用户焦点——它得到的信息量是零,
686
+ // 所以只能猜。请求了像素却没拿到,必须说清为什么、以及重试有没有用。
687
+ const note =
688
+ screenshot || !state.non_actionable_reason
689
+ ? ""
690
+ : `\n[screenshot unavailable: ${state.non_actionable_reason}]` +
691
+ " A hidden or minimized window produces this persistently, so re-observing will not" +
692
+ " help. The accessibility tree above is complete — act on element indices. Do not" +
693
+ " activate the app.";
694
+ const text = `${state.text}${advisoryTextOf(result, state.text)}`;
695
+ emitText(globals, `${text}${note}`, options);
696
+ return screenshot ? { state: text, screenshot } : { state: text };
697
+ },
698
+
699
+ async paste(text, options) {
700
+ if (typeof text !== "string") {
701
+ throw new ComputerUseError("paste requires text", { code: "INTERNAL" });
702
+ }
703
+ await act("paste", {
704
+ app_ref: binding.appRef,
705
+ text,
706
+ format: options?.format ?? "text",
707
+ });
708
+ },
709
+
710
+ async click(target, options) {
711
+ const bound = bindTarget(binding, target, "click");
712
+ const button = options?.mouseButton ? MOUSE_BUTTONS[options.mouseButton] : "left";
713
+ if (!button) {
714
+ throw new ComputerUseError(
715
+ `click mouseButton must be left, right or middle (got ${options.mouseButton})`,
716
+ { code: "INTERNAL" },
717
+ );
718
+ }
719
+ await act("left_click", {
720
+ target: bound,
721
+ // 索引作用域:元素目标不再带 state_id,app_ref 说明"按哪个 app 的最新观察解析"。
722
+ // 少了它,多 app 会话里的索引会退到"全局最近一次观察",可能属于另一个 app。
723
+ app_ref: binding.appRef,
724
+ mouse_button: button,
725
+ click_count: options?.clickCount ?? 1,
726
+ ...(options?.modifiers ? { modifiers: options.modifiers } : {}),
727
+ ...(options?.strategy ? { strategy: options.strategy } : {}),
728
+ });
729
+ },
730
+
731
+ async drag(from, to, options) {
732
+ await act("left_click_drag", {
733
+ from_target: bindTarget(binding, from, "drag from"),
734
+ to: bindTarget(binding, to, "drag to"),
735
+ app_ref: binding.appRef,
736
+ ...(options?.modifiers ? { modifiers: options.modifiers } : {}),
737
+ });
738
+ },
739
+
740
+ async pressKey(key, options) {
741
+ if (typeof key !== "string" || !key.trim()) {
742
+ throw new ComputerUseError("pressKey requires a key or chord", { code: "INTERNAL" });
743
+ }
744
+ await act("key", {
745
+ text: normalizeKeyChord(key, ctx.platform),
746
+ app_ref: binding.appRef,
747
+ ...(options?.holdSeconds !== undefined ? { hold_seconds: options.holdSeconds } : {}),
748
+ ...(options?.strategy ? { strategy: options.strategy } : {}),
749
+ });
750
+ },
751
+
752
+ async scroll(target, direction, pages, options) {
753
+ const dir = typeof direction === "string" ? direction.trim().toLowerCase() : "";
754
+ if (!DIRECTIONS.has(dir)) {
755
+ throw new ComputerUseError(
756
+ `scroll direction must be up, down, left or right (got ${direction})`,
757
+ { code: "INTERNAL" },
758
+ );
759
+ }
760
+ await act("scroll", {
761
+ target: bindTarget(binding, target, "scroll"),
762
+ app_ref: binding.appRef,
763
+ scroll_direction: LONG_DIRECTION[dir] ?? dir,
764
+ scroll_amount: pages === undefined ? 1 : pages,
765
+ ...(options?.strategy ? { strategy: options.strategy } : {}),
766
+ });
767
+ },
768
+
769
+ async selectText(elementIndex, text, options) {
770
+ const bound = bindTarget(binding, elementIndex, "selectText");
771
+ // Codex 的签名是按内容匹配。range 由 SDK 在**已观察到的元素 value** 上本地算出,
772
+ // 不需要额外的 broker 往返:value 就在上一次 getAXState 的结构化结果里。
773
+ const range = resolveTextRange(binding, elementIndex, text, options);
774
+ await act("select_text", { target: bound, app_ref: binding.appRef, text_range: range });
775
+ },
776
+
777
+ async setValue(elementIndex, value) {
778
+ if (typeof value !== "string") {
779
+ throw new ComputerUseError("setValue requires a string value", { code: "INTERNAL" });
780
+ }
781
+ await act("set_value", {
782
+ target: bindTarget(binding, elementIndex, "setValue"),
783
+ app_ref: binding.appRef,
784
+ value,
785
+ });
786
+ },
787
+
788
+ async typeText(text) {
789
+ if (typeof text !== "string") {
790
+ throw new ComputerUseError("typeText requires text", { code: "INTERNAL" });
791
+ }
792
+ await act("type", { text, app_ref: binding.appRef });
793
+ },
794
+
795
+ async performSecondaryAction(elementIndex, action) {
796
+ if (typeof action !== "string" || !action.trim()) {
797
+ throw new ComputerUseError(
798
+ "performSecondaryAction requires an action advertised by the element; do not guess one",
799
+ { code: "ACTION_UNAVAILABLE" },
800
+ );
801
+ }
802
+ await act("perform_action", {
803
+ target: bindTarget(binding, elementIndex, "performSecondaryAction"),
804
+ app_ref: binding.appRef,
805
+ action,
806
+ });
807
+ },
808
+ };
809
+ }
810
+
811
+ /**
812
+ * 按内容定位 [start, length]。
813
+ *
814
+ * `prefix` / `suffix` 用来消歧重复匹配;`selectionType` 把选区折叠成光标位置。
815
+ * 匹配不唯一时抛 NOT_SELECTABLE 并报候选数 —— 静默取第一个会让模型在错的位置编辑。
816
+ */
817
+ function resolveTextRange(binding, elementIndex, text, options) {
818
+ if (text && typeof text === "object" && Number.isInteger(text.start)) {
819
+ return [text.start, text.length ?? 0];
820
+ }
821
+ if (typeof text !== "string" || text.length === 0) {
822
+ throw new ComputerUseError("selectText requires the text to locate", { code: "INTERNAL" });
823
+ }
824
+ const element = binding.elements?.[elementIndex];
825
+ const value = typeof element?.value === "string" ? element.value : undefined;
826
+ if (value === undefined) {
827
+ throw new ComputerUseError(
828
+ `selectText cannot locate text in element ${elementIndex}: the latest accessibility state exposed no value for it. Observe again, or use a range { start, length }.`,
829
+ { code: "NOT_SELECTABLE", details: { elementIndex } },
830
+ );
831
+ }
832
+ const needle = `${options?.prefix ?? ""}${text}${options?.suffix ?? ""}`;
833
+ const hits = [];
834
+ for (let at = value.indexOf(needle); at !== -1; at = value.indexOf(needle, at + 1)) {
835
+ hits.push(at);
836
+ }
837
+ if (hits.length === 0) {
838
+ throw new ComputerUseError(
839
+ `selectText found no occurrence of the requested text in element ${elementIndex}`,
840
+ { code: "NOT_SELECTABLE", details: { elementIndex } },
841
+ );
842
+ }
843
+ if (hits.length > 1) {
844
+ throw new ComputerUseError(
845
+ `selectText matched ${hits.length} occurrences in element ${elementIndex}; disambiguate with prefix/suffix`,
846
+ { code: "NOT_SELECTABLE", details: { elementIndex, matches: hits.length } },
847
+ );
848
+ }
849
+ const start = hits[0] + (options?.prefix?.length ?? 0);
850
+ const selectionType = options?.selectionType ?? "text";
851
+ if (selectionType === "cursor_before") return [start, 0];
852
+ if (selectionType === "cursor_after") return [start + text.length, 0];
853
+ return [start, text.length];
854
+ }
855
+
856
+ /**
857
+ * 键位别名规范化:接受 xdotool / X keysym 写法(Codex 全线用它,模型先验也在那边),
858
+ * 输出 DCode 的内部 token 集合。只做输入侧兼容,不改跨平台修饰键规则
859
+ * (macOS cmd,Linux / Windows ctrl)。
860
+ */
861
+ const KEY_ALIASES = Object.freeze({
862
+ return: "return", enter: "return", kp_enter: "return",
863
+ control_l: "ctrl", control_r: "ctrl", control: "ctrl", ctrl: "ctrl",
864
+ alt_l: "alt", alt_r: "alt", meta_l: "alt", alt: "alt",
865
+ shift_l: "shift", shift_r: "shift", shift: "shift",
866
+ escape: "esc", esc: "esc",
867
+ prior: "pageup", next: "pagedown",
868
+ period: ".", comma: ",", greater: ">", slash: "/", minus: "-", equal: "=",
869
+ });
870
+
871
+ function normalizeKeyChord(chord, platform) {
872
+ return chord
873
+ .split("+")
874
+ .map((raw) => {
875
+ const token = raw.trim();
876
+ const lower = token.toLowerCase();
877
+ if (lower === "super_l" || lower === "super_r" || lower === "super") {
878
+ return platform === "darwin" ? "cmd" : platform === "win32" ? "win" : "super";
879
+ }
880
+ return KEY_ALIASES[lower] ?? token;
881
+ })
882
+ .filter(Boolean)
883
+ .join("+");
884
+ }
885
+
886
+ // ─────────────────────────────────────────────────────────── 装配
887
+
888
+ export async function setupComputerUseRuntime({ globals }) {
889
+ const bridge = globals[BRIDGE_SYMBOL];
890
+ if (!bridge || typeof bridge !== "object" || typeof bridge.call !== "function") {
891
+ throw new Error(
892
+ "Computer Use runtime bridge is unavailable. Use Computer Use from a DCode desktop or shared-host session.",
893
+ );
894
+ }
895
+ bridge.assertAvailable?.();
896
+
897
+ const platform = globals.process?.platform ?? process.platform;
898
+
899
+ const call = async (methodName, args) => {
900
+ // not-ready 是 Helper 冷启动的正常一步,不是失败:payload 明确要求"稍等后重试同一
901
+ // 调用"。只在 retryable 为真时重试 —— possibly_sent 的动作绝不重放(见常量注释)。
902
+ for (let attempt = 0; ; attempt += 1) {
903
+ bridge.assertAvailable?.();
904
+ const result = await bridge.call(methodName, args ?? {});
905
+ const notReady = notReadyEnvelopeOf(result);
906
+ if (!notReady) {
907
+ projectToHost(globals, result);
908
+ return result;
909
+ }
910
+ if (notReady.retryable !== true || attempt >= NOT_READY_MAX_ATTEMPTS - 1) {
911
+ // 不可重试,或已等到上限:把 producer 自己的话和 reasonCode 交给模型,
912
+ // 不要替换成通用句子(那正是今天反复出问题的地方)。
913
+ throw new ComputerUseError(
914
+ `${methodName}: ${notReady.message ?? "Computer Use is not ready."}`,
915
+ {
916
+ code: notReady.retryable === true ? "TIMEOUT" : "CONTROLLER_BUSY",
917
+ details: {
918
+ method: methodName,
919
+ reasonCode: notReady.reasonCode,
920
+ retryable: notReady.retryable === true,
921
+ attempts: attempt + 1,
922
+ },
923
+ },
924
+ );
925
+ }
926
+ await sleep(NOT_READY_BACKOFF_MS[Math.min(attempt, NOT_READY_BACKOFF_MS.length - 1)]);
927
+ }
928
+ };
929
+
930
+ const ctx = { call, globals, platform };
931
+
932
+ /** 每个绑定目标在 Worker 内的视图;跨 cell 的真身由 shared host 的 session 持有。 */
933
+ const createBinding = (label, appRef) => ({
934
+ label,
935
+ appRef,
936
+ stateId: undefined,
937
+ frameId: undefined,
938
+ elements: undefined,
939
+ // delta 的基线是否是模型**看过**的那棵树。见 createTarget 的 observe()。
940
+ treeSeen: false,
941
+ });
942
+
943
+ const bindApp = async (target, windowId) => {
944
+ let objectRef;
945
+ let label = typeof target === "string" ? target : "";
946
+ // 也接受工具层那种 app_ref 对象。SKILL 把绑定面和逃逸口并排放着,模型学到
947
+ // `{bundle_id: ...}` 之后很自然会把它传给 getApp(2026-09-11 真机就是这样,
948
+ // 撞了 "getApp requires an app display name or bundle id")。对象形态没有歧义,
949
+ // 直接透传比让它退回去改写更省一次往返。
950
+ if (target && typeof target === "object" && !Array.isArray(target)) {
951
+ const direct = windowId === undefined ? { ...target } : { ...target, window_id: windowId };
952
+ if (
953
+ typeof direct.name !== "string" &&
954
+ typeof direct.bundle_id !== "string" &&
955
+ typeof direct.pid !== "number"
956
+ ) {
957
+ throw new ComputerUseError(
958
+ "getApp needs an app display name, a bundle id, or {name|bundle_id|pid}",
959
+ { code: "INVALID_APP" },
960
+ );
961
+ }
962
+ objectRef = direct;
963
+ label = String(direct.name ?? direct.bundle_id ?? direct.pid);
964
+ } else if (typeof target !== "string" || !target.trim()) {
965
+ throw new ComputerUseError(
966
+ "getApp needs an app display name, a bundle id, or {name|bundle_id|pid}",
967
+ { code: "INVALID_APP" },
968
+ );
969
+ }
970
+ let appRef = objectRef ?? appRefFor(target, windowId);
971
+ const binding = createBinding(label, appRef);
972
+ const app = createTarget(ctx, binding);
973
+ // 绑定即观察(对齐 Codex):返回前展示该 app 的**全量** AX 树。
974
+ let result = await call("get_app_state", {
975
+ app_ref: appRef,
976
+ include_screenshot: false,
977
+ disable_diffing: true,
978
+ // 这棵树不展示给模型(下面 981 行那段注释说明为什么),所以它**不能**被算作
979
+ // 「模型看到过的树」。不标的后果(2026-09-15 探针实测):索引位移校验拿当前树
980
+ // 跟当前树自比,必然一致,静默点错元素的保护完全失效。
981
+ tree_shown_to_model: false,
982
+ });
983
+ if (isAppNotFound(result) && objectRef === undefined) {
984
+ // 只有字符串需要猜字段;对象形态是显式的,猜错的是调用方而不是我们。
985
+ const alternate = alternateAppRef(target, windowId);
986
+ if (alternate) {
987
+ appRef = alternate;
988
+ binding.appRef = alternate;
989
+ result = await call("get_app_state", {
990
+ app_ref: alternate,
991
+ include_screenshot: false,
992
+ disable_diffing: true,
993
+ tree_shown_to_model: false,
994
+ });
995
+ }
996
+ }
997
+ assertOk("get_app_state", result);
998
+ const state = appStateOf("get_app_state", result);
999
+ binding.stateId = state.state_id;
1000
+ binding.elements = state.elements;
1001
+ // 绑定成功后把 app_ref 收敛成**解析出来的身份**,不再继续拿模型给的原始字符串。
1002
+ //
1003
+ // Bug 原因(2026-09-12 真机):模型写 `getApp("地图")`(Maps 的中文本地化名)。观察成功了
1004
+ // —— capture_app 按名字找不到活动应用会走透明拉起分支,那里的 LaunchServices 查表能把
1005
+ // "地图" 解成 com.apple.Maps。但同一个 cell 里紧接着的 pressKey 失败:
1006
+ // press_key_to_app: app_ref did not resolve to a unique live application
1007
+ // 因为活动应用列表里 Maps 的 name 字段是英文 "Maps",{name:"地图"} 匹配零行,而键盘路径
1008
+ // 只按活动应用严格匹配。观察路径宽容、输入路径严格,两边就此分叉。
1009
+ //
1010
+ // 观察结果里本来就带着已解析的身份(state.app.pid / bundle_id),此前只是没用。收敛之后
1011
+ // 本地化名、模糊名、大小写差异都只在第一跳解决一次,后续所有路径拿到的都是无歧义身份。
1012
+ // 两个字段都给:pid 精确(同 cell 内进程不会换),bundle_id 供 broker 侧交叉校验身份。
1013
+ // window_id 若已绑定必须保留 —— 它决定 macOS 后台键盘的 synthetic-focus session。
1014
+ const resolvedPid = typeof state.app?.pid === "number" ? state.app.pid : undefined;
1015
+ const resolvedBundleId =
1016
+ typeof state.app?.bundle_id === "string" && state.app.bundle_id.trim()
1017
+ ? state.app.bundle_id
1018
+ : undefined;
1019
+ if (resolvedPid !== undefined || resolvedBundleId !== undefined) {
1020
+ const boundWindowId =
1021
+ typeof appRef?.window_id === "number" ? { window_id: appRef.window_id } : {};
1022
+ appRef = {
1023
+ ...(resolvedPid !== undefined ? { pid: resolvedPid } : {}),
1024
+ ...(resolvedBundleId !== undefined ? { bundle_id: resolvedBundleId } : {}),
1025
+ ...boundWindowId,
1026
+ };
1027
+ binding.appRef = appRef;
1028
+ }
1029
+ // 判据取自**最终的 appRef**,不是位置参数 windowId。
1030
+ //
1031
+ // Bug 根因(2026-09-17 复查):这里原先写 `windowId !== undefined`,只认
1032
+ // getWindow(target, id) 那种位置参数形态。而 `getApp({pid, window_id})` 也能钉窗口
1033
+ // (objectRef 直接透传,见上面的 direct),走那条路时 windowId 恒为 undefined,
1034
+ // 于是这道 fail-closed 被整个跳过:Helper 静默降级到最前窗口、只回一句 note,
1035
+ // 模型在错窗口上继续操作。两种钉法必须同一套校验。
1036
+ const boundWindow = typeof appRef?.window_id === "number" ? appRef.window_id : windowId;
1037
+ if (boundWindow !== undefined && state.window?.window_id_fallback === true) {
1038
+ // 现状会静默降级到最前窗口并只回一句 note,模型极易读漏后在错窗口上继续操作。
1039
+ throw new ComputerUseError(
1040
+ `Window ${boundWindow} of ${label} could not be resolved; the Helper fell back to the frontmost window. Call agent.computerUse.computer.list_windows to pick a fresh window_id.`,
1041
+ { code: "STALE_STATE", details: { app: label, windowId: boundWindow } },
1042
+ );
1043
+ }
1044
+ // getApp 不展示状态(2026-09-13)。它仍然做一次观察 —— 身份解析、窗口校验与索引解析
1045
+ // 基线都要它 —— 但不再把那棵树写进输出。
1046
+ //
1047
+ // 根因:Codex 的 `cua.getApp()` 同样自动输出结果,但它的 kernel 是**持久**的,
1048
+ // `const app = await cua.getApp(...)` 跨 cell 存活,所以一个会话只调一次 getApp,
1049
+ // 那次展示确实省掉一次单独观察。DCode 的 node_repl 每个 cell 都是全新 Worker,绑定
1050
+ // 不跨 cell,于是**每个 cell 都要重新 getApp**,那次"顺便展示"就从"一次性省一个往返"
1051
+ // 变成了"每个 cell 都多一棵注定作废的树"。行为照抄了 Codex,前提没跟着抄。
1052
+ //
1053
+ // 代价不只是 token。真机实测(会话 sess_f14c07fd):8 个 cell 里 5 个吐两棵树,
1054
+ // 36% 的工具输出(43902/120854 字符)是被同 cell 后续观察直接覆盖的前序树;更糟的是
1055
+ // 同一个 `[35]` 在一个 cell 的输出里指向两个不同元素(先出现的 79 元素树里是
1056
+ // `genericelement Yanbao Maquanying Homeland`,后出现的 105 元素树里才是
1057
+ // `button Tsinghua University`),而**只有最后那棵**是索引解析真正会用的,输出里却
1058
+ // 没有任何标记说明这件事。读错就点到无关元素,且不会报错 —— 那个索引在旧树里合法。
1059
+ //
1060
+ // 想看状态就显式 getAXState(),与其它观察方法一致。
1061
+ //
1062
+ // 结构化元素视图。Codex 的 Target 只给字符串,但模型写的是代码,单 cell 内
1063
+ // 「观察 → 找元素 → 动作」能省一次往返;树被按优先级裁剪(targeting/format.ts 的
1064
+ // `indices are sparse` 注记)时,它还是模型拿到隐藏元素索引的唯一途径。因此它已被
1065
+ // 提为**文档化成员**(SKILL 的 Keyboard 段 + docs/computer-use.md 的 API 段),
1066
+ // 保持 enumerable:false 只是为了不进 Object.keys —— R2 的「Target 附加成员为 0」
1067
+ // 说的是与 Codex 逐字对齐的**可枚举**面,不是禁止具名逃逸口。
1068
+ Object.defineProperty(app, "elements", {
1069
+ enumerable: false,
1070
+ value: async () => {
1071
+ // 与 getScreenshot 同档的静默观察(见 createTarget 的 observe):结果只回给 JS、
1072
+ // 不 emit 任何东西,所以两条声明都必须给。
1073
+ //
1074
+ // Bug 原因(2026-09-16 评审):这里过去两条都没给。
1075
+ // 1. 漏 tree_shown_to_model=false ⇒ producer 默认按「模型看过」记账
1076
+ // (observation.ts 的 `treeShownToModel !== false`),索引位移台账被一棵模型
1077
+ // 没看过的树覆盖,校验退化成当前树跟当前树自比,静默点错元素的保护失效。
1078
+ // 2. 这次 capture 还把 Helper 的 diff 基线推到了这棵未展示的树上,而 binding.treeSeen
1079
+ // 仍是 true ⇒ 下一次 getAXState 会拿到相对未展示树的增量,SKILL 承诺的
1080
+ // 「diff 只相对本 cell 展示过的树」就成了假话。清 treeSeen 让它强制整树。
1081
+ const fresh = appStateOf(
1082
+ "get_app_state",
1083
+ await call("get_app_state", { app_ref: appRef, tree_shown_to_model: false }),
1084
+ );
1085
+ binding.stateId = fresh.state_id;
1086
+ binding.elements = fresh.elements;
1087
+ binding.treeSeen = false;
1088
+ return fresh.elements;
1089
+ },
1090
+ });
1091
+ return app;
1092
+ };
1093
+
1094
+ const inventory = async (options) => {
1095
+ const result = await call("list_apps", {});
1096
+ assertOk("list_apps", result);
1097
+ const apps = [];
1098
+ for (const text of textBlocks(result)) {
1099
+ const parsed = parseJsonValue(text);
1100
+ if (Array.isArray(parsed?.apps)) apps.push(...parsed.apps);
1101
+ else if (Array.isArray(parsed)) apps.push(...parsed);
1102
+ }
1103
+ const state = { apps };
1104
+ emitText(globals, JSON.stringify(state, null, 2), options);
1105
+ return state;
1106
+ };
1107
+
1108
+ // `cua.computer` —— 平台特定逃逸口。角色与 Codex 一致(官方文档:
1109
+ // "cua.browsers and cua.computer expose additional platform-specific computer APIs")。
1110
+ // 内容是存活的 14 个工具,入参 schema 逐字保留。
1111
+ //
1112
+ // 返回值**解封**(2026-09-16):此前原样返回 MCP envelope,于是模型拿到的是
1113
+ // `{content:[{type:"text",text:"[...]"}],_meta:{…}}`,还得自己 `JSON.parse(r.content[0].text)`。
1114
+ // 真机代价(list_windows):4964 字符里有用载荷只 1723(35%),另外 65% 是信封与
1115
+ // `_meta` 里的 app 图标 base64(上限 24KiB ⇒ base64 最坏 ~32KiB)。而 `_meta` 按设计
1116
+ // 就是**宿主专用**的(target-app-display 设计文档:host-only、"stripped before handlers
1117
+ // observe responses"),projectToHost 已经单独把它送给宿主展示 —— 所以泄漏给模型是
1118
+ // 契约违规,不是契约选择。连设计文档自己的示例都写成 `wins[1].window_id`(把它当裸数组),
1119
+ // 可见"保留信封"从来不是有人依赖的行为;全仓 grep 也确认零消费者。
1120
+ const unwrapEnvelope = (result) => {
1121
+ if (result === null || typeof result !== "object") return result;
1122
+ if (!Array.isArray(result.content)) return result;
1123
+ const textBlocks = result.content.filter(
1124
+ (block) => block && block.type === "text" && typeof block.text === "string",
1125
+ );
1126
+ // 只在"单一 text block"这种明确形态上解封;多块 / 图像等一律原样返回,
1127
+ // 避免把一个我们不认识的形状猜成裸值。
1128
+ if (textBlocks.length !== 1 || result.content.length !== 1) return result;
1129
+ const text = textBlocks[0].text;
1130
+ try {
1131
+ return JSON.parse(text);
1132
+ } catch {
1133
+ return text;
1134
+ }
1135
+ };
1136
+ const computer = Object.create(null);
1137
+ Object.defineProperty(computer, "target", {
1138
+ enumerable: true,
1139
+ value: platform === "darwin" ? "mac" : platform === "win32" ? "windows" : "linux",
1140
+ });
1141
+ const excluded = new Set(PLATFORM_EXCLUDED_METHODS[platform] ?? []);
1142
+ for (const methodName of COMPUTER_METHOD_NAMES) {
1143
+ if (excluded.has(methodName)) continue;
1144
+ Object.defineProperty(computer, methodName, {
1145
+ enumerable: true,
1146
+ value: async (args = {}) => unwrapEnvelope(await call(methodName, args)),
1147
+ });
1148
+ }
1149
+
1150
+ const cua = {
1151
+ async getState(options) {
1152
+ return await inventory(options);
1153
+ },
1154
+ async getApp(target) {
1155
+ return await bindApp(target);
1156
+ },
1157
+ async listApps(options) {
1158
+ return (await inventory(options)).apps;
1159
+ },
1160
+ computer: Object.freeze(computer),
1161
+ async requestAccess(capabilities) {
1162
+ const result = await call("request_access", capabilities ? { capabilities } : {});
1163
+ assertOk("request_access", result);
1164
+ const texts = textBlocks(result);
1165
+ return parseJsonRecord(texts[0]) ?? { ready: true };
1166
+ },
1167
+ async stop(reason) {
1168
+ const result = await call("stop_computer_control", reason ? { reason } : {});
1169
+ assertOk("stop_computer_control", result);
1170
+ },
1171
+ };
1172
+ // 未文档化成员:窗口绑定。Codex 把窗口概念按平台分裂(macOS 面没有窗口寻址,
1173
+ // Windows 的 window2 面整套以 Window 对象寻址),DCode 三平台统一命名做不了那种分裂,
1174
+ // 所以窗口寻址留在逃逸口 + 这个未文档化入口。
1175
+ Object.defineProperty(cua, "getWindow", {
1176
+ enumerable: false,
1177
+ value: async (target, windowId) => await bindApp(target, windowId),
1178
+ });
1179
+
1180
+ // 入口是 `agent.computerUse`(DCode 自己的命名空间约定,与 agent.documentation /
1181
+ // agent.browsers 并列)。codex 用的是裸 `cua` 全局;这一处是有意的分歧。
1182
+ // 平铺的 14 个工具在 `agent.computerUse.computer.*` 下(COMPUTER_METHOD_NAMES)。
1183
+ const agent = (globals.agent ??= {});
1184
+ agent.computerUse = cua;
1185
+
1186
+ const previousDocumentation = agent.documentation;
1187
+ const previousGet =
1188
+ previousDocumentation && typeof previousDocumentation.get === "function"
1189
+ ? previousDocumentation.get.bind(previousDocumentation)
1190
+ : undefined;
1191
+ agent.documentation = Object.freeze({
1192
+ get: async (name) => {
1193
+ // Browser Use 的文档 loader 必须原样转交 —— CUA 只认自己那一个名字。
1194
+ if (name !== "computer-use") {
1195
+ if (previousGet) return await previousGet(name);
1196
+ throw new Error(`Unknown documentation entry: ${name}`);
1197
+ }
1198
+ if (typeof bridge.documentationRoot !== "string") {
1199
+ throw new Error("Computer Use documentation is unavailable");
1200
+ }
1201
+ return await readFile(join(bridge.documentationRoot, "computer-use.md"), "utf8");
1202
+ },
1203
+ });
1204
+
1205
+ return cua;
1206
+ }
1207
+
1208
+ export { COMPUTER_METHOD_NAMES, PLATFORM_EXCLUDED_METHODS, ComputerUseError, normalizeKeyChord };