dsh-plugin-computer-use 2.4.1

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/index.js ADDED
@@ -0,0 +1,1595 @@
1
+ /**
2
+ * computer-use — let the model see the desktop and drive apps, on any OS.
3
+ *
4
+ * v2 rewires the whole backend onto the Cua Driver SDK (@trycua/cua-driver):
5
+ * a Rust in-process computer-use runtime loaded directly into the dsh host.
6
+ * No daemon, no MCP server, no PowerShell scripts — `pnpm` pulls one native
7
+ * package per OS/arch and the tools just work (Windows tested; the same code
8
+ * path drives the macOS / Linux natives).
9
+ *
10
+ * v2.2 fixes the two things that made v2 unusable on a real Windows desktop:
11
+ *
12
+ * - **DPI bootstrap.** The driver's whole Windows backend measures screens,
13
+ * captures pixels and maps coordinates through GetSystemMetrics/
14
+ * GetWindowRect — APIs that return DPI-VIRTUALIZED values unless the host
15
+ * process declared Per-Monitor-V2 awareness (upstream assumes the daemon
16
+ * exe; an in-process SDK inherits the HOST, and node.exe is unaware). On a
17
+ * 200%-scaled primary the driver then captured only the top-left quarter of
18
+ * the display and every desktop coordinate was off by the scale factor.
19
+ * The plugin now flips the process to Per-Monitor-V2 via a tiny FFI call
20
+ * (koffi, prebuilt binaries, no compiler) BEFORE the driver runtime is
21
+ * created — after that the driver's own numbers are correct
22
+ * (`get_desktop_state` returns the true 3200x2000 @2x, not a crop).
23
+ * - **Session lifecycle.** The driver's implicit session dies after 5 min of
24
+ * idle and ended names are never revived by ordinary actions — every call
25
+ * then failed with "session has ended" until the process restarted. The
26
+ * plugin now runs one named session (`dsh-computer-use`) that it starts at
27
+ * boot, passes explicitly on every call that accepts it, and revives with
28
+ * `start_session` + one retry whenever the driver reports `session_ended`.
29
+ *
30
+ * v2.4 fixes what the session log of a real "greet a WeChat contact" run
31
+ * exposed — 50 idle minutes after host boot every window/app query silently
32
+ * answered "no windows matched" and the model fell back to pwsh + blind
33
+ * desktop coordinates:
34
+ *
35
+ * - **Queries carry the session label too.** `list_windows` / `list_apps` /
36
+ * `launch_app` used to go untagged, hanging off the transport's implicit
37
+ * session, which dies after 5 idle minutes — while the tagged action tools
38
+ * kept self-healing. Now EVERY driver call except session lifecycle itself
39
+ * is tagged, a stuck session escalates through end→start→retry, and one
40
+ * that still won't come back triggers a full runtime rebuild (the racy
41
+ * watchdog of 0.28.1 could wedge a transport for its whole lifetime; fixed
42
+ * upstream in 0.28.2).
43
+ * - **Query errors surface.** An isError `list_windows` used to parse into an
44
+ * empty table ("no windows matched"); it now throws the driver's own text.
45
+ * - **CJK/emoji typing goes through the clipboard.** Foreground SendInput
46
+ * typing through an active IME corrupts non-ASCII text (measured: 这→!!,
47
+ * 👋→🙏); `computer_type` desktop= now pastes such text via the driver
48
+ * clipboard and restores the user's clipboard afterwards.
49
+ * - **Elastic desktop coordinates.** x/y given in PHYSICAL desktop px (the
50
+ * native size of a HiDPI capture) are mapped into the model frame instead
51
+ * of bouncing with "outside the 1568x980 screenshot".
52
+ * - **`computer_screenshot save=path.png`** writes the capture to disk
53
+ * (desktop captures at native resolution) and stages it on the clipboard,
54
+ * so "send a screenshot into a chat" needs no pwsh detour.
55
+ *
56
+ * The driver's native paradigm is window-centric and background-first, so the
57
+ * tool surface speaks it now:
58
+ *
59
+ * - **Element-first.** `computer_screenshot(app=…)` returns the window's
60
+ * screenshot AND its accessibility tree (indexed elements with tokens);
61
+ * `computer_click(element=token)` invokes through UIA/AX — works on
62
+ * backgrounded, minimized and even off-desktop windows.
63
+ * - **No focus stealing.** Every action defaults to `delivery_mode:
64
+ * "background"`: PostMessage/UIA routes that never raise the window or move
65
+ * the human's cursor. When a target genuinely drops posted input (some
66
+ * Chromium/Electron surfaces), the plugin retries the same action with
67
+ * `delivery_mode: "foreground"` once — the driver restores the previous
68
+ * foreground afterwards.
69
+ * - **Keyboard-first still.** Descriptions keep pushing `computer_open` /
70
+ * `computer_key` / `computer_sequence` over pixel hunts.
71
+ *
72
+ * Coordinates are pixels of the latest screenshot of the SAME target: window
73
+ * actions use window-local px of the last window capture; desktop-scope
74
+ * actions use px of the last `computer_screenshot` (primary display).
75
+ *
76
+ * Screenshots ride to the model as attachment images —
77
+ * `dsh-plugin-read-image-preview` renders them with its generic image card;
78
+ * nothing here ships a client half.
79
+ *
80
+ * @module dsh-plugin-computer-use
81
+ */
82
+
83
+ import zlib from 'node:zlib'
84
+ import z from '@deepseek-ai/schemastery'
85
+ import { defineTool } from '@deepseek-ai/dsh-tools'
86
+
87
+ export const name = 'computer-use'
88
+ export const inject = ['tools', 'attachments']
89
+
90
+ export const Config = z.object({
91
+ /** Model-visible frame long-edge cap, in px. */
92
+ maxDim: z.natural().min(320).max(3840),
93
+ /** Settle time between the gesture and the result screenshot. */
94
+ settleMs: z.natural().max(5000),
95
+ /** 'once-per-agent': the first mutating call asks, later ones run; 'never': no own gate. */
96
+ askPolicy: z.union([z.const('once-per-agent'), z.const('always'), z.const('never')]),
97
+ /** Content-free product telemetry of the bundled Cua Driver runtime. */
98
+ telemetry: z.boolean(),
99
+ /** Windows: switch the host process to Per-Monitor-V2 DPI awareness before the
100
+ * driver starts (strongly recommended; set false only if some other component
101
+ * already owns process DPI awareness and you must not touch it). */
102
+ dpiAware: z.boolean(),
103
+ })
104
+
105
+ // ─────────────────────────────────────────────────────────────────────────────
106
+ // Minimal PNG codec (decode → RGBA, box resize, crop, encode). Screenshots
107
+ // arrive as 8-bit RGB/RGBA PNGs; the driver never downscales desktop captures,
108
+ // so the model-frame cap is enforced here. No native or third-party deps.
109
+ // ─────────────────────────────────────────────────────────────────────────────
110
+
111
+ const CRC_TABLE = (() => {
112
+ const t = new Int32Array(256)
113
+ for (let n = 0; n < 256; n++) {
114
+ let c = n
115
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
116
+ t[n] = c
117
+ }
118
+ return t
119
+ })()
120
+
121
+ function crc32(buf) {
122
+ let c = 0xffffffff
123
+ for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
124
+ return (c ^ 0xffffffff) >>> 0
125
+ }
126
+
127
+ /** Decode an 8-bit non-interlaced RGB/RGBA PNG into `{ width, height, data }` (RGBA). Exported for the self-test. */
128
+ export function pngDecode(buf) {
129
+ if (buf.length < 24 || buf.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG')
130
+ let off = 8
131
+ let ihdr = null
132
+ const idat = []
133
+ while (off + 8 <= buf.length) {
134
+ const len = buf.readUInt32BE(off)
135
+ const type = buf.toString('ascii', off + 4, off + 8)
136
+ const data = buf.subarray(off + 8, off + 8 + len)
137
+ if (type === 'IHDR') {
138
+ ihdr = { w: data.readUInt32BE(0), h: data.readUInt32BE(4), depth: data[8], color: data[9], interlace: data[12] }
139
+ } else if (type === 'IDAT') idat.push(data)
140
+ else if (type === 'IEND') break
141
+ off += 12 + len
142
+ }
143
+ if (!ihdr) throw new Error('PNG without IHDR')
144
+ if (ihdr.depth !== 8 || ihdr.interlace !== 0 || (ihdr.color !== 2 && ihdr.color !== 6)) {
145
+ throw new Error('unsupported PNG (color=' + ihdr.color + ' depth=' + ihdr.depth + ' interlace=' + ihdr.interlace + ')')
146
+ }
147
+ const ch = ihdr.color === 6 ? 4 : 3
148
+ const stride = ihdr.w * ch
149
+ const raw = zlib.inflateSync(Buffer.concat(idat))
150
+ const out = Buffer.alloc(ihdr.w * ihdr.h * 4)
151
+ let pos = 0
152
+ let prev = Buffer.alloc(stride)
153
+ for (let y = 0; y < ihdr.h; y++) {
154
+ const filter = raw[pos++]
155
+ const line = Buffer.from(raw.subarray(pos, pos + stride))
156
+ pos += stride
157
+ if (filter === 1) { for (let i = ch; i < stride; i++) line[i] = (line[i] + line[i - ch]) & 0xff }
158
+ else if (filter === 2) { for (let i = 0; i < stride; i++) line[i] = (line[i] + prev[i]) & 0xff }
159
+ else if (filter === 3) { for (let i = 0; i < stride; i++) line[i] = (line[i] + (((i < ch ? 0 : line[i - ch]) + prev[i]) >> 1)) & 0xff }
160
+ else if (filter === 4) {
161
+ for (let i = 0; i < stride; i++) {
162
+ const a = i < ch ? 0 : line[i - ch]
163
+ const b = prev[i]
164
+ const c = i < ch ? 0 : prev[i - ch]
165
+ const p = a + b - c
166
+ const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c)
167
+ line[i] = (line[i] + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 0xff
168
+ }
169
+ } else if (filter !== 0) throw new Error('bad PNG filter ' + filter)
170
+ for (let x = 0; x < ihdr.w; x++) {
171
+ const si = x * ch
172
+ const di = (y * ihdr.w + x) * 4
173
+ out[di] = line[si]; out[di + 1] = line[si + 1]; out[di + 2] = line[si + 2]
174
+ out[di + 3] = ch === 4 ? line[si + 3] : 255
175
+ }
176
+ prev = line
177
+ }
178
+ return { width: ihdr.w, height: ihdr.h, data: out }
179
+ }
180
+
181
+ /** Encode RGBA as a filter-0 PNG. Exported for the self-test. */
182
+ export function pngEncode(img) {
183
+ const { width, height, data } = img
184
+ const chunk = (type, body) => {
185
+ const len = Buffer.alloc(4); len.writeUInt32BE(body.length)
186
+ const td = Buffer.from(type, 'ascii')
187
+ const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(Buffer.concat([td, body])))
188
+ return Buffer.concat([len, td, body, crc])
189
+ }
190
+ const ihdr = Buffer.alloc(13)
191
+ ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4)
192
+ ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0
193
+ const raw = Buffer.alloc(height * (1 + width * 4))
194
+ for (let y = 0; y < height; y++) {
195
+ raw[y * (1 + width * 4)] = 0
196
+ data.copy(raw, y * (1 + width * 4) + 1, y * width * 4, (y + 1) * width * 4)
197
+ }
198
+ return Buffer.concat([
199
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
200
+ chunk('IHDR', ihdr),
201
+ chunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
202
+ chunk('IEND', Buffer.alloc(0)),
203
+ ])
204
+ }
205
+
206
+ /** Box-average resize of an RGBA image. Exported for the self-test. */
207
+ export function resizeRgba(img, tw, th) {
208
+ const { width, height, data } = img
209
+ const out = Buffer.alloc(tw * th * 4)
210
+ for (let ty = 0; ty < th; ty++) {
211
+ const y0 = Math.floor((ty * height) / th)
212
+ const y1 = Math.max(y0 + 1, Math.floor(((ty + 1) * height) / th))
213
+ for (let tx = 0; tx < tw; tx++) {
214
+ const x0 = Math.floor((tx * width) / tw)
215
+ const x1 = Math.max(x0 + 1, Math.floor(((tx + 1) * width) / tw))
216
+ let r = 0; let g = 0; let b = 0; let a = 0; let n = 0
217
+ for (let y = y0; y < y1; y++) {
218
+ for (let x = x0; x < x1; x++) {
219
+ const i = (y * width + x) * 4
220
+ r += data[i]; g += data[i + 1]; b += data[i + 2]; a += data[i + 3]; n++
221
+ }
222
+ }
223
+ const o = (ty * tw + tx) * 4
224
+ out[o] = r / n; out[o + 1] = g / n; out[o + 2] = b / n; out[o + 3] = a / n
225
+ }
226
+ }
227
+ return { width: tw, height: th, data: out }
228
+ }
229
+
230
+ /** Crop an RGBA image (bounds clamped). Exported for the self-test. */
231
+ export function cropRgba(img, x, y, w, h) {
232
+ const cx = Math.max(0, Math.min(x, img.width - 1))
233
+ const cy = Math.max(0, Math.min(y, img.height - 1))
234
+ const cw = Math.max(1, Math.min(w, img.width - cx))
235
+ const chh = Math.max(1, Math.min(h, img.height - cy))
236
+ const out = Buffer.alloc(cw * chh * 4)
237
+ for (let row = 0; row < chh; row++) {
238
+ img.data.copy(out, row * cw * 4, ((cy + row) * img.width + cx) * 4, ((cy + row) * img.width + cx + cw) * 4)
239
+ }
240
+ return { width: cw, height: chh, data: out }
241
+ }
242
+
243
+ /**
244
+ * Map desktop coordinates to driver desktop-space px. Pure — exported for the
245
+ * self-test. `frame` is { sw, sh, fw, fh }: physical screen size and the
246
+ * model-frame size it was downscaled to.
247
+ *
248
+ * Coordinates are expected in FRAME px (the screenshot the model sees), but
249
+ * ones given in PHYSICAL desktop px (the capture's native size — the model
250
+ * reads native-resolution features off HiDPI captures regularly) are mapped
251
+ * into the frame per-axis instead of bouncing the call: the roundtrip is
252
+ * exact, and "outside the 1568x980 screenshot" retries cost a full model
253
+ * round-trip for nothing. Genuinely out-of-bounds input still throws.
254
+ */
255
+ export function deskMapXY(frame, x, y, label = 'coordinate') {
256
+ if (frame === null || typeof frame !== 'object') throw new Error('no desktop screenshot yet: call computer_screenshot first')
257
+ if (typeof x !== 'number' || !isFinite(x) || typeof y !== 'number' || !isFinite(y)) throw new Error(label + ' must be numbers')
258
+ if (x < 0 || y < 0) throw new Error(label + ' outside the ' + frame.fw + 'x' + frame.fh + ' screenshot')
259
+ let scaled = false
260
+ if (x > frame.fw) {
261
+ if (x > frame.sw) throw new Error(label + ' outside the ' + frame.fw + 'x' + frame.fh + ' screenshot (physical desktop is ' + frame.sw + 'x' + frame.sh + ')')
262
+ x = (x * frame.fw) / frame.sw
263
+ scaled = true
264
+ }
265
+ if (y > frame.fh) {
266
+ if (y > frame.sh) throw new Error(label + ' outside the ' + frame.fw + 'x' + frame.fh + ' screenshot (physical desktop is ' + frame.sw + 'x' + frame.sh + ')')
267
+ y = (y * frame.fh) / frame.sh
268
+ scaled = true
269
+ }
270
+ return { x: Math.round((x * frame.sw) / frame.fw), y: Math.round((y * frame.sh) / frame.fh), scaled }
271
+ }
272
+
273
+ // ─────────────────────────────────────────────────────────────────────────────
274
+ // Plugin body
275
+ // ─────────────────────────────────────────────────────────────────────────────
276
+
277
+ /**
278
+ * @param {import('@deepseek-ai/cordis').Context} ctx - plugin context.
279
+ * @param {{ maxDim?: number, settleMs?: number, askPolicy?: string, telemetry?: boolean }} [config]
280
+ */
281
+ export function apply(ctx, config) {
282
+ const maxDim = config && config.maxDim !== undefined ? config.maxDim : 1568
283
+ const settleMs = config && config.settleMs !== undefined ? config.settleMs : 600
284
+ const askPolicy = config && config.askPolicy === 'always' ? 'always' : config && config.askPolicy === 'never' ? 'never' : 'once-per-agent'
285
+ const telemetry = !!(config && config.telemetry === true)
286
+ const dpiAwareCfg = !(config && config.dpiAware === false)
287
+ // May computer_open / window captures bring an ALREADY-RUNNING app's
288
+ // minimized window back to the foreground? Off = never touch the foreground.
289
+ const restoreCfg = !(config && config.restore === false)
290
+ // May computer_open walk an ALREADY-RUNNING app through its own launcher when
291
+ // that app exposes no window (tray state)? Off by default — see computer_open.
292
+ const activateRunningCfg = !!(config && config.activateRunning === true)
293
+ if (!telemetry) process.env.CUA_DRIVER_RS_TELEMETRY_ENABLED = '0'
294
+
295
+ // ── DPI bootstrap (Windows) ────────────────────────────────────────────────
296
+ // The driver assumes a Per-Monitor-V2-aware host (its daemon exe ships that
297
+ // manifest; upstream capture/input code comments say so outright). The
298
+ // in-process SDK instead inherits THIS process — and node.exe is DPI-unaware.
299
+ // Every screen metric the driver reads is then DPI-virtualized while BitBlt
300
+ // and SendInput still work in physical pixels: on a 200%-scaled primary the
301
+ // desktop capture silently covers only the top-left quarter of the display
302
+ // and all desktop coordinates drift by the scale factor. Flip the process
303
+ // BEFORE the driver runtime creates its worker threads (threads inherit the
304
+ // process context at creation); afterwards every driver number is physical.
305
+ /** { text, aware } — filled during the first getDriver() chain. */
306
+ const dpiState = { text: 'pending', aware: false }
307
+ const ensureProcessDpiAware = async () => {
308
+ if (process.platform !== 'win32') { dpiState.text = 'n/a (not Windows)'; dpiState.aware = true; return }
309
+ if (!dpiAwareCfg) { dpiState.text = 'skipped (dpiAware: false)'; dpiState.aware = false; return }
310
+ try {
311
+ const m = await import('koffi')
312
+ const koffi = m.default && m.default.load ? m.default : m
313
+ const user32 = koffi.load('user32.dll')
314
+ let shcore = null
315
+ try { shcore = koffi.load('shcore.dll') } catch {}
316
+ // PROCESS_DPI_AWARENESS: 0 unaware, 1 system, 2 per-monitor; NULL handle = this process.
317
+ const query = () => {
318
+ if (!shcore) return -1
319
+ try {
320
+ const getProc = shcore.func('int32_t GetProcessDpiAwareness(void*, int32_t*)')
321
+ const out = koffi.alloc('int32_t', 1)
322
+ if (getProc(null, out) !== 0) return -1
323
+ return koffi.decode(out, 'int32_t')
324
+ } catch { return -1 }
325
+ }
326
+ let now = query()
327
+ if (now !== 2) {
328
+ try { user32.func('bool SetProcessDpiAwarenessContext(intptr_t)')(-4) } catch {} // PER_MONITOR_AWARE_V2
329
+ now = query()
330
+ if (now === 0 || now === -1) {
331
+ try { user32.func('bool SetProcessDPIAware()')() } catch {}
332
+ now = query()
333
+ }
334
+ }
335
+ dpiState.aware = now >= 1
336
+ dpiState.text = now === 2
337
+ ? 'per-monitor-v2'
338
+ : now === 1
339
+ ? 'system-DPI-aware (fallback: metrics are physical, mixed-scale window captures may stretch)'
340
+ : now === 0
341
+ ? 'UNAWARE (runtime switch refused — HiDPI captures/coordinates are unreliable)'
342
+ : 'unknown (shcore query unsupported)'
343
+ } catch (e) {
344
+ dpiState.aware = false
345
+ dpiState.text = 'unknown (koffi unavailable: ' + (e && e.message ? e.message : e) + ')'
346
+ }
347
+ }
348
+
349
+ // ── named driver session ───────────────────────────────────────────────────
350
+ // The implicit session the runtime creates for a transport is private and,
351
+ // once it ends (5-minute idle TTL / explicit end), ordinary calls against it
352
+ // fail forever: “this session has ended; call start_session explicitly”.
353
+ // A public label survives that: start_session revives it and is idempotent.
354
+ // (`session` is not sticky server-side — repeat it.)
355
+ const SESSION_LABEL = 'dsh-computer-use'
356
+ // Every driver call is tagged with the label EXCEPT session lifecycle /
357
+ // transport introspection itself. The old whitelist left the queries
358
+ // (list_windows / list_apps / launch_app …) untagged: they hung off the
359
+ // transport's implicit session and were the exact calls that died after the
360
+ // idle TTL while the tagged action tools kept self-healing. Every plugin
361
+ // tool call has been verified to accept `session` on 0.28.1.
362
+ const NO_SESSION_TOOLS = new Set(['start_session', 'end_session', 'escalate_session', 'shutdown', 'list_tools_json', 'list_sessions', 'list_host_sessions_json', 'get_session', 'get_session_state', 'metadata', 'is_available', 'execution_mode'])
363
+ const isSessionEnded = (r) => r.errorCode === 'session_ended' || /session has ended|call start_session/i.test(String((r && r.text) ?? ''))
364
+
365
+ /** Lazily-created in-process Cua Driver runtime (one per profile process). */
366
+ let driver = null
367
+ let driverPromise = null
368
+ let driverDead = false
369
+ /** In-flight runtime rebuild (stuck-session escape hatch); see rebuildDriver. */
370
+ let rebuilding = null
371
+ const getDriver = () => {
372
+ if (driver) return Promise.resolve(driver)
373
+ if (driverDead) throw new Error('Cua Driver runtime was shut down; restart dsh to reactivate computer-use')
374
+ if (!driverPromise) {
375
+ driverPromise = ensureProcessDpiAware().then(() => {
376
+ ctx.logger.info('computer-use: host process DPI awareness = ' + dpiState.text)
377
+ return import('@trycua/cua-driver')
378
+ }).then((m) => {
379
+ const created = m.CuaDriver.create()
380
+ driver = created
381
+ ctx.effect(() => () => {
382
+ driverDead = true
383
+ driver = null
384
+ driverPromise = null
385
+ Promise.resolve(created.callTool('end_session', JSON.stringify({ session: SESSION_LABEL }))).catch(() => {})
386
+ Promise.resolve(created.shutdown()).catch(() => {})
387
+ })
388
+ return Promise.resolve(created.callTool('start_session', JSON.stringify({ session: SESSION_LABEL })))
389
+ .then((r) => { if (r && r.isError) ctx.logger.warn('computer-use: start_session failed: ' + String(r.text).slice(0, 160)) }, () => {})
390
+ .then(() => created)
391
+ }, (e) => { driverPromise = null; throw new Error('cannot load @trycua/cua-driver native runtime: ' + (e && e.message ? e.message : e)) })
392
+ }
393
+ return driverPromise
394
+ }
395
+
396
+ /** Destroy and recreate the runtime (a transport can wedge permanently —
397
+ * measured on 0.28.1: after the idle TTL killed the session, no amount of
398
+ * start_session revived the calls; the watchdog data race fixed upstream
399
+ * in 0.28.2 is the likely culprit). Shared so concurrent callers rebuild once. */
400
+ const rebuildDriver = () => {
401
+ if (!rebuilding) {
402
+ rebuilding = (async () => {
403
+ const old = driver
404
+ driver = null
405
+ driverPromise = null
406
+ if (old) {
407
+ await Promise.resolve(old.callTool('end_session', JSON.stringify({ session: SESSION_LABEL }))).catch(() => {})
408
+ await Promise.resolve(old.shutdown()).catch(() => {})
409
+ }
410
+ const fresh = await getDriver()
411
+ ctx.logger.warn('computer-use: rebuilt the Cua Driver runtime after a stuck session')
412
+ return fresh
413
+ })().finally(() => { rebuilding = null })
414
+ }
415
+ return rebuilding
416
+ }
417
+
418
+ /** Call one driver tool by name; returns the parsed ToolResult. */
419
+ const raw = async (tool, args) => {
420
+ let a = args ?? {}
421
+ if (!NO_SESSION_TOOLS.has(tool) && a.session === undefined) a = { ...a, session: SESSION_LABEL }
422
+ const json = JSON.stringify(a)
423
+ const d = await getDriver()
424
+ let r = await d.callTool(tool, json)
425
+ if (!(r && r.isError && isSessionEnded(r))) return r
426
+ // Idle TTL tripped (or the transport wedged around it). A bare revive is
427
+ // not always enough — measured on 0.28.1, start_session can report success
428
+ // while the session stays dead — so end the corpse first, revive, retry…
429
+ await Promise.resolve(d.callTool('end_session', JSON.stringify({ session: SESSION_LABEL }))).catch(() => {})
430
+ const revived = await Promise.resolve(d.callTool('start_session', JSON.stringify({ session: SESSION_LABEL }))).catch(() => null)
431
+ if (revived === null || revived.isError) ctx.logger.warn('computer-use: session revive failed: ' + String(revived === null ? 'call threw' : revived.text).slice(0, 140))
432
+ r = await d.callTool(tool, json)
433
+ if (!(r && r.isError && isSessionEnded(r))) return r
434
+ // …and if it is STILL dead the runtime itself is wedged: rebuild it once.
435
+ const d2 = await rebuildDriver()
436
+ return await d2.callTool(tool, json)
437
+ }
438
+ /** Same, but throws on isError with the driver's own message. */
439
+ const call = async (tool, args) => {
440
+ const r = await raw(tool, args)
441
+ if (r.isError) throw new Error(tool + ': ' + String(r.text ?? 'failed').slice(0, 400))
442
+ return r
443
+ }
444
+ const structured = (r) => {
445
+ try { return JSON.parse(r.structuredJson) } catch {}
446
+ try { return JSON.parse(r.rawJson ?? '{}').structuredContent ?? {} } catch { return {} }
447
+ }
448
+
449
+ /**
450
+ * list_windows with the driver's errors surfaced. An isError result used to
451
+ * parse into an empty table — "no windows matched" — sending the model
452
+ * name-hunting (微信? WeChat? Weixin?) while the session was actually dead;
453
+ * the field log of exactly that wasted five steps before the pwsh detour.
454
+ */
455
+ const listWindows = async (args) => {
456
+ const r = await raw('list_windows', args)
457
+ if (r.isError) throw new Error('list_windows: ' + String(r.text ?? 'failed').slice(0, 300))
458
+ return structured(r)
459
+ }
460
+
461
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
462
+ const timer = setTimeout(() => {
463
+ if (signal && signal.aborted) reject(new Error('cancelled'))
464
+ else resolve()
465
+ }, ms)
466
+ if (signal && typeof signal.addEventListener === 'function') {
467
+ signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('cancelled')) }, { once: true })
468
+ }
469
+ })
470
+
471
+ // ── target bookkeeping ─────────────────────────────────────────────────────
472
+ /** Last window the model captured or acted on; lets follow-ups omit targets. */
473
+ let last = null
474
+ /** Desktop frame mapping (get_desktop_state may be downscaled for the model). */
475
+ let deskFrame = null
476
+
477
+ const windowTarget = (t) => ({ kind: 'window', pid: t.pid, window_id: t.windowId })
478
+
479
+ /** Resolve {app|pid|window_id} args to {pid, windowId, title}. */
480
+ const resolveWindow = async (a) => {
481
+ if (a.app !== undefined || a.pid !== undefined) {
482
+ let pid
483
+ if (a.pid !== undefined) pid = Math.round(Number(a.pid))
484
+ const w = await listWindows({ on_screen_only: false })
485
+ const wins = w.windows ?? []
486
+ if (pid === undefined) {
487
+ const needle = String(a.app).toLowerCase()
488
+ const cands = wins.filter((x) => String(x.app_name ?? '').toLowerCase().includes(needle) || String(x.title ?? '').toLowerCase().includes(needle))
489
+ if (cands.length === 0) throw new Error('no window matches app "' + a.app + '" — check computer_windows')
490
+ pid = cands[0].pid
491
+ }
492
+ let mine = wins.filter((x) => x.pid === pid)
493
+ if (mine.length === 0) throw new Error('pid ' + pid + ' has no windows — check computer_windows')
494
+ if (a.window_id !== undefined) {
495
+ const hit = mine.find((x) => (x.window_id ?? x.windowId) === Math.round(Number(a.window_id)))
496
+ if (!hit) throw new Error('window_id ' + a.window_id + ' not found under pid ' + pid)
497
+ last = { pid, windowId: hit.window_id ?? hit.windowId, title: hit.title ?? '', app: String(a.app ?? '') }
498
+ return last
499
+ }
500
+ mine = mine.filter((x) => x.is_on_screen !== false)
501
+ mine.sort((x, y) => (y.z_index ?? 0) - (x.z_index ?? 0))
502
+ last = { pid, windowId: mine[0].window_id ?? mine[0].windowId, title: mine[0].title ?? '', app: String(a.app ?? '') }
503
+ return last
504
+ }
505
+ if (last) return last
506
+ throw new Error('no target: pass app (e.g. app="notepad") or pid/window_id, or capture first — see computer_windows')
507
+ }
508
+
509
+ // ── "is it already running?" ───────────────────────────────────────────────
510
+ // `launch_app` on Windows ALWAYS creates a new process (ShellExecuteEx; the
511
+ // `creates_new_application_instance` flag is a documented no-op there), so it
512
+ // can never "focus-resolve" anything. Re-running a chat app that way spawns a
513
+ // second, logged-out instance that begs for a fresh login — the app is
514
+ // already running, its (minimized / tray) window just isn't the one the model
515
+ // ended up looking at. Every "open this app" path therefore asks the driver's
516
+ // own app inventory first and reuses the live instance when there is one.
517
+ const normApp = (s) => String(s ?? '').trim().toLowerCase().replace(/\.exe$/, '')
518
+ const scoreApp = (want, cand) => {
519
+ const w = normApp(want)
520
+ const name = normApp(cand && cand.name)
521
+ const exe = normApp(String((cand && cand.bundle_id) ?? '').split(/[\\/]/).pop())
522
+ if (name === '' && exe === '') return 0
523
+ if (name === w || exe === w) return 100
524
+ if (w.length < 3) return 0
525
+ if (name.includes(w) || exe.includes(w)) return 60
526
+ if (w.includes(name) && name !== '') return 40
527
+ return 0
528
+ }
529
+ /**
530
+ * The installed-app inventory entry for `want`, or null.
531
+ * Only consulted on the launch path (it is the slow call); its running flags
532
+ * are NOT used for the "already running?" decision — the official guidance is
533
+ * to reason about windows from `list_windows`, and that view is also the one
534
+ * a reuse decision actually needs.
535
+ */
536
+ const findInstalledApp = async (want) => {
537
+ let apps = []
538
+ try {
539
+ const r = await raw('list_apps', {})
540
+ if (r.isError) throw new Error(String(r.text ?? 'list_apps failed').slice(0, 200))
541
+ apps = structured(r).apps ?? []
542
+ } catch (e) {
543
+ // Tolerated (the name-launch fallback still works) — but say so: a silent
544
+ // null here is what sent computer_open into launch_app on a dead session.
545
+ ctx.logger.warn('computer-use: list_apps failed (' + String(e && e.message ? e.message : e).slice(0, 160) + ') — falling back to a name-only launch')
546
+ return null
547
+ }
548
+ let best = null
549
+ let bestScore = 0
550
+ for (const entry of apps) {
551
+ const s = scoreApp(want, entry)
552
+ if (s > bestScore) { bestScore = s; best = entry }
553
+ }
554
+ return best === null || bestScore <= 0 ? null : best
555
+ }
556
+
557
+ /**
558
+ * How to launch an app that is not running yet.
559
+ * - UWP/Store: `aumid` — the docs' own advice for capturing the packaged pid;
560
+ * its `launch_path` is the same `shell:appsFolder\…` string but activates
561
+ * through the broker and reports pid 0.
562
+ * - desktop: the shortcut's full commandline (`launch_path`), so arguments
563
+ * survive; falls back to the bare name.
564
+ */
565
+ const launchSelectorFor = async (want) => {
566
+ const entry = await findInstalledApp(want)
567
+ const lp = entry !== null && typeof entry.launch_path === 'string' ? entry.launch_path : ''
568
+ if (entry !== null && entry.kind === 'uwp') {
569
+ const aumid = lp.startsWith('shell:appsFolder\\') ? lp.slice('shell:appsFolder\\'.length) : ''
570
+ if (aumid !== '') return { aumid }
571
+ if (typeof entry.bundle_id === 'string' && entry.bundle_id.includes('!')) return { aumid: entry.bundle_id }
572
+ }
573
+ if (lp !== '' && !lp.startsWith('shell:appsFolder')) return { launch_path: lp }
574
+ return { name: want }
575
+ }
576
+
577
+ const windowsOf = async (pid) => {
578
+ const w = await listWindows({ pid })
579
+ const mine = (w.windows ?? []).filter((x) => x.pid === pid)
580
+ const onScreen = mine.filter((x) => x.is_on_screen !== false)
581
+ const pick = (onScreen.length > 0 ? onScreen : mine).slice()
582
+ pick.sort((x, y) => (y.z_index ?? 0) - (x.z_index ?? 0))
583
+ return pick
584
+ }
585
+
586
+ /**
587
+ * Find an EXISTING window for an app by name / exe / title, across every pid.
588
+ * Never bind to the pid `list_apps` reports: for freshly started packaged apps
589
+ * that pid can be a launcher that owns no window (Win11 Notepad: the app entry
590
+ * points at one process while the window belongs to another that appears a
591
+ * moment later), so a pid-scoped lookup silently finds nothing and the caller
592
+ * would launch a second instance.
593
+ */
594
+ const findAppWindow = async (want, preferPid) => {
595
+ const w = structured(await raw('list_windows', { on_screen_only: false }))
596
+ const w0 = normApp(want)
597
+ const scored = []
598
+ for (const x of w.windows ?? []) {
599
+ const app = normApp(x.app_name)
600
+ const title = String(x.title ?? '').toLowerCase()
601
+ let score = 0
602
+ if (app !== '' && app === w0) score = 100
603
+ else if (app !== '' && w0.length >= 3 && app.includes(w0)) score = 70
604
+ else if (title !== '' && w0.length >= 3 && title.includes(w0)) score = 45
605
+ if (score === 0) continue
606
+ if (x.is_on_screen !== false) score += 10
607
+ if (preferPid !== undefined && x.pid === preferPid) score += 25
608
+ scored.push({ score, z: x.z_index ?? 0, x })
609
+ }
610
+ if (scored.length === 0) return null
611
+ scored.sort((a, b) => (b.score - a.score) || (b.z - a.z))
612
+ const win = scored[0].x
613
+ return { pid: win.pid, windowId: win.window_id ?? win.windowId, title: win.title ?? '', app: want, minimized: win.minimized === true }
614
+ }
615
+
616
+ /** windowsOf(pid), falling back to a name match (see findAppWindow). */
617
+ const windowFor = async (want, pid) => {
618
+ if (pid !== undefined && pid !== null && Number(pid) > 0) {
619
+ const mine = await windowsOf(Math.round(Number(pid)))
620
+ if (mine.length > 0) {
621
+ const w = mine[0]
622
+ return { pid: Math.round(Number(pid)), windowId: w.window_id ?? w.windowId, title: w.title ?? '', app: want, minimized: w.minimized === true }
623
+ }
624
+ }
625
+ return await findAppWindow(want, pid)
626
+ }
627
+
628
+ /**
629
+ * launch_app + wait for a window to show up.
630
+ * `selector` is an app name string or a full argument object ({name}|{path}|{aumid}|{launch_path}).
631
+ * Returns { pid, target } where `target` is a resolvable window (or null).
632
+ */
633
+ const launchAndSettle = async (selector, settle, signal) => {
634
+ const args = typeof selector === 'string' ? { name: selector } : selector
635
+ const want = typeof args.name === 'string' ? args.name : String(args.path ?? args.aumid ?? args.launch_path ?? '')
636
+ const r = await call('launch_app', args)
637
+ const sc = structured(r)
638
+ const pid = sc.pid
639
+ const first = (sc.windows ?? [])[0]
640
+ if (first !== undefined) {
641
+ return { pid, target: { pid: first.pid ?? pid, windowId: first.window_id ?? first.windowId, title: first.title ?? '', app: want, minimized: first.minimized === true } }
642
+ }
643
+ // A packaged-app launch (launch_path/aumid) reports pid 0 and no windows —
644
+ // measured behaviour — so a name lookup must come first instead of burning
645
+ // the whole settle budget on a pid that cannot ever match. Such launches are
646
+ // also slow to settle (measured ~9 s for Win11 Notepad), so pid 0 gets a
647
+ // longer window than the pid-scoped path.
648
+ const budget = (pid === 0 ? settle + 10000 : settle + 4000)
649
+ for (let waited = 0; waited < budget; waited += 400) {
650
+ // Not pid-scoped on purpose: the launcher pid may own no window at all.
651
+ const t = await windowFor(want, pid)
652
+ if (t !== null) return { pid: t.pid, target: t }
653
+ await sleep(400, signal)
654
+ }
655
+ // The launch reported success but nothing appeared. Distinguish "still
656
+ // starting" from "started and died" so the model is not told a dead launch
657
+ // succeeded (a name-only launch on this driver can exit within seconds).
658
+ let died = false
659
+ if (Number(pid) > 0) {
660
+ try {
661
+ const r = await raw('list_apps', {})
662
+ // An errored inventory must not read as "the process died": unknown
663
+ // stays unknown (died=false) rather than telling the model the app
664
+ // exited when the real problem is the driver call.
665
+ if (!r.isError) {
666
+ const seen = structured(r).apps ?? []
667
+ died = !seen.some((x) => Math.round(Number(x.pid)) === Math.round(Number(pid)))
668
+ }
669
+ } catch {}
670
+ }
671
+ return { pid, target: null, died }
672
+ }
673
+
674
+ // ── capture ────────────────────────────────────────────────────────────────
675
+ const IMAGE_VALUE = {
676
+ type: 'object', additionalProperties: false, required: true,
677
+ properties: {
678
+ attachmentId: { type: 'string', required: true },
679
+ mediaType: { type: 'string', enum: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], required: true },
680
+ bytes: { type: 'integer', required: true },
681
+ width: { type: 'integer', required: true },
682
+ height: { type: 'integer', required: true },
683
+ },
684
+ }
685
+ const SHOT_VALUE = {
686
+ type: 'object', additionalProperties: false,
687
+ properties: {
688
+ summary: { type: 'string', required: true },
689
+ frameText: { type: 'string', required: true },
690
+ elements: { type: 'string' },
691
+ image: IMAGE_VALUE,
692
+ },
693
+ }
694
+ const SHOT_RENDER = { schema: SHOT_VALUE, render: (_a, v) => [
695
+ { type: 'text', text: v.summary + '\n' + v.frameText + (v.elements ? '\n\n' + v.elements : '') },
696
+ { type: 'image', attachment: v.image },
697
+ ] }
698
+
699
+ const attach = async (png, name) => {
700
+ const ref = await ctx.attachments.saveImage({ data: png, mediaType: 'image/png', name })
701
+ return { attachmentId: ref.attachmentId, mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height }
702
+ }
703
+
704
+ const renderElements = (els, total) => {
705
+ if (!els || els.length === 0) return ''
706
+ const CAP = 80
707
+ const lines = els.slice(0, CAP).map((e) => {
708
+ const label = String(e.label ?? '').replace(/\s+/g, ' ').slice(0, 60)
709
+ const acts = (e.actions ?? []).join('/')
710
+ return e.element_index + ' ' + (e.role ?? '?') + (label ? ' "' + label + '"' : '') + (acts ? ' [' + acts + ']' : '') + ' ' + e.element_token
711
+ })
712
+ let out = 'ELEMENTS (element= + token; from THIS snapshot only):\n' + lines.join('\n')
713
+ if (els.length > CAP) out += '\n… ' + (els.length - CAP) + ' more; pass query= to filter'
714
+ else if (total > els.length) out += '\n(' + total + ' elements total; pass query= to project)'
715
+ return out
716
+ }
717
+
718
+ /** Best-effort: bring an existing window back from minimized / off-screen. */
719
+ const restoreWindow = async (pid, windowId) => {
720
+ if (!restoreCfg) return { ok: false, detail: 'disabled (config restore: false)' }
721
+ try {
722
+ const r = await call('bring_to_front', { pid, window_id: windowId })
723
+ const sc = structured(r)
724
+ return { ok: true, detail: 'previous_fg=' + String(sc.previous_fg_hwnd ?? '?') + ' now_fg=' + String(sc.now_fg_hwnd ?? '?') }
725
+ } catch (e) {
726
+ return { ok: false, detail: String(e && e.message ? e.message : e).slice(0, 200) }
727
+ }
728
+ }
729
+
730
+ /**
731
+ * Write a capture to disk and stage it on the clipboard. `computer_screenshot
732
+ * save=` exists so "attach a proof screenshot in a chat" stops being a pwsh
733
+ * detour (which first had to rediscover DPI awareness, measured) — the file
734
+ * lands on disk AND on the clipboard, ready for a computer_key ctrl+v.
735
+ */
736
+ const saveCapture = async (path, png) => {
737
+ if (typeof path !== 'string' || !/\.png$/i.test(String(path).trim())) throw new Error('save must be a file path ending in .png')
738
+ const fs = await import('node:fs')
739
+ const { dirname } = await import('node:path')
740
+ const target = String(path).trim()
741
+ await fs.promises.mkdir(dirname(target), { recursive: true })
742
+ await fs.promises.writeFile(target, png)
743
+ let clip = ''
744
+ try {
745
+ await call('clipboard_write', { image_path: target })
746
+ clip = ' and copied onto the clipboard (computer_key desktop ctrl+v pastes it)'
747
+ } catch (e) {
748
+ clip = ' (clipboard copy failed: ' + String(e && e.message ? e.message : e).slice(0, 120) + ')'
749
+ }
750
+ return 'Saved to ' + target + clip
751
+ }
752
+
753
+ /** Window capture → screenshot (+ optional element tree) SHOT value. */
754
+ const captureWindow = async (t, opts) => {
755
+ const includeTree = !(opts && opts.tree === false)
756
+ const build = () => {
757
+ const args = { pid: t.pid, window_id: t.windowId, include_accessibility_tree: includeTree }
758
+ if (opts && opts.query) args.query = opts.query
759
+ return args
760
+ }
761
+ let restored = ''
762
+ let r
763
+ const grab = () => call('get_window_state', build())
764
+ try {
765
+ r = await grab()
766
+ } catch (e) {
767
+ // A minimized window has no rendered content: the driver bails instead of
768
+ // handing back an all-black PNG. When the caller allows it, restore the
769
+ // window once and retry — otherwise teach the model the exact remedy.
770
+ const msg = String(e && e.message ? e.message : e)
771
+ if (!(opts && opts.restoreMinimized === true) || !/minimized window/i.test(msg)) throw e
772
+ const res = await restoreWindow(t.pid, t.windowId)
773
+ if (!res.ok) {
774
+ throw new Error('window ' + t.windowId + ' is minimized and could not be restored (' + res.detail + ') — the app is running, but its window is not visible; ask the user to restore it, then retry ' + msg.slice(0, 160))
775
+ }
776
+ restored = ' [was minimized — restored via bring_to_front (' + res.detail + '); the no-foreground contract is broken for this call]'
777
+ await sleep(settleMs, opts && opts.signal)
778
+ r = await grab()
779
+ }
780
+ let sc = structured(r)
781
+ let png = Buffer.from((r.images[0] && r.images[0].dataBase64) ?? '', 'base64')
782
+ let didRestore = restored !== ''
783
+ // A minimized window can fail in two different ways: the driver either
784
+ // throws ("cannot capture minimized window") or hands back an EMPTY image
785
+ // (measured on a window launched minimized). Handle the empty case here —
786
+ // without it, "open a not-yet-running app" dies on a bare
787
+ // "window capture returned no image".
788
+ if (png.length === 0 && opts && opts.restoreMinimized === true && !didRestore) {
789
+ const known = opts.windowHint
790
+ const looksMinimized = known === undefined ? true : known.minimized === true
791
+ if (looksMinimized) {
792
+ const res = await restoreWindow(t.pid, t.windowId)
793
+ if (res.ok) {
794
+ didRestore = true
795
+ restored = ' [was minimized — restored via bring_to_front (' + res.detail + '); the no-foreground contract is broken for this call]'
796
+ await sleep(settleMs, opts && opts.signal)
797
+ const r2 = await grab()
798
+ const png2 = Buffer.from((r2.images[0] && r2.images[0].dataBase64) ?? '', 'base64')
799
+ if (png2.length > 0) { r = r2; png = png2; sc = structured(r2) }
800
+ }
801
+ }
802
+ }
803
+ if (png.length === 0) throw new Error('window capture returned no image' + (didRestore ? ' (even after restoring the minimized window)' : ''))
804
+ const els = sc.elements ?? []
805
+ if (els.length > 0) last = { ...t, tokensByIndex: Object.fromEntries(els.map((e) => [e.element_index, e.element_token])) }
806
+ // Window captures arrive pre-resized by the driver (set_config max_image_
807
+ // dimension), so a saved file is capped at the model frame — only the
808
+ // desktop path can write true native resolution.
809
+ let saved = ''
810
+ if (typeof (opts && opts.save) === 'string' && opts.save !== '') saved = ' ' + (await saveCapture(opts.save, png))
811
+ return {
812
+ summary: ((opts && opts.summary) || ('Window "' + (sc.window_title ?? t.title ?? '') + '" captured (pid ' + t.pid + ', window ' + t.windowId + ').')) + restored + saved,
813
+ frameText: 'Coordinates for this window = pixels of THIS image (window-local, top-left origin). Screen bounds: ' + JSON.stringify(sc.window_bounds ?? {}) + '.',
814
+ elements: renderElements(els, sc.total_element_count ?? els.length),
815
+ image: await attach(png, 'win.png'),
816
+ }
817
+ }
818
+
819
+ /**
820
+ * Window capture with the two degradations the driver needs help with:
821
+ * a hung UIA tree (retry screenshot-only) and a STALE hwnd (re-resolve the
822
+ * window by pid/name and retry once). Window ids are volatile — `launch_app`
823
+ * can hand back an hwnd that is already gone by the time the capture runs —
824
+ * so a handle is never treated as ground truth.
825
+ */
826
+ const shotFor = async (target, opts) => {
827
+ try {
828
+ return await captureWindow(target, opts)
829
+ } catch (e) {
830
+ const msg = String(e && e.message ? e.message : e)
831
+ if (/timed out|unresponsive/i.test(msg)) {
832
+ const shot = await captureWindow(target, { ...opts, tree: false })
833
+ return { ...shot, summary: shot.summary + ' (element tree unavailable: the UIA provider did not answer — act by pixel from this screenshot, or re-capture later)' }
834
+ }
835
+ if (/No window with window_id|window_id .*not found|no longer exists|invalid window/i.test(msg)) {
836
+ const app = target.app ?? (opts && opts.windowHint && opts.windowHint.app)
837
+ const fresh = app === undefined ? null : await windowFor(app, target.pid)
838
+ if (fresh !== null) {
839
+ const shot = await captureWindow(fresh, { ...opts, windowHint: fresh })
840
+ return { ...shot, summary: shot.summary + ' (the window handle had changed — re-resolved it by pid/name before capturing)' }
841
+ }
842
+ }
843
+ throw e
844
+ }
845
+ }
846
+
847
+ /** Primary-desktop capture → SHOT value. `save` writes the ORIGINAL
848
+ * full-resolution PNG (before the model-frame downscale) to disk. */
849
+ const captureDesktop = async (summary, save) => {
850
+ const r = await call('get_desktop_state', {})
851
+ const sc = structured(r)
852
+ const png = Buffer.from((r.images[0] && r.images[0].dataBase64) ?? '', 'base64')
853
+ if (png.length === 0) throw new Error('desktop capture returned no image')
854
+ let img = pngDecode(png)
855
+ const long = Math.max(img.width, img.height)
856
+ if (long > maxDim) img = resizeRgba(img, Math.round((img.width * maxDim) / long), Math.round((img.height * maxDim) / long))
857
+ deskFrame = { sw: sc.screen_width ?? img.width, sh: sc.screen_height ?? img.height, fw: img.width, fh: img.height }
858
+ const scale = typeof sc.scale_factor === 'number' && sc.scale_factor > 0 ? ' @' + sc.scale_factor + 'x' : ''
859
+ const dpiWarn = dpiState.aware ? '' : ' ⚠ host DPI awareness inactive (' + dpiState.text + ') — on scaled displays this frame may cover only part of the primary display and desktop coordinates drift; prefer window-scoped actions via app=.'
860
+ let saved = ''
861
+ if (typeof save === 'string' && save !== '') saved = ' ' + (await saveCapture(save, png))
862
+ return {
863
+ summary: (summary ?? 'Primary display captured') + ' (' + sc.screen_width + 'x' + sc.screen_height + scale + ' px' + dpiWarn + ')' + saved,
864
+ frameText: 'Desktop coordinate frame = pixels of THIS image (top-left origin of the PRIMARY display). Windows on OTHER monitors are NOT in this image — capture and act on them via app=/computer_windows.',
865
+ image: await attach(pngEncode(img), 'desktop.png'),
866
+ }
867
+ }
868
+
869
+ /** Frame px → driver desktop-space px (physical-px input auto-mapped; see deskMapXY). */
870
+ const deskXY = (x, y, label) => deskMapXY(deskFrame, x, y, label)
871
+
872
+ // ── action plumbing ────────────────────────────────────────────────────────
873
+ const agentsGranted = new Set()
874
+ const agentKey = (agent) => (agent && typeof agent.id === 'string' && agent.id !== '') ? agent.id : '__no-agent__'
875
+
876
+ // The harness approval service ('approval') owns a per-session policy: 'ask'
877
+ // prompts, 'never' (approvals disabled / full-access mode) auto-REJECTS every
878
+ // ask. Emitting ask there would only self-block, so when the session cannot
879
+ // prompt we pass actions through — the user's global policy is the gate.
880
+ //
881
+ // effectivePolicy is private upstream; the public overrideOf(session) reads
882
+ // the session's own 'approval/policy' fold and misses only the configured
883
+ // default (policy: never, i.e. danger-full-access deployments). Probe both
884
+ // shapes; when neither resolves, assume the session CANNOT be prompted — an
885
+ // unnecessary ask under 'never' is auto-rejected and locks every tool, while
886
+ // a skipped ask under 'ask' still surfaces in the next screenshot.
887
+ const approval = ctx.get('approval')
888
+ const sessionRefusesPrompt = (exec) => {
889
+ const session = exec && exec.agent && exec.agent.session
890
+ if (approval === undefined || session === undefined) return false
891
+ try {
892
+ // Most complete first: session fold + configured default in one answer.
893
+ if (typeof approval.effectivePolicy === 'function') return approval.effectivePolicy(session) === 'never'
894
+ // Public API: the session's own 'approval/policy' fold, else undefined.
895
+ if (typeof approval.overrideOf === 'function') {
896
+ const override = approval.overrideOf(session)
897
+ if (override === 'never') return true
898
+ if (override === 'ask') return false
899
+ }
900
+ // Configured deployment default ('never' under danger-full-access).
901
+ const policy = approval.config && approval.config.policy
902
+ const value = policy && typeof policy.get === 'function' ? policy.get() : policy
903
+ if (value === 'never') return true
904
+ if (value === 'ask') return false
905
+ } catch { /* undecidable: fall through */ }
906
+ // No probe resolved: assume prompting is impossible. An ask the approval
907
+ // service would auto-reject locks every gated tool; passing through only
908
+ // risks an unasked action, which the next screenshot still reveals.
909
+ return true
910
+ }
911
+
912
+ const isEscalationHint = (r) => {
913
+ const blob = String(r.text ?? '') + String(r.rawJson ?? '')
914
+ // XAML hotkey refusals are NOT escalatable (the UIA-accelerator route ignores
915
+ // delivery_mode) — their guidance must reach the model verbatim instead.
916
+ return /background_unavailable|"effect":"refused"|not accepted in the background/i.test(blob)
917
+ }
918
+
919
+ /**
920
+ * Run one input call: background first; if the driver refuses the background
921
+ * route, retry the exact same call in foreground mode (driver restores the
922
+ * previous foreground afterwards).
923
+ */
924
+ const action = async (tool, base) => {
925
+ const r = await raw(tool, base)
926
+ if (r.isError && isEscalationHint(r)) {
927
+ const r2 = await raw(tool, { ...base, delivery_mode: 'foreground' })
928
+ if (r2.isError) throw new Error(tool + ': ' + String(r2.text ?? 'failed').slice(0, 400))
929
+ r2.escalated = true
930
+ return r2
931
+ }
932
+ if (r.isError) throw new Error(tool + ': ' + String(r.text ?? 'failed').slice(0, 400))
933
+ return r
934
+ }
935
+ const actionNote = (r) => (r.escalated ? ' [background input was refused; re-delivered with a brief foreground swap]' : /"effect":"suspected_noop"/.test(String(r.rawJson ?? '')) ? ' [driver suspects the target ignored the input — verify on the screenshot]' : '')
936
+
937
+ /**
938
+ * Press one chord. XAML/WinUI/UWP targets have no hotkey route beyond a
939
+ * discoverable UIA AcceleratorKey (hidden menu items are invisible, so
940
+ * ctrl+something often cannot be found) — when hotkey refuses, fall back to
941
+ * press_key with a modifiers array in FOREGROUND: a real SendInput chord the
942
+ * target's input pipeline cannot ignore; the driver restores the previous
943
+ * foreground after the keystrokes flush.
944
+ */
945
+ const pressChord = async (targetArg, mods, key) => {
946
+ if (mods.length === 0) return await action('press_key', { ...targetArg, key })
947
+ const r = await raw('hotkey', { ...targetArg, keys: [...mods, key] })
948
+ if (!r.isError) return r
949
+ // Windows gates SetForegroundWindow (foreground lock), so the first swap can
950
+ // be refused with foreground_unavailable; the delivery attempt itself grants
951
+ // the process foreground rights, making a quick retry usually succeed.
952
+ let r2 = await raw('press_key', { ...targetArg, key, modifiers: mods, delivery_mode: 'foreground' })
953
+ if (r2.isError && /foreground_unavailable/i.test(String(r2.text ?? '') + String(r2.rawJson ?? ''))) {
954
+ await sleep(350)
955
+ r2 = await raw('press_key', { ...targetArg, key, modifiers: mods, delivery_mode: 'foreground' })
956
+ }
957
+ if (r2.isError) throw new Error('hotkey ' + mods.join('+') + '+' + key + ': ' + String(r.text ?? 'failed').slice(0, 260) + ' (foreground fallback too: ' + String(r2.text ?? 'failed').slice(0, 160) + ')')
958
+ r2.escalated = true
959
+ return r2
960
+ }
961
+
962
+ // ── key vocabulary (driver names) ─────────────────────────────────────────
963
+ const KEY_ALIAS = {
964
+ enter: 'return', return: 'return', esc: 'escape', escape: 'escape', tab: 'tab', space: 'space',
965
+ backspace: 'backspace', delete: 'delete', del: 'delete', insert: 'insert',
966
+ home: 'home', end: 'end', pageup: 'pageup', pagedown: 'pagedown', pgup: 'pageup', pgdn: 'pagedown',
967
+ up: 'up', down: 'down', left: 'left', right: 'right',
968
+ win: 'win', meta: 'win', cmd: 'win', super: 'win',
969
+ ctrl: 'ctrl', control: 'ctrl', alt: 'alt', option: 'alt', shift: 'shift',
970
+ }
971
+ const keyName = (tok) => {
972
+ const t = tok.toLowerCase()
973
+ if (KEY_ALIAS[t]) return KEY_ALIAS[t]
974
+ if (/^f([1-9]|1[0-2])$/.test(t)) return t
975
+ if (/^.$/.test(t)) return t // letters, digits, simple punctuation
976
+ return null
977
+ }
978
+ const parseKeys = (spec) => {
979
+ const toks = String(spec).toLowerCase().split('+').map((t) => t.trim()).filter((t) => t !== '')
980
+ if (toks.length === 0) throw new Error('keys must look like "ctrl+s" or "alt+F4"')
981
+ const mods = []
982
+ let key = null
983
+ for (const t of toks) {
984
+ const n = keyName(t)
985
+ if (n === null) throw new Error('unknown key "' + t + '" (a-z, 0-9, f1-f12, enter, esc, tab, space, backspace, delete, insert, home, end, pageup, pagedown, up, down, left, right, ctrl, alt, shift, win)')
986
+ if (n === 'ctrl' || n === 'alt' || n === 'shift' || n === 'win') mods.push(n)
987
+ else key = n
988
+ }
989
+ if (key === null) throw new Error('chord needs one non-modifier key after the modifiers')
990
+ return { mods, key }
991
+ }
992
+
993
+ const COORD_DESC = 'Coordinate frames: with a window target, x/y are pixels of the LAST computer_screenshot OF THAT WINDOW; desktop scope uses pixels of the last desktop screenshot (physical-desktop px are auto-mapped into the frame).'
994
+ const KEYB_FIRST = ' KEYBOARD-FIRST: prefer computer_open / computer_key / computer_sequence over pixel clicks whenever a keyboard path exists. ELEMENT-FIRST: if the last window screenshot listed an element for the target, click by its element= token — it works on backgrounded/minimized windows and never touches the user\'s cursor.'
995
+ const register = (definition) => ctx.effect(() => ctx.tools.register(defineTool(definition)), 'computer-use: tool ' + definition.name)
996
+
997
+ /** Shared action wrapper: grant, run, settle, re-capture the acted-on view. */
998
+ const actAndShot = async (exec, grant, summary, run) => {
999
+ grant()
1000
+ const r = await run()
1001
+ const note = actionNote(r) + (r.fallbackNote ?? '') + (r.verification && r.verification.verified === false ? ' [driver could not verify the effect]' : '')
1002
+ await sleep(settleMs, exec.signal)
1003
+ let value
1004
+ if (r.scopeView === 'desktop') value = await captureDesktop(summary + note)
1005
+ else value = await shotFor(r.target ?? last, { tree: false, summary: summary + note })
1006
+ return value
1007
+ }
1008
+ const granting = (exec) => { agentsGranted.add(agentKey(exec.agent)) }
1009
+
1010
+ // Foreground SendInput typing goes through the ACTIVE IME, which corrupts
1011
+ // non-ASCII text — measured on a real run: 这→!!, computer-use→computer--se,
1012
+ // 👋(U+1F44B)→🙏(U+1F64F). Route such text through the driver clipboard and a
1013
+ // real ctrl+v instead, restoring the user's clipboard afterwards (best-effort:
1014
+ // a non-text clipboard cannot be saved and is left as the pasted text).
1015
+ const NON_ASCII = /[^\x00-\x7F]/
1016
+ const pasteDesktop = (exec, text) => actAndShot(exec, () => granting(exec),
1017
+ 'Pasted ' + text.length + ' chars into the foreground app (non-ASCII text is clipboard-pasted; typing it directly corrupts it through the IME).',
1018
+ async () => {
1019
+ let saved = null
1020
+ try {
1021
+ const r = await raw('clipboard_read', { include_text: true })
1022
+ if (!r.isError && typeof structured(r).text === 'string') saved = structured(r).text
1023
+ } catch {}
1024
+ let clipErr = null
1025
+ try {
1026
+ await call('clipboard_write', { text })
1027
+ } catch (e) { clipErr = e }
1028
+ if (clipErr !== null) {
1029
+ // Clipboard unavailable: degrade to direct typing rather than failing.
1030
+ const r = await action('type_text', { target: { kind: 'desktop', display_id: 'primary' }, text })
1031
+ r.fallbackNote = ' [clipboard unavailable (' + String(clipErr && clipErr.message ? clipErr.message : clipErr).slice(0, 120) + ') — typed directly; the IME may have corrupted non-ASCII chars, verify on the screenshot]'
1032
+ return r
1033
+ }
1034
+ const r = await pressChord({ target: { kind: 'desktop', display_id: 'primary' } }, ['ctrl'], 'v')
1035
+ // Let the target consume the paste before putting the user's text back.
1036
+ if (saved !== null && saved !== text) {
1037
+ const prev = saved
1038
+ setTimeout(() => { raw('clipboard_write', { text: prev }).catch(() => {}) }, 800)
1039
+ }
1040
+ return r
1041
+ })
1042
+
1043
+ // ── tools ──────────────────────────────────────────────────────────────────
1044
+ register({
1045
+ name: 'computer_screenshot',
1046
+ description: 'Capture a WINDOW (default target: the last window acted on) or the PRIMARY desktop. A window capture returns the screenshot PLUS the accessibility tree elements (indexed, with element= tokens valid for the NEXT action against that window). Pass app="name" to target any app — including windows on other monitors, hidden, or minimized. No args = desktop overview. save="path.png" additionally writes the capture to disk (desktop captures at native resolution, window captures at the frame cap) and puts it on the clipboard — use it whenever a file or a pasteable screenshot is the goal instead of a pwsh detour. ' + COORD_DESC,
1047
+ parameters: {
1048
+ app: { type: 'string', description: 'App/window name to capture (substring match), e.g. "notepad", "Chrome".' },
1049
+ pid: { type: 'number', description: 'Target pid (alternative to app).' },
1050
+ window_id: { type: 'number', description: 'Exact window under pid (from computer_windows).' },
1051
+ query: { type: 'string', description: 'Project the element list to this case-insensitive substring (window captures).' },
1052
+ desktop: { type: 'boolean', description: 'true = force the primary-desktop capture.' },
1053
+ elements: { type: 'boolean', description: 'false = skip the accessibility tree (screenshot only).' },
1054
+ save: { type: 'string', description: 'Optional absolute .png path: also write the capture to disk and stage it on the system clipboard (paste with computer_key desktop ctrl+v).' },
1055
+ },
1056
+ output: SHOT_RENDER,
1057
+ isConcurrencySafe: () => true,
1058
+ timeoutMs: 30000,
1059
+ async execute(a) {
1060
+ const save = typeof a.save === 'string' && a.save !== '' ? a.save : undefined
1061
+ if (a.desktop === true || (a.app === undefined && a.pid === undefined && a.window_id === undefined && last === null)) {
1062
+ return await captureDesktop(undefined, save)
1063
+ }
1064
+ if (a.desktop === undefined && a.app === undefined && a.pid === undefined && a.window_id === undefined) {
1065
+ return await shotFor(last, { tree: a.elements !== false, save })
1066
+ }
1067
+ const t = await resolveWindow(a)
1068
+ return await shotFor(t, { tree: a.elements !== false, query: typeof a.query === 'string' ? a.query : undefined, save })
1069
+ },
1070
+ })
1071
+
1072
+ register({
1073
+ name: 'computer_windows',
1074
+ description: 'List all top-level windows: pid, window_id, app name, title, screen bounds, z-order, on-screen flag. Read-only. Use it to find the pid/window_id for screenshots and actions, or to see what is open on monitors the desktop screenshot does not cover.',
1075
+ parameters: {
1076
+ app: { type: 'string', description: 'Optional substring filter on app name / title.' },
1077
+ on_screen_only: { type: 'boolean', description: 'Drop off-screen/minimized windows. Default false (they are capturable!).' },
1078
+ },
1079
+ output: {
1080
+ schema: { type: 'object', additionalProperties: false, properties: { table: { type: 'string', required: true } } },
1081
+ render: (_a, v) => [{ type: 'text', text: v.table }],
1082
+ },
1083
+ isConcurrencySafe: () => true,
1084
+ timeoutMs: 15000,
1085
+ async execute(a) {
1086
+ const sc = await listWindows({ on_screen_only: a.on_screen_only === true })
1087
+ let wins = sc.windows ?? []
1088
+ if (a.app !== undefined) {
1089
+ const needle = String(a.app).toLowerCase()
1090
+ wins = wins.filter((x) => String(x.app_name ?? '').toLowerCase().includes(needle) || String(x.title ?? '').toLowerCase().includes(needle))
1091
+ }
1092
+ if (wins.length === 0) return { table: 'no windows matched' }
1093
+ const rows = wins.slice(0, 40).map((w) => 'pid=' + w.pid + ' win=' + (w.window_id ?? w.windowId) + (w.is_on_screen === false ? ' [off-screen]' : '') + (w.minimized ? ' [minimized]' : '') + ' z=' + (w.z_index ?? '?') + ' ' + JSON.stringify(w.app_name ?? '') + ' ' + JSON.stringify(String(w.title ?? '').slice(0, 60)) + ' ' + JSON.stringify(w.bounds ?? {}))
1094
+ return { table: rows.join('\n') + (wins.length > 40 ? '\n… ' + (wins.length - 40) + ' more (filter with app=)' : '') }
1095
+ },
1096
+ })
1097
+
1098
+ register({
1099
+ name: 'computer_click',
1100
+ description: 'Click IN A TARGET WINDOW (background — your cursor and focus are untouched) or on the desktop. Addressing, best first: element=<token from the last computer_screenshot of that window>; else window-local x,y of that window screenshot (the driver UIA hit-tests first, then posts raw events); else desktop x,y for taskbar/wallpaper. button=left|right|middle, clicks=1-3, keys=modifier chord held during the click. ' + COORD_DESC,
1101
+ parameters: {
1102
+ app: { type: 'string', description: 'Target app (default: last captured window). Omit with x,y only for DESKTOP clicks.' },
1103
+ pid: { type: 'number' },
1104
+ window_id: { type: 'number' },
1105
+ element: { type: 'string', description: 'element token from the last window capture.' },
1106
+ x: { type: 'number', description: 'X px of the last screenshot of the SAME target.' },
1107
+ y: { type: 'number', description: 'Y px of the last screenshot of the SAME target.' },
1108
+ button: { type: 'string', description: 'left | right | middle. Default left.' },
1109
+ clicks: { type: 'number', description: 'Click count 1-3. Default 1.' },
1110
+ keys: { type: 'string', description: 'Optional held modifiers, e.g. "ctrl" or "ctrl+shift". Windows background clicks DROP modifiers (UIA/PostMessage carry no key state) — they land only when the click escalates to foreground.' },
1111
+ desktop: { type: 'boolean', description: 'true = click on the primary desktop at desktop-screenshot x,y (moves the real pointer).' },
1112
+ from_zoom: { type: 'boolean', description: 'true = x,y are pixels of the latest computer_zoom image of this window (the driver maps them back to window coordinates).' },
1113
+ },
1114
+ output: SHOT_RENDER,
1115
+ timeoutMs: 30000,
1116
+ async execute(a, exec) {
1117
+ const button = a.button === undefined ? 'left' : String(a.button)
1118
+ if (button !== 'left' && button !== 'right' && button !== 'middle') throw new Error('button must be left|right|middle')
1119
+ const count = a.clicks === undefined ? 1 : Math.round(Number(a.clicks))
1120
+ if (!(count >= 1 && count <= 3)) throw new Error('clicks must be 1-3')
1121
+ const modifier = a.keys === undefined ? undefined : parseKeys(a.keys).mods
1122
+ let where
1123
+ let deskNote = ''
1124
+ if (a.desktop === true || (a.app === undefined && a.pid === undefined && a.element === undefined && last === null)) {
1125
+ if (a.x === undefined || a.y === undefined) throw new Error('desktop click needs x,y (pixels of the last desktop screenshot)')
1126
+ const p = deskXY(Number(a.x), Number(a.y), 'click target')
1127
+ if (p.scaled) deskNote = ' [x,y were physical desktop px — mapped into the screenshot frame]'
1128
+ where = { target: { kind: 'desktop', display_id: 'primary' }, x: p.x, y: p.y }
1129
+ } else {
1130
+ const t = (a.app !== undefined || a.pid !== undefined) ? await resolveWindow(a) : (last ?? await resolveWindow(a))
1131
+ if (typeof a.element === 'string' && a.element !== '') {
1132
+ // element_token uses the legacy addressing form: token + its (pid, window_id) scope.
1133
+ where = { pid: t.pid, window_id: t.windowId, element_token: a.element }
1134
+ } else {
1135
+ if (a.x === undefined || a.y === undefined) throw new Error('pass element= or window-local x,y (pixels of the last computer_screenshot of this window)')
1136
+ where = { target: windowTarget(t), x: Number(a.x), y: Number(a.y) }
1137
+ }
1138
+ }
1139
+ const desc = 'Clicked ' + button + (count > 1 ? ' x' + count : '') + (a.keys ? ' holding ' + a.keys : '') + (a.element ? ' on element ' + a.element : a.x !== undefined ? ' at (' + a.x + ',' + a.y + ')' : '') + '.' + deskNote
1140
+ return await actAndShot(exec, () => granting(exec), desc, async () => {
1141
+ const r = await action('click', { ...where, button, count, ...(modifier ? { modifier } : {}), ...(a.from_zoom === true ? { from_zoom: true } : {}) })
1142
+ r.scopeView = where.target && where.target.kind === 'desktop' ? 'desktop' : 'window'
1143
+ return r
1144
+ })
1145
+ },
1146
+ })
1147
+
1148
+ register({
1149
+ name: 'computer_drag',
1150
+ description: 'Press-drag-release in a target window (window-local px of its last screenshot) or on the desktop (desktop px, real pointer). Title-bar/resize drags cannot be posted in background — the plugin auto-escalates those to foreground. moves real windows; prefer app-internal drags on content only.',
1151
+ parameters: {
1152
+ app: { type: 'string' },
1153
+ pid: { type: 'number' },
1154
+ window_id: { type: 'number' },
1155
+ fromX: { type: 'number', required: true },
1156
+ fromY: { type: 'number', required: true },
1157
+ toX: { type: 'number', required: true },
1158
+ toY: { type: 'number', required: true },
1159
+ desktop: { type: 'boolean', description: 'true = drag on the primary desktop (coordinates from the last desktop screenshot).' },
1160
+ },
1161
+ output: SHOT_RENDER,
1162
+ timeoutMs: 30000,
1163
+ async execute(a, exec) {
1164
+ let args
1165
+ let view
1166
+ let deskNote = ''
1167
+ if (a.desktop === true || (a.app === undefined && a.pid === undefined && last === null)) {
1168
+ const p1 = deskXY(Number(a.fromX), Number(a.fromY), 'drag start')
1169
+ const p2 = deskXY(Number(a.toX), Number(a.toY), 'drag end')
1170
+ if (p1.scaled || p2.scaled) deskNote = ' [coordinates were physical desktop px — mapped into the screenshot frame]'
1171
+ args = { target: { kind: 'desktop', display_id: 'primary' }, from_x: p1.x, from_y: p1.y, to_x: p2.x, to_y: p2.y }
1172
+ view = 'desktop'
1173
+ } else {
1174
+ const t = (a.app !== undefined || a.pid !== undefined) ? await resolveWindow(a) : last
1175
+ args = { target: windowTarget(t), from_x: Number(a.fromX), from_y: Number(a.fromY), to_x: Number(a.toX), to_y: Number(a.toY) }
1176
+ view = 'window'
1177
+ }
1178
+ return await actAndShot(exec, () => granting(exec), 'Dragged (' + a.fromX + ',' + a.fromY + ') → (' + a.toX + ',' + a.toY + ').' + deskNote, async () => {
1179
+ const r = await action('drag', args)
1180
+ r.scopeView = view
1181
+ return r
1182
+ })
1183
+ },
1184
+ })
1185
+
1186
+ register({
1187
+ name: 'computer_scroll',
1188
+ description: 'Scroll a target window (posted to the window — background, no pointer move). direction=up|down|left|right, amount=1-20 ticks (default 3), by=line|page (default line).',
1189
+ parameters: {
1190
+ app: { type: 'string' },
1191
+ pid: { type: 'number' },
1192
+ window_id: { type: 'number' },
1193
+ direction: { type: 'string', required: true, description: 'up | down | left | right.' },
1194
+ amount: { type: 'number', description: 'Scroll ticks 1-20. Default 3.' },
1195
+ by: { type: 'string', description: 'line | page. Default line.' },
1196
+ },
1197
+ output: SHOT_RENDER,
1198
+ timeoutMs: 30000,
1199
+ async execute(a, exec) {
1200
+ const direction = String(a.direction)
1201
+ if (!['up', 'down', 'left', 'right'].includes(direction)) throw new Error('direction must be up|down|left|right')
1202
+ const amount = a.amount === undefined ? 3 : Math.round(Number(a.amount))
1203
+ if (!(amount >= 1 && amount <= 20)) throw new Error('amount must be 1-20')
1204
+ const t = (a.app !== undefined || a.pid !== undefined) ? await resolveWindow(a) : last
1205
+ if (!t) throw new Error('no target window: pass app= or capture first')
1206
+ return await actAndShot(exec, () => granting(exec), 'Scrolled ' + direction + ' x' + amount + ' in window.', async () =>
1207
+ await action('scroll', { target: windowTarget(t), direction, amount, ...(a.by === 'page' ? { by: 'page' } : {}) }))
1208
+ },
1209
+ })
1210
+
1211
+ register({
1212
+ name: 'computer_type',
1213
+ description: 'Type text into a target window IN THE BACKGROUND (UIA SetValue for fields listed in the last capture — pass element= for reliability; XAML/Store apps require element=; classic Win32 accepts plain WM_CHAR). Pass desktop=true to type into whatever the user currently has focused instead — non-ASCII text (CJK, emoji) is then delivered via clipboard + ctrl+v, because direct typing goes through the active IME and comes out corrupted; the user\'s clipboard is restored afterwards. Newlines are not typed in the window path; send computer_key enter between lines (a desktop paste carries them as-is).',
1214
+ parameters: {
1215
+ app: { type: 'string' },
1216
+ pid: { type: 'number' },
1217
+ window_id: { type: 'number' },
1218
+ element: { type: 'string', description: 'element token of the field (last window capture).' },
1219
+ text: { type: 'string', required: true, description: 'Text to type. Max 5000 chars.' },
1220
+ desktop: { type: 'boolean', description: 'true = type into the current foreground app (no window target).' },
1221
+ },
1222
+ output: SHOT_RENDER,
1223
+ timeoutMs: 30000,
1224
+ async execute(a, exec) {
1225
+ if (typeof a.text !== 'string' || a.text.length === 0) throw new Error('text must be a non-empty string')
1226
+ if (a.text.length > 5000) throw new Error('text too long (max 5000 chars)')
1227
+ if (a.desktop === true) {
1228
+ // Non-ASCII must not go through the IME-ridden SendInput path (see
1229
+ // pasteDesktop); window-scope typing is Unicode-native (UIA/WM_CHAR).
1230
+ if (NON_ASCII.test(a.text)) return await pasteDesktop(exec, a.text)
1231
+ return await actAndShot(exec, () => granting(exec), 'Typed ' + a.text.length + ' chars into the foreground app.', async () => {
1232
+ const r = await action('type_text', { target: { kind: 'desktop', display_id: 'primary' }, text: a.text })
1233
+ r.scopeView = 'desktop'
1234
+ return r
1235
+ })
1236
+ }
1237
+ const t = (a.app !== undefined || a.pid !== undefined) ? await resolveWindow(a) : last
1238
+ if (!t) throw new Error('no target window: pass app= or capture first')
1239
+ const args = typeof a.element === 'string' && a.element !== ''
1240
+ ? { pid: t.pid, window_id: t.windowId, element_token: a.element, text: a.text }
1241
+ : { target: windowTarget(t), text: a.text }
1242
+ return await actAndShot(exec, () => granting(exec), 'Typed ' + a.text.length + ' chars into window.', () => action('type_text', args))
1243
+ },
1244
+ })
1245
+
1246
+ register({
1247
+ name: 'computer_key',
1248
+ description: 'Press a key or chord in a target window (background; driver picks UIA-accelerator/PostMessage/SendInput automatically), repeat 1-10. For chords on XAML/WinUI/UWP apps that hide shortcuts behind menus (e.g. Notepad ctrl+a), it auto-falls back to a real foreground SendInput chord that briefly swaps focus then restores. Names: a-z, 0-9, f1-f12, enter, esc, tab, space, backspace, delete, insert, home, end, pageup, pagedown, up, down, left, right, ctrl, alt, shift, win. desktop=true sends to the foreground app instead.',
1249
+ parameters: {
1250
+ app: { type: 'string' },
1251
+ pid: { type: 'number' },
1252
+ window_id: { type: 'number' },
1253
+ keys: { type: 'string', required: true, description: 'Plus-joined key names, e.g. "ctrl+shift+s".' },
1254
+ repeat: { type: 'number', description: 'Press the chord N times, 1-10. Default 1.' },
1255
+ desktop: { type: 'boolean', description: 'true = send to the current foreground app.' },
1256
+ },
1257
+ output: SHOT_RENDER,
1258
+ timeoutMs: 30000,
1259
+ async execute(a, exec) {
1260
+ const { mods, key } = parseKeys(a.keys)
1261
+ const repeat = a.repeat === undefined ? 1 : Math.round(Number(a.repeat))
1262
+ if (!(repeat >= 1 && repeat <= 10)) throw new Error('repeat must be 1-10')
1263
+ let targetArg
1264
+ if (a.desktop === true) {
1265
+ targetArg = { target: { kind: 'desktop', display_id: 'primary' } }
1266
+ } else {
1267
+ const t = (a.app !== undefined || a.pid !== undefined) ? await resolveWindow(a) : last
1268
+ if (!t) throw new Error('no target window: pass app= or capture first')
1269
+ targetArg = { target: windowTarget(t) }
1270
+ }
1271
+ const view = a.desktop === true ? 'desktop' : 'window'
1272
+ return await actAndShot(exec, () => granting(exec), 'Pressed ' + a.keys + (repeat > 1 ? ' x' + repeat : '') + '.', async () => {
1273
+ let r
1274
+ for (let i = 0; i < repeat; i++) {
1275
+ r = await pressChord(targetArg, mods, key)
1276
+ }
1277
+ r.scopeView = view
1278
+ return r
1279
+ })
1280
+ },
1281
+ })
1282
+
1283
+ register({
1284
+ name: 'computer_open',
1285
+ description: 'Open an app by name WITHOUT ever starting a second instance of something that is already running: an existing window is reused (a minimized one is restored), and an app that is ALREADY RUNNING but exposes no window (tray state) is reported instead of relaunched — ask the user to open it, or pass activate_running=true to let its own launcher try. Only a truly absent app is launched. Returns that window captured, with the element tree. PREFERRED over Start-menu clicking. Launching is always background; restoring a minimized window is the one path that takes the foreground.',
1286
+ parameters: {
1287
+ name: { type: 'string', required: true, description: 'App name as the OS knows it, e.g. "notepad", "微信", "Chrome". Max 100 chars.' },
1288
+ settle_seconds: { type: 'number', description: 'Extra wait for a newly launched window to materialize, 0-15. Default 2.' },
1289
+ activate_running: { type: 'boolean', description: 'true = when the app is already running but has no window (tray-only), let its own launcher try to activate it. Default false: report it and let the user open it from the tray, because re-entering a chat app\'s startup path can drop its stored session.' },
1290
+ },
1291
+ output: SHOT_RENDER,
1292
+ timeoutMs: 45000,
1293
+ async execute(a, exec) {
1294
+ const appName = typeof a.name === 'string' ? a.name.trim() : ''
1295
+ if (appName === '' || appName.length > 100) throw new Error('name must be 1-100 chars')
1296
+ const settle = a.settle_seconds === undefined ? 2000 : Math.min(15000, Math.max(0, Number(a.settle_seconds) * 1000))
1297
+ const mayActivate = a.activate_running === true || activateRunningCfg
1298
+ granting(exec)
1299
+
1300
+ // Order matters, and this is the order: PROCESS FIRST, window second.
1301
+ //
1302
+ // "Is a process for this app alive?" is the cheapest and most reliable
1303
+ // signal (the driver derives list_apps.running from the process table and
1304
+ // the foreground pid). The window layer is the fragile one — measured:
1305
+ // list_windows can report zero windows for the whole lifetime of a
1306
+ // long-lived host, and it is also session/DPI sensitive. Deciding "it is
1307
+ // not running, so launch it" from a WINDOW lookup therefore relaunches an
1308
+ // app that is plainly alive, which is precisely the reported bug. So:
1309
+ // ask the process table first, and only launch when it says no.
1310
+
1311
+ // 1) Already running? Then never launch. Reuse its window when there is
1312
+ // one (restoring it if minimized); otherwise it is a tray-state app.
1313
+ const entry = await findInstalledApp(appName)
1314
+ if (entry !== null && entry.running === true) {
1315
+ let target = await windowFor(appName, entry.pid)
1316
+ for (let waited = 0; target === null && waited < 2000; waited += 400) {
1317
+ await sleep(400, exec.signal)
1318
+ target = await windowFor(appName, entry.pid)
1319
+ }
1320
+ if (target !== null) {
1321
+ last = target
1322
+ const shot = await shotFor(target, { restoreMinimized: true, signal: exec.signal, summary: 'Reused the window of the already-running "' + appName + '" (pid ' + target.pid + ') — no new instance was started.' })
1323
+ return { ...shot, summary: shot.summary + ' Nothing was relaunched.' }
1324
+ }
1325
+ // Alive, no window: for chat apps (WeChat / WeCom / QQ / DingTalk) that
1326
+ // IS the tray state, and walking them through their launcher can drop a
1327
+ // stored session and demand a fresh QR scan. Report; let the user click
1328
+ // the tray icon, which restores the real window without starting
1329
+ // anything. activate_running=true opts into letting its launcher try.
1330
+ if (mayActivate) {
1331
+ const activate = entry.kind === 'uwp'
1332
+ ? await launchSelectorFor(appName)
1333
+ : (typeof entry.launch_path === 'string' && entry.launch_path !== '' ? { launch_path: entry.launch_path } : (typeof entry.bundle_id === 'string' && entry.bundle_id !== '' ? { path: entry.bundle_id } : null))
1334
+ if (activate !== null) {
1335
+ await call('launch_app', { ...activate, start_minimized: true }).catch(() => null)
1336
+ for (let waited = 0; waited < 6000; waited += 600) {
1337
+ await sleep(600, exec.signal)
1338
+ const t = await windowFor(appName, entry.pid)
1339
+ if (t !== null) {
1340
+ last = t
1341
+ return await shotFor(last, { restoreMinimized: true, signal: exec.signal, summary: 'Activated the already-running "' + appName + '" through its own launcher (pid ' + t.pid + ') — no second instance.' })
1342
+ }
1343
+ }
1344
+ }
1345
+ }
1346
+ return await captureDesktop('"' + appName + '" is ALREADY RUNNING (pid ' + entry.pid + ') but exposes no window — this is usually its tray state, so nothing was started. Ask the user to open it from the tray (clicking the tray icon restores the real window and keeps the session), then retry. If the app truly has no window and no tray icon, retry with activate_running=true to let its launcher try.')
1347
+ }
1348
+
1349
+ // 2) Not running: launch it (aumid for Store apps, shortcut commandline
1350
+ // for desktop apps — see launchSelectorFor).
1351
+ const launched = await launchAndSettle(await launchSelectorFor(appName), settle, exec.signal)
1352
+ if (launched.target !== null) {
1353
+ last = launched.target
1354
+ return await shotFor(last, { restoreMinimized: true, windowHint: last, signal: exec.signal, summary: 'Launched "' + appName + '" (pid ' + last.pid + ', background — foreground untouched).' })
1355
+ }
1356
+
1357
+ // 3) Launch produced no window. It may still be starting (slow packaged
1358
+ // apps take ~10 s) — give it one more patient look before giving up.
1359
+ let late = await windowFor(appName)
1360
+ for (let waited = 0; late === null && waited < 6000; waited += 600) {
1361
+ await sleep(600, exec.signal)
1362
+ late = await windowFor(appName)
1363
+ }
1364
+ if (late !== null) {
1365
+ last = late
1366
+ return await shotFor(last, { restoreMinimized: true, windowHint: last, signal: exec.signal, summary: 'Launched "' + appName + '" (slow start; pid ' + late.pid + ').' })
1367
+ }
1368
+ const why = launched.died === true
1369
+ ? 'its process exited immediately — the app did NOT start (check the app name, or launch it by path)'
1370
+ : 'no window appeared'
1371
+ return await captureDesktop('Tried to open "' + appName + '" but ' + why + '. Nothing to show; check computer_windows, or ask the user to open it.')
1372
+ },
1373
+ })
1374
+
1375
+ const compileStep = (rawStep) => {
1376
+ const s = String(rawStep)
1377
+ const c1 = s.indexOf(':')
1378
+ const head = (c1 === -1 ? s : s.slice(0, c1)).toLowerCase().trim()
1379
+ const rest = c1 === -1 ? '' : s.slice(c1 + 1)
1380
+ if (head === 'key') {
1381
+ const { mods, key } = parseKeys(rest)
1382
+ return { kind: 'key', mods, key }
1383
+ }
1384
+ if (head === 'type') {
1385
+ if (rest === '' || rest.length > 5000) throw new Error('type text must be 1-5000 chars')
1386
+ return { kind: 'type', text: rest }
1387
+ }
1388
+ if (head === 'wait') {
1389
+ const sec = parseFloat(rest)
1390
+ if (!isFinite(sec) || sec < 0 || sec > 15) throw new Error('wait seconds must be 0-15')
1391
+ return { kind: 'wait', ms: Math.round(sec * 1000) }
1392
+ }
1393
+ if (head === 'click') {
1394
+ let button = 'left'
1395
+ let clicks = 1
1396
+ let xy = rest
1397
+ if (rest.indexOf(':') !== -1) {
1398
+ const li = rest.lastIndexOf(':')
1399
+ xy = rest.slice(li + 1)
1400
+ rest.slice(0, li).split(':').forEach((m) => {
1401
+ if (m === 'left' || m === 'right' || m === 'middle') button = m
1402
+ else if (/^[1-3]$/.test(m)) clicks = parseInt(m, 10)
1403
+ else if (m !== '') throw new Error('click modifiers must be left|right|middle or 1-3, got "' + m + '"')
1404
+ })
1405
+ }
1406
+ const seg = xy.split(',').map((t) => t.trim())
1407
+ if (seg.length !== 2 || !isFinite(Number(seg[0])) || !isFinite(Number(seg[1]))) throw new Error('click needs "x,y" in the target window\'s last screenshot px')
1408
+ return { kind: 'click', x: Number(seg[0]), y: Number(seg[1]), button, clicks }
1409
+ }
1410
+ if (head === 'scroll') {
1411
+ const seg = rest.split(',').map((t) => t.trim())
1412
+ if (!['up', 'down', 'left', 'right'].includes(seg[0])) throw new Error('scroll direction must be up|down|left|right')
1413
+ const amt = seg[1] !== undefined && seg[1] !== '' ? Number(seg[1]) : 3
1414
+ if (!(amt >= 1 && amt <= 20)) throw new Error('scroll amount must be 1-20')
1415
+ return { kind: 'scroll', direction: seg[0], amount: amt }
1416
+ }
1417
+ if (head === 'open') {
1418
+ const appName = rest.trim()
1419
+ if (appName === '' || appName.length > 100) throw new Error('open name must be 1-100 chars')
1420
+ return { kind: 'open', name: appName }
1421
+ }
1422
+ throw new Error('unknown action "' + head + '" (use key/type/wait/click/scroll/open — for held-modifier clicks use click:ctrl not keydown)')
1423
+ }
1424
+
1425
+ register({
1426
+ name: 'computer_sequence',
1427
+ description: 'Run a multi-step flow atomically in ONE call with ONE approval and ONE final screenshot (intermediate states not shown). Steps target the last window (or app= for this sequence); step DSL, one string per step: key:ctrl+s · type:任意文本 · wait:1.5 · click:x,y or click:right:2:x,y · scroll:down,5 · open:微信 (launch + retarget). All steps validate before the first input. Coordinates = px of the last screenshot of the acting window.' + KEYB_FIRST,
1428
+ parameters: {
1429
+ steps: { type: 'array', required: true, description: 'Ordered step strings, max 30 steps.', items: { type: 'string' } },
1430
+ app: { type: 'string', description: 'Optional app to retarget this sequence to first.' },
1431
+ },
1432
+ output: SHOT_RENDER,
1433
+ timeoutMs: 90000,
1434
+ async execute(a, exec) {
1435
+ if (!Array.isArray(a.steps) || a.steps.length === 0) throw new Error('steps must be a non-empty array of step strings')
1436
+ if (a.steps.length > 30) throw new Error('max 30 steps per sequence')
1437
+ const compiled = []
1438
+ for (let i = 0; i < a.steps.length; i++) {
1439
+ try { compiled.push(compileStep(a.steps[i])) } catch (e) {
1440
+ throw new Error('step ' + (i + 1) + ' "' + String(a.steps[i]).slice(0, 60) + '": ' + e.message)
1441
+ }
1442
+ }
1443
+ granting(exec)
1444
+ if (a.app !== undefined) await resolveWindow({ app: a.app })
1445
+ const notes = []
1446
+ let view = last ? 'window' : 'desktop'
1447
+ for (const step of compiled) {
1448
+ if (step.kind === 'wait') { await sleep(step.ms, exec.signal); continue }
1449
+ if (step.kind === 'open') {
1450
+ // Same order as computer_open: ask the process table FIRST, and only
1451
+ // launch when it says the app is not running.
1452
+ const entry = await findInstalledApp(step.name)
1453
+ if (entry !== null && entry.running === true) {
1454
+ const t = await windowFor(step.name, entry.pid)
1455
+ if (t !== null) {
1456
+ last = t
1457
+ view = 'window'
1458
+ // Only un-minimize; the single final capture at the end covers it.
1459
+ const w = await listWindows({ pid: t.pid })
1460
+ const me = (w.windows ?? []).find((x) => (x.window_id ?? x.windowId) === t.windowId)
1461
+ if (me !== undefined && me.minimized === true && restoreCfg) await restoreWindow(t.pid, t.windowId)
1462
+ continue
1463
+ }
1464
+ // Running with no window (tray state): do not relaunch it.
1465
+ notes.push('"' + step.name + '" was already running with no window (tray state) — not relaunched')
1466
+ continue
1467
+ }
1468
+ const r = await call('launch_app', await launchSelectorFor(step.name))
1469
+ const sc = structured(r)
1470
+ await sleep(1200, exec.signal)
1471
+ const t = await windowFor(step.name, sc.pid)
1472
+ if (t !== null) { last = t; view = 'window' }
1473
+ continue
1474
+ }
1475
+ if (!last) throw new Error('sequence step needs a window target (pass app= or open: first): ' + step.kind)
1476
+ const t = last
1477
+ if (step.kind === 'key') {
1478
+ await pressChord({ target: windowTarget(t) }, step.mods, step.key)
1479
+ } else if (step.kind === 'type') {
1480
+ await action('type_text', { target: windowTarget(t), text: step.text })
1481
+ } else if (step.kind === 'click') {
1482
+ await action('click', { target: windowTarget(t), x: step.x, y: step.y, button: step.button, count: step.clicks })
1483
+ } else if (step.kind === 'scroll') {
1484
+ await action('scroll', { target: windowTarget(t), direction: step.direction, amount: step.amount })
1485
+ }
1486
+ }
1487
+ await sleep(settleMs, exec.signal)
1488
+ const summary = 'Ran ' + a.steps.length + ' steps: ' + a.steps.join(' → ').slice(0, 300) + (notes.length > 0 ? ' [' + notes.join('; ') + ']' : '')
1489
+ return view === 'window' ? await shotFor(last, { tree: false, summary }) : await captureDesktop(summary)
1490
+ },
1491
+ })
1492
+
1493
+ register({
1494
+ name: 'computer_zoom',
1495
+ description: 'Zoom into a region of a WINDOW at native resolution (reads small text/icons the model frame blurs). x0,y0,x1,y1 are px of the last screenshot OF THAT WINDOW; region ≤ ~500px wide (the driver pads 20% per side). The zoom image is NOT the coordinate frame — pass from_zoom=true to computer_click to click inside it.',
1496
+ parameters: {
1497
+ app: { type: 'string' },
1498
+ pid: { type: 'number' },
1499
+ window_id: { type: 'number' },
1500
+ x0: { type: 'number', required: true },
1501
+ y0: { type: 'number', required: true },
1502
+ x1: { type: 'number', required: true, description: 'Must be > x0.' },
1503
+ y1: { type: 'number', required: true, description: 'Must be > y0.' },
1504
+ },
1505
+ output: SHOT_RENDER,
1506
+ isConcurrencySafe: () => true,
1507
+ timeoutMs: 25000,
1508
+ async execute(a) {
1509
+ const t = (a.app !== undefined || a.pid !== undefined) ? await resolveWindow(a) : last
1510
+ if (!t) throw new Error('zoom needs a window: pass app= (or capture a window first). For desktop regions just look at the desktop screenshot again.')
1511
+ const r = await call('zoom', { pid: t.pid, window_id: t.windowId, x1: Number(a.x0), y1: Number(a.y0), x2: Number(a.x1), y2: Number(a.y1) })
1512
+ const img = r.images[0]
1513
+ if (!img) throw new Error('zoom returned no image')
1514
+ const png = Buffer.from(img.dataBase64, 'base64')
1515
+ const ref = await ctx.attachments.saveImage({ data: png, mediaType: img.mimeType === 'image/png' ? 'image/png' : (img.mimeType || 'image/jpeg'), name: 'zoom.jpg' })
1516
+ return {
1517
+ summary: 'Zoomed window region (' + a.x0 + ',' + a.y0 + ')-(' + a.x1 + ',' + a.y1 + ') at native resolution.',
1518
+ frameText: 'This zoom is NOT the coordinate frame; computer_click with from_zoom=true maps clicks from THIS image back to the window.',
1519
+ image: { attachmentId: ref.attachmentId, mediaType: ref.mediaType, bytes: ref.bytes, width: ref.width, height: ref.height },
1520
+ }
1521
+ },
1522
+ })
1523
+
1524
+ register({
1525
+ name: 'computer_cursor',
1526
+ description: 'Read the real OS cursor position (primary-desktop pixel space, same as the desktop screenshot). Window actions do not use the real cursor — they post to the target window directly.',
1527
+ parameters: {},
1528
+ output: {
1529
+ schema: { type: 'object', additionalProperties: false, properties: { physical: { type: 'string', required: true } } },
1530
+ render: (_a, v) => [{ type: 'text', text: 'cursor at ' + v.physical + ' (primary-desktop px; background window actions ignore the pointer)' }],
1531
+ },
1532
+ isConcurrencySafe: () => true,
1533
+ timeoutMs: 15000,
1534
+ async execute() {
1535
+ const sc = structured(await call('get_cursor_position', {}))
1536
+ return { physical: sc.x + ',' + sc.y }
1537
+ },
1538
+ })
1539
+
1540
+ register({
1541
+ name: 'computer_move',
1542
+ description: 'Move the REAL OS pointer on the primary desktop to (x, y) of the last desktop screenshot and leave it there (hover state for the user/foreground apps). Background window actions never move the pointer, so use this only when a FOREGROUND app needs hover. Does not click.',
1543
+ parameters: {
1544
+ x: { type: 'number', required: true },
1545
+ y: { type: 'number', required: true },
1546
+ },
1547
+ output: SHOT_RENDER,
1548
+ timeoutMs: 25000,
1549
+ async execute(a, exec) {
1550
+ const p = deskXY(Number(a.x), Number(a.y), 'move target')
1551
+ return await actAndShot(exec, () => granting(exec), 'Real pointer moved to desktop px (' + a.x + ',' + a.y + ').' + (p.scaled ? ' [x,y were physical desktop px — mapped into the screenshot frame]' : ''), async () => {
1552
+ const r = await call('move_cursor', { target: { kind: 'desktop', display_id: 'primary' }, x: p.x, y: p.y })
1553
+ r.scopeView = 'desktop'
1554
+ return r
1555
+ })
1556
+ },
1557
+ })
1558
+
1559
+ register({
1560
+ name: 'computer_wait',
1561
+ description: 'Wait for a few seconds (animations, loading) and return a fresh screenshot of the current target (window if captured, else desktop).',
1562
+ parameters: {
1563
+ seconds: { type: 'number', required: true, description: 'Seconds to wait, 0.1-15.' },
1564
+ },
1565
+ output: SHOT_RENDER,
1566
+ timeoutMs: 25000,
1567
+ async execute(a, exec) {
1568
+ const s = Number(a.seconds)
1569
+ if (!isFinite(s) || s < 0.1 || s > 15) throw new Error('seconds must be 0.1-15')
1570
+ await sleep(Math.round(s * 1000), exec.signal)
1571
+ return last ? await shotFor(last, { tree: false, summary: 'Waited ' + s + 's.' }) : await captureDesktop('Waited ' + s + 's.')
1572
+ },
1573
+ })
1574
+
1575
+ const MUTATING = new Set(['computer_move', 'computer_click', 'computer_drag', 'computer_scroll', 'computer_type', 'computer_key', 'computer_open', 'computer_sequence'])
1576
+ ctx.on('tools/pre-execute', (exec, next) => {
1577
+ if (!MUTATING.has(exec.name)) return next()
1578
+ if (askPolicy === 'never') return next()
1579
+ if (askPolicy === 'once-per-agent' && agentsGranted.has(agentKey(exec.agent))) return next()
1580
+ // Full-access / approvals-disabled session: asking would be auto-rejected by
1581
+ // the approval service — honor the session's mode instead of self-blocking.
1582
+ if (sessionRefusesPrompt(exec)) return next()
1583
+ return { kind: 'ask', reason: '允许 computer-use 操作应用?一次批准覆盖同一会话的后续动作(askPolicy: always 可改为每次一询);默认后台注入——不移动你的鼠标、不抢焦点,仅个别拒收后台输入的应用会短暂前置。首个动作: ' + exec.name + '。' }
1584
+ })
1585
+
1586
+ ctx.logger.info('computer-use v2.4: cua-driver backend (session "' + SESSION_LABEL + '", all calls labelled); ' + MUTATING.size + ' mutating tools gated; askPolicy=' + askPolicy + '; telemetry=' + telemetry + '; restoreExisting=' + restoreCfg)
1587
+
1588
+ // Best-effort: push the model-frame cap into the driver for window captures
1589
+ // (the driver resizes window screenshots AND keeps x,y/zoom coordinates
1590
+ // consistent with the resized image; desktop captures are resized plugin-side).
1591
+ getDriver().then(() => raw('set_config', { key: 'max_image_dimension', value: maxDim })).then(
1592
+ () => ctx.logger.info('computer-use: max_image_dimension=' + maxDim),
1593
+ () => {},
1594
+ )
1595
+ }