@loadmill/droid-cua 2.40.0 → 2.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +33 -3
  2. package/build/index.js +69 -10
  3. package/build/src/browser/connection.js +5 -0
  4. package/build/src/browser/factory.js +1 -0
  5. package/build/src/browser/playwright/connection.js +3 -0
  6. package/build/src/cli/headless-runner.js +55 -3
  7. package/build/src/cli/headless-target.js +15 -7
  8. package/build/src/commands/help.js +10 -4
  9. package/build/src/core/execution-engine.js +26 -1
  10. package/build/src/core/report/render-html-report.js +36 -6
  11. package/build/src/core/report/run-log-model.js +65 -0
  12. package/build/src/core/report/run-report-model.js +1 -0
  13. package/build/src/core/report/run-report-sections.js +15 -0
  14. package/build/src/core/tool-handlers/assert.js +45 -2
  15. package/build/src/core/tool-handlers/query-network.js +92 -0
  16. package/build/src/desktop/computer-use.js +137 -51
  17. package/build/src/desktop/primary-display.js +74 -0
  18. package/build/src/device/openai.js +25 -0
  19. package/build/src/modes/execution-mode.js +10 -1
  20. package/build/src/network/browser-capture.js +110 -0
  21. package/build/src/network/certificate-authority.js +205 -0
  22. package/build/src/network/certificate-install.js +242 -0
  23. package/build/src/network/local-proxy.js +287 -0
  24. package/build/src/network/network-capture.js +243 -0
  25. package/build/src/network/query-network.js +215 -0
  26. package/build/src/network/run-network-capture.js +107 -0
  27. package/build/src/prompts/base.js +9 -6
  28. package/build/src/prompts/execution.js +10 -6
  29. package/package.json +2 -1
  30. package/skill/droid-cua/SKILL.md +1 -1
  31. package/skill/droid-cua/references/authoring-tests.md +16 -0
  32. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +10 -0
  33. package/skill/droid-cua/references/running-and-healing.md +9 -1
@@ -3,6 +3,7 @@ import { buildTypeActionPayload, buildTypeActionText, } from "../device/sensitiv
3
3
  import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
4
4
  import { getWindowsVisibleBounds } from "./windows-visible-bounds.js";
5
5
  import sharp from "sharp";
6
+ import { capturePrimaryDisplay } from "./primary-display.js";
6
7
  const SCREENSHOT_WIDTH = 1440;
7
8
  const ACCESSIBILITY_HIT_TOLERANCE = 12;
8
9
  const MACOS_CAPTURE_INSETS = { left: 50, right: 50, top: 38, bottom: 62 };
@@ -104,7 +105,7 @@ function formatPermissionError(error, platform) {
104
105
  if (platform === "darwin" && /permission|not authorized|screencapture|screen recording|cgwindow|axerror/i.test(message)) {
105
106
  return `${message} Grant Screen & System Audio Recording and Accessibility access to Droid (or your terminal for CLI runs) in System Settings > Privacy & Security, then restart it.`;
106
107
  }
107
- if (platform === "win32" && /access denied|elevat|integrity level|could not focus/i.test(message)) {
108
+ if (platform === "win32" && /access denied|elevat|integrity level/i.test(message)) {
108
109
  return `${message} Run Droid in an interactive desktop session and at the same integrity level as the target application; elevated Windows applications cannot be controlled by a non-elevated Droid process.`;
109
110
  }
110
111
  return message;
@@ -236,22 +237,26 @@ function normalizeShortcut(keys, platform) {
236
237
  return keys.map((key) => aliases[String(key).toUpperCase()] || String(key).toLowerCase()).join("+");
237
238
  }
238
239
  /**
239
- * @param {{ app?: string, windowId?: number | null, excludePids?: number[], platform?: string, inputMode?: "accessibility" | "physical", focusOnConnect?: boolean, client?: any, accessibility?: any, visibleBoundsReader?: typeof getWindowsVisibleBounds }} options
240
+ * @param {{ scope?: "window" | "desktop", app?: string, windowId?: number | null, excludePids?: number[], platform?: string, inputMode?: "accessibility" | "physical", focusOnConnect?: boolean, client?: any, accessibility?: any, visibleBoundsReader?: typeof getWindowsVisibleBounds, displayCapture?: typeof capturePrimaryDisplay }} options
240
241
  */
241
- export async function createDesktopController({ app: requestedApp, windowId: requestedWindowId = null, excludePids = [], platform = process.platform, inputMode = "accessibility", focusOnConnect = false, client: injectedClient = null, accessibility: injectedAccessibility = null, visibleBoundsReader = getWindowsVisibleBounds, } = {}) {
242
+ export async function createDesktopController({ scope = "window", app: requestedApp, windowId: requestedWindowId = null, excludePids = [], platform = process.platform, inputMode = "accessibility", focusOnConnect = false, client: injectedClient = null, accessibility: injectedAccessibility = null, visibleBoundsReader = getWindowsVisibleBounds, displayCapture = capturePrimaryDisplay, } = {}) {
242
243
  if (!SUPPORTED_PLATFORMS.has(platform)) {
243
244
  throw new Error("Desktop control is currently supported only on macOS and Windows.");
244
245
  }
245
- if (!requestedApp)
246
+ if (scope !== "window" && scope !== "desktop")
247
+ throw new Error('Desktop scope must be "window" or "desktop".');
248
+ if (scope === "desktop" && (requestedApp || requestedWindowId != null))
249
+ throw new Error("Full desktop cannot select an application or window.");
250
+ if (scope === "desktop" && inputMode !== "physical")
251
+ throw new Error("Full desktop requires physical input.");
252
+ if (scope === "window" && !requestedApp)
246
253
  throw new Error("--desktop-app is required with --target desktop.");
247
254
  if (!INPUT_MODES.has(inputMode))
248
255
  throw new Error('Desktop input mode must be "accessibility" or "physical".');
249
256
  const client = injectedClient || await createLibraryClient();
250
257
  try {
251
- const apps = (await discoverApplications(client)).filter((app) => typeof app.bundleId === "string" && app.bundleId && !excludePids.includes(app.pid));
252
- const application = chooseApplication(apps, requestedApp);
253
- const windows = readWindows(await client.listWindows(application.bundleId));
254
- let targetWindow = chooseWindow(windows, requestedWindowId == null ? null : Number(requestedWindowId));
258
+ const application = scope === "window" ? chooseApplication((await discoverApplications(client)).filter((app) => typeof app.bundleId === "string" && app.bundleId && !excludePids.includes(app.pid)), requestedApp) : null;
259
+ let targetWindow = application ? chooseWindow(readWindows(await client.listWindows(application.bundleId)), requestedWindowId == null ? null : Number(requestedWindowId)) : null;
255
260
  let capture = null;
256
261
  let deviceInfo = null;
257
262
  let native = injectedAccessibility;
@@ -260,19 +265,38 @@ export async function createDesktopController({ app: requestedApp, windowId: req
260
265
  async function getWindow() {
261
266
  return parseJsonResult(await client.getWindow(targetWindow.windowId), "Window lookup");
262
267
  }
263
- async function focus() {
268
+ async function refreshWindow() {
269
+ const windows = readWindows(await client.listWindows(application.bundleId))
270
+ .filter((window) => window.isOnScreen && window.bounds?.width > 0 && window.bounds?.height > 0);
271
+ const selected = windows.find((window) => window.isFocused)
272
+ || windows.find((window) => window.windowId === targetWindow.windowId)
273
+ || chooseWindow(windows, null);
274
+ if (selected.windowId !== targetWindow.windowId)
275
+ capture = null;
276
+ targetWindow = selected;
277
+ if (deviceInfo) {
278
+ deviceInfo.desktopWindowId = selected.windowId;
279
+ deviceInfo.device_name = `${application.displayName} — ${selected.title || "Untitled"}`;
280
+ }
281
+ }
282
+ async function activateWindow() {
264
283
  const activation = parseJsonResult(await client.activateWindow(targetWindow.windowId, 2000), "Window activation");
265
- const focused = await getWindow();
266
- if (!activation.activated || !focused.isFocused) {
267
- throw new Error("Droid could not focus the selected desktop window, so no input was sent.");
284
+ if (!activation.activated || !(await getWindow()).isFocused) {
285
+ throw new Error(`Droid could not focus the selected desktop window, so no input was sent.${activation.reason ? ` Reason: ${activation.reason}.` : ""}`);
268
286
  }
269
287
  }
288
+ async function focus() {
289
+ if (scope === "desktop")
290
+ return;
291
+ await refreshWindow();
292
+ await activateWindow();
293
+ }
270
294
  async function captureFromDesktop(window) {
271
295
  if (!window.isFocused) {
272
296
  if (inputMode !== "physical") {
273
297
  throw new Error("Windows desktop capture requires the target window to be visible and focused. Bring it to the foreground and retry.");
274
298
  }
275
- await focus();
299
+ await activateWindow();
276
300
  window = await getWindow();
277
301
  }
278
302
  native ||= await loadNativeDriver();
@@ -293,29 +317,38 @@ export async function createDesktopController({ app: requestedApp, windowId: req
293
317
  }
294
318
  async function screenshot({ allowDesktopFallback = true } = {}) {
295
319
  try {
296
- const currentWindows = readWindows(await client.listWindows(application.bundleId));
297
- const focusedWindow = currentWindows.find((candidate) => candidate.isFocused && candidate.isOnScreen);
298
- if (focusedWindow)
299
- targetWindow = focusedWindow;
300
- const window = await getWindow();
301
- if (!window.isOnScreen)
302
- throw new Error("The selected desktop window is no longer visible.");
303
- const result = await client.screenshot({
304
- width: SCREENSHOT_WIDTH,
305
- quality: 0,
306
- target_window_id: targetWindow.windowId,
307
- });
308
- assertResult(result, "Desktop screenshot");
309
- let image = { ...readScreenshot(result), bounds: { ...window.bounds }, actionBounds: { ...window.bounds } };
310
- if (allowDesktopFallback && platform === "win32" && await isBlackScreenshot(image.data)) {
311
- image = await captureFromDesktop(window);
320
+ let image;
321
+ if (scope === "desktop") {
322
+ native ||= await loadNativeDriver();
323
+ const screen = await displayCapture(native, platform);
324
+ image = { ...screen, actionBounds: { ...screen.bounds } };
325
+ }
326
+ else {
327
+ await refreshWindow();
328
+ const window = await getWindow();
329
+ if (!window.isOnScreen)
330
+ throw new Error("The selected desktop window is no longer visible.");
331
+ const result = await client.screenshot({
332
+ width: SCREENSHOT_WIDTH,
333
+ quality: 0,
334
+ target_window_id: targetWindow.windowId,
335
+ });
336
+ assertResult(result, "Desktop screenshot");
337
+ image = { ...readScreenshot(result), bounds: { ...window.bounds }, actionBounds: { ...window.bounds } };
338
+ if (allowDesktopFallback && platform === "win32" && await isBlackScreenshot(image.data)) {
339
+ image = await captureFromDesktop(window);
340
+ }
312
341
  }
313
342
  capture = {
314
343
  bounds: image.bounds,
315
344
  actionBounds: image.actionBounds,
316
345
  width: image.width,
317
346
  height: image.height,
318
- contentBounds: getScreenshotContentBounds(image.width, image.height, image.bounds, platform),
347
+ windowId: targetWindow?.windowId ?? null,
348
+ displayId: image.displayId,
349
+ contentBounds: scope === "desktop"
350
+ ? { x: 0, y: 0, width: image.width, height: image.height }
351
+ : getScreenshotContentBounds(image.width, image.height, image.bounds, platform),
319
352
  };
320
353
  if (deviceInfo) {
321
354
  deviceInfo.device_width = image.actionBounds.width;
@@ -323,7 +356,7 @@ export async function createDesktopController({ app: requestedApp, windowId: req
323
356
  deviceInfo.scaled_width = image.width;
324
357
  deviceInfo.scaled_height = image.height;
325
358
  deviceInfo.scale = image.width / image.actionBounds.width;
326
- deviceInfo.desktopWindowId = targetWindow.windowId;
359
+ deviceInfo.desktopWindowId = targetWindow?.windowId ?? null;
327
360
  }
328
361
  return image.data;
329
362
  }
@@ -334,25 +367,77 @@ export async function createDesktopController({ app: requestedApp, windowId: req
334
367
  function scaledPoint(x, y) {
335
368
  if (!capture)
336
369
  throw new Error("Capture the desktop window before executing an action.");
370
+ if (scope === "desktop" && (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x >= capture.width || y >= capture.height)) {
371
+ throw new Error("The action is outside the primary display.");
372
+ }
337
373
  const content = capture.contentBounds;
338
374
  const bounds = capture.actionBounds;
339
- return [
375
+ const point = [
340
376
  Math.round(bounds.x + ((x - content.x) * bounds.width) / content.width),
341
377
  Math.round(bounds.y + ((y - content.y) * bounds.height) / content.height),
342
378
  ];
379
+ // Retina rounding at the last image pixel must stay inside the display.
380
+ return scope === "desktop" ? [Math.min(point[0], bounds.width - 1), Math.min(point[1], bounds.height - 1)] : point;
343
381
  }
344
382
  async function prepareAction(requireFocus) {
345
383
  if (!capture)
346
- throw new Error("Capture the desktop window before executing an action.");
347
- const before = await getWindow();
348
- if (!sameBounds(before.bounds, capture.bounds)) {
384
+ throw new Error("A fresh desktop screenshot is required before acting.");
385
+ if (scope === "desktop") {
386
+ const display = native.getDisplaySize(capture.displayId);
387
+ if (display.width !== capture.bounds.width || display.height !== capture.bounds.height) {
388
+ throw new Error("The display resized after the screenshot. A fresh screenshot is required before acting.");
389
+ }
390
+ return;
391
+ }
392
+ let window;
393
+ try {
394
+ window = parseJsonResult(await client.getWindow(capture.windowId), "Window lookup");
395
+ }
396
+ catch {
397
+ throw new Error("The captured desktop window is no longer available. A fresh screenshot is required before acting.");
398
+ }
399
+ if (!window.isOnScreen || window.bundleId !== application.bundleId || capture.windowId !== targetWindow.windowId) {
400
+ throw new Error("The captured desktop window is no longer available. A fresh screenshot is required before acting.");
401
+ }
402
+ if (!sameBounds(window.bounds, capture.bounds)) {
349
403
  throw new Error("The desktop window moved or resized after the screenshot. A fresh screenshot is required before acting.");
350
404
  }
351
- if (!requireFocus)
352
- return;
353
- await focus();
405
+ if (requireFocus) {
406
+ await activateWindow();
407
+ if (!sameBounds((await getWindow()).bounds, capture.bounds)) {
408
+ throw new Error("The desktop window moved or resized during focus. A fresh screenshot is required before acting.");
409
+ }
410
+ }
354
411
  }
355
- const targetOptions = () => ({ targetWindowId: targetWindow.windowId, focusStrategy: "strict" });
412
+ const targetOptions = () => scope === "desktop"
413
+ ? { focusStrategy: "none" }
414
+ : { targetWindowId: targetWindow.windowId, focusStrategy: "strict" };
415
+ // Native pointer calls avoid the library's first-monitor coordinate validation.
416
+ // Keyboard calls retain its text/clipboard handling, with app focus disabled.
417
+ const physical = scope === "window" ? client : {
418
+ click: (x, y) => native.mouseClick(x, y, "left", 1),
419
+ doubleClick: (x, y) => native.mouseClick(x, y, "left", 2),
420
+ moveMouse: (x, y) => native.mouseMove(x, y),
421
+ drag: async (to, from) => {
422
+ native.mouseMove(from[0], from[1]);
423
+ await new Promise((resolve) => setTimeout(resolve, 50));
424
+ native.mouseButton("press", from[0], from[1]);
425
+ try {
426
+ await new Promise((resolve) => setTimeout(resolve, 50));
427
+ native.mouseDrag(to[0], to[1]);
428
+ await new Promise((resolve) => setTimeout(resolve, 50));
429
+ }
430
+ finally {
431
+ native.mouseButton("release", to[0], to[1]);
432
+ }
433
+ },
434
+ scroll: (x, y, direction, amount) => {
435
+ native.mouseMove(x, y);
436
+ native.mouseScroll(direction === "down" ? amount : direction === "up" ? -amount : 0, direction === "right" ? amount : direction === "left" ? -amount : 0);
437
+ },
438
+ type: client.type,
439
+ key: client.key,
440
+ };
356
441
  async function performCursorFreeAction(actionType, x, y, direction = null) {
357
442
  const tree = parseJsonResult(await client.getUiTree(targetWindow.windowId, 20), "Accessibility tree");
358
443
  const selected = chooseAccessibilityElement(tree, x, y, actionType, direction);
@@ -385,8 +470,8 @@ export async function createDesktopController({ app: requestedApp, windowId: req
385
470
  }
386
471
  else {
387
472
  const result = action.type === "click"
388
- ? await client.click(x, y, application.bundleId, targetOptions())
389
- : await client.doubleClick(x, y, application.bundleId, targetOptions());
473
+ ? await physical.click(x, y, application?.bundleId, targetOptions())
474
+ : await physical.doubleClick(x, y, application?.bundleId, targetOptions());
390
475
  assertResult(result, "Desktop click");
391
476
  }
392
477
  break;
@@ -396,7 +481,7 @@ export async function createDesktopController({ app: requestedApp, windowId: req
396
481
  addOutput({ type: "action", text: `Moving mouse to (${x}, ${y})`, ...meta({ x, y, unit: "px" }) });
397
482
  if (inputMode === "accessibility")
398
483
  throw new Error(cursorFreeError(action.type));
399
- assertResult(await client.moveMouse(x, y, application.bundleId, targetOptions()), "Desktop mouse move");
484
+ assertResult(await physical.moveMouse(x, y, application?.bundleId, targetOptions()), "Desktop mouse move");
400
485
  break;
401
486
  }
402
487
  case "drag": {
@@ -408,7 +493,7 @@ export async function createDesktopController({ app: requestedApp, windowId: req
408
493
  addOutput({ type: "action", text: `Dragging from (${from[0]}, ${from[1]}) to (${to[0]}, ${to[1]})`, ...meta({ pathStart: { x: from[0], y: from[1] }, pathEnd: { x: to[0], y: to[1] }, unit: "px" }) });
409
494
  if (inputMode === "accessibility")
410
495
  throw new Error(cursorFreeError(action.type));
411
- assertResult(await client.drag(to, from, application.bundleId, targetOptions()), "Desktop drag");
496
+ assertResult(await physical.drag(to, from, application?.bundleId, targetOptions()), "Desktop drag");
412
497
  break;
413
498
  }
414
499
  case "scroll": {
@@ -423,20 +508,20 @@ export async function createDesktopController({ app: requestedApp, windowId: req
423
508
  await performCursorFreeAction(action.type, x, y, direction);
424
509
  }
425
510
  else {
426
- assertResult(await client.scroll(x, y, direction, amount, application.bundleId, targetOptions()), "Desktop scroll");
511
+ assertResult(await physical.scroll(x, y, direction, amount, application?.bundleId, targetOptions()), "Desktop scroll");
427
512
  }
428
513
  break;
429
514
  }
430
515
  case "type": {
431
516
  const text = typeof action.text === "string" ? action.text : "";
432
517
  addOutput({ type: "action", text: buildTypeActionText(action, text), ...meta(buildTypeActionPayload(action, text)) });
433
- assertResult(await client.type(text, application.bundleId, targetOptions()), "Desktop typing");
518
+ assertResult(await physical.type(text, application?.bundleId, targetOptions()), "Desktop typing");
434
519
  break;
435
520
  }
436
521
  case "keypress": {
437
522
  const shortcut = normalizeShortcut(action.keys, platform);
438
523
  addOutput({ type: "action", text: `Pressing key: ${shortcut}`, ...meta({ keys: action.keys, shortcut }) });
439
- assertResult(await client.key(shortcut, application.bundleId, targetOptions()), "Desktop keypress");
524
+ assertResult(await physical.key(shortcut, application?.bundleId, targetOptions()), "Desktop keypress");
440
525
  break;
441
526
  }
442
527
  case "wait": {
@@ -463,20 +548,21 @@ export async function createDesktopController({ app: requestedApp, windowId: req
463
548
  deviceInfo = {
464
549
  platform: "desktop",
465
550
  source: "local",
466
- device_name: `${application.displayName} — ${targetWindow.title || "Untitled"}`,
551
+ device_name: application ? `${application.displayName} — ${targetWindow.title || "Untitled"}` : "Full desktop",
467
552
  device_width: capture.actionBounds.width,
468
553
  device_height: capture.actionBounds.height,
469
554
  scaled_width: capture.width,
470
555
  scaled_height: capture.height,
471
556
  scale: capture.width / capture.actionBounds.width,
472
- desktopAppId: application.bundleId,
473
- desktopWindowId: targetWindow.windowId,
557
+ desktopScope: scope,
558
+ desktopAppId: application?.bundleId ?? null,
559
+ desktopWindowId: targetWindow?.windowId ?? null,
474
560
  desktopInputMode: inputMode,
475
561
  };
476
562
  return {
477
563
  application,
478
- window: targetWindow,
479
- deviceId: `desktop:${targetWindow.windowId}`,
564
+ get window() { return targetWindow; },
565
+ deviceId: scope === "desktop" ? "desktop:primary" : `desktop:${targetWindow.windowId}`,
480
566
  deviceInfo,
481
567
  focus,
482
568
  getScreenshotAsBase64: () => screenshot(),
@@ -0,0 +1,74 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import sharp from "sharp";
7
+ const runFile = promisify(execFile);
8
+ const WINDOWS_CAPTURE = `
9
+ $ErrorActionPreference = 'Stop'
10
+ Add-Type -AssemblyName System.Drawing
11
+ Add-Type -TypeDefinition @'
12
+ using System;
13
+ using System.Runtime.InteropServices;
14
+ public static class PrimaryDisplay {
15
+ [StructLayout(LayoutKind.Sequential)] public struct Point { public int X; public int Y; }
16
+ [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
17
+ [DllImport("user32.dll")] public static extern int GetSystemMetrics(int index);
18
+ [DllImport("user32.dll")] public static extern IntPtr MonitorFromPoint(Point point, uint flags);
19
+ }
20
+ '@
21
+ [void][PrimaryDisplay]::SetProcessDPIAware()
22
+ $width = [PrimaryDisplay]::GetSystemMetrics(0)
23
+ $height = [PrimaryDisplay]::GetSystemMetrics(1)
24
+ $bitmap = [System.Drawing.Bitmap]::new($width, $height)
25
+ $graphics = [System.Drawing.Graphics]::FromImage($bitmap)
26
+ try {
27
+ $graphics.CopyFromScreen(0, 0, 0, 0, $bitmap.Size)
28
+ $bitmap.Save('CAPTURE_PATH', [System.Drawing.Imaging.ImageFormat]::Png)
29
+ # The native driver's display lookup accepts a uint32 monitor handle.
30
+ [PrimaryDisplay]::MonitorFromPoint([PrimaryDisplay+Point]::new(), 1).ToInt64() -band 0xffffffff
31
+ } finally {
32
+ $graphics.Dispose()
33
+ $bitmap.Dispose()
34
+ }
35
+ `;
36
+ /** Capture only the primary display, independently of the library's cached window target. */
37
+ export async function capturePrimaryDisplay(native, platform = process.platform, run = runFile) {
38
+ const directory = await mkdtemp(join(tmpdir(), "droid-desktop-"));
39
+ const path = join(directory, "screen.png");
40
+ try {
41
+ let displayId;
42
+ if (platform === "darwin") {
43
+ await run("/usr/sbin/screencapture", ["-x", "-m", "-t", "png", path], { timeout: 10000 });
44
+ }
45
+ else if (platform === "win32") {
46
+ const script = WINDOWS_CAPTURE.replace("CAPTURE_PATH", path.replaceAll("'", "''"));
47
+ const { stdout } = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
48
+ windowsHide: true,
49
+ timeout: 10000,
50
+ });
51
+ displayId = Number(stdout.trim());
52
+ if (!Number.isSafeInteger(displayId) || displayId <= 0)
53
+ throw new Error("Primary display lookup failed.");
54
+ }
55
+ else {
56
+ throw new Error("Desktop control is currently supported only on macOS and Windows.");
57
+ }
58
+ // Use the native driver's coordinate units, which can differ from screenshot pixels.
59
+ const display = native.getDisplaySize(displayId);
60
+ const { data, info } = await sharp(await readFile(path))
61
+ .resize({ width: 1440, withoutEnlargement: true })
62
+ .png().toBuffer({ resolveWithObject: true });
63
+ return {
64
+ data: data.toString("base64"),
65
+ width: info.width,
66
+ height: info.height,
67
+ bounds: { x: 0, y: 0, width: display.width, height: display.height },
68
+ displayId: displayId ?? display.displayId,
69
+ };
70
+ }
71
+ finally {
72
+ await rm(directory, { recursive: true, force: true });
73
+ }
74
+ }
@@ -41,6 +41,29 @@ function normalizeMessageContent(content) {
41
41
  function buildScreenshotDataUrl(screenshotBase64) {
42
42
  return `data:image/png;base64,${screenshotBase64}`;
43
43
  }
44
+ const queryNetworkTool = {
45
+ type: "function",
46
+ name: "query_network",
47
+ description: "Answer a question about captured HTTP traffic. All matching exchanges are considered in time order. Select the headers/bodies needed, or [] for metadata. Filters combine with AND; flow_ids selects any listed ID. Time filters select request start times (inclusive). from_step uses step-0001 style IDs. Null filters search the whole run. observe_ms waits the full duration before searching, maximum 30000. Narrow too_broad results; never treat errors, incomplete data or absence_supported=false as proof of absence.",
48
+ strict: true,
49
+ parameters: {
50
+ type: "object",
51
+ properties: {
52
+ question: { type: "string", description: "Ask for the specific values or conclusion needed, rather than a full body dump." },
53
+ sections: { type: "array", items: { type: "string", enum: ["request_headers", "response_headers", "request_body", "response_body"] } },
54
+ url_contains: { type: ["string", "null"] },
55
+ method: { type: ["string", "null"] },
56
+ status: { type: ["integer", "null"] },
57
+ flow_ids: { type: ["array", "null"], items: { type: "string" } },
58
+ after: { type: ["string", "null"] },
59
+ before: { type: ["string", "null"] },
60
+ from_step: { type: ["string", "null"] },
61
+ observe_ms: { type: ["integer", "null"] },
62
+ },
63
+ required: ["question", "sections", "url_contains", "method", "status", "flow_ids", "after", "before", "from_step", "observe_ms"],
64
+ additionalProperties: false,
65
+ },
66
+ };
44
67
  const previewClickTool = {
45
68
  type: "function",
46
69
  name: "preview_click",
@@ -234,6 +257,8 @@ export function buildFunctionTools({ aimAssistMode = DEFAULT_AIM_ASSIST_MODE, to
234
257
  if (toolCapabilities.closeKeyboard) {
235
258
  tools.push(deviceInfo?.platform?.toLowerCase() === "ios" ? iosCloseKeyboardTool : closeKeyboardTool);
236
259
  }
260
+ if (toolCapabilities.queryNetwork)
261
+ tools.push(queryNetworkTool);
237
262
  if (toolCapabilities.loadmill) {
238
263
  tools.push(loadmillTool);
239
264
  }
@@ -102,6 +102,7 @@ export class ExecutionMode {
102
102
  // changed these three fields. Reset them to the values execution needs.
103
103
  this.engine.toolCapabilities = {
104
104
  assert: true,
105
+ queryNetwork: Boolean(this.engine.networkQuery),
105
106
  navigate: this.isBrowserSession,
106
107
  closeCurrentTab: this.isBrowserSession,
107
108
  closeKeyboard: !this.isBrowserSession,
@@ -245,6 +246,7 @@ export class ExecutionMode {
245
246
  async execute(context = {}) {
246
247
  const runContext = {
247
248
  ...context,
249
+ shouldStop: () => this.shouldStop,
248
250
  runId: context.runId || `run-${Date.now()}`
249
251
  };
250
252
  const addOutput = runContext.addOutput || printCliOutput;
@@ -274,6 +276,10 @@ export class ExecutionMode {
274
276
  return { success: false, error: 'Stopped by user' };
275
277
  }
276
278
  }
279
+ if (this.engine.networkQuery) {
280
+ this.engine.networkQuery.markStep(stepContext.stepId);
281
+ this.session.addToTranscript(`[Step ${stepContext.stepId}] ${instruction}`);
282
+ }
277
283
  this.emit(addOutput, 'user', instruction, runContext, stepContext, {
278
284
  eventType: 'instruction_started',
279
285
  payload: {
@@ -371,7 +377,10 @@ export class ExecutionMode {
371
377
  captureSource: CAPTURE_SOURCES.INSTRUCTION_INPUT
372
378
  });
373
379
  const response = await this.sendCUARequest({
374
- messages: this.session.messages,
380
+ // The chain already contains the base prompt; keep new tool-provided context.
381
+ messages: this.session.previousResponseId
382
+ ? this.session.messages.filter((message) => message.role !== "system" || message.content !== this.session.systemPrompt)
383
+ : this.session.messages,
375
384
  screenshotBase64,
376
385
  previousResponseId: this.session.previousResponseId,
377
386
  deviceInfo: this.session.deviceInfo,
@@ -0,0 +1,110 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { BODY_CAPTURE_LIMIT } from "./network-capture.js";
3
+ /** Observe one Playwright context without routing or changing its requests. */
4
+ export function captureBrowserNetwork(context, record) {
5
+ const flows = new Map();
6
+ const pending = new Set();
7
+ function track(work) {
8
+ pending.add(work);
9
+ void work.finally(() => pending.delete(work));
10
+ return work;
11
+ }
12
+ function error(flowId, cause) {
13
+ return record({ type: "error", flowId, phase: "browser", code: null, message: cause.message });
14
+ }
15
+ function body(flowId, side, headers, buffer, { complete = true, omitted, bytes = buffer.length } = {}) {
16
+ return record({
17
+ type: `${side}_body`, flowId, headers,
18
+ body: buffer.subarray(0, BODY_CAPTURE_LIMIT), bytes,
19
+ truncated: bytes > BODY_CAPTURE_LIMIT, complete, decoded: true, omitted,
20
+ });
21
+ }
22
+ const onRequest = (request) => {
23
+ if (!/^https?:/.test(request.url()))
24
+ return;
25
+ const flowId = randomUUID();
26
+ const timestamp = new Date().toISOString();
27
+ const flow = { flowId, response: null, responseReady: null, ready: null };
28
+ flows.set(request, flow);
29
+ flow.ready = track((async () => {
30
+ const headers = await request.allHeaders();
31
+ await record({
32
+ type: "request", flowId, timestamp, method: request.method(), url: request.url(), headers,
33
+ resourceType: request.resourceType(), serviceWorker: Boolean(request.serviceWorker()),
34
+ });
35
+ await body(flowId, "request", headers, request.postDataBuffer() || Buffer.alloc(0));
36
+ })().catch((cause) => error(flowId, cause)));
37
+ };
38
+ const onResponse = (response) => {
39
+ const flow = flows.get(response.request());
40
+ if (!flow)
41
+ return; // Ignore traffic that began before this test.
42
+ const timestamp = new Date().toISOString();
43
+ flow.response = response;
44
+ flow.responseReady = track((async () => {
45
+ await flow.ready;
46
+ const headers = await response.allHeaders();
47
+ await record({
48
+ type: "response", flowId: flow.flowId, timestamp, status: response.status(), headers,
49
+ fromServiceWorker: response.fromServiceWorker(),
50
+ });
51
+ return headers;
52
+ })().catch((cause) => error(flow.flowId, cause)));
53
+ };
54
+ const onFinished = (request) => {
55
+ const flow = flows.get(request);
56
+ if (!flow)
57
+ return;
58
+ track((async () => {
59
+ await flow.ready;
60
+ const headers = (await flow.responseReady) || {};
61
+ const contentType = String(headers["content-type"] || "").toLowerCase();
62
+ let omitted;
63
+ if (!/(?:^text\/|json|xml|javascript|x-www-form-urlencoded)/.test(contentType))
64
+ omitted = "binary-or-unknown-content-type";
65
+ if (flow.response?.status() >= 300 && flow.response.status() < 400)
66
+ omitted = "redirect-body-unavailable";
67
+ // Avoid retrieving known-large resources. Unknown sizes are bounded after reading.
68
+ if (Number(headers["content-length"]) > BODY_CAPTURE_LIMIT)
69
+ omitted = "body-too-large";
70
+ if (omitted) {
71
+ await body(flow.flowId, "response", headers, Buffer.alloc(0), { omitted, bytes: Number(headers["content-length"]) || 0 });
72
+ }
73
+ else {
74
+ await body(flow.flowId, "response", headers, await flow.response.body());
75
+ }
76
+ })().catch(async (cause) => {
77
+ await error(flow.flowId, cause);
78
+ await body(flow.flowId, "response", {}, Buffer.alloc(0), { complete: false, omitted: "unavailable" });
79
+ }).finally(() => { flows.delete(request); }));
80
+ };
81
+ const onFailed = (request) => {
82
+ const flow = flows.get(request);
83
+ if (!flow)
84
+ return;
85
+ track((async () => {
86
+ await flow.ready;
87
+ await flow.responseReady;
88
+ await error(flow.flowId, new Error(request.failure()?.errorText || "Browser request failed."));
89
+ await body(flow.flowId, "response", {}, Buffer.alloc(0), { complete: false, omitted: "request-failed" });
90
+ flows.delete(request);
91
+ })());
92
+ };
93
+ const onClose = () => { track(error(undefined, new Error("Browser context closed during network capture."))); };
94
+ const listeners = { request: onRequest, response: onResponse, requestfinished: onFinished, requestfailed: onFailed, close: onClose };
95
+ for (const [name, listener] of Object.entries(listeners))
96
+ context.on(name, listener);
97
+ const flush = () => Promise.allSettled([...pending]);
98
+ return {
99
+ flush,
100
+ async stop() {
101
+ for (const [name, listener] of Object.entries(listeners))
102
+ context.off(name, listener);
103
+ await flush();
104
+ for (const flow of flows.values()) {
105
+ await body(flow.flowId, "response", {}, Buffer.alloc(0), { complete: false, omitted: "unfinished" });
106
+ }
107
+ flows.clear();
108
+ },
109
+ };
110
+ }