@loadmill/droid-cua 1.1.2 → 2.0.3

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.
@@ -1,25 +1,81 @@
1
- import { exec } from "child_process";
2
- import { promisify } from "util";
3
1
  import { logger } from "../../utils/logger.js";
4
- const execAsync = promisify(exec);
2
+ import { emitDesktopDebug, truncateForDebug } from "../../utils/desktop-debug.js";
3
+ import { execAdb } from "./tools.js";
5
4
  function adbShell(deviceId, command) {
6
- return execAsync(`adb -s ${deviceId} shell "${command}"`);
5
+ return execAdb(["-s", deviceId, "shell", command]);
6
+ }
7
+ function normalizeMobileKeypress(keys = []) {
8
+ if (!Array.isArray(keys) || keys.length === 0) {
9
+ throw new Error("Keypress action is missing keys");
10
+ }
11
+ if (keys.length > 1) {
12
+ throw new Error(`Unsupported mobile key chord: ${keys.join(", ")}. Use taps and text entry instead.`);
13
+ }
14
+ const key = String(keys[0]).trim().toUpperCase();
15
+ const mobileKeyMap = {
16
+ ESC: "KEYCODE_HOME",
17
+ ESCAPE: "KEYCODE_HOME",
18
+ HOME: "KEYCODE_HOME",
19
+ BACK: "KEYCODE_BACK",
20
+ ENTER: "KEYCODE_ENTER",
21
+ RETURN: "KEYCODE_ENTER",
22
+ BACKSPACE: "KEYCODE_DEL",
23
+ DELETE: "KEYCODE_DEL",
24
+ SPACE: "KEYCODE_SPACE"
25
+ };
26
+ const mappedKey = mobileKeyMap[key];
27
+ if (!mappedKey) {
28
+ throw new Error(`Unsupported mobile keypress: ${keys[0]}. Only single mobile-safe keys are allowed.`);
29
+ }
30
+ return { originalKey: keys[0], mappedKey };
7
31
  }
8
32
  export async function handleModelAction(deviceId, action, scale = 1.0, context = null) {
9
33
  const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
34
+ const meta = (payload = {}) => ({
35
+ eventType: 'tool_call',
36
+ actionType: action?.type,
37
+ runId: context?.runId,
38
+ stepId: context?.stepId,
39
+ instructionIndex: context?.instructionIndex,
40
+ payload: {
41
+ platform: 'android',
42
+ ...payload
43
+ }
44
+ });
10
45
  try {
11
46
  const { x, y, x1, y1, x2, y2, text, keys, path } = action;
47
+ emitDesktopDebug("device.action.execute", "device", {
48
+ runId: context?.runId,
49
+ sessionId: context?.sessionId,
50
+ stepId: context?.stepId,
51
+ instructionIndex: context?.instructionIndex
52
+ }, {
53
+ platform: "android",
54
+ deviceId,
55
+ actionType: action?.type,
56
+ scale,
57
+ x,
58
+ y,
59
+ x1,
60
+ y1,
61
+ x2,
62
+ y2,
63
+ text: typeof text === "string" ? truncateForDebug(text, 300) : undefined,
64
+ keyCount: Array.isArray(keys) ? keys.length : 0,
65
+ pathPoints: Array.isArray(path) ? path.length : 0
66
+ });
12
67
  switch (action.type) {
13
68
  case "click":
69
+ case "double_click":
14
70
  const realX = Math.round(x / scale);
15
71
  const realY = Math.round(y / scale);
16
- addOutput({ type: 'action', text: `Clicking at (${realX}, ${realY})` });
72
+ addOutput({ type: 'action', text: `Clicking at (${realX}, ${realY})`, ...meta({ x: realX, y: realY, unit: 'px' }) });
17
73
  await adbShell(deviceId, `input tap ${realX} ${realY}`);
18
74
  break;
19
75
  case "scroll":
20
76
  const scrollX = Math.round(action.scroll_x / scale);
21
77
  const scrollY = Math.round(action.scroll_y / scale);
22
- addOutput({ type: 'action', text: `Scrolling by (${scrollX}, ${scrollY})` });
78
+ addOutput({ type: 'action', text: `Scrolling by (${scrollX}, ${scrollY})`, ...meta({ scrollX, scrollY, unit: 'px' }) });
23
79
  const startX = 500;
24
80
  const startY = 500;
25
81
  const endX = startX + scrollX;
@@ -34,7 +90,11 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
34
90
  const realStartY = Math.round(start.y / scale);
35
91
  const realEndX = Math.round(end.x / scale);
36
92
  const realEndY = Math.round(end.y / scale);
37
- addOutput({ type: 'action', text: `Dragging from (${realStartX}, ${realStartY}) to (${realEndX}, ${realEndY})` });
93
+ addOutput({
94
+ type: 'action',
95
+ text: `Dragging from (${realStartX}, ${realStartY}) to (${realEndX}, ${realEndY})`,
96
+ ...meta({ pathStart: { x: realStartX, y: realStartY }, pathEnd: { x: realEndX, y: realEndY }, unit: 'px' })
97
+ });
38
98
  await adbShell(deviceId, `input swipe ${realStartX} ${realStartY} ${realEndX} ${realEndY} 500`);
39
99
  }
40
100
  else {
@@ -42,25 +102,19 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
42
102
  }
43
103
  break;
44
104
  case "type":
45
- addOutput({ type: 'action', text: `Typing text: ${text}` });
105
+ addOutput({ type: 'action', text: `Typing text: ${text}`, ...meta({ text }) });
46
106
  const escapedText = text.replace(/(["\\$`])/g, "\\$1").replace(/ /g, "%s");
47
107
  await adbShell(deviceId, `input text "${escapedText}"`);
48
108
  break;
49
109
  case "keypress":
50
- // Map ESC to Android Home button (since ESC doesn't exist on mobile)
51
- const mappedKeys = keys.map(key => {
52
- if (key.toUpperCase() === 'ESC' || key.toUpperCase() === 'ESCAPE') {
53
- return 'KEYCODE_HOME';
54
- }
55
- return key;
56
- });
57
- addOutput({ type: 'action', text: `Pressing key: ${mappedKeys.join(', ')}` });
58
- for (const key of mappedKeys) {
59
- await adbShell(deviceId, `input keyevent ${key}`);
110
+ {
111
+ const { originalKey, mappedKey } = normalizeMobileKeypress(keys);
112
+ addOutput({ type: 'action', text: `Pressing key: ${mappedKey}`, ...meta({ keys: [originalKey], mappedKeys: [mappedKey] }) });
113
+ await adbShell(deviceId, `input keyevent ${mappedKey}`);
60
114
  }
61
115
  break;
62
116
  case "wait":
63
- addOutput({ type: 'action', text: 'Waiting...' });
117
+ addOutput({ type: 'action', text: 'Waiting...', ...meta({}) });
64
118
  await new Promise(res => setTimeout(res, 1000));
65
119
  break;
66
120
  default:
@@ -74,8 +128,31 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
74
128
  message: error.message,
75
129
  stack: error.stack
76
130
  });
131
+ emitDesktopDebug("device.error", "device", {
132
+ runId: context?.runId,
133
+ sessionId: context?.sessionId,
134
+ stepId: context?.stepId,
135
+ instructionIndex: context?.instructionIndex
136
+ }, {
137
+ platform: "android",
138
+ operation: "action.execute",
139
+ actionType: action?.type,
140
+ message: error.message
141
+ });
77
142
  // Show user-friendly error message
78
- addOutput({ type: 'error', text: `Error executing action: ${error.message}` });
143
+ addOutput({
144
+ type: 'error',
145
+ text: `Error executing action: ${error.message}`,
146
+ eventType: 'error',
147
+ actionType: action?.type,
148
+ runId: context?.runId,
149
+ stepId: context?.stepId,
150
+ instructionIndex: context?.instructionIndex,
151
+ payload: {
152
+ message: error.message,
153
+ platform: 'android'
154
+ }
155
+ });
79
156
  addOutput({ type: 'info', text: 'Full error details have been logged to the debug log.' });
80
157
  }
81
158
  }
@@ -1,109 +1,191 @@
1
- import { exec, spawn } from "child_process";
2
1
  import { once } from "events";
3
- import { promisify } from "util";
4
2
  import sharp from "sharp";
5
3
  import { logger } from "../../utils/logger.js";
6
- const execAsync = promisify(exec);
7
- function wait(ms) {
8
- return new Promise(resolve => setTimeout(resolve, ms));
9
- }
4
+ import { emitDesktopDebug } from "../../utils/desktop-debug.js";
5
+ import { execAdb, execEmulator, spawnAdb, spawnEmulator } from "./tools.js";
10
6
  async function listConnectedDevices() {
11
- const { stdout } = await execAsync("adb devices");
7
+ const { stdout } = await execAdb(["devices"]);
12
8
  return stdout
13
- .trim()
14
9
  .split("\n")
15
10
  .slice(1)
16
- .map(line => line.split("\t")[0])
17
- .filter(id => id.length > 0);
11
+ .map(line => line.trim())
12
+ .filter(Boolean)
13
+ .map(line => {
14
+ const [id, state] = line.split("\t");
15
+ return {
16
+ id: id?.trim() || "",
17
+ state: state?.trim() || "",
18
+ };
19
+ })
20
+ .filter(entry => entry.id.length > 0);
18
21
  }
19
- async function waitForDeviceConnection(avdName, timeoutMs = 120000) {
20
- const deadline = Date.now() + timeoutMs;
21
- while (Date.now() < deadline) {
22
- const devices = await listConnectedDevices();
23
- const match = devices.find(id => id.includes(avdName));
24
- if (match)
25
- return match;
26
- await wait(2000);
22
+ function wait(ms) {
23
+ return new Promise((resolve) => setTimeout(resolve, ms));
24
+ }
25
+ function parseTargetToken(rawTarget) {
26
+ if (!rawTarget)
27
+ return { kind: "auto" };
28
+ if (rawTarget.startsWith("adb:")) {
29
+ return { kind: "adb", serial: rawTarget.slice(4) };
30
+ }
31
+ if (rawTarget.startsWith("avd:")) {
32
+ return { kind: "avd", avdName: rawTarget.slice(4) };
33
+ }
34
+ return { kind: "legacy", value: rawTarget };
35
+ }
36
+ async function getEmulatorAvdName(serial) {
37
+ const candidates = [
38
+ ["-s", serial, "emu", "avd", "name"],
39
+ ["-s", serial, "shell", "getprop", "ro.boot.qemu.avd_name"],
40
+ ["-s", serial, "shell", "getprop", "ro.kernel.qemu.avd_name"],
41
+ ["-s", serial, "shell", "getprop", "persist.sys.avd_name"],
42
+ ];
43
+ for (const args of candidates) {
44
+ try {
45
+ const { stdout } = await execAdb(args);
46
+ const value = stdout.trim();
47
+ if (value)
48
+ return value;
49
+ }
50
+ catch { }
27
51
  }
28
52
  return null;
29
53
  }
30
- async function waitForDeviceBoot(deviceId, timeoutMs = 60000) {
54
+ async function waitForWindowServiceReady(deviceId, timeoutMs = 60000) {
31
55
  const deadline = Date.now() + timeoutMs;
32
56
  while (Date.now() < deadline) {
33
57
  try {
34
- const { stdout } = await execAsync(`adb -s ${deviceId} shell getprop sys.boot_completed`);
35
- if (stdout.trim() === "1")
58
+ const { stdout } = await execAdb(["-s", deviceId, "shell", "wm", "size"]);
59
+ if (/Physical size:\s*\d+x\d+/.test(stdout)) {
36
60
  return true;
61
+ }
37
62
  }
38
63
  catch { }
39
64
  await wait(2000);
40
65
  }
41
66
  return false;
42
67
  }
43
- /**
44
- * Get list of available AVDs
45
- */
46
- async function listAvailableAVDs() {
68
+ async function findRunningEmulatorByAvdName(avdName) {
69
+ const devices = await listConnectedDevices();
70
+ const readyEmulators = devices.filter((entry) => entry.state === "device" && entry.id.startsWith("emulator-"));
71
+ for (const entry of readyEmulators) {
72
+ const runningAvdName = await getEmulatorAvdName(entry.id);
73
+ if (runningAvdName === avdName) {
74
+ return entry.id;
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+ async function launchAndWaitForAvd(avdName, timeoutMs = 60000) {
80
+ let avdExists = false;
47
81
  try {
48
- const { stdout } = await execAsync("emulator -list-avds");
49
- return stdout.trim().split("\n").filter(name => name.length > 0);
82
+ const { stdout } = await execEmulator(["-list-avds"]);
83
+ avdExists = stdout
84
+ .split("\n")
85
+ .map((line) => line.trim())
86
+ .filter(Boolean)
87
+ .includes(avdName);
88
+ }
89
+ catch { }
90
+ if (!avdExists) {
91
+ throw new Error(`AVD "${avdName}" was not found. Create it in Android Studio and retry.`);
50
92
  }
51
- catch {
52
- return [];
93
+ const baseline = await listConnectedDevices();
94
+ const baselineReadyEmulators = new Set(baseline.filter((entry) => entry.state === "device" && entry.id.startsWith("emulator-")).map((entry) => entry.id));
95
+ console.log(`Launching Android emulator "${avdName}"...`);
96
+ const emulatorProcess = spawnEmulator(["-avd", avdName], { detached: true, stdio: "ignore" });
97
+ emulatorProcess.unref();
98
+ const deadline = Date.now() + timeoutMs;
99
+ while (Date.now() < deadline) {
100
+ const exactMatch = await findRunningEmulatorByAvdName(avdName);
101
+ if (exactMatch) {
102
+ return exactMatch;
103
+ }
104
+ const devices = await listConnectedDevices();
105
+ const newReadyEmulator = devices.find((entry) => entry.state === "device" && entry.id.startsWith("emulator-") && !baselineReadyEmulators.has(entry.id));
106
+ if (newReadyEmulator) {
107
+ return newReadyEmulator.id;
108
+ }
109
+ await wait(2000);
53
110
  }
111
+ throw new Error("Emulator launch timed out after 60s. Check AVD health and retry.");
54
112
  }
55
- export async function connectToDevice(avdName) {
113
+ export async function connectToDevice(deviceTargetId) {
114
+ emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "start", targetId: deviceTargetId || null });
115
+ const parsedTarget = parseTargetToken(deviceTargetId || "");
56
116
  const devices = await listConnectedDevices();
57
- // If no AVD specified, try to use an already-running emulator or pick the first available
58
- if (!avdName) {
59
- // Check for already-running emulator
60
- for (const id of devices) {
61
- if (id.startsWith("emulator-")) {
62
- console.log(`Using already-running emulator: ${id}`);
63
- return id;
117
+ const deviceMap = new Map(devices.map((entry) => [entry.id, entry.state]));
118
+ const readyDevices = devices.filter((entry) => entry.state === "device");
119
+ if (parsedTarget.kind === "auto") {
120
+ if (readyDevices.length === 0) {
121
+ emitDesktopDebug("device.error", "device", {}, {
122
+ platform: "android",
123
+ operation: "connect",
124
+ message: "No ready Android devices detected."
125
+ });
126
+ throw new Error("No ready Android devices found. Connect a device (or start an emulator), authorize adb, and retry.");
127
+ }
128
+ const selected = readyDevices[0].id;
129
+ console.log(`Using connected Android device: ${selected}`);
130
+ emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: selected, reused: true });
131
+ return selected;
132
+ }
133
+ if (parsedTarget.kind === "avd") {
134
+ const alreadyRunning = await findRunningEmulatorByAvdName(parsedTarget.avdName);
135
+ if (alreadyRunning) {
136
+ const ready = await waitForWindowServiceReady(alreadyRunning, 60000);
137
+ if (!ready) {
138
+ throw new Error("Emulator launch timed out after 60s. Check AVD health and retry.");
64
139
  }
140
+ console.log(`Using already-running emulator for AVD "${parsedTarget.avdName}": ${alreadyRunning}`);
141
+ emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: alreadyRunning, reused: true });
142
+ return alreadyRunning;
65
143
  }
66
- // No running emulator, pick first available AVD
67
- const avds = await listAvailableAVDs();
68
- if (avds.length === 0) {
69
- console.error("No Android AVDs found. Create one with Android Studio or run:");
70
- console.error(" avdmanager create avd -n Pixel_8 -k 'system-images;android-35;google_apis;arm64-v8a'");
71
- process.exit(1);
144
+ const launchedId = await launchAndWaitForAvd(parsedTarget.avdName, 60000);
145
+ const ready = await waitForWindowServiceReady(launchedId, 60000);
146
+ if (!ready) {
147
+ throw new Error("Emulator launch timed out after 60s. Check AVD health and retry.");
72
148
  }
73
- avdName = avds[0];
74
- console.log(`No AVD specified, using first available: ${avdName}`);
75
- }
76
- for (const id of devices) {
77
- if (id.startsWith("emulator-")) {
78
- try {
79
- const { stdout } = await execAsync(`adb -s ${id} emu avd name`);
80
- if (stdout.trim() === avdName) {
81
- console.log(`Emulator ${avdName} is already running as ${id}`);
82
- return id;
83
- }
84
- }
85
- catch { }
149
+ console.log(`Connected to launched emulator "${parsedTarget.avdName}" as ${launchedId}`);
150
+ emitDesktopDebug("device.connect", "device", {}, {
151
+ platform: "android",
152
+ stage: "success",
153
+ deviceId: launchedId,
154
+ reused: false,
155
+ avdName: parsedTarget.avdName
156
+ });
157
+ return launchedId;
158
+ }
159
+ const targetId = parsedTarget.kind === "adb" ? parsedTarget.serial : parsedTarget.value;
160
+ const state = deviceMap.get(targetId);
161
+ if (state === "device") {
162
+ const ready = await waitForWindowServiceReady(targetId, 60000);
163
+ if (!ready) {
164
+ throw new Error("Selected device is connected but not ready yet. Wait a moment and retry.");
86
165
  }
166
+ console.log(`Using selected Android device: ${targetId}`);
167
+ emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: targetId, reused: true });
168
+ return targetId;
87
169
  }
88
- console.log(`No emulator with AVD "${avdName}" is running. Launching...`);
89
- const emulatorProcess = spawn("emulator", ["-avd", avdName], { detached: true, stdio: "ignore" });
90
- emulatorProcess.unref();
91
- const deviceId = await waitForDeviceConnection("emulator-", 120000);
92
- if (!deviceId) {
93
- console.error(`Emulator ${avdName} did not appear in time.`);
94
- process.exit(1);
170
+ if (parsedTarget.kind === "legacy" && targetId) {
171
+ const runningLegacyAvd = await findRunningEmulatorByAvdName(targetId);
172
+ if (runningLegacyAvd) {
173
+ emitDesktopDebug("device.connect", "device", {}, { platform: "android", stage: "success", deviceId: runningLegacyAvd, reused: true });
174
+ return runningLegacyAvd;
175
+ }
95
176
  }
96
- console.log(`Device ${deviceId} detected. Waiting for boot...`);
97
- const booted = await waitForDeviceBoot(deviceId);
98
- if (!booted) {
99
- console.error(`Emulator ${avdName} did not finish booting.`);
100
- process.exit(1);
177
+ let message = "Selected device is no longer connected. Refresh and select again.";
178
+ if (state === "offline") {
179
+ message = "Device is offline. Reconnect device or restart adb.";
101
180
  }
102
- console.log(`Emulator ${avdName} is fully booted.`);
103
- return deviceId;
181
+ else if (state === "unauthorized") {
182
+ message = "Authorize this computer on the device and retry.";
183
+ }
184
+ emitDesktopDebug("device.error", "device", {}, { platform: "android", operation: "connect", message, targetId: targetId || deviceTargetId, state: state || "missing" });
185
+ throw new Error(message);
104
186
  }
105
187
  export async function getDeviceInfo(deviceId) {
106
- const { stdout } = await execAsync(`adb -s ${deviceId} shell wm size`);
188
+ const { stdout } = await execAdb(["-s", deviceId, "shell", "wm", "size"]);
107
189
  const match = stdout.match(/Physical size:\s*(\d+)x(\d+)/);
108
190
  if (!match) {
109
191
  console.error("Could not get device screen size.");
@@ -114,7 +196,18 @@ export async function getDeviceInfo(deviceId) {
114
196
  const scale = width > targetWidth ? targetWidth / width : 1.0;
115
197
  const scaledWidth = Math.round(width * scale);
116
198
  const scaledHeight = Math.round(height * scale);
199
+ const [{ stdout: manufacturerRaw }, { stdout: modelRaw }, { stdout: deviceNameRaw }] = await Promise.all([
200
+ execAdb(["-s", deviceId, "shell", "getprop", "ro.product.manufacturer"]),
201
+ execAdb(["-s", deviceId, "shell", "getprop", "ro.product.model"]),
202
+ execAdb(["-s", deviceId, "shell", "getprop", "ro.product.device"])
203
+ ]);
204
+ const manufacturer = manufacturerRaw.trim();
205
+ const model = modelRaw.trim();
206
+ const deviceName = [manufacturer, model].filter(Boolean).join(" ").trim() || deviceNameRaw.trim() || deviceId;
117
207
  return {
208
+ platform: "android",
209
+ device_name: deviceName,
210
+ model: model || deviceNameRaw.trim() || deviceId,
118
211
  device_width: width,
119
212
  device_height: height,
120
213
  scaled_width: scaledWidth,
@@ -123,7 +216,7 @@ export async function getDeviceInfo(deviceId) {
123
216
  };
124
217
  }
125
218
  export async function getScreenshotAsBase64(deviceId, deviceInfo) {
126
- const adb = spawn("adb", ["-s", deviceId, "exec-out", "screencap", "-p"]);
219
+ const adb = spawnAdb(["-s", deviceId, "exec-out", "screencap", "-p"]);
127
220
  const chunks = [];
128
221
  const stderrChunks = [];
129
222
  adb.stdout.on("data", chunk => chunks.push(chunk));
@@ -135,12 +228,14 @@ export async function getScreenshotAsBase64(deviceId, deviceInfo) {
135
228
  if (code !== 0) {
136
229
  const stderrOutput = Buffer.concat(stderrChunks).toString();
137
230
  logger.error(`ADB screencap failed with code ${code}`, { stderr: stderrOutput });
231
+ emitDesktopDebug("device.error", "device", {}, { platform: "android", operation: "screenshot", deviceId, message: `adb screencap exited with code ${code}` });
138
232
  throw new Error(`adb screencap exited with code ${code}`);
139
233
  }
140
234
  let buffer = Buffer.concat(chunks);
141
235
  logger.debug(`Screenshot captured: ${buffer.length} bytes before scaling`);
142
236
  if (buffer.length === 0) {
143
237
  logger.error('Screenshot buffer is empty!', { deviceId, chunks: chunks.length });
238
+ emitDesktopDebug("device.error", "device", {}, { platform: "android", operation: "screenshot", deviceId, message: "Screenshot capture returned empty buffer" });
144
239
  throw new Error('Screenshot capture returned empty buffer');
145
240
  }
146
241
  if (deviceInfo.scale < 1.0) {
@@ -150,5 +245,13 @@ export async function getScreenshotAsBase64(deviceId, deviceInfo) {
150
245
  .toBuffer();
151
246
  logger.debug(`Screenshot scaled: ${buffer.length} bytes after scaling`);
152
247
  }
153
- return buffer.toString("base64");
248
+ const base64 = buffer.toString("base64");
249
+ emitDesktopDebug("device.screenshot", "device", {}, {
250
+ platform: "android",
251
+ deviceId,
252
+ width: deviceInfo?.scaled_width,
253
+ height: deviceInfo?.scaled_height,
254
+ base64Length: base64.length
255
+ });
256
+ return base64;
154
257
  }
@@ -0,0 +1,21 @@
1
+ import { execFile, spawn } from "child_process";
2
+ import { promisify } from "util";
3
+ const execFileAsync = promisify(execFile);
4
+ export function getAdbCommand() {
5
+ return process.env.DROID_CUA_ANDROID_ADB_PATH?.trim() || "adb";
6
+ }
7
+ export function getEmulatorCommand() {
8
+ return process.env.DROID_CUA_ANDROID_EMULATOR_PATH?.trim() || "emulator";
9
+ }
10
+ export function execAdb(args, options = {}) {
11
+ return execFileAsync(getAdbCommand(), args, options);
12
+ }
13
+ export function execEmulator(args, options = {}) {
14
+ return execFileAsync(getEmulatorCommand(), args, options);
15
+ }
16
+ export function spawnAdb(args, options = {}) {
17
+ return spawn(getAdbCommand(), args, options);
18
+ }
19
+ export function spawnEmulator(args, options = {}) {
20
+ return spawn(getEmulatorCommand(), args, options);
21
+ }
@@ -54,12 +54,23 @@ export function extractFailureDetails(transcript) {
54
54
  const parts = recentTranscript.split("ASSERTION RESULT: FAIL");
55
55
  return parts[1]?.trim() || "Could not confidently validate the assertion.";
56
56
  }
57
- export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMode, context) {
57
+ export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMode, context, stepContext = null) {
58
58
  const details = extractFailureDetails(transcript);
59
59
  const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
60
- addOutput({ type: 'error', text: '❌ ASSERTION FAILED' });
61
- addOutput({ type: 'error', text: `Assertion: ${assertionPrompt}` });
62
- addOutput({ type: 'error', text: `Details: ${details}` });
60
+ const meta = {
61
+ eventType: 'assertion_result',
62
+ runId: context?.runId,
63
+ stepId: stepContext?.stepId,
64
+ instructionIndex: stepContext?.instructionIndex,
65
+ payload: {
66
+ assertion: assertionPrompt,
67
+ passed: false,
68
+ details
69
+ }
70
+ };
71
+ addOutput({ type: 'error', text: '❌ ASSERTION FAILED', ...meta });
72
+ addOutput({ type: 'error', text: `Assertion: ${assertionPrompt}`, ...meta });
73
+ addOutput({ type: 'error', text: `Details: ${details}`, ...meta });
63
74
  if (isHeadlessMode) {
64
75
  // Headless mode: exit with error code
65
76
  if (context?.exit) {
@@ -69,7 +80,18 @@ export function handleAssertionFailure(assertionPrompt, transcript, isHeadlessMo
69
80
  }
70
81
  // Interactive mode: caller should clear remaining instructions
71
82
  }
72
- export function handleAssertionSuccess(assertionPrompt, context = null) {
83
+ export function handleAssertionSuccess(assertionPrompt, context = null, stepContext = null) {
73
84
  const addOutput = context?.addOutput || ((item) => console.log(item.text || item));
74
- addOutput({ type: 'success', text: `✓ Assertion passed: ${assertionPrompt}` });
85
+ addOutput({
86
+ type: 'success',
87
+ text: `✓ Assertion passed: ${assertionPrompt}`,
88
+ eventType: 'assertion_result',
89
+ runId: context?.runId,
90
+ stepId: stepContext?.stepId,
91
+ instructionIndex: stepContext?.instructionIndex,
92
+ payload: {
93
+ assertion: assertionPrompt,
94
+ passed: true
95
+ }
96
+ });
75
97
  }
@@ -7,8 +7,8 @@
7
7
  import { getDeviceBackend, detectPlatform, setCurrentPlatform, getCurrentPlatform } from "./factory.js";
8
8
  let currentBackend = null;
9
9
  /**
10
- * Connect to a device (Android emulator or iOS simulator)
11
- * @param {string} deviceName - AVD name (Android) or Simulator name (iOS)
10
+ * Connect to a device (Android target ID/serial or iOS simulator name)
11
+ * @param {string} deviceName - Android device ID/serial or iOS simulator name
12
12
  * @param {string} platform - Optional platform override ('android' or 'ios')
13
13
  * @returns {Promise<string>} Device ID
14
14
  */
@@ -11,7 +11,7 @@ import * as iosActions from "./ios/actions.js";
11
11
  let currentPlatform = null;
12
12
  /**
13
13
  * Detect platform from device name or environment variable
14
- * @param {string} deviceName - The device/AVD/simulator name
14
+ * @param {string} deviceName - The device ID/name/simulator name
15
15
  * @returns {string} 'ios' or 'android'
16
16
  */
17
17
  export function detectPlatform(deviceName) {
@@ -11,6 +11,9 @@
11
11
  * @property {number} scaled_width - Width as seen by the model (after scaling)
12
12
  * @property {number} scaled_height - Height as seen by the model (after scaling)
13
13
  * @property {number} scale - Scale factor (scaled_width / device_width)
14
+ * @property {string} [platform] - Device platform ('android' or 'ios')
15
+ * @property {string} [device_name] - Human-readable device or simulator name
16
+ * @property {string} [model] - Device model identifier/name
14
17
  */
15
18
  /**
16
19
  * @typedef {Object} ActionContext
@@ -22,7 +25,7 @@
22
25
  * Required exports for a device backend:
23
26
  *
24
27
  * connectToDevice(deviceName: string): Promise<string>
25
- * - Connects to or launches the device/emulator/simulator
28
+ * - Connects to the selected device/simulator
26
29
  * - Returns a device ID for subsequent operations
27
30
  *
28
31
  * getDeviceInfo(deviceId: string): Promise<DeviceInfo>
@@ -37,13 +40,14 @@
37
40
  * handleModelAction(deviceId: string, action: object, scale: number, context: ActionContext): Promise<void>
38
41
  * - Executes an action from the CUA model
39
42
  * - Supported action types: click, type, scroll, drag, keypress, wait
43
+ * - keypress is limited to single mobile-safe keys only; desktop shortcut chords are unsupported
40
44
  */
41
45
  export const SUPPORTED_ACTIONS = [
42
46
  'click', // Tap at (x, y) coordinates
43
47
  'type', // Enter text
44
48
  'scroll', // Scroll by (scroll_x, scroll_y)
45
49
  'drag', // Drag from start to end via path
46
- 'keypress', // Press hardware keys (ESC/ESCAPE maps to home)
50
+ 'keypress', // Press a single mobile-safe key (ESC/ESCAPE maps to home)
47
51
  'wait', // Wait for UI to settle
48
52
  'screenshot' // Capture screen (handled by engine, not backend)
49
53
  ];