@melra/computer-runtime 0.3.0-alpha.10

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/dist/index.js ADDED
@@ -0,0 +1,1306 @@
1
+ // Copyright 2026 XAGI Labs Private Limited
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { execFile } from "node:child_process";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { constants } from "node:fs";
6
+ import { access, mkdir, readFile } from "node:fs/promises";
7
+ import { basename, join } from "node:path";
8
+ import { promisify } from "node:util";
9
+ const execFileAsync = promisify(execFile);
10
+ /** Depth-first cap on what an adapter may report, matched in each helper script. */
11
+ const MAX_ELEMENTS = 200;
12
+ /**
13
+ * Reads an adapter helper's JSON into a `DesktopObservation`, keeping only the
14
+ * declared fields and only when they carry a usable value. A helper that reports
15
+ * an empty window title or a zero display is reporting "I could not see it", and
16
+ * that has to arrive as an absent field: a caller verifying `windowTitle` with
17
+ * `result_equals` must not be handed `""` as though it were observed.
18
+ */
19
+ function observationFrom(json) {
20
+ let parsed;
21
+ try {
22
+ parsed = JSON.parse(json.trim());
23
+ }
24
+ catch {
25
+ throw new Error("computer_inspect_unparseable");
26
+ }
27
+ if (typeof parsed !== "object" || parsed === null) {
28
+ throw new Error("computer_inspect_unparseable");
29
+ }
30
+ const record = parsed;
31
+ const text = (key) => {
32
+ const value = record[key];
33
+ return typeof value === "string" && value.trim() !== "" ? value : undefined;
34
+ };
35
+ const size = (key) => {
36
+ const value = record[key];
37
+ return typeof value === "number" && Number.isFinite(value) && value > 0
38
+ ? Math.round(value)
39
+ : undefined;
40
+ };
41
+ const application = text("application");
42
+ const windowTitle = text("windowTitle");
43
+ const displayWidth = size("displayWidth");
44
+ const displayHeight = size("displayHeight");
45
+ const elements = elementsFrom(record["elements"]);
46
+ return {
47
+ ...(application === undefined ? {} : { application }),
48
+ ...(windowTitle === undefined ? {} : { windowTitle }),
49
+ ...(displayWidth === undefined ? {} : { displayWidth }),
50
+ ...(displayHeight === undefined ? {} : { displayHeight }),
51
+ ...(elements === undefined ? {} : { elements }),
52
+ };
53
+ }
54
+ /**
55
+ * An element list is dropped entirely when the key is absent, and entries within
56
+ * it are dropped individually when a platform could not measure one. A
57
+ * zero-sized or unnamed control is real; one with no role or no geometry cannot
58
+ * be clicked, so keeping it would only offer the caller a target that fails at
59
+ * the point of use.
60
+ */
61
+ function elementsFrom(value) {
62
+ if (!Array.isArray(value))
63
+ return undefined;
64
+ const elements = [];
65
+ for (const entry of value.slice(0, MAX_ELEMENTS)) {
66
+ if (typeof entry !== "object" || entry === null)
67
+ continue;
68
+ const record = entry;
69
+ const role = record["role"];
70
+ const name = record["name"];
71
+ const box = ["x", "y", "width", "height"].map((key) => record[key]);
72
+ const confidence = record["confidence"];
73
+ if (typeof role !== "string" || role.trim() === "")
74
+ continue;
75
+ if (!box.every((n) => typeof n === "number" && Number.isFinite(n)))
76
+ continue;
77
+ const [x, y, width, height] = box;
78
+ if (width <= 0 || height <= 0)
79
+ continue;
80
+ elements.push({
81
+ role,
82
+ ...(typeof name === "string" && name.trim() !== "" ? { name } : {}),
83
+ ...(typeof confidence === "number" && Number.isFinite(confidence)
84
+ ? { confidence: Math.min(1, Math.max(0, confidence)) }
85
+ : {}),
86
+ x: Math.round(x),
87
+ y: Math.round(y),
88
+ width: Math.round(width),
89
+ height: Math.round(height),
90
+ });
91
+ }
92
+ return elements;
93
+ }
94
+ /**
95
+ * Where each platform's package manager puts `tesseract`. Probed by path rather
96
+ * than resolved through `PATH`, matching the other tool detection here: a `PATH`
97
+ * lookup would let a directory earlier in the caller's environment decide which
98
+ * binary gets to read the screen.
99
+ */
100
+ const TESSERACT_PATHS = [
101
+ "/opt/homebrew/bin/tesseract",
102
+ "/usr/local/bin/tesseract",
103
+ "/usr/bin/tesseract",
104
+ "C:\\Program Files\\Tesseract-OCR\\tesseract.exe",
105
+ ];
106
+ async function tesseract() {
107
+ for (const path of TESSERACT_PATHS) {
108
+ if (await executable(path))
109
+ return path;
110
+ }
111
+ return undefined;
112
+ }
113
+ /** Below this a word is noise rather than a reading, and is not reported. */
114
+ const MIN_OCR_CONFIDENCE = 0.3;
115
+ /** Below this a word is reported by `inspect` but refused as a click target. */
116
+ const MIN_TARGET_CONFIDENCE = 0.7;
117
+ /**
118
+ * Words read off a screenshot, in the display's own pixel space.
119
+ *
120
+ * Tesseract's TSV carries one row per token with a `level` column: `1` is the
121
+ * whole page, `5` is a word. Only words are kept, because a line row spanning
122
+ * "Save Cancel" has a centre point that lands between two buttons.
123
+ *
124
+ * The page row's width is what the image measured, and a Retina capture is
125
+ * larger than the display it came from by the backing scale factor, so every
126
+ * box is scaled by the ratio between them. A capture with no readable page row
127
+ * yields nothing rather than coordinates at an assumed scale.
128
+ *
129
+ * Exported for the same reason as `matchElement`: the parsing is the part worth
130
+ * testing without a screen to point at.
131
+ */
132
+ export function parseOcrElements(tsv, displayWidth) {
133
+ const rows = tsv.split("\n").map((line) => line.split("\t"));
134
+ const imageWidth = Number(rows.find((columns) => columns[0] === "1")?.[8]);
135
+ if (!Number.isFinite(imageWidth) || imageWidth <= 0)
136
+ return [];
137
+ const scale = displayWidth / imageWidth;
138
+ const elements = [];
139
+ for (const columns of rows) {
140
+ if (columns[0] !== "5" || columns.length < 12)
141
+ continue;
142
+ // Text is the last column and tesseract does not quote it, so a token
143
+ // containing a tab would otherwise arrive truncated.
144
+ const name = columns.slice(11).join("\t").trim();
145
+ const confidence = Number(columns[10]) / 100;
146
+ const box = [6, 7, 8, 9].map((index) => Number(columns[index]));
147
+ if (name === "")
148
+ continue;
149
+ if (!Number.isFinite(confidence) || confidence < MIN_OCR_CONFIDENCE)
150
+ continue;
151
+ if (!box.every((value) => Number.isFinite(value)))
152
+ continue;
153
+ const [x, y, width, height] = box;
154
+ if (width <= 0 || height <= 0)
155
+ continue;
156
+ elements.push({
157
+ role: "text",
158
+ name,
159
+ x: Math.round(x * scale),
160
+ y: Math.round(y * scale),
161
+ width: Math.round(width * scale),
162
+ height: Math.round(height * scale),
163
+ confidence: Math.min(1, Math.round(confidence * 100) / 100),
164
+ });
165
+ if (elements.length >= MAX_ELEMENTS)
166
+ break;
167
+ }
168
+ return elements;
169
+ }
170
+ /**
171
+ * Resolves a named target against an observed element list.
172
+ *
173
+ * Ambiguity is an error rather than a first-match, because the failure mode of
174
+ * guessing is clicking the wrong "Delete" and there is no undo for a desktop
175
+ * action. Exact names beat substrings for the same reason: a window with both
176
+ * "Save" and "Save As…" must resolve "Save" to the one the caller wrote. An
177
+ * element read off a screenshot has to clear `MIN_TARGET_CONFIDENCE` on top of
178
+ * matching, since a doubtful reading is a doubtful click and the same reasoning
179
+ * applies — worth reporting from `inspect`, not worth acting on.
180
+ *
181
+ * Exported because the matching rules are the part worth testing directly —
182
+ * everything around them needs a live desktop.
183
+ */
184
+ export function matchElement(elements, target) {
185
+ if (target.role === undefined && target.name === undefined) {
186
+ throw new Error("computer_target_requires_role_or_name");
187
+ }
188
+ const role = target.role?.toLocaleLowerCase();
189
+ const candidates = role === undefined
190
+ ? elements
191
+ : elements.filter((e) => e.role.toLocaleLowerCase() === role);
192
+ let matches = candidates;
193
+ if (target.name !== undefined) {
194
+ const wanted = target.name.toLocaleLowerCase();
195
+ const exact = candidates.filter((e) => e.name?.toLocaleLowerCase() === wanted);
196
+ matches =
197
+ exact.length > 0
198
+ ? exact
199
+ : candidates.filter((e) => e.name?.toLocaleLowerCase().includes(wanted) === true);
200
+ }
201
+ if (matches.length === 0)
202
+ throw new Error("computer_target_not_found");
203
+ if (matches.length > 1) {
204
+ const listed = matches
205
+ .slice(0, 5)
206
+ .map((e) => `${e.role}:${e.name ?? "?"}`)
207
+ .join(", ");
208
+ throw new Error(`computer_target_ambiguous: ${listed}`);
209
+ }
210
+ const chosen = matches[0];
211
+ if (chosen.confidence !== undefined &&
212
+ chosen.confidence < MIN_TARGET_CONFIDENCE) {
213
+ throw new Error(`computer_target_confidence_too_low: ${chosen.confidence.toFixed(2)} < ${MIN_TARGET_CONFIDENCE}`);
214
+ }
215
+ return chosen;
216
+ }
217
+ /**
218
+ * Validates a positional action's coordinates and hands them back already
219
+ * narrowed, so callers work with numbers instead of re-asserting the optionals
220
+ * the schema has to leave open for the actions that carry no position.
221
+ */
222
+ function requirePoints(operation) {
223
+ if (operation.x === undefined || operation.y === undefined) {
224
+ throw new Error(`computer_${operation.action}_requires_coordinates`);
225
+ }
226
+ if (operation.action !== "drag")
227
+ return { x: operation.x, y: operation.y };
228
+ if (operation.toX === undefined || operation.toY === undefined) {
229
+ throw new Error("computer_drag_requires_destination");
230
+ }
231
+ return {
232
+ x: operation.x,
233
+ y: operation.y,
234
+ to: { x: operation.toX, y: operation.toY },
235
+ };
236
+ }
237
+ const ALLOWED_KEYS = {
238
+ ENTER: 36,
239
+ TAB: 48,
240
+ SPACE: 49,
241
+ BACKSPACE: 51,
242
+ ESCAPE: 53,
243
+ LEFT: 123,
244
+ RIGHT: 124,
245
+ DOWN: 125,
246
+ UP: 126,
247
+ HOME: 115,
248
+ END: 119,
249
+ PAGEUP: 116,
250
+ PAGEDOWN: 121,
251
+ DELETE: 117,
252
+ };
253
+ async function executable(path) {
254
+ try {
255
+ await access(path, constants.X_OK);
256
+ return true;
257
+ }
258
+ catch {
259
+ return false;
260
+ }
261
+ }
262
+ async function run(file, args, timeoutMs, signal, env) {
263
+ try {
264
+ const result = await execFileAsync(file, args, {
265
+ timeout: timeoutMs,
266
+ maxBuffer: 1_000_000,
267
+ windowsHide: true,
268
+ ...(signal === undefined ? {} : { signal }),
269
+ ...(env === undefined ? {} : { env: { ...process.env, ...env } }),
270
+ });
271
+ return {
272
+ stdout: result.stdout,
273
+ stderr: result.stderr,
274
+ };
275
+ }
276
+ catch (cause) {
277
+ /**
278
+ * Node builds this rejection's message from the whole command line, which
279
+ * for an adapter means the entire script: a Windows screenshot failure
280
+ * arrived as fifteen lines of echoed PowerShell with the actual reason
281
+ * nowhere in it, and a timeout arrived as the same fifteen lines with an
282
+ * empty stderr, indistinguishable from a script that failed instantly.
283
+ *
284
+ * Report the interpreter's own words instead, and name a timeout as a
285
+ * timeout — a caller can act on "the helper was killed after 10000ms" and
286
+ * can act on a permission error, but not on a copy of the script it did
287
+ * not write.
288
+ */
289
+ const error = cause;
290
+ const program = basename(file);
291
+ if (error.killed === true || error.code === "ETIMEDOUT") {
292
+ throw new Error(`computer_helper_timeout:${program}:${timeoutMs}ms`);
293
+ }
294
+ if (signal?.aborted === true)
295
+ throw new Error("task_cancelled");
296
+ // First line only: interpreters follow the reason with a stack trace naming
297
+ // the script we generated, which is noise to whoever reads this.
298
+ const reason = (error.stderr ?? "").trim().split("\n")[0]?.trim();
299
+ throw new Error(reason === undefined || reason === ""
300
+ ? `computer_helper_failed:${program}`
301
+ : `computer_helper_failed:${program}: ${reason}`);
302
+ }
303
+ }
304
+ function coordinateScript(operation) {
305
+ const normalized = operation.coordinateSpace === "normalized";
306
+ const axis = (value, side) => normalized ? `frame.size.${side} * ${value}` : String(value);
307
+ return `
308
+ ObjC.import("AppKit");
309
+ ObjC.import("CoreGraphics");
310
+ const frame = $.NSScreen.mainScreen.frame;
311
+ const x = ${axis(operation.x ?? 0, "width")};
312
+ const y = ${axis(operation.y ?? 0, "height")};
313
+ const point = $.CGPointMake(x, y);
314
+ function post(type, at) {
315
+ $.CGEventPost($.kCGHIDEventTap, $.CGEventCreateMouseEvent(null, type, at, $.kCGMouseButtonLeft));
316
+ }
317
+ post($.kCGEventMouseMoved, point);
318
+ ${operation.action === "click" ? `
319
+ post($.kCGEventLeftMouseDown, point);
320
+ post($.kCGEventLeftMouseUp, point);` : ""}
321
+ ${operation.action === "drag" ? `
322
+ const toX = ${axis(operation.toX ?? 0, "width")};
323
+ const toY = ${axis(operation.toY ?? 0, "height")};
324
+ post($.kCGEventLeftMouseDown, point);
325
+ // A single jump from press to release is not a drag to most views — they track
326
+ // the intermediate motion and a lone dragged event reads as a click that moved.
327
+ for (let step = 1; step <= ${MACOS_DRAG_STEPS}; step += 1) {
328
+ const ratio = step / ${MACOS_DRAG_STEPS};
329
+ post($.kCGEventLeftMouseDragged, $.CGPointMake(x + (toX - x) * ratio, y + (toY - y) * ratio));
330
+ }
331
+ post($.kCGEventLeftMouseUp, $.CGPointMake(toX, toY));` : ""}
332
+ `;
333
+ }
334
+ /** Intermediate motion events synthesised between a drag's press and release. */
335
+ const MACOS_DRAG_STEPS = 10;
336
+ /**
337
+ * Depth the accessibility walk descends before stopping.
338
+ *
339
+ * ponytail: fixed depth, not an adaptive budget. Every JXA accessor is an Apple
340
+ * Event round trip, so a full-tree walk of a document-heavy app costs seconds;
341
+ * four levels reaches the toolbar and window controls a caller names by hand
342
+ * without paying for a text view's every glyph run. Raise it here if a real
343
+ * target turns out to sit deeper.
344
+ */
345
+ const MACOS_ELEMENT_DEPTH = 4;
346
+ const MACOS_INSPECT_SCRIPT = `
347
+ ObjC.import("AppKit");
348
+ const frame = $.NSScreen.mainScreen.frame;
349
+ const out = {
350
+ displayWidth: Math.round(frame.size.width),
351
+ displayHeight: Math.round(frame.size.height),
352
+ };
353
+ // Naming the frontmost application needs Accessibility permission and naming
354
+ // its window needs the application to expose one. Either can be missing on a
355
+ // working machine, so each degrades to an absent field instead of an error.
356
+ try {
357
+ const frontmost = Application("System Events")
358
+ .applicationProcesses.whose({ frontmost: true })[0];
359
+ out.application = frontmost.name();
360
+ const window = frontmost.windows[0];
361
+ try {
362
+ out.windowTitle = window.name();
363
+ } catch (error) {}
364
+ // Every accessor below is guarded on its own: an element can vanish between
365
+ // being listed and being measured, and one that does must not cost the whole
366
+ // list. A partial tree is useful; an exception here is not.
367
+ const elements = [];
368
+ const label = (node) => {
369
+ for (const read of [() => node.title(), () => node.description(), () => node.value()]) {
370
+ try {
371
+ const value = read();
372
+ if (typeof value === "string" && value.trim() !== "") return value;
373
+ } catch (error) {}
374
+ }
375
+ return null;
376
+ };
377
+ const visit = (node, depth) => {
378
+ if (depth > ${MACOS_ELEMENT_DEPTH} || elements.length >= ${MAX_ELEMENTS}) return;
379
+ let children = [];
380
+ try {
381
+ children = node.uiElements();
382
+ } catch (error) {
383
+ return;
384
+ }
385
+ for (const child of children) {
386
+ if (elements.length >= ${MAX_ELEMENTS}) return;
387
+ try {
388
+ const position = child.position();
389
+ const size = child.size();
390
+ const entry = {
391
+ role: child.role(),
392
+ x: position[0],
393
+ y: position[1],
394
+ width: size[0],
395
+ height: size[1],
396
+ };
397
+ const name = label(child);
398
+ if (name !== null) entry.name = name;
399
+ elements.push(entry);
400
+ } catch (error) {}
401
+ visit(child, depth + 1);
402
+ }
403
+ };
404
+ visit(window, 0);
405
+ out.elements = elements;
406
+ } catch (error) {}
407
+ JSON.stringify(out);
408
+ `;
409
+ /**
410
+ * Reads secure-input state out of an `ioreg` dump of the console-session
411
+ * dictionary. `undefined` means the dump held no session at all, which is "not
412
+ * observable here" rather than "off".
413
+ *
414
+ * Exported for the test: the negative case is the one a developer machine
415
+ * naturally produces, so the positive case — a real password field taking the
416
+ * keyboard — can only be exercised against captured output.
417
+ */
418
+ export function parseSecureInput(stdout) {
419
+ // Absence of the secure-input key only means "off" if a session was reported.
420
+ if (!stdout.includes("kCGSSessionOnConsoleKey"))
421
+ return undefined;
422
+ const match = /"kCGSSessionSecureInputPID"=(-?\d+)/.exec(stdout);
423
+ // The key is written with the holding process's pid and cleared to 0, so a
424
+ // present-but-zero value is still "off".
425
+ return match !== null && Number(match[1]) !== 0;
426
+ }
427
+ /**
428
+ * Whether some process holds a secure keyboard entry session — a password field
429
+ * is focused, or the screen is locked. While it does, synthetic keystrokes are
430
+ * dropped by the window server, so `type` and `key` would report success while
431
+ * typing into nothing.
432
+ *
433
+ * The state lives in the console-session dictionary on `IOResources`. `-d 1`
434
+ * keeps this to a few kilobytes rather than dumping the whole registry.
435
+ */
436
+ async function macSecureInput(timeoutMs, signal) {
437
+ try {
438
+ const { stdout } = await run("/usr/sbin/ioreg", ["-l", "-d", "1", "-w", "0", "-r", "-c", "IOResources"], Math.min(timeoutMs, 5_000), signal);
439
+ return parseSecureInput(stdout);
440
+ }
441
+ catch {
442
+ return undefined;
443
+ }
444
+ }
445
+ class MacOsAdapter {
446
+ async capabilities() {
447
+ const osascript = await executable("/usr/bin/osascript");
448
+ const screencapture = await executable("/usr/sbin/screencapture");
449
+ return {
450
+ platform: "darwin",
451
+ adapter: "macos-native",
452
+ available: osascript || screencapture,
453
+ screenshot: screencapture,
454
+ pointer: osascript,
455
+ keyboard: osascript,
456
+ scroll: osascript,
457
+ inspect: osascript,
458
+ elements: osascript,
459
+ ocr: (await tesseract()) !== undefined,
460
+ drag: osascript,
461
+ coordinateSpaces: ["normalized", "pixel"],
462
+ limitations: [
463
+ "macOS Screen Recording permission is required for screenshots",
464
+ "macOS Accessibility permission is required for input actions",
465
+ "normalized coordinates currently target the main display",
466
+ "inspect omits application and windowTitle without Accessibility permission",
467
+ ],
468
+ };
469
+ }
470
+ /**
471
+ * Refuses to send keystrokes while secure input is held. `osascript` exits 0
472
+ * either way, so without this the task verifies as a success that typed
473
+ * nothing into a focused password field.
474
+ *
475
+ * Unconditional, and deliberately not a policy rule: it is not a restriction
476
+ * MELRA imposes on the caller but a fact about where the events go, so
477
+ * unhinged mode has nothing to lift here. An unobservable state is allowed
478
+ * through — only a positive reading blocks.
479
+ */
480
+ async assertInputLands(operation, signal) {
481
+ if ((await macSecureInput(operation.timeoutMs, signal)) === true) {
482
+ throw new Error("computer_secure_input_active");
483
+ }
484
+ }
485
+ async execute(operation, artifactDirectory, signal) {
486
+ switch (operation.action) {
487
+ case "capabilities":
488
+ return { ...(await this.capabilities()) };
489
+ case "screenshot": {
490
+ await mkdir(artifactDirectory, { recursive: true });
491
+ const path = join(artifactDirectory, `computer-${randomUUID()}.png`);
492
+ await run("/usr/sbin/screencapture", ["-x", path], operation.timeoutMs, signal);
493
+ const bytes = await readFile(path);
494
+ return {
495
+ success: true,
496
+ captured: true,
497
+ path,
498
+ size: bytes.byteLength,
499
+ sha256: createHash("sha256").update(bytes).digest("hex"),
500
+ };
501
+ }
502
+ case "inspect": {
503
+ const { stdout } = await run("/usr/bin/osascript", ["-l", "JavaScript", "-e", MACOS_INSPECT_SCRIPT], operation.timeoutMs, signal);
504
+ const secureInput = await macSecureInput(operation.timeoutMs, signal);
505
+ return {
506
+ success: true,
507
+ action: "inspect",
508
+ ...observationFrom(stdout),
509
+ ...(secureInput === undefined ? {} : { secureInput }),
510
+ };
511
+ }
512
+ case "click":
513
+ case "move":
514
+ case "drag":
515
+ requirePoints(operation);
516
+ await run("/usr/bin/osascript", ["-l", "JavaScript", "-e", coordinateScript(operation)], operation.timeoutMs, signal);
517
+ return {
518
+ success: true,
519
+ action: operation.action,
520
+ coordinateSpace: operation.coordinateSpace,
521
+ x: operation.x,
522
+ y: operation.y,
523
+ ...(operation.action === "drag"
524
+ ? { toX: operation.toX, toY: operation.toY }
525
+ : {}),
526
+ };
527
+ case "type": {
528
+ if (operation.text === undefined) {
529
+ throw new Error("computer_type_requires_text");
530
+ }
531
+ await this.assertInputLands(operation, signal);
532
+ await run("/usr/bin/osascript", [
533
+ "-e",
534
+ "on run argv",
535
+ "-e",
536
+ "tell application \"System Events\" to keystroke (item 1 of argv)",
537
+ "-e",
538
+ "end run",
539
+ "--",
540
+ operation.text,
541
+ ], operation.timeoutMs, signal);
542
+ return { success: true, action: "type", characters: operation.text.length };
543
+ }
544
+ case "key": {
545
+ const key = operation.key?.toLocaleUpperCase();
546
+ const keyCode = key === undefined ? undefined : ALLOWED_KEYS[key];
547
+ if (keyCode === undefined)
548
+ throw new Error("computer_key_not_allowed");
549
+ await this.assertInputLands(operation, signal);
550
+ await run("/usr/bin/osascript", [
551
+ "-e",
552
+ `tell application "System Events" to key code ${keyCode}`,
553
+ ], operation.timeoutMs, signal);
554
+ return { success: true, action: "key", key };
555
+ }
556
+ case "scroll": {
557
+ const deltaY = Math.max(-2_000, Math.min(2_000, operation.deltaY ?? 0));
558
+ const script = `
559
+ ObjC.import("CoreGraphics");
560
+ const event = $.CGEventCreateScrollWheelEvent(null, $.kCGScrollEventUnitPixel, 1, ${Math.round(deltaY)});
561
+ $.CGEventPost($.kCGHIDEventTap, event);
562
+ `;
563
+ await run("/usr/bin/osascript", ["-l", "JavaScript", "-e", script], operation.timeoutMs, signal);
564
+ return { success: true, action: "scroll", deltaY };
565
+ }
566
+ }
567
+ }
568
+ }
569
+ class LinuxXdotoolAdapter {
570
+ async command(name) {
571
+ for (const prefix of ["/usr/bin", "/bin", "/usr/local/bin"]) {
572
+ const path = join(prefix, name);
573
+ if (await executable(path))
574
+ return path;
575
+ }
576
+ return undefined;
577
+ }
578
+ async capabilities() {
579
+ const xdotool = await this.command("xdotool");
580
+ const screenshot = (await this.command("gnome-screenshot")) ??
581
+ (await this.command("scrot"));
582
+ return {
583
+ platform: "linux",
584
+ adapter: "linux-xdotool",
585
+ available: xdotool !== undefined || screenshot !== undefined,
586
+ screenshot: screenshot !== undefined,
587
+ pointer: xdotool !== undefined,
588
+ keyboard: xdotool !== undefined,
589
+ scroll: xdotool !== undefined,
590
+ inspect: xdotool !== undefined,
591
+ elements: false,
592
+ // X11 has no accessibility tree, so reading the screen is the only way a
593
+ // named target resolves here at all — not a fallback but the whole path.
594
+ ocr: screenshot !== undefined && (await tesseract()) !== undefined,
595
+ drag: xdotool !== undefined,
596
+ coordinateSpaces: ["normalized", "pixel"],
597
+ limitations: [
598
+ "input actions require xdotool and an X11 session",
599
+ "Wayland compositors may deny synthetic input",
600
+ "normalized coordinates target the current display size reported by xdotool",
601
+ "inspect cannot report secure input; X11 has no equivalent state",
602
+ "X11 has no accessibility tree, so a named target is resolved by reading the screen: it needs tesseract installed and finds only text, never an unlabelled icon",
603
+ ],
604
+ };
605
+ }
606
+ async xdotoolOutput(args, operation, signal) {
607
+ const executablePath = await this.command("xdotool");
608
+ if (executablePath === undefined)
609
+ throw new Error("computer_input_unavailable");
610
+ return await run(executablePath, args, operation.timeoutMs, signal);
611
+ }
612
+ async xdotool(args, operation, signal) {
613
+ await this.xdotoolOutput(args, operation, signal);
614
+ }
615
+ async geometry(operation, signal) {
616
+ const { stdout } = await this.xdotoolOutput(["getdisplaygeometry"], operation, signal);
617
+ const [width, height] = stdout.trim().split(/\s+/).map(Number);
618
+ if (width === undefined ||
619
+ height === undefined ||
620
+ !Number.isFinite(width) ||
621
+ !Number.isFinite(height)) {
622
+ throw new Error("computer_display_geometry_unavailable");
623
+ }
624
+ return { width, height };
625
+ }
626
+ async toPixels(operation, x, y, signal) {
627
+ if (operation.coordinateSpace === "pixel") {
628
+ return { x: Math.round(x), y: Math.round(y) };
629
+ }
630
+ const { width, height } = await this.geometry(operation, signal);
631
+ return { x: Math.round(width * x), y: Math.round(height * y) };
632
+ }
633
+ async coordinates(operation, signal) {
634
+ const point = requirePoints(operation);
635
+ return await this.toPixels(operation, point.x, point.y, signal);
636
+ }
637
+ async execute(operation, artifactDirectory, signal) {
638
+ switch (operation.action) {
639
+ case "capabilities":
640
+ return { ...(await this.capabilities()) };
641
+ case "screenshot": {
642
+ await mkdir(artifactDirectory, { recursive: true });
643
+ const path = join(artifactDirectory, `computer-${randomUUID()}.png`);
644
+ const gnome = await this.command("gnome-screenshot");
645
+ const scrot = await this.command("scrot");
646
+ if (gnome !== undefined) {
647
+ await run(gnome, ["-f", path], operation.timeoutMs, signal);
648
+ }
649
+ else if (scrot !== undefined) {
650
+ await run(scrot, [path], operation.timeoutMs, signal);
651
+ }
652
+ else {
653
+ throw new Error("computer_screenshot_unavailable");
654
+ }
655
+ const bytes = await readFile(path);
656
+ return {
657
+ success: true,
658
+ captured: true,
659
+ path,
660
+ size: bytes.byteLength,
661
+ sha256: createHash("sha256").update(bytes).digest("hex"),
662
+ };
663
+ }
664
+ case "inspect": {
665
+ const { width, height } = await this.geometry(operation, signal);
666
+ const observation = {
667
+ displayWidth: width,
668
+ displayHeight: height,
669
+ };
670
+ // No active window is an ordinary state on a bare X session, and
671
+ // `xdotool` exits non-zero for it, so a failure here means "nothing is
672
+ // focused" rather than "inspect failed".
673
+ try {
674
+ const { stdout } = await this.xdotoolOutput(["getactivewindow", "getwindowname"], operation, signal);
675
+ const title = stdout.trim();
676
+ if (title !== "")
677
+ observation.windowTitle = title;
678
+ }
679
+ catch {
680
+ /* no focused window */
681
+ }
682
+ try {
683
+ const { stdout } = await this.xdotoolOutput(["getactivewindow", "getwindowpid"], operation, signal);
684
+ const pid = Number(stdout.trim());
685
+ if (Number.isInteger(pid) && pid > 0) {
686
+ const command = await readFile(`/proc/${pid}/comm`, "utf8");
687
+ if (command.trim() !== "")
688
+ observation.application = command.trim();
689
+ }
690
+ }
691
+ catch {
692
+ /* window belongs to a remote or exited process */
693
+ }
694
+ return { success: true, action: "inspect", ...observation };
695
+ }
696
+ case "click":
697
+ case "move": {
698
+ const point = await this.coordinates(operation, signal);
699
+ const args = ["mousemove", String(point.x), String(point.y)];
700
+ if (operation.action === "click")
701
+ args.push("click", "1");
702
+ await this.xdotool(args, operation, signal);
703
+ return { success: true, action: operation.action, ...point };
704
+ }
705
+ case "drag": {
706
+ const points = requirePoints(operation);
707
+ // `requirePoints` guarantees this for a drag; repeated so the compiler
708
+ // sees it too, and so a future caller cannot reach here without one.
709
+ if (points.to === undefined) {
710
+ throw new Error("computer_drag_requires_destination");
711
+ }
712
+ const from = await this.toPixels(operation, points.x, points.y, signal);
713
+ const to = await this.toPixels(operation, points.to.x, points.to.y, signal);
714
+ await this.xdotool([
715
+ "mousemove",
716
+ String(from.x),
717
+ String(from.y),
718
+ "mousedown",
719
+ "1",
720
+ "mousemove",
721
+ String(to.x),
722
+ String(to.y),
723
+ "mouseup",
724
+ "1",
725
+ ], operation, signal);
726
+ return {
727
+ success: true,
728
+ action: "drag",
729
+ x: from.x,
730
+ y: from.y,
731
+ toX: to.x,
732
+ toY: to.y,
733
+ };
734
+ }
735
+ case "type":
736
+ if (operation.text === undefined)
737
+ throw new Error("computer_type_requires_text");
738
+ await this.xdotool(["type", "--", operation.text], operation, signal);
739
+ return { success: true, action: "type", characters: operation.text.length };
740
+ case "key": {
741
+ const key = operation.key?.toLocaleUpperCase();
742
+ const mapping = {
743
+ ENTER: "Return",
744
+ TAB: "Tab",
745
+ SPACE: "space",
746
+ BACKSPACE: "BackSpace",
747
+ ESCAPE: "Escape",
748
+ LEFT: "Left",
749
+ RIGHT: "Right",
750
+ DOWN: "Down",
751
+ UP: "Up",
752
+ HOME: "Home",
753
+ END: "End",
754
+ PAGEUP: "Page_Up",
755
+ PAGEDOWN: "Page_Down",
756
+ DELETE: "Delete",
757
+ };
758
+ if (key === undefined || mapping[key] === undefined) {
759
+ throw new Error("computer_key_not_allowed");
760
+ }
761
+ await this.xdotool(["key", mapping[key]], operation, signal);
762
+ return { success: true, action: "key", key };
763
+ }
764
+ case "scroll": {
765
+ const clicks = Math.max(-20, Math.min(20, Math.round((operation.deltaY ?? 0) / 100)));
766
+ const button = clicks < 0 ? "4" : "5";
767
+ for (let index = 0; index < Math.abs(clicks); index += 1) {
768
+ await this.xdotool(["click", button], operation, signal);
769
+ }
770
+ return { success: true, action: "scroll", deltaY: operation.deltaY ?? 0 };
771
+ }
772
+ }
773
+ }
774
+ }
775
+ /**
776
+ * `SendKeys` reads `+^%~(){}[]` as modifiers and grouping rather than as
777
+ * literal characters, so a password containing `+` types a Shift chord and a
778
+ * `(` opens a group that never closes. Each one is wrapped in braces, which is
779
+ * how `SendKeys` spells "the literal character".
780
+ *
781
+ * Exported because it is the part of the Windows adapter worth testing on any
782
+ * platform: the escaping is pure, and getting it wrong silently corrupts typed
783
+ * text rather than failing.
784
+ */
785
+ export function escapeSendKeys(text) {
786
+ return text.replace(/[+^%~(){}[\]]/g, (character) => `{${character}}`);
787
+ }
788
+ /** `SendKeys` names for the fixed key allowlist. */
789
+ const WINDOWS_KEYS = {
790
+ ENTER: "{ENTER}",
791
+ TAB: "{TAB}",
792
+ SPACE: " ",
793
+ BACKSPACE: "{BACKSPACE}",
794
+ ESCAPE: "{ESC}",
795
+ LEFT: "{LEFT}",
796
+ RIGHT: "{RIGHT}",
797
+ DOWN: "{DOWN}",
798
+ UP: "{UP}",
799
+ HOME: "{HOME}",
800
+ END: "{END}",
801
+ PAGEUP: "{PGUP}",
802
+ PAGEDOWN: "{PGDN}",
803
+ DELETE: "{DELETE}",
804
+ };
805
+ /**
806
+ * Pointer and wheel input, which .NET does not expose — `SetCursorPos` and
807
+ * `mouse_event` have to be reached through P/Invoke.
808
+ *
809
+ * Reads its inputs from the environment rather than being interpolated with
810
+ * them, so nothing a caller supplies is ever parsed as PowerShell.
811
+ */
812
+ const WINDOWS_POINTER_SCRIPT = `
813
+ $ErrorActionPreference = 'Stop'
814
+ Add-Type -AssemblyName System.Windows.Forms
815
+ Add-Type -TypeDefinition @'
816
+ using System;
817
+ using System.Runtime.InteropServices;
818
+ public static class MelraInput {
819
+ [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
820
+ [DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, IntPtr extra);
821
+ }
822
+ '@
823
+ $bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen
824
+ if ($env:MELRA_NORMALIZED -eq '1') {
825
+ $x = [int][Math]::Round($bounds.X + $bounds.Width * [double]$env:MELRA_X)
826
+ $y = [int][Math]::Round($bounds.Y + $bounds.Height * [double]$env:MELRA_Y)
827
+ } else {
828
+ $x = [int][Math]::Round([double]$env:MELRA_X)
829
+ $y = [int][Math]::Round([double]$env:MELRA_Y)
830
+ }
831
+ if ($env:MELRA_MOVE -eq '1') { [void][MelraInput]::SetCursorPos($x, $y) }
832
+ if ($env:MELRA_CLICK -eq '1') {
833
+ [MelraInput]::mouse_event(0x0002, 0, 0, 0, [IntPtr]::Zero)
834
+ [MelraInput]::mouse_event(0x0004, 0, 0, 0, [IntPtr]::Zero)
835
+ }
836
+ if ($env:MELRA_DRAG -eq '1') {
837
+ if ($env:MELRA_NORMALIZED -eq '1') {
838
+ $tx = [int][Math]::Round($bounds.X + $bounds.Width * [double]$env:MELRA_TO_X)
839
+ $ty = [int][Math]::Round($bounds.Y + $bounds.Height * [double]$env:MELRA_TO_Y)
840
+ } else {
841
+ $tx = [int][Math]::Round([double]$env:MELRA_TO_X)
842
+ $ty = [int][Math]::Round([double]$env:MELRA_TO_Y)
843
+ }
844
+ [MelraInput]::mouse_event(0x0002, 0, 0, 0, [IntPtr]::Zero)
845
+ # Controls track the motion between press and release; jumping straight to the
846
+ # destination reads as a click, not a drag.
847
+ for ($step = 1; $step -le 10; $step++) {
848
+ [void][MelraInput]::SetCursorPos(
849
+ [int][Math]::Round($x + ($tx - $x) * $step / 10),
850
+ [int][Math]::Round($y + ($ty - $y) * $step / 10))
851
+ Start-Sleep -Milliseconds 10
852
+ }
853
+ [MelraInput]::mouse_event(0x0004, 0, 0, 0, [IntPtr]::Zero)
854
+ }
855
+ if ($env:MELRA_WHEEL -ne $null -and $env:MELRA_WHEEL -ne '') {
856
+ [MelraInput]::mouse_event(0x0800, 0, 0, [uint32][int]$env:MELRA_WHEEL, [IntPtr]::Zero)
857
+ }
858
+ # The press point, and for a drag the release point after it. Both come from
859
+ # here because only this script has seen the virtual desktop the caller's
860
+ # normalized coordinates resolve against.
861
+ Write-Output "$x $y $tx $ty".Trim()
862
+ `;
863
+ /**
864
+ * Foreground window, its owning process, and the virtual desktop size, as JSON.
865
+ *
866
+ * `ConvertTo-Json` is given an ordered hashtable so the shape is fixed, and
867
+ * `-Compress` keeps it to the single line the caller parses.
868
+ */
869
+ const WINDOWS_INSPECT_SCRIPT = `
870
+ $ErrorActionPreference = 'Stop'
871
+ Add-Type -AssemblyName System.Windows.Forms
872
+ Add-Type -TypeDefinition @'
873
+ using System;
874
+ using System.Text;
875
+ using System.Runtime.InteropServices;
876
+ public static class MelraWindow {
877
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
878
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr handle, StringBuilder text, int count);
879
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr handle, out uint processId);
880
+ }
881
+ '@
882
+ $bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen
883
+ $out = [ordered]@{ displayWidth = $bounds.Width; displayHeight = $bounds.Height }
884
+ $window = [MelraWindow]::GetForegroundWindow()
885
+ if ($window -ne [IntPtr]::Zero) {
886
+ $buffer = New-Object System.Text.StringBuilder 512
887
+ [void][MelraWindow]::GetWindowTextW($window, $buffer, $buffer.Capacity)
888
+ $out.windowTitle = $buffer.ToString()
889
+ $processId = 0
890
+ [void][MelraWindow]::GetWindowThreadProcessId($window, [ref]$processId)
891
+ # The owning process can exit between the two calls, and a protected process
892
+ # cannot be opened at all; either way the window is still worth reporting.
893
+ $process = Get-Process -Id $processId -ErrorAction SilentlyContinue
894
+ if ($process -ne $null) { $out.application = $process.ProcessName }
895
+ # UIAutomationClient ships with the .NET Framework, so listing elements costs
896
+ # no extra install. The walk is wrapped whole rather than per-element: unlike
897
+ # the macOS accessibility API, a failure here is the provider refusing the
898
+ # window outright, not one control going stale, and a half-list would be
899
+ # indistinguishable from a window that really has three buttons.
900
+ try {
901
+ Add-Type -AssemblyName UIAutomationClient
902
+ Add-Type -AssemblyName UIAutomationTypes
903
+ $root = [System.Windows.Automation.AutomationElement]::FromHandle($window)
904
+ $condition = New-Object System.Windows.Automation.PropertyCondition(
905
+ [System.Windows.Automation.AutomationElement]::IsControlElementProperty, $true)
906
+ $found = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)
907
+ $elements = @()
908
+ foreach ($node in $found) {
909
+ if ($elements.Count -ge ${MAX_ELEMENTS}) { break }
910
+ $rect = $node.Current.BoundingRectangle
911
+ if ($rect.Width -le 0 -or $rect.Height -le 0) { continue }
912
+ $entry = [ordered]@{
913
+ role = $node.Current.ControlType.ProgrammaticName
914
+ x = [int]$rect.X
915
+ y = [int]$rect.Y
916
+ width = [int]$rect.Width
917
+ height = [int]$rect.Height
918
+ }
919
+ if ($node.Current.Name -ne '') { $entry.name = $node.Current.Name }
920
+ $elements += $entry
921
+ }
922
+ # A single-element array unrolls to a bare object through ConvertTo-Json, so
923
+ # the shape is forced back to a list before the caller ever sees it.
924
+ $out.elements = @($elements)
925
+ } catch {}
926
+ }
927
+ # The default depth of 2 stops inside the element list and renders each entry as
928
+ # a type name; 4 reaches every leaf the shape actually has.
929
+ Write-Output ($out | ConvertTo-Json -Compress -Depth 4)
930
+ `;
931
+ const WINDOWS_SCREENSHOT_SCRIPT = `
932
+ $ErrorActionPreference = 'Stop'
933
+ Add-Type -AssemblyName System.Drawing
934
+ Add-Type -AssemblyName System.Windows.Forms
935
+ $bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen
936
+ $bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
937
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
938
+ try {
939
+ $graphics.CopyFromScreen($bounds.X, $bounds.Y, 0, 0, $bitmap.Size)
940
+ $bitmap.Save($env:MELRA_PATH, [System.Drawing.Imaging.ImageFormat]::Png)
941
+ } finally {
942
+ $graphics.Dispose()
943
+ $bitmap.Dispose()
944
+ }
945
+ `;
946
+ const WINDOWS_TYPE_SCRIPT = `
947
+ $ErrorActionPreference = 'Stop'
948
+ Add-Type -AssemblyName System.Windows.Forms
949
+ [System.Windows.Forms.SendKeys]::SendWait($env:MELRA_KEYS)
950
+ `;
951
+ /**
952
+ * Windows computer use through Windows PowerShell and .NET.
953
+ *
954
+ * `capabilities` used to report `unavailable` here and every input action threw
955
+ * a bare `computer_use_unavailable`, which is the whole of Windows computer use
956
+ * being missing rather than degraded.
957
+ *
958
+ * PowerShell is in the terminal runtime's unconditional deny list, and stays
959
+ * there: this is the trusted adapter invoking a fixed script it owns, the same
960
+ * arrangement under which the macOS adapter uses `osascript`. Nothing a caller
961
+ * supplies reaches the script as source — coordinates, wheel deltas, and text
962
+ * arrive in the environment.
963
+ */
964
+ class WindowsAdapter {
965
+ powershell = join(process.env["SystemRoot"] ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
966
+ async capabilities() {
967
+ // Windows PowerShell 5.1 ships with the OS, so this is present unless the
968
+ // install has been trimmed. `pwsh` is deliberately not consulted: it is an
969
+ // optional install and its absence is not the question being asked.
970
+ const available = await executable(this.powershell);
971
+ return {
972
+ platform: "win32",
973
+ adapter: "windows-powershell",
974
+ available,
975
+ screenshot: available,
976
+ pointer: available,
977
+ keyboard: available,
978
+ scroll: available,
979
+ inspect: available,
980
+ elements: available,
981
+ ocr: available && (await tesseract()) !== undefined,
982
+ drag: available,
983
+ coordinateSpaces: ["normalized", "pixel"],
984
+ limitations: [
985
+ ...(available
986
+ ? []
987
+ : ["Windows PowerShell was not found at the expected system path"]),
988
+ "input is delivered to whichever window holds focus; focus is not verified",
989
+ "SendKeys cannot type into a window running elevated unless this process is elevated too",
990
+ "normalized coordinates span the whole virtual desktop, not one display",
991
+ "per-monitor DPI scaling is not compensated for",
992
+ "inspect cannot report secure input; Windows exposes no equivalent state",
993
+ "every action pays PowerShell startup, and pointer/scroll additionally compile a P/Invoke shim, so raise timeoutMs above its 10s default on a slow or loaded machine",
994
+ ],
995
+ };
996
+ }
997
+ async powershellScript(script, operation, env, signal) {
998
+ if (!(await executable(this.powershell))) {
999
+ throw new Error("computer_input_unavailable");
1000
+ }
1001
+ const { stdout } = await run(this.powershell, [
1002
+ "-NoProfile",
1003
+ "-NonInteractive",
1004
+ "-ExecutionPolicy",
1005
+ "Bypass",
1006
+ "-Command",
1007
+ script,
1008
+ ], operation.timeoutMs, signal, env);
1009
+ return stdout;
1010
+ }
1011
+ async execute(operation, artifactDirectory, signal) {
1012
+ switch (operation.action) {
1013
+ case "capabilities":
1014
+ return { ...(await this.capabilities()) };
1015
+ case "screenshot": {
1016
+ await mkdir(artifactDirectory, { recursive: true });
1017
+ const path = join(artifactDirectory, `computer-${randomUUID()}.png`);
1018
+ await this.powershellScript(WINDOWS_SCREENSHOT_SCRIPT, operation, { MELRA_PATH: path }, signal);
1019
+ const bytes = await readFile(path);
1020
+ return {
1021
+ success: true,
1022
+ captured: true,
1023
+ path,
1024
+ size: bytes.byteLength,
1025
+ sha256: createHash("sha256").update(bytes).digest("hex"),
1026
+ };
1027
+ }
1028
+ case "inspect": {
1029
+ const stdout = await this.powershellScript(WINDOWS_INSPECT_SCRIPT, operation, {}, signal);
1030
+ return { success: true, action: "inspect", ...observationFrom(stdout) };
1031
+ }
1032
+ case "click":
1033
+ case "move":
1034
+ case "drag": {
1035
+ const points = requirePoints(operation);
1036
+ const stdout = await this.powershellScript(WINDOWS_POINTER_SCRIPT, operation, {
1037
+ MELRA_X: String(points.x),
1038
+ MELRA_Y: String(points.y),
1039
+ MELRA_NORMALIZED: operation.coordinateSpace === "normalized" ? "1" : "0",
1040
+ MELRA_MOVE: "1",
1041
+ MELRA_CLICK: operation.action === "click" ? "1" : "0",
1042
+ MELRA_DRAG: points.to === undefined ? "0" : "1",
1043
+ MELRA_TO_X: String(points.to?.x ?? 0),
1044
+ MELRA_TO_Y: String(points.to?.y ?? 0),
1045
+ MELRA_WHEEL: "",
1046
+ }, signal);
1047
+ // The script resolves normalized coordinates against the virtual
1048
+ // desktop, so the pixel points it actually used are reported back rather
1049
+ // than recomputed here from a display size this process never saw.
1050
+ const [x, y, toX, toY] = stdout.trim().split(/\s+/).map(Number);
1051
+ if (x === undefined ||
1052
+ y === undefined ||
1053
+ !Number.isFinite(x) ||
1054
+ !Number.isFinite(y)) {
1055
+ throw new Error("computer_display_geometry_unavailable");
1056
+ }
1057
+ if (points.to !== undefined &&
1058
+ (toX === undefined ||
1059
+ toY === undefined ||
1060
+ !Number.isFinite(toX) ||
1061
+ !Number.isFinite(toY))) {
1062
+ throw new Error("computer_display_geometry_unavailable");
1063
+ }
1064
+ return {
1065
+ success: true,
1066
+ action: operation.action,
1067
+ coordinateSpace: operation.coordinateSpace,
1068
+ x,
1069
+ y,
1070
+ ...(points.to === undefined ? {} : { toX, toY }),
1071
+ };
1072
+ }
1073
+ case "type": {
1074
+ if (operation.text === undefined) {
1075
+ throw new Error("computer_type_requires_text");
1076
+ }
1077
+ await this.powershellScript(WINDOWS_TYPE_SCRIPT, operation, { MELRA_KEYS: escapeSendKeys(operation.text) }, signal);
1078
+ return { success: true, action: "type", characters: operation.text.length };
1079
+ }
1080
+ case "key": {
1081
+ const key = operation.key?.toLocaleUpperCase();
1082
+ const sequence = key === undefined ? undefined : WINDOWS_KEYS[key];
1083
+ if (sequence === undefined)
1084
+ throw new Error("computer_key_not_allowed");
1085
+ await this.powershellScript(WINDOWS_TYPE_SCRIPT, operation, { MELRA_KEYS: sequence }, signal);
1086
+ return { success: true, action: "key", key };
1087
+ }
1088
+ case "scroll": {
1089
+ const deltaY = operation.deltaY ?? 0;
1090
+ // One wheel notch is 120 units, and the sign is inverted against the
1091
+ // web convention: a positive `deltaY` scrolls the page down, which is a
1092
+ // negative wheel rotation.
1093
+ const notches = Math.max(-20, Math.min(20, Math.round(deltaY / 100)));
1094
+ // The wheel goes to the window under the cursor, so scroll positions it
1095
+ // first when told where — and leaves it alone when not, rather than
1096
+ // parking it at the origin and scrolling whatever happens to be there.
1097
+ const positioned = operation.x !== undefined && operation.y !== undefined;
1098
+ await this.powershellScript(WINDOWS_POINTER_SCRIPT, operation, {
1099
+ MELRA_X: String(operation.x ?? 0),
1100
+ MELRA_Y: String(operation.y ?? 0),
1101
+ MELRA_NORMALIZED: operation.coordinateSpace === "normalized" ? "1" : "0",
1102
+ MELRA_MOVE: positioned ? "1" : "0",
1103
+ MELRA_CLICK: "0",
1104
+ MELRA_WHEEL: String(-notches * 120),
1105
+ }, signal);
1106
+ return { success: true, action: "scroll", deltaY };
1107
+ }
1108
+ }
1109
+ }
1110
+ }
1111
+ class UnavailableAdapter {
1112
+ async capabilities() {
1113
+ return {
1114
+ platform: process.platform,
1115
+ adapter: "unavailable",
1116
+ available: false,
1117
+ screenshot: false,
1118
+ pointer: false,
1119
+ keyboard: false,
1120
+ scroll: false,
1121
+ inspect: false,
1122
+ elements: false,
1123
+ ocr: false,
1124
+ drag: false,
1125
+ coordinateSpaces: ["normalized", "pixel"],
1126
+ limitations: ["no supported local computer-use adapter was detected"],
1127
+ };
1128
+ }
1129
+ async execute(operation) {
1130
+ if (operation.action === "capabilities") {
1131
+ return { ...(await this.capabilities()) };
1132
+ }
1133
+ throw new Error("computer_use_unavailable");
1134
+ }
1135
+ }
1136
+ /**
1137
+ * Replays a recorded desktop instead of touching the real one.
1138
+ *
1139
+ * Computer use is the one adapter whose evaluations cannot run honestly in CI:
1140
+ * asserting what a click does means either taking hold of the mouse on the
1141
+ * machine running the suite, or stopping at the approval and never testing what
1142
+ * happens after it. Replaying a recording exercises the whole path — resolution,
1143
+ * execution, verification, receipt — against a desktop that is identical on
1144
+ * every machine.
1145
+ *
1146
+ * An action the recording does not contain is refused rather than defaulted, so
1147
+ * a trace that has drifted from the scenario using it fails loudly.
1148
+ */
1149
+ export class ReplayComputerAdapter {
1150
+ trace;
1151
+ constructor(trace) {
1152
+ this.trace = trace;
1153
+ }
1154
+ async capabilities() {
1155
+ return { ...this.trace.capabilities };
1156
+ }
1157
+ async execute(operation, _artifactDirectory, signal) {
1158
+ if (signal?.aborted === true)
1159
+ throw new Error("task_cancelled");
1160
+ const step = this.trace.steps[operation.action];
1161
+ if (step === undefined) {
1162
+ throw new Error(`computer_trace_missing_step:${operation.action}`);
1163
+ }
1164
+ return { ...step };
1165
+ }
1166
+ }
1167
+ export function createSystemComputerAdapter() {
1168
+ if (process.platform === "darwin")
1169
+ return new MacOsAdapter();
1170
+ if (process.platform === "linux")
1171
+ return new LinuxXdotoolAdapter();
1172
+ if (process.platform === "win32")
1173
+ return new WindowsAdapter();
1174
+ return new UnavailableAdapter();
1175
+ }
1176
+ export class ComputerRuntime {
1177
+ options;
1178
+ adapter;
1179
+ constructor(options) {
1180
+ this.options = options;
1181
+ this.adapter = options.adapter ?? createSystemComputerAdapter();
1182
+ }
1183
+ async execute(operation, signal) {
1184
+ if (signal?.aborted === true)
1185
+ throw new Error("task_cancelled");
1186
+ const capabilities = await this.adapter.capabilities();
1187
+ if (operation.action === "capabilities") {
1188
+ return { ...capabilities };
1189
+ }
1190
+ if (!capabilities.available) {
1191
+ throw new Error("computer_use_unavailable");
1192
+ }
1193
+ const requiredCapability = operation.action === "screenshot"
1194
+ ? "screenshot"
1195
+ : operation.action === "inspect"
1196
+ ? "inspect"
1197
+ : operation.action === "drag"
1198
+ ? "drag"
1199
+ : operation.action === "click" || operation.action === "move"
1200
+ ? "pointer"
1201
+ : operation.action === "type" || operation.action === "key"
1202
+ ? "keyboard"
1203
+ : operation.action === "scroll"
1204
+ ? "scroll"
1205
+ : undefined;
1206
+ if (requiredCapability !== undefined &&
1207
+ !capabilities[requiredCapability]) {
1208
+ throw new Error(`computer_${requiredCapability}_unavailable`);
1209
+ }
1210
+ if (operation.action === "inspect") {
1211
+ return await this.observe(operation, capabilities, signal);
1212
+ }
1213
+ const positional = ["click", "move", "drag"].includes(operation.action);
1214
+ let resolved = operation;
1215
+ let element;
1216
+ if (operation.target !== undefined) {
1217
+ if (!positional)
1218
+ throw new Error("computer_target_not_positional");
1219
+ if (operation.x !== undefined || operation.y !== undefined) {
1220
+ throw new Error("computer_target_conflicts_with_coordinates");
1221
+ }
1222
+ if (!capabilities.elements && !capabilities.ocr) {
1223
+ throw new Error("computer_elements_unavailable");
1224
+ }
1225
+ element = matchElement(await this.elements(operation, capabilities, signal), operation.target);
1226
+ const { target: _named, ...rest } = operation;
1227
+ resolved = {
1228
+ ...rest,
1229
+ // The tree measures in pixels, so a normalized request resolves into
1230
+ // pixel space rather than being scaled back into a fraction of a display
1231
+ // the element was never expressed against.
1232
+ coordinateSpace: "pixel",
1233
+ x: element.x + Math.floor(element.width / 2),
1234
+ y: element.y + Math.floor(element.height / 2),
1235
+ };
1236
+ }
1237
+ if (positional) {
1238
+ requirePoints(resolved);
1239
+ }
1240
+ if (resolved.coordinateSpace === "normalized") {
1241
+ // Every positional field shares one space, so the bound applies to the
1242
+ // drag destination as much as to the press point.
1243
+ const outOfRange = [
1244
+ resolved.x,
1245
+ resolved.y,
1246
+ resolved.toX,
1247
+ resolved.toY,
1248
+ ].some((value) => value !== undefined && value > 1);
1249
+ if (outOfRange) {
1250
+ throw new Error("computer_normalized_coordinates_out_of_range");
1251
+ }
1252
+ }
1253
+ const result = await this.adapter.execute(resolved, this.options.artifactDirectory, signal);
1254
+ // The resolved element rides back on the result so a caller can verify what
1255
+ // was actually hit with `result_equals` on `element.name`, rather than
1256
+ // trusting that the name it asked for was the name that got clicked.
1257
+ return element === undefined ? result : { ...result, element };
1258
+ }
1259
+ /** The frontmost window's elements, or a refusal if the platform saw none. */
1260
+ async elements(operation, capabilities, signal) {
1261
+ const observed = await this.observe(operation, capabilities, signal);
1262
+ const elements = elementsFrom(observed["elements"]);
1263
+ if (elements === undefined || elements.length === 0) {
1264
+ throw new Error("computer_elements_unavailable");
1265
+ }
1266
+ return elements;
1267
+ }
1268
+ /**
1269
+ * An `inspect` observation, with words read off a screenshot when the platform
1270
+ * reported no element tree.
1271
+ *
1272
+ * The fallback lives here rather than in each adapter so all four inherit it,
1273
+ * and it only runs when the tree came back empty: a tree states what a control
1274
+ * *is*, while a reading only says what it looks like, so the tree wins
1275
+ * wherever there is one. Absence of a display width is a hard stop rather than
1276
+ * an assumed scale — without it there is nothing to map a Retina capture back
1277
+ * onto, and a wrong scale means a click that lands somewhere nobody named.
1278
+ */
1279
+ async observe(operation, capabilities, signal) {
1280
+ const observed = await this.adapter.execute({ ...operation, action: "inspect" }, this.options.artifactDirectory, signal);
1281
+ const reported = elementsFrom(observed["elements"]);
1282
+ if (reported !== undefined && reported.length > 0)
1283
+ return observed;
1284
+ if (!capabilities.ocr || !capabilities.screenshot)
1285
+ return observed;
1286
+ const displayWidth = observed["displayWidth"];
1287
+ if (typeof displayWidth !== "number")
1288
+ return observed;
1289
+ const read = await this.readScreen(operation, displayWidth, signal);
1290
+ return read.length === 0 ? observed : { ...observed, elements: read };
1291
+ }
1292
+ /** Takes a screenshot through the adapter and reads its words. */
1293
+ async readScreen(operation, displayWidth, signal) {
1294
+ const program = await tesseract();
1295
+ if (program === undefined)
1296
+ return [];
1297
+ const { target: _named, ...rest } = operation;
1298
+ const captured = await this.adapter.execute({ ...rest, action: "screenshot" }, this.options.artifactDirectory, signal);
1299
+ const path = captured["path"];
1300
+ if (typeof path !== "string")
1301
+ return [];
1302
+ const { stdout } = await run(program, [path, "stdout", "tsv"], operation.timeoutMs, signal);
1303
+ return parseOcrElements(stdout, displayWidth);
1304
+ }
1305
+ }
1306
+ //# sourceMappingURL=index.js.map