@loadmill/droid-cua 2.34.0 → 2.35.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.
package/README.md CHANGED
@@ -79,6 +79,7 @@ Desktop app support:
79
79
 
80
80
  **All platforms:**
81
81
  - Loadmill sign-in for the Loadmill models, or an OpenAI API key for the OpenAI models
82
+ - Node.js 20 or newer for CLI use
82
83
 
83
84
  **Android:**
84
85
  - Android Debug Bridge (ADB)
@@ -180,6 +181,15 @@ droid-cua --avd adb:emulator-5554 --instructions tests
180
181
  # Headless iOS simulator run
181
182
  droid-cua --platform ios --avd "iPhone 16" --instructions tests/login.dcua
182
183
 
184
+ # List running desktop applications and their window IDs (macOS or Windows)
185
+ droid-cua --target desktop --list-desktop-apps
186
+
187
+ # Control one already-running desktop application window
188
+ droid-cua --target desktop --desktop-app com.apple.TextEdit --desktop-window 123 --instructions tests/editor.dcua
189
+
190
+ # Explicitly use cursor-free accessibility input instead of the default physical input
191
+ droid-cua --target desktop --desktop-app com.apple.TextEdit --desktop-input accessibility --instructions tests/editor.dcua
192
+
183
193
  # Headless run with prompt-parity config
184
194
  droid-cua --avd adb:emulator-5554 --instructions tests/login.dcua --config ci/droid-cua.json
185
195
 
@@ -246,6 +256,11 @@ Supported CLI options include:
246
256
  - `--app`
247
257
  - `--list-devices`
248
258
  - `--browser`
259
+ - `--target` (`mobile`, `web`, or `desktop`)
260
+ - `--desktop-app` (desktop application ID or exact name)
261
+ - `--desktop-window` (required when the application has multiple visible windows)
262
+ - `--desktop-input` (`physical` by default; use `accessibility` for cursor-free input)
263
+ - `--list-desktop-apps`
249
264
  - `--instructions`
250
265
  - `--config`
251
266
  - `--cua-model`
@@ -268,6 +283,8 @@ Supported CLI options include:
268
283
  `gpt-5.6-terra`, `gpt-5.6-luna`, and `gpt-5.4`. OpenAI models use direct OpenAI
269
284
  mode and require `OPENAI_API_KEY`.
270
285
 
286
+ Desktop control supports macOS and Windows. In the desktop app, enable **Experimental desktop control** at the bottom of Settings to show Desktop in the platform picker. Desktop runs use visual input by default: they capture the selected window and send physical pointer and keyboard input at the model's coordinates. Use `--desktop-input accessibility` for cursor-free CLI input instead. macOS requires Screen & System Audio Recording and Accessibility permissions for Droid or the terminal running the CLI. Windows targets must run at the same integrity level as Droid; a non-elevated Droid process cannot inject input into an elevated application.
287
+
271
288
  Config and precedence rules:
272
289
  - Use `--config <file>` to supply prompt-affecting settings for headless runs.
273
290
  - CLI flags override config file values.
@@ -293,7 +310,7 @@ HTML report (`--report`):
293
310
  - Writes a self-contained HTML report of the run — the same Run Details view the desktop app shows, with screenshots inlined — that you can share and open in any browser.
294
311
  - With no value it saves `<test>--<date>--<time>.html` (e.g. `login--2026-06-03--16-09-20.html`) in the current directory; pass `--report <file>` to pick a filename or `--report <dir>` to pick a folder.
295
312
  - In the desktop app the same report is available from a run's **Share → Download HTML report** button.
296
- - Runs with captured screenshots also expose **Share → Generate video**. The desktop app renders a square mobile replay or 16:9 browser replay locally, then prompts for a save location using `<test>--<date>--<time>--test-replay.mp4` as the suggested filename. While rendering, a compact **Background tasks** card shows progress for **Generating test replay**; it can be minimized to a task-count card or canceled without opening the save dialog.
313
+ - Runs with captured screenshots also expose **Share → Generate video**. The desktop app renders a square mobile replay or 16:9 web and desktop replay locally, then prompts for a save location using `<test>--<date>--<time>--test-replay.mp4` as the suggested filename. While rendering, a compact **Background tasks** card shows progress for **Generating test replay**; it can be minimized to a task-count card or canceled without opening the save dialog.
297
314
  - Authenticated headless runs upload their HTML report and screenshots to Loadmill by default. The Loadmill run record also includes the resolved Droid version, model, provider, Agent Pace, Aim Assist, Context Optimization, and target environment. Use `--no-cloud-report` to disable artifact upload; the Droid run record is still reported.
298
315
 
299
316
  ```sh
@@ -0,0 +1,5 @@
1
+ # Third-Party Notices
2
+
3
+ Desktop control uses `@zavora-ai/computer-use-mcp` version 7.1.0.
4
+
5
+ Copyright (c) Zavora AI contributors. Licensed under the MIT License. The dependency's complete license text is distributed with its npm package.
package/build/index.js CHANGED
@@ -43,6 +43,7 @@ import { resolveRunPool } from "./src/device/cloud/loadmill-cloud/run-pool.js";
43
43
  import { ARTIFACT_READY_STATUSES } from "./src/device/cloud/loadmill-cloud/run-status.js";
44
44
  import { formatDuration } from "./src/core/report/run-log-model.js";
45
45
  import { createBrowserSessionTeardown, withLoadmillCloudSession } from "./src/cli/loadmill-cloud-session.js";
46
+ import { createDesktopController, listDesktopApplications } from "./src/desktop/computer-use.js";
46
47
  dotenv.config();
47
48
  process.once("SIGTERM", async () => {
48
49
  process.exitCode = 143;
@@ -310,8 +311,20 @@ async function main() {
310
311
  console.log(`Run with: --device-name "<name>" --os-version "<osVersion>"`);
311
312
  process.exit(0);
312
313
  }
314
+ if (targetConfig.listDesktopApps) {
315
+ const applications = await listDesktopApplications({ excludePids: [process.pid] });
316
+ for (const application of applications) {
317
+ console.log(`${application.displayName} — ${application.bundleId} (pid ${application.pid})`);
318
+ for (const window of application.windows) {
319
+ console.log(` ${window.windowId}: ${window.title || "Untitled"}`);
320
+ }
321
+ }
322
+ console.log(`\n${applications.length} desktop applications with visible windows.`);
323
+ process.exit(0);
324
+ }
313
325
  const isHeadlessInstructionsRun = Boolean(instructionsFile);
314
326
  const isBrowserTarget = targetConfig.target === "web";
327
+ const isDesktopTarget = targetConfig.target === "desktop";
315
328
  const isLambdaTestTarget = targetConfig.deviceSource === "lambdatest";
316
329
  const isAwsDeviceFarmTarget = targetConfig.deviceSource === "aws-device-farm";
317
330
  const isLoadmillCloudTarget = targetConfig.deviceSource === "loadmill-cloud";
@@ -326,6 +339,7 @@ async function main() {
326
339
  let loadmillCloudBrowserSessionConnected = false;
327
340
  let loadmillCloudBrowserTeardown = null;
328
341
  let loadmillCloudStatusLine = null;
342
+ let desktopController = null;
329
343
  const headlessDebug = createHeadlessDebugArtifacts({
330
344
  cwd: process.cwd(),
331
345
  enabled: isHeadlessInstructionsRun && debugMode
@@ -338,6 +352,9 @@ async function main() {
338
352
  if (isBrowserTarget && !isHeadlessInstructionsRun) {
339
353
  throw new Error("--target web is only supported with --instructions headless execution.");
340
354
  }
355
+ if (isDesktopTarget && !isHeadlessInstructionsRun) {
356
+ throw new Error("--target desktop is only supported with --instructions headless execution.");
357
+ }
341
358
  if (isCloudTarget && !isHeadlessInstructionsRun) {
342
359
  throw new Error(`--device-source ${targetConfig.deviceSource} is only supported with --instructions headless execution.`);
343
360
  }
@@ -398,7 +415,7 @@ async function main() {
398
415
  }
399
416
  }
400
417
  // If no device specified, show interactive selection menu
401
- if (!isBrowserTarget && !isCloudTarget && !avdName && !platform) {
418
+ if (!isBrowserTarget && !isDesktopTarget && !isCloudTarget && !avdName && !platform) {
402
419
  const selection = await selectDevice();
403
420
  platform = selection.platform;
404
421
  avdName = selection.deviceName;
@@ -506,6 +523,24 @@ async function main() {
506
523
  console.log(`Starting URL: ${deviceInfo.currentUrl}`);
507
524
  }
508
525
  }
526
+ else if (isDesktopTarget) {
527
+ desktopController = await createDesktopController({
528
+ app: targetConfig.desktopApp,
529
+ windowId: targetConfig.desktopWindow,
530
+ inputMode: targetConfig.desktopInput,
531
+ excludePids: [process.pid],
532
+ });
533
+ targetId = desktopController.deviceId;
534
+ deviceInfo = desktopController.deviceInfo;
535
+ console.log(`Attached to desktop window: ${deviceInfo.device_name}`);
536
+ console.log(`Model sees window: ${deviceInfo.scaled_width}x${deviceInfo.scaled_height}`);
537
+ console.log(`Desktop input: ${deviceInfo.desktopInputMode === "physical" ? "physical OS pointer" : "cursor-free accessibility"}`);
538
+ process.once("SIGINT", async () => {
539
+ console.log("\nStopping desktop run...");
540
+ await desktopController?.close().catch(() => { });
541
+ process.exit(130);
542
+ });
543
+ }
509
544
  else if (isLambdaTestTarget) {
510
545
  lambdaTestCredentials = resolveLambdaTestCredentials(process.env);
511
546
  const lambdaTestBuildName = createLambdaTestCliBuildName();
@@ -787,8 +822,10 @@ async function main() {
787
822
  recordScreenshots,
788
823
  screenshotDir,
789
824
  isBrowserTarget,
825
+ isDesktopTarget,
826
+ desktopController,
790
827
  isCloudTarget,
791
- runTarget: { isBrowserTarget, isCloudTarget },
828
+ runTarget: { isBrowserTarget, isDesktopTarget, isCloudTarget },
792
829
  report: reportConfig
793
830
  });
794
831
  exitCode = result.exitCode;
@@ -845,6 +882,10 @@ async function main() {
845
882
  console.log(`Done in ${formatDuration(Date.now() - processStartTime)}.`);
846
883
  }
847
884
  }
885
+ if (desktopController) {
886
+ await desktopController.close();
887
+ desktopController = null;
888
+ }
848
889
  }
849
890
  if (exitCode !== null) {
850
891
  process.exit(exitCode);
@@ -83,9 +83,10 @@ function emitCliAppContextDebug(runId, appContext, appContextText) {
83
83
  });
84
84
  }
85
85
  }
86
- export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, onCuaResponse = null, secrets = null }) {
87
- const resolvedRunTarget = runTarget ?? { isBrowserTarget, isCloudTarget };
86
+ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionPrompt, screenshotRecorder, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget, desktopController, isCloudTarget, runTarget = null, onCuaResponse = null, secrets = null }) {
87
+ const resolvedRunTarget = runTarget ?? { isBrowserTarget, isDesktopTarget, isCloudTarget };
88
88
  const isBrowserRunTarget = Boolean(resolvedRunTarget.isBrowserTarget);
89
+ const isDesktopRunTarget = Boolean(resolvedRunTarget.isDesktopTarget);
89
90
  const isCloudRunTarget = Boolean(resolvedRunTarget.isCloudTarget);
90
91
  const sendCUARequest = async (requestArgs) => {
91
92
  const response = await runtimeContext.openAi.sendCUARequest({
@@ -101,7 +102,7 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
101
102
  toolCapabilities: {
102
103
  assert: true,
103
104
  navigate: isBrowserRunTarget,
104
- closeKeyboard: !isBrowserRunTarget,
105
+ closeKeyboard: !isBrowserRunTarget && !isDesktopRunTarget,
105
106
  loadmill: true,
106
107
  },
107
108
  secretsByKey: secrets?.secretsByKey || {},
@@ -130,6 +131,11 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
130
131
  engineOptions.handleModelAction = (browserSessionId, action, scale, actionContext) => handleBrowserModelAction(browserSessionId, action, scale, actionContext);
131
132
  engineOptions.getCurrentPlatform = getBrowserCurrentPlatform;
132
133
  }
134
+ else if (isDesktopRunTarget) {
135
+ engineOptions.getScreenshotAsBase64 = desktopController.getScreenshotAsBase64;
136
+ engineOptions.handleModelAction = desktopController.handleModelAction;
137
+ engineOptions.getCurrentPlatform = () => "desktop";
138
+ }
133
139
  if (isCloudRunTarget) {
134
140
  if (isBrowserRunTarget) {
135
141
  engineOptions.connectToBrowser = createRefuseReconnectConnectToBrowser();
@@ -142,7 +148,7 @@ export function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, exe
142
148
  }
143
149
  return engineOptions;
144
150
  }
145
- async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
151
+ async function runHeadlessTest({ runId, testPath, testName, instructions, session, deviceInfo, headlessConfig, appContext, testDataContext = "", runtimeContext, headlessDebug, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget, desktopController, isCloudTarget, runTarget = null, secrets = null, report = null, isBatchRun = false, reportToLoadmill = true, runConfiguration = {}, cloudUploadSession = null, uploadManager = droidReportUploadManager, }) {
146
152
  let executionMode = null;
147
153
  let loadmillSessionUser = null;
148
154
  let runUsageTotals = setDroidCuaRunUsageModel(createDroidCuaRunUsageTotals(), runtimeContext.config.cuaModel);
@@ -218,6 +224,8 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
218
224
  recordScreenshots,
219
225
  screenshotDir,
220
226
  isBrowserTarget,
227
+ isDesktopTarget,
228
+ desktopController,
221
229
  isCloudTarget,
222
230
  runTarget,
223
231
  onCuaResponse: (response) => {
@@ -428,8 +436,8 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
428
436
  throw error;
429
437
  }
430
438
  }
431
- export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
432
- const resolvedRunTarget = runTarget ?? { isBrowserTarget, isCloudTarget };
439
+ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, session, deviceInfo, headlessConfig, appContext, runtimeContext, headlessDebug, secrets = null, recordScreenshots, screenshotDir, isBrowserTarget, isDesktopTarget = false, desktopController = null, isCloudTarget, runTarget = null, report = null, droidVersion = getDroidVersion(), runHeadlessTestImpl = runHeadlessTest, uploadManager = droidReportUploadManager, }) {
440
+ const resolvedRunTarget = runTarget ?? { isBrowserTarget, isDesktopTarget, isCloudTarget };
433
441
  const runConfiguration = buildDroidRunConfiguration({
434
442
  droidVersion,
435
443
  runtimeConfig: runtimeContext.config,
@@ -489,6 +497,8 @@ export async function runHeadlessInstructionRuns({ runs, instructionsTarget, ses
489
497
  recordScreenshots,
490
498
  screenshotDir: testScreenshotDir,
491
499
  isBrowserTarget: resolvedRunTarget.isBrowserTarget,
500
+ isDesktopTarget: resolvedRunTarget.isDesktopTarget,
501
+ desktopController,
492
502
  isCloudTarget: resolvedRunTarget.isCloudTarget,
493
503
  runTarget: resolvedRunTarget,
494
504
  report,
@@ -4,13 +4,13 @@ function normalizeTarget(value) {
4
4
  return "mobile";
5
5
  }
6
6
  if (typeof value !== "string") {
7
- throw new Error("--target must be one of: mobile, web.");
7
+ throw new Error("--target must be one of: mobile, web, desktop.");
8
8
  }
9
9
  const normalized = value.trim().toLowerCase();
10
- if (normalized === "mobile" || normalized === "web") {
10
+ if (normalized === "mobile" || normalized === "web" || normalized === "desktop") {
11
11
  return normalized;
12
12
  }
13
- throw new Error("--target must be one of: mobile, web.");
13
+ throw new Error("--target must be one of: mobile, web, desktop.");
14
14
  }
15
15
  function normalizeSessionMode(value) {
16
16
  if (value == null || value === "") {
@@ -251,6 +251,53 @@ export function resolveHeadlessTarget(args = {}) {
251
251
  listDevices: false,
252
252
  };
253
253
  }
254
+ if (target === "desktop") {
255
+ if (avdName || platform || args.browser != null || args["session-mode"] != null) {
256
+ throw new Error("--avd, --platform, --browser, and --session-mode cannot be used with --target desktop.");
257
+ }
258
+ if (deviceSource !== "local") {
259
+ throw new Error("--device-source can only be used with --target mobile.");
260
+ }
261
+ assertNoCloudFlags(args);
262
+ if (args["list-devices"] != null) {
263
+ throw new Error("--list-devices can only be used with --target mobile.");
264
+ }
265
+ const listDesktopApps = args["list-desktop-apps"] != null;
266
+ const desktopApp = hasNonEmptyString(args["desktop-app"]) ? args["desktop-app"].trim() : null;
267
+ const rawWindow = readNonEmptyCliValue(args["desktop-window"]);
268
+ const desktopWindow = rawWindow == null ? null : Number(rawWindow);
269
+ const desktopInput = hasNonEmptyString(args["desktop-input"]) ? args["desktop-input"].trim().toLowerCase() : "physical";
270
+ if (!listDesktopApps && !desktopApp) {
271
+ throw new Error("--desktop-app is required with --target desktop.");
272
+ }
273
+ if (rawWindow != null && (!Number.isInteger(desktopWindow) || desktopWindow < 0)) {
274
+ throw new Error("--desktop-window must be a non-negative integer window ID.");
275
+ }
276
+ if (desktopInput !== "accessibility" && desktopInput !== "physical") {
277
+ throw new Error('--desktop-input must be "accessibility" or "physical".');
278
+ }
279
+ return {
280
+ target,
281
+ sessionMode: null,
282
+ browserFamily: null,
283
+ deviceSource: "local",
284
+ avdName: null,
285
+ platform: null,
286
+ deviceName: null,
287
+ osVersion: null,
288
+ appPath: null,
289
+ cloudSessionId: null,
290
+ artifacts: null,
291
+ listDevices: false,
292
+ listDesktopApps,
293
+ desktopApp,
294
+ desktopWindow,
295
+ desktopInput,
296
+ };
297
+ }
298
+ if (args["desktop-app"] != null || args["desktop-window"] != null || args["desktop-input"] != null || args["list-desktop-apps"] != null) {
299
+ throw new Error("--desktop-app, --desktop-window, --desktop-input, and --list-desktop-apps can only be used with --target desktop.");
300
+ }
254
301
  if (args["session-mode"] != null) {
255
302
  throw new Error("--session-mode can only be used with --target web.");
256
303
  }
@@ -19,7 +19,11 @@ export async function handleHelp(args, session, context) {
19
19
  addOutput({ type: 'info', text: ' droid-cua --device-source lambdatest --instructions <file> [options]' });
20
20
  addOutput({ type: 'info', text: '' });
21
21
  addOutput({ type: 'info', text: 'Options:' });
22
- addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default) or web' });
22
+ addOutput({ type: 'info', text: ' --target <kind> Target kind: mobile (default), web, or desktop' });
23
+ addOutput({ type: 'info', text: ' --desktop-app <id> Running desktop app ID or exact name' });
24
+ addOutput({ type: 'info', text: ' --desktop-window <id> Window ID when the app has multiple windows' });
25
+ addOutput({ type: 'info', text: ' --desktop-input <mode> Physical pointer (default) or cursor-free accessibility' });
26
+ addOutput({ type: 'info', text: ' --list-desktop-apps List running desktop apps and windows' });
23
27
  addOutput({ type: 'info', text: ' --avd <name> Device name (Android device ID/serial or iOS Simulator)' });
24
28
  addOutput({ type: 'info', text: ' --platform <platform> Force platform: android or ios' });
25
29
  addOutput({ type: 'info', text: ' --device-source <src> Mobile source: local (default) or lambdatest' });
@@ -13,7 +13,11 @@ export class Session {
13
13
  this.browserName = targetInfo?.browserName || null;
14
14
  this.currentUrl = targetInfo?.currentUrl || null;
15
15
  this.sessionMode = targetInfo?.sessionMode || null;
16
- this.targetKind = targetInfo?.platform === "browser" ? "browser" : "mobile";
16
+ this.targetKind = targetInfo?.platform === "browser"
17
+ ? "browser"
18
+ : targetInfo?.platform === "desktop"
19
+ ? "desktop"
20
+ : "mobile";
17
21
  this.messages = [];
18
22
  this.previousResponseId = null;
19
23
  this.transcript = [];
@@ -1,13 +1,13 @@
1
1
  import path from "path";
2
2
  import { resolveEffectiveHelperModel } from "./runtime-config.js";
3
3
  import { buildTestDataImportMessages } from "../prompts/test-data-import.js";
4
- export const TEST_DATA_IMPORT_EXTENSIONS = [".csv", ".txt"];
4
+ export const TEST_DATA_IMPORT_EXTENSIONS = [".csv", ".txt", ".json", ".xml", ".md"];
5
5
  export function isSupportedTestDataImportPath(filePath) {
6
6
  return TEST_DATA_IMPORT_EXTENSIONS.includes(path.extname(String(filePath ?? "")).toLowerCase());
7
7
  }
8
8
  export function assertSupportedTestDataImportPath(filePath) {
9
9
  if (!isSupportedTestDataImportPath(filePath)) {
10
- throw new Error("Test data import supports CSV and TXT files only.");
10
+ throw new Error("Choose a CSV, TXT, JSON, XML, or Markdown file.");
11
11
  }
12
12
  }
13
13
  function parseTestDataImportContent(content) {
@@ -0,0 +1,432 @@
1
+ import { buildActionEventMeta } from "../device/action-event-meta.js";
2
+ import { buildTypeActionPayload, buildTypeActionText, } from "../device/sensitive-action.js";
3
+ import { getConfiguredStepDelayMs } from "../utils/step-delay.js";
4
+ const SCREENSHOT_WIDTH = 1440;
5
+ const ACCESSIBILITY_HIT_TOLERANCE = 12;
6
+ const MACOS_CAPTURE_INSETS = { left: 50, right: 50, top: 38, bottom: 62 };
7
+ const SUPPORTED_PLATFORMS = new Set(["darwin", "win32"]);
8
+ const INPUT_MODES = new Set(["accessibility", "physical"]);
9
+ function parseJsonResult(result, label) {
10
+ if (result?.isError) {
11
+ throw new Error(result.content?.find((item) => item.type === "text")?.text || `${label} failed.`);
12
+ }
13
+ if (result?.structuredContent) {
14
+ return result.structuredContent;
15
+ }
16
+ const text = result?.content?.find((item) => item.type === "text")?.text;
17
+ try {
18
+ return JSON.parse(text);
19
+ }
20
+ catch {
21
+ throw new Error(`${label} returned an invalid response.`);
22
+ }
23
+ }
24
+ function assertResult(result, label) {
25
+ if (result?.isError) {
26
+ throw new Error(result.content?.find((item) => item.type === "text")?.text || `${label} failed.`);
27
+ }
28
+ }
29
+ function sameBounds(left, right) {
30
+ return left && right && left.x === right.x && left.y === right.y && left.width === right.width && left.height === right.height;
31
+ }
32
+ function getScreenshotContentBounds(width, height, windowBounds, platform) {
33
+ const fullScreenshot = { x: 0, y: 0, width, height };
34
+ if (platform !== "darwin")
35
+ return fullScreenshot;
36
+ const { left, right, top, bottom } = MACOS_CAPTURE_INSETS;
37
+ const scaleX = width / (windowBounds.width + left + right);
38
+ const scaleY = height / (windowBounds.height + top + bottom);
39
+ if (Math.abs(scaleX - scaleY) / Math.max(scaleX, scaleY) > 0.02)
40
+ return fullScreenshot;
41
+ // ponytail: computer-use-mcp v7 omits capture bounds; remove this calibration when upstream exposes them.
42
+ return {
43
+ x: left * scaleX,
44
+ y: top * scaleY,
45
+ width: windowBounds.width * scaleX,
46
+ height: windowBounds.height * scaleY,
47
+ };
48
+ }
49
+ function formatPermissionError(error, platform) {
50
+ const message = error instanceof Error ? error.message : String(error);
51
+ if (platform === "darwin" && /permission|not authorized|screencapture|screen recording|cgwindow|axerror/i.test(message)) {
52
+ 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.`;
53
+ }
54
+ if (platform === "win32" && /access denied|elevat|integrity level|could not focus/i.test(message)) {
55
+ 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.`;
56
+ }
57
+ return message;
58
+ }
59
+ async function createLibraryClient() {
60
+ const [{ createComputerUseServer }, { connectInProcess }] = await Promise.all([
61
+ import("@zavora-ai/computer-use-mcp"),
62
+ import("@zavora-ai/computer-use-mcp/client"),
63
+ ]);
64
+ return connectInProcess(createComputerUseServer({ profile: "ax", vision: true }));
65
+ }
66
+ async function loadAccessibilityDriver() {
67
+ const { loadNative } = await import("@zavora-ai/computer-use-mcp/host-native");
68
+ return loadNative();
69
+ }
70
+ function walkAccessibilityTree(root, depth = 0, elements = []) {
71
+ if (!root || typeof root !== "object")
72
+ return elements;
73
+ elements.push({ ...root, depth });
74
+ for (const child of Array.isArray(root.children) ? root.children : []) {
75
+ walkAccessibilityTree(child, depth + 1, elements);
76
+ }
77
+ return elements;
78
+ }
79
+ function distanceToBounds(bounds, x, y) {
80
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0)
81
+ return Infinity;
82
+ const dx = Math.max(bounds.x - x, 0, x - bounds.x - bounds.width);
83
+ const dy = Math.max(bounds.y - y, 0, y - bounds.y - bounds.height);
84
+ return Math.hypot(dx, dy);
85
+ }
86
+ function actionFor(element, actionType, direction = null) {
87
+ const actions = Array.isArray(element.actions) ? element.actions : [];
88
+ if (actionType === "click")
89
+ return actions.find((action) => /press|invoke|click/i.test(action));
90
+ if (actionType === "double_click")
91
+ return actions.find((action) => /open|double.?click/i.test(action));
92
+ if (actionType === "scroll") {
93
+ const expected = direction === "up" ? /scroll.?up/i
94
+ : direction === "down" ? /scroll.?down/i
95
+ : direction === "left" ? /scroll.?left/i
96
+ : /scroll.?right/i;
97
+ return actions.find((action) => expected.test(action));
98
+ }
99
+ return null;
100
+ }
101
+ function chooseAccessibilityElement(root, x, y, actionType, direction = null) {
102
+ const elements = walkAccessibilityTree(root);
103
+ const candidates = elements
104
+ .map((element) => ({ element, action: actionFor(element, actionType, direction), distance: distanceToBounds(element.bounds, x, y) }))
105
+ .filter(({ element, action, distance }) => action && element.role && String(element.label || "").trim() && distance <= ACCESSIBILITY_HIT_TOLERANCE)
106
+ .sort((left, right) => {
107
+ const leftArea = left.element.bounds.width * left.element.bounds.height;
108
+ const rightArea = right.element.bounds.width * right.element.bounds.height;
109
+ return left.distance - right.distance || leftArea - rightArea || right.element.depth - left.element.depth;
110
+ });
111
+ const selected = candidates[0];
112
+ if (!selected)
113
+ return null;
114
+ if (selected.distance > 0 && candidates[1]?.distance === selected.distance)
115
+ return null;
116
+ const duplicates = elements.filter((element) => element.role === selected.element.role && element.label === selected.element.label);
117
+ return duplicates.length === 1 ? selected : null;
118
+ }
119
+ function cursorFreeError(actionType) {
120
+ return `Cursor-free desktop ${actionType.replaceAll("_", " ")} is unavailable at this location because the application did not expose a unique accessibility action. No mouse input was sent. Re-run with --desktop-input physical to allow OS pointer control.`;
121
+ }
122
+ function cursorFreeKeyboardError(actionType) {
123
+ return `Cursor-free desktop ${actionType.replaceAll("_", " ")} is unavailable because keyboard input requires application focus. No keyboard input was sent. Re-run with --desktop-input physical to allow focused OS input.`;
124
+ }
125
+ function readApplications(result) {
126
+ const data = parseJsonResult(result, "Application discovery");
127
+ return Array.isArray(data) ? data : [];
128
+ }
129
+ async function discoverApplications(client) {
130
+ let applications = readApplications(await client.listRunningApps());
131
+ if (applications.length === 0) {
132
+ await new Promise((resolve) => setTimeout(resolve, 100));
133
+ applications = readApplications(await client.listRunningApps());
134
+ }
135
+ return applications;
136
+ }
137
+ function readWindows(result) {
138
+ const data = parseJsonResult(result, "Window discovery");
139
+ return Array.isArray(data) ? data : Array.isArray(data.windows) ? data.windows : [];
140
+ }
141
+ function chooseApplication(apps, requestedApp) {
142
+ const needle = String(requestedApp || "").trim().toLowerCase();
143
+ const matches = apps.filter((app) => app.bundleId?.toLowerCase() === needle || app.displayName?.trim().toLowerCase() === needle);
144
+ if (matches.length === 1)
145
+ return matches[0];
146
+ if (matches.length > 1) {
147
+ throw new Error(`Desktop application "${requestedApp}" is ambiguous. Use its application ID instead.`);
148
+ }
149
+ throw new Error(`Desktop application "${requestedApp}" is not running. Use --list-desktop-apps to see available applications.`);
150
+ }
151
+ function chooseWindow(windows, requestedWindowId) {
152
+ const visible = windows.filter((window) => window.isOnScreen && window.bounds?.width > 0 && window.bounds?.height > 0);
153
+ if (requestedWindowId != null) {
154
+ const selected = visible.find((window) => window.windowId === requestedWindowId);
155
+ if (!selected)
156
+ throw new Error(`Desktop window ${requestedWindowId} was not found for the selected application.`);
157
+ return selected;
158
+ }
159
+ const focused = visible.filter((window) => window.isFocused);
160
+ if (focused.length === 1)
161
+ return focused[0];
162
+ if (visible.length === 1)
163
+ return visible[0];
164
+ if (visible.length === 0)
165
+ throw new Error("The selected desktop application has no visible windows.");
166
+ const choices = visible.map((window) => `${window.windowId}: ${window.title || "Untitled"}`).join("\n");
167
+ throw new Error(`The selected desktop application has multiple windows. Choose one with --desktop-window <id>:\n${choices}`);
168
+ }
169
+ function normalizeShortcut(keys, platform) {
170
+ if (!Array.isArray(keys) || keys.length === 0)
171
+ throw new Error("Keypress action is missing keys.");
172
+ const aliases = {
173
+ CTRL: "control",
174
+ CONTROL: "control",
175
+ CMD: "command",
176
+ COMMAND: "command",
177
+ META: platform === "darwin" ? "command" : "win",
178
+ OPTION: "alt",
179
+ ALT: "alt",
180
+ ESC: "escape",
181
+ RETURN: "enter",
182
+ };
183
+ return keys.map((key) => aliases[String(key).toUpperCase()] || String(key).toLowerCase()).join("+");
184
+ }
185
+ /**
186
+ * @param {{ app?: string, windowId?: number | null, excludePids?: number[], platform?: string, inputMode?: "accessibility" | "physical", client?: any, accessibility?: any }} options
187
+ */
188
+ export async function createDesktopController({ app: requestedApp, windowId: requestedWindowId = null, excludePids = [], platform = process.platform, inputMode = "accessibility", client: injectedClient = null, accessibility: injectedAccessibility = null, } = {}) {
189
+ if (!SUPPORTED_PLATFORMS.has(platform)) {
190
+ throw new Error("Desktop control is currently supported only on macOS and Windows.");
191
+ }
192
+ if (!requestedApp)
193
+ throw new Error("--desktop-app is required with --target desktop.");
194
+ if (!INPUT_MODES.has(inputMode))
195
+ throw new Error('Desktop input mode must be "accessibility" or "physical".');
196
+ const client = injectedClient || await createLibraryClient();
197
+ try {
198
+ const apps = (await discoverApplications(client)).filter((app) => typeof app.bundleId === "string" && app.bundleId && !excludePids.includes(app.pid));
199
+ const application = chooseApplication(apps, requestedApp);
200
+ const windows = readWindows(await client.listWindows(application.bundleId));
201
+ let targetWindow = chooseWindow(windows, requestedWindowId == null ? null : Number(requestedWindowId));
202
+ let capture = null;
203
+ let deviceInfo = null;
204
+ let accessibility = injectedAccessibility;
205
+ async function getWindow() {
206
+ return parseJsonResult(await client.getWindow(targetWindow.windowId), "Window lookup");
207
+ }
208
+ async function screenshot() {
209
+ let window;
210
+ let result;
211
+ try {
212
+ const currentWindows = readWindows(await client.listWindows(application.bundleId));
213
+ const focusedWindow = currentWindows.find((candidate) => candidate.isFocused && candidate.isOnScreen);
214
+ if (focusedWindow)
215
+ targetWindow = focusedWindow;
216
+ window = await getWindow();
217
+ if (!window.isOnScreen)
218
+ throw new Error("The selected desktop window is no longer visible.");
219
+ result = await client.screenshot({
220
+ width: SCREENSHOT_WIDTH,
221
+ quality: 0,
222
+ target_window_id: targetWindow.windowId,
223
+ });
224
+ assertResult(result, "Desktop screenshot");
225
+ }
226
+ catch (error) {
227
+ throw new Error(formatPermissionError(error, platform));
228
+ }
229
+ const image = result.content?.find((item) => item.type === "image");
230
+ const sizeText = result.content?.find((item) => item.type === "text")?.text || "";
231
+ const size = /^(\d+)x(\d+)$/.exec(sizeText.trim());
232
+ if (!image?.data || !size)
233
+ throw new Error("Desktop screenshot returned no image or dimensions.");
234
+ capture = {
235
+ bounds: { ...window.bounds },
236
+ width: Number(size[1]),
237
+ height: Number(size[2]),
238
+ };
239
+ capture.contentBounds = getScreenshotContentBounds(capture.width, capture.height, window.bounds, platform);
240
+ if (deviceInfo) {
241
+ deviceInfo.device_width = window.bounds.width;
242
+ deviceInfo.device_height = window.bounds.height;
243
+ deviceInfo.scaled_width = capture.width;
244
+ deviceInfo.scaled_height = capture.height;
245
+ deviceInfo.scale = capture.width / window.bounds.width;
246
+ deviceInfo.desktopWindowId = targetWindow.windowId;
247
+ }
248
+ return image.data;
249
+ }
250
+ function scaledPoint(x, y) {
251
+ if (!capture)
252
+ throw new Error("Capture the desktop window before executing an action.");
253
+ const content = capture.contentBounds;
254
+ return [
255
+ Math.round(capture.bounds.x + ((x - content.x) * capture.bounds.width) / content.width),
256
+ Math.round(capture.bounds.y + ((y - content.y) * capture.bounds.height) / content.height),
257
+ ];
258
+ }
259
+ async function prepareAction(requireFocus) {
260
+ if (!capture)
261
+ throw new Error("Capture the desktop window before executing an action.");
262
+ const before = await getWindow();
263
+ if (!sameBounds(before.bounds, capture.bounds)) {
264
+ throw new Error("The desktop window moved or resized after the screenshot. A fresh screenshot is required before acting.");
265
+ }
266
+ if (!requireFocus)
267
+ return;
268
+ const activation = parseJsonResult(await client.activateWindow(targetWindow.windowId, 2000), "Window activation");
269
+ const focused = await getWindow();
270
+ if (!activation.activated || !focused.isFocused) {
271
+ throw new Error("Droid could not focus the selected desktop window, so no input was sent.");
272
+ }
273
+ }
274
+ const targetOptions = () => ({ targetWindowId: targetWindow.windowId, focusStrategy: "strict" });
275
+ async function performCursorFreeAction(actionType, x, y, direction = null) {
276
+ const tree = parseJsonResult(await client.getUiTree(targetWindow.windowId, 20), "Accessibility tree");
277
+ const selected = chooseAccessibilityElement(tree, x, y, actionType, direction);
278
+ if (!selected)
279
+ throw new Error(cursorFreeError(actionType));
280
+ accessibility ||= await loadAccessibilityDriver();
281
+ const result = accessibility.performAction(targetWindow.windowId, selected.element.role, selected.element.label, selected.action);
282
+ if (!result?.performed)
283
+ throw new Error(`${cursorFreeError(actionType)}${result?.reason ? ` Reason: ${result.reason}.` : ""}`);
284
+ }
285
+ async function handleAction(_deviceId, action, _scale = 1, context = null) {
286
+ const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
287
+ const meta = (payload = {}) => buildActionEventMeta({ context, action, platform: "desktop", payload });
288
+ try {
289
+ if (context?.shouldStop?.())
290
+ return { success: false, error: "Stopped by user" };
291
+ if (inputMode === "accessibility" && (action.type === "type" || action.type === "keypress")) {
292
+ throw new Error(cursorFreeKeyboardError(action.type));
293
+ }
294
+ const requiresFocus = inputMode === "physical";
295
+ if (action.type !== "wait")
296
+ await prepareAction(requiresFocus);
297
+ switch (action.type) {
298
+ case "click":
299
+ case "double_click": {
300
+ const [x, y] = scaledPoint(action.x, action.y);
301
+ addOutput({ type: "action", text: action.type === "click" ? "Click" : `Double-clicking at (${x}, ${y})`, ...meta({ x, y, unit: "px" }) });
302
+ if (inputMode === "accessibility") {
303
+ await performCursorFreeAction(action.type, x, y);
304
+ }
305
+ else {
306
+ const result = action.type === "click"
307
+ ? await client.click(x, y, application.bundleId, targetOptions())
308
+ : await client.doubleClick(x, y, application.bundleId, targetOptions());
309
+ assertResult(result, "Desktop click");
310
+ }
311
+ break;
312
+ }
313
+ case "move": {
314
+ const [x, y] = scaledPoint(action.x, action.y);
315
+ addOutput({ type: "action", text: `Moving mouse to (${x}, ${y})`, ...meta({ x, y, unit: "px" }) });
316
+ if (inputMode === "accessibility")
317
+ throw new Error(cursorFreeError(action.type));
318
+ assertResult(await client.moveMouse(x, y, application.bundleId, targetOptions()), "Desktop mouse move");
319
+ break;
320
+ }
321
+ case "drag": {
322
+ if (!Array.isArray(action.path) || action.path.length < 2)
323
+ throw new Error("Drag action requires a path with at least two points.");
324
+ const from = scaledPoint(action.path[0].x, action.path[0].y);
325
+ const last = action.path[action.path.length - 1];
326
+ const to = scaledPoint(last.x, last.y);
327
+ 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" }) });
328
+ if (inputMode === "accessibility")
329
+ throw new Error(cursorFreeError(action.type));
330
+ assertResult(await client.drag(to, from, application.bundleId, targetOptions()), "Desktop drag");
331
+ break;
332
+ }
333
+ case "scroll": {
334
+ const [x, y] = scaledPoint(action.x ?? capture.width / 2, action.y ?? capture.height / 2);
335
+ const scrollX = Number(action.scroll_x) || 0;
336
+ const scrollY = Number(action.scroll_y) || 0;
337
+ const horizontal = Math.abs(scrollX) > Math.abs(scrollY);
338
+ const direction = horizontal ? (scrollX > 0 ? "right" : "left") : (scrollY > 0 ? "down" : "up");
339
+ const amount = Math.max(1, Math.min(10, Math.round(Math.abs(horizontal ? scrollX : scrollY) / 100)));
340
+ addOutput({ type: "action", text: `Scrolling ${direction}`, ...meta({ scrollX, scrollY, x, y, unit: "px" }) });
341
+ if (inputMode === "accessibility") {
342
+ await performCursorFreeAction(action.type, x, y, direction);
343
+ }
344
+ else {
345
+ assertResult(await client.scroll(x, y, direction, amount, application.bundleId, targetOptions()), "Desktop scroll");
346
+ }
347
+ break;
348
+ }
349
+ case "type": {
350
+ const text = typeof action.text === "string" ? action.text : "";
351
+ addOutput({ type: "action", text: buildTypeActionText(action, text), ...meta(buildTypeActionPayload(action, text)) });
352
+ assertResult(await client.type(text, application.bundleId, targetOptions()), "Desktop typing");
353
+ break;
354
+ }
355
+ case "keypress": {
356
+ const shortcut = normalizeShortcut(action.keys, platform);
357
+ addOutput({ type: "action", text: `Pressing key: ${shortcut}`, ...meta({ keys: action.keys, shortcut }) });
358
+ assertResult(await client.key(shortcut, application.bundleId, targetOptions()), "Desktop keypress");
359
+ break;
360
+ }
361
+ case "wait": {
362
+ addOutput({ type: "action", text: "Waiting...", ...meta() });
363
+ const timeoutMs = Number.isFinite(action.ms) && action.ms >= 0 ? Math.round(action.ms) : getConfiguredStepDelayMs();
364
+ await new Promise((resolve) => setTimeout(resolve, timeoutMs));
365
+ break;
366
+ }
367
+ default:
368
+ addOutput({ type: "info", text: `Unknown action: ${JSON.stringify(action)}` });
369
+ }
370
+ return { success: true };
371
+ }
372
+ catch (error) {
373
+ const message = formatPermissionError(error, platform);
374
+ addOutput({ type: "error", text: message, eventType: "error", actionType: action?.type, payload: { message, platform: "desktop" } });
375
+ return { success: false, error: message };
376
+ }
377
+ }
378
+ await screenshot();
379
+ deviceInfo = {
380
+ platform: "desktop",
381
+ source: "local",
382
+ device_name: `${application.displayName} — ${targetWindow.title || "Untitled"}`,
383
+ device_width: targetWindow.bounds.width,
384
+ device_height: targetWindow.bounds.height,
385
+ scaled_width: capture.width,
386
+ scaled_height: capture.height,
387
+ scale: capture.width / targetWindow.bounds.width,
388
+ desktopAppId: application.bundleId,
389
+ desktopWindowId: targetWindow.windowId,
390
+ desktopInputMode: inputMode,
391
+ };
392
+ return {
393
+ application,
394
+ window: targetWindow,
395
+ deviceId: `desktop:${targetWindow.windowId}`,
396
+ deviceInfo,
397
+ getScreenshotAsBase64: screenshot,
398
+ handleModelAction: handleAction,
399
+ close: () => client.close(),
400
+ };
401
+ }
402
+ catch (error) {
403
+ await client.close().catch(() => { });
404
+ throw error;
405
+ }
406
+ }
407
+ /**
408
+ * @param {{ excludePids?: number[], platform?: string, client?: any }} options
409
+ */
410
+ export async function listDesktopApplications({ excludePids = [], platform = process.platform, client: injectedClient = null } = {}) {
411
+ if (!SUPPORTED_PLATFORMS.has(platform)) {
412
+ throw new Error("Desktop control is currently supported only on macOS and Windows.");
413
+ }
414
+ const client = injectedClient || await createLibraryClient();
415
+ try {
416
+ const apps = (await discoverApplications(client))
417
+ .filter((app) => typeof app.bundleId === "string" && app.bundleId && !excludePids.includes(app.pid))
418
+ .sort((left, right) => left.displayName.localeCompare(right.displayName));
419
+ const entries = [];
420
+ for (const app of apps) {
421
+ const windows = readWindows(await client.listWindows(app.bundleId))
422
+ .filter((window) => window.isOnScreen && window.bounds?.width > 0 && window.bounds?.height > 0);
423
+ if (windows.length > 0)
424
+ entries.push({ ...app, windows });
425
+ }
426
+ return entries;
427
+ }
428
+ finally {
429
+ if (!injectedClient)
430
+ await client.close();
431
+ }
432
+ }
@@ -30,6 +30,9 @@ ${customSection}
30
30
  function isBrowserTarget(deviceInfo = {}) {
31
31
  return typeof deviceInfo.platform === "string" && deviceInfo.platform.trim().toLowerCase() === "browser";
32
32
  }
33
+ function isDesktopTarget(deviceInfo = {}) {
34
+ return typeof deviceInfo.platform === "string" && deviceInfo.platform.trim().toLowerCase() === "desktop";
35
+ }
33
36
  function isCloudIosTarget(deviceInfo = {}) {
34
37
  const platform = deviceInfo.platform?.trim().toLowerCase();
35
38
  const source = deviceInfo.source?.trim().toLowerCase() || "local";
@@ -42,6 +45,9 @@ function describeControlledTarget(deviceInfo = {}) {
42
45
  if (platform === "browser") {
43
46
  return browserName ? `a browser session (${browserName})` : "a browser session";
44
47
  }
48
+ if (platform === "desktop") {
49
+ return deviceName ? `a desktop application (${deviceName})` : "a desktop application";
50
+ }
45
51
  if (platform === "ios") {
46
52
  const target = isCloudIosTarget(deviceInfo) ? "an iOS device" : "an iOS simulator";
47
53
  return deviceName ? `${target} (${deviceName})` : target;
@@ -248,9 +254,37 @@ function buildMobileBaseSystemPrompt(deviceInfo, options = {}) {
248
254
  - Never use CTRL, CMD, ALT, OPTION, or SHIFT in a keypress action
249
255
  `;
250
256
  }
257
+ function buildDesktopBaseSystemPrompt(deviceInfo, options = {}) {
258
+ let browserPrompt = buildBrowserBaseSystemPrompt(deviceInfo, options)
259
+ .replace("Follow the user's instructions to interact with the browser.", "Follow the user's instructions to interact with the selected application.")
260
+ .replace("visible browser window", "selected application window")
261
+ .replace("CRITICAL - Desktop Browser Input Constraints:", "CRITICAL - Desktop Input Constraints:")
262
+ .replace("This is a desktop browser environment.", "This is a desktop application environment.");
263
+ if (deviceInfo.desktopInputMode !== "physical") {
264
+ browserPrompt = browserPrompt
265
+ .replace("Available actions: click, scroll, type, keypress, wait, screenshot.", "Available actions: click, scroll, wait, screenshot.")
266
+ .replace("Standard desktop keys and modifier chords are allowed when they are clearly useful.", "Keyboard input is unavailable because it would focus the application.")
267
+ .replace("Prefer clicking visible controls over keyboard shortcuts when both are equally reliable.", "Use visible application controls for every interaction.")
268
+ .replace("Use keypress chords only when they directly help the task, such as Enter to submit, Escape to dismiss a modal, or Control/Meta shortcuts for focused text fields.", "Use visible application controls for submit, dismiss, and correction actions.");
269
+ browserPrompt = browserPrompt.replace("After EVERY action (click, type, keypress, scroll)", "After EVERY action (click, scroll)");
270
+ }
271
+ const notesIndex = browserPrompt.indexOf(" Browser-Specific Notes:");
272
+ const withoutBrowserNotes = notesIndex === -1 ? browserPrompt : browserPrompt.slice(0, notesIndex);
273
+ const inputNote = deviceInfo.desktopInputMode === "physical"
274
+ ? "Droid may use the physical OS pointer for this run"
275
+ : "Cursor-free mode never focuses the application: use only clicks and scrolling on controls with matching accessibility actions; never emit type, keypress, move, hover, or drag";
276
+ return `${withoutBrowserNotes} Desktop-Specific Notes:
277
+ - Droid is controlling only the selected application window, not the whole display
278
+ - ${inputNote}
279
+ - Do not use shortcuts that switch applications, virtual desktops, or operating-system context
280
+ - Dialogs owned by the selected application may be controlled when they become its active window
281
+ `;
282
+ }
251
283
  function buildBaseSystemPrompt(deviceInfo, options = {}) {
252
- return isBrowserTarget(deviceInfo)
253
- ? buildBrowserBaseSystemPrompt(deviceInfo, options)
254
- : buildMobileBaseSystemPrompt(deviceInfo, options);
284
+ if (isBrowserTarget(deviceInfo))
285
+ return buildBrowserBaseSystemPrompt(deviceInfo, options);
286
+ if (isDesktopTarget(deviceInfo))
287
+ return buildDesktopBaseSystemPrompt(deviceInfo, options);
288
+ return buildMobileBaseSystemPrompt(deviceInfo, options);
255
289
  }
256
- export { appendCustomSections, buildBaseSystemPrompt, buildCustomInstructionsSection, describeControlledTarget, isBrowserTarget, };
290
+ export { appendCustomSections, buildBaseSystemPrompt, buildCustomInstructionsSection, describeControlledTarget, isBrowserTarget, isDesktopTarget, };
@@ -17,8 +17,8 @@ export function buildTestDataImportMessages({ currentContent = "", importedFileN
17
17
  "- Do not invent values that are not present in the current file, imported file, or user instructions.",
18
18
  "- If currentContent is template-only, replace it with real imported data and do not keep commented template examples.",
19
19
  "- If currentContent is not template-only, preserve its useful structure and add or update entities within it.",
20
- "- For matrix-like CSV rows, prefer splitting repeated dimensions into separate entities when the structure is clear.",
21
- "- If rows are indivisible scenarios or examples, keep them grouped as Test Data Entries or Use Case Data.",
20
+ "- For tabular or nested data, preserve meaningful groups and relationships. Split repeated dimensions into separate entities when the structure is clear.",
21
+ "- If records are indivisible scenarios or examples, keep them grouped as Test Data Entries or Use Case Data.",
22
22
  "- User instructions can guide structure and naming, but content must remain concrete data, not test instructions.",
23
23
  "- Output markdown only inside the JSON content string; do not use code fences.",
24
24
  ].join("\n")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadmill/droid-cua",
3
- "version": "2.34.0",
3
+ "version": "2.35.0",
4
4
  "description": "AI-powered Android testing agent using OpenAI's computer-use model and ADB",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -12,6 +12,7 @@
12
12
  "bin/**/*",
13
13
  "skill/**/*",
14
14
  "README.md",
15
+ "THIRD_PARTY_NOTICES.md",
15
16
  "LICENSE"
16
17
  ],
17
18
  "scripts": {
@@ -63,6 +64,7 @@
63
64
  "license": "UNLICENSED",
64
65
  "dependencies": {
65
66
  "@aws-sdk/client-device-farm": "^3.1065.0",
67
+ "@zavora-ai/computer-use-mcp": "7.1.0",
66
68
  "dotenv": "^16.4.7",
67
69
  "ink": "^4.4.1",
68
70
  "ink-spinner": "^5.0.0",
@@ -78,6 +80,6 @@
78
80
  "typescript": "^5.7.3"
79
81
  },
80
82
  "engines": {
81
- "node": ">=18.17.0"
83
+ "node": ">=20.0.0"
82
84
  }
83
85
  }