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/LICENSE +21 -0
- package/README.md +115 -0
- package/cordis.patch.yml +20 -0
- package/index.js +1595 -0
- package/package.json +39 -0
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
|
+
}
|