@loadmill/droid-cua 2.23.0 → 2.25.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 (36) hide show
  1. package/README.md +30 -0
  2. package/build/index.js +12 -0
  3. package/build/src/cli/headless-runner.js +2 -0
  4. package/build/src/cli/skill-command.js +240 -0
  5. package/build/src/commands/help.js +2 -1
  6. package/build/src/commands/run.js +5 -3
  7. package/build/src/core/cua-models.js +29 -9
  8. package/build/src/core/droid-cua-run-usage.js +16 -7
  9. package/build/src/core/execution-engine.js +7 -1
  10. package/build/src/core/runtime-config.js +2 -5
  11. package/build/src/core/session.js +2 -2
  12. package/build/src/core/tool-handlers/close-keyboard.js +103 -0
  13. package/build/src/device/actions.js +7 -0
  14. package/build/src/device/android/actions.js +16 -0
  15. package/build/src/device/cloud/actions.js +27 -0
  16. package/build/src/device/cloud/cloud-appium-client.js +15 -0
  17. package/build/src/device/connection.js +5 -2
  18. package/build/src/device/factory.js +3 -0
  19. package/build/src/device/interface.js +4 -0
  20. package/build/src/device/ios/actions.js +13 -0
  21. package/build/src/device/ios/appium-client.js +16 -0
  22. package/build/src/device/keyboard.js +119 -0
  23. package/build/src/device/openai.js +19 -1
  24. package/build/src/modes/design-mode-ink.js +1 -0
  25. package/build/src/modes/design-mode.js +1 -0
  26. package/build/src/modes/execution-mode.js +88 -16
  27. package/build/src/prompts/base.js +29 -4
  28. package/build/src/prompts/execution.js +14 -3
  29. package/package.json +2 -1
  30. package/skill/droid-cua/SKILL.md +39 -0
  31. package/skill/droid-cua/agents/openai.yaml +6 -0
  32. package/skill/droid-cua/assets/droid-cua-app-icon.png +0 -0
  33. package/skill/droid-cua/references/authoring-tests.md +39 -0
  34. package/skill/droid-cua/references/onboarding-and-troubleshooting.md +74 -0
  35. package/skill/droid-cua/references/project-and-auth.md +41 -0
  36. package/skill/droid-cua/references/running-and-healing.md +110 -0
package/README.md CHANGED
@@ -134,6 +134,32 @@ Install:
134
134
  npm install -g @loadmill/droid-cua
135
135
  ```
136
136
 
137
+ Install the bundled Agent Skill for your coding agent:
138
+
139
+ In the desktop app, choose **Help → Install Droid CUA Skill**, then select your
140
+ coding agent from the submenu. The app installs the skill globally for your user account.
141
+
142
+ Alternatively, install it from the CLI:
143
+
144
+ ```sh
145
+ # Global installation (default)
146
+ droid-cua skill install cursor
147
+ droid-cua skill install claude-code
148
+ droid-cua skill install codex
149
+
150
+ # Or install in the current project
151
+ droid-cua skill install codex --local
152
+ ```
153
+
154
+ Use `droid-cua skill status <agent>` to inspect an installation and
155
+ `droid-cua skill uninstall <agent>` to remove it. Installed skill
156
+ files are never overwritten or removed after local modification unless `--force`
157
+ is supplied. After upgrading the npm package, update an installed skill explicitly:
158
+
159
+ ```sh
160
+ droid-cua skill install <agent> --force
161
+ ```
162
+
137
163
  Examples:
138
164
  ```sh
139
165
  # Interactive CLI
@@ -230,6 +256,10 @@ Supported CLI options include:
230
256
  - `--report`
231
257
  - `--debug`
232
258
 
259
+ `--cua-model` accepts `loadmill-smart`, `loadmill-beacon`, `gpt-5.6-terra`,
260
+ `gpt-5.6-luna`, and `gpt-5.4`. OpenAI models use direct OpenAI mode and require
261
+ `OPENAI_API_KEY`.
262
+
233
263
  Config and precedence rules:
234
264
  - Use `--config <file>` to supply prompt-affecting settings for headless runs.
235
265
  - CLI flags override config file values.
package/build/index.js CHANGED
@@ -29,8 +29,20 @@ import { createStatusLine } from "./src/cli/cloud-status-line.js";
29
29
  import { createLoadmillApiClient } from "./src/integrations/loadmill/client.js";
30
30
  import { getCloudProviderAdapter } from "./src/device/cloud/registry.js";
31
31
  import { setCloudProgressListener } from "./src/device/cloud/progress.js";
32
+ import { runSkillCommand } from "./src/cli/skill-command.js";
32
33
  dotenv.config();
33
34
  const args = minimist(process.argv.slice(2));
35
+ if (args._[0] === "skill") {
36
+ try {
37
+ await runSkillCommand({ args });
38
+ process.exit(0);
39
+ }
40
+ catch (error) {
41
+ const message = error instanceof Error ? error.message : String(error);
42
+ console.error(`\nSkill command failed: ${message}`);
43
+ process.exit(1);
44
+ }
45
+ }
34
46
  const targetConfig = resolveHeadlessTarget(args);
35
47
  let avdName = targetConfig.avdName;
36
48
  let platform = targetConfig.platform || null; // 'ios' or 'android'
@@ -86,6 +86,7 @@ function buildHeadlessEngineOptions({ runtimeContext, headlessConfig, executionP
86
86
  toolCapabilities: {
87
87
  assert: true,
88
88
  navigate: isBrowserTarget,
89
+ closeKeyboard: !isBrowserTarget,
89
90
  loadmill: true,
90
91
  },
91
92
  secretsByKey: secrets?.secretsByKey || {},
@@ -174,6 +175,7 @@ async function runHeadlessTest({ runId, testPath, testName, instructions, sessio
174
175
  aimAssistMode: headlessConfig.aimAssistMode,
175
176
  testDataContext,
176
177
  availableSecretKeys: secrets?.availableSecretKeys || [],
178
+ cuaModel: runtimeContext.config.cuaModel,
177
179
  });
178
180
  session.setSystemPrompt(executionPrompt);
179
181
  const screenshotRecorder = headlessDebug.createExecutionScreenshotRecorder({
@@ -0,0 +1,240 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { cp, lstat, mkdir, readdir, readFile, rm } from "node:fs/promises";
5
+ const SKILL_NAME = "droid-cua";
6
+ const AGENTS = Object.freeze({
7
+ cursor: { displayName: "Cursor", relativePath: path.join(".cursor", "skills", SKILL_NAME) },
8
+ "claude-code": { displayName: "Claude Code", relativePath: path.join(".claude", "skills", SKILL_NAME) },
9
+ codex: { displayName: "Codex", relativePath: path.join(".agents", "skills", SKILL_NAME) },
10
+ });
11
+ const SUBCOMMANDS = new Set(["install", "status", "uninstall"]);
12
+ const ALLOWED_OPTIONS = new Set(["_", "local", "force", "h", "help"]);
13
+ const COMMAND_DESCRIPTIONS = Object.freeze({
14
+ install: "Install Droid CUA skill for a coding agent",
15
+ status: "Show Droid CUA skill status for a coding agent",
16
+ uninstall: "Uninstall Droid CUA skill for a coding agent",
17
+ });
18
+ function skillCommandHelp(subcommand) {
19
+ const localDescriptions = {
20
+ install: "Install in current project instead of global",
21
+ status: "Check current project instead of global",
22
+ uninstall: "Uninstall from current project instead of global",
23
+ };
24
+ const options = [
25
+ ` --local ${localDescriptions[subcommand]}`,
26
+ ];
27
+ if (subcommand !== "status") {
28
+ options.push(" --force Overwrite or remove modified skill files");
29
+ }
30
+ options.push(" -h, --help display help for command");
31
+ return [
32
+ `Usage: droid-cua skill ${subcommand} [options] [agent]`,
33
+ "",
34
+ COMMAND_DESCRIPTIONS[subcommand],
35
+ "",
36
+ "Agents:",
37
+ " cursor",
38
+ " claude-code",
39
+ " codex",
40
+ "",
41
+ "Example:",
42
+ ` droid-cua skill ${subcommand} claude-code`,
43
+ "",
44
+ "Options:",
45
+ ...options,
46
+ ].join("\n");
47
+ }
48
+ function skillRootHelp() {
49
+ return [
50
+ "Usage: droid-cua skill <command> [options] [agent]",
51
+ "",
52
+ "Manage the bundled Droid CUA skill",
53
+ "",
54
+ "Commands:",
55
+ " install Install the skill",
56
+ " status Show installation status",
57
+ " uninstall Uninstall the skill",
58
+ ].join("\n");
59
+ }
60
+ async function pathType(targetPath) {
61
+ try {
62
+ const stats = await lstat(targetPath);
63
+ if (stats.isDirectory())
64
+ return "directory";
65
+ if (stats.isFile())
66
+ return "file";
67
+ if (stats.isSymbolicLink())
68
+ return "symlink";
69
+ return "other";
70
+ }
71
+ catch (error) {
72
+ if (error?.code === "ENOENT")
73
+ return "missing";
74
+ throw error;
75
+ }
76
+ }
77
+ async function snapshotDirectory(rootPath, relativePath = "") {
78
+ const absolutePath = relativePath ? path.join(rootPath, relativePath) : rootPath;
79
+ const entries = await readdir(absolutePath, { withFileTypes: true });
80
+ const snapshot = [];
81
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
82
+ const childRelativePath = relativePath ? path.join(relativePath, entry.name) : entry.name;
83
+ if (entry.isDirectory()) {
84
+ snapshot.push({ path: `${childRelativePath}/`, type: "directory" });
85
+ snapshot.push(...await snapshotDirectory(rootPath, childRelativePath));
86
+ }
87
+ else if (entry.isFile()) {
88
+ const contents = await readFile(path.join(rootPath, childRelativePath));
89
+ snapshot.push({ path: childRelativePath, type: "file", contents: contents.toString("base64") });
90
+ }
91
+ else {
92
+ snapshot.push({ path: childRelativePath, type: entry.isSymbolicLink() ? "symlink" : "other" });
93
+ }
94
+ }
95
+ return snapshot;
96
+ }
97
+ async function directoryMatches(sourcePath, destinationPath) {
98
+ if (await pathType(destinationPath) !== "directory")
99
+ return false;
100
+ const [sourceSnapshot, destinationSnapshot] = await Promise.all([
101
+ snapshotDirectory(sourcePath),
102
+ snapshotDirectory(destinationPath),
103
+ ]);
104
+ return JSON.stringify(sourceSnapshot) === JSON.stringify(destinationSnapshot);
105
+ }
106
+ async function getInstallationState(sourcePath, destinationPath) {
107
+ if (await pathType(destinationPath) === "missing")
108
+ return "not-installed";
109
+ return await directoryMatches(sourcePath, destinationPath) ? "installed" : "modified";
110
+ }
111
+ function normalizeStringOption(value, label) {
112
+ if (typeof value !== "string" || value.trim().length === 0) {
113
+ throw new Error(`${label} requires a value.`);
114
+ }
115
+ return value.trim().toLowerCase();
116
+ }
117
+ function parseSkillCommandArgs(args) {
118
+ const unknownOption = Object.keys(args).find((key) => !ALLOWED_OPTIONS.has(key));
119
+ if (unknownOption)
120
+ throw new Error(`Unknown skill option --${unknownOption}.`);
121
+ if (!Array.isArray(args._) || args._[0] !== "skill") {
122
+ throw new Error("Usage: droid-cua skill <command> [options] [agent]");
123
+ }
124
+ if (args._.length === 1) {
125
+ return { help: skillRootHelp() };
126
+ }
127
+ const subcommand = normalizeStringOption(args._[1], "Skill subcommand");
128
+ if (!SUBCOMMANDS.has(subcommand)) {
129
+ throw new Error(`Unknown skill subcommand "${subcommand}". Expected install, status, or uninstall.`);
130
+ }
131
+ for (const option of ["local", "force", "h", "help"]) {
132
+ if (args[option] != null && typeof args[option] !== "boolean") {
133
+ throw new Error(`--${option === "h" ? "help" : option} does not accept a value.`);
134
+ }
135
+ }
136
+ if (args.help === true || args.h === true || args._.length === 2) {
137
+ return { help: skillCommandHelp(subcommand) };
138
+ }
139
+ if (args._.length !== 3)
140
+ throw new Error(skillCommandHelp(subcommand));
141
+ if (subcommand === "status" && args.force != null) {
142
+ throw new Error("--force is only supported by install and uninstall.");
143
+ }
144
+ const agent = normalizeStringOption(args._[2], "Agent");
145
+ if (!Object.hasOwn(AGENTS, agent)) {
146
+ throw new Error(`Unknown agent "${agent}". Expected cursor, claude-code, or codex.`);
147
+ }
148
+ return {
149
+ subcommand,
150
+ agent,
151
+ local: args.local === true,
152
+ force: args.force === true,
153
+ };
154
+ }
155
+ export function resolveSkillDestination({ agent, local = false, cwd = process.cwd(), home = os.homedir() }) {
156
+ const basePath = local ? cwd : home;
157
+ return {
158
+ agent,
159
+ ...AGENTS[agent],
160
+ path: path.join(basePath, AGENTS[agent].relativePath),
161
+ };
162
+ }
163
+ export async function resolveBundledSkillPath({ startDir = path.dirname(fileURLToPath(import.meta.url)) } = {}) {
164
+ let currentPath = path.resolve(startDir);
165
+ while (true) {
166
+ const candidate = path.join(currentPath, "skill", SKILL_NAME);
167
+ if (await pathType(path.join(candidate, "SKILL.md")) === "file")
168
+ return candidate;
169
+ const parentPath = path.dirname(currentPath);
170
+ if (parentPath === currentPath)
171
+ break;
172
+ currentPath = parentPath;
173
+ }
174
+ throw new Error("Bundled Droid CUA skill was not found. Reinstall @loadmill/droid-cua and try again.");
175
+ }
176
+ function stateLabel(state) {
177
+ if (state === "not-installed")
178
+ return "not installed";
179
+ return state;
180
+ }
181
+ function shouldUseColor() {
182
+ return Boolean(process.stdout.isTTY) && !Object.hasOwn(process.env, "NO_COLOR");
183
+ }
184
+ function formatInstallSuccess(destination, { alreadyInstalled, color }) {
185
+ const checkmark = color ? "\u001b[32m✓\u001b[39m" : "✓";
186
+ const skillFilePath = path.join(destination.path, "SKILL.md");
187
+ const displayedPath = color ? `\u001b[2m${skillFilePath}\u001b[22m` : skillFilePath;
188
+ const message = alreadyInstalled
189
+ ? `${destination.agent} skill already installed`
190
+ : `Installed ${destination.agent} skill`;
191
+ return `${checkmark} ${message}\n${displayedPath}`;
192
+ }
193
+ export async function runSkillCommand({ args, cwd = process.cwd(), home = os.homedir(), sourcePath = null, output = console, color = shouldUseColor(), } = {}) {
194
+ const command = parseSkillCommandArgs(args ?? {});
195
+ if (command.help) {
196
+ output.log(command.help);
197
+ return [];
198
+ }
199
+ const bundledSkillPath = sourcePath ?? await resolveBundledSkillPath();
200
+ if (await pathType(path.join(bundledSkillPath, "SKILL.md")) !== "file") {
201
+ throw new Error(`Invalid bundled skill at ${bundledSkillPath}: SKILL.md is missing.`);
202
+ }
203
+ const destination = resolveSkillDestination({ agent: command.agent, local: command.local, cwd, home });
204
+ const states = [{
205
+ ...destination,
206
+ state: await getInstallationState(bundledSkillPath, destination.path),
207
+ }];
208
+ if (command.subcommand !== "status" && !command.force) {
209
+ const conflicts = states.filter(({ state }) => state === "modified");
210
+ if (conflicts.length > 0) {
211
+ const paths = conflicts.map(({ path: destinationPath }) => destinationPath).join(", ");
212
+ throw new Error(`Refusing to ${command.subcommand} modified skill files at ${paths}. Re-run with --force to replace them.`);
213
+ }
214
+ }
215
+ for (const destination of states) {
216
+ if (command.subcommand === "status") {
217
+ output.log(`${destination.displayName}: ${stateLabel(destination.state)} at ${destination.path}`);
218
+ }
219
+ else if (command.subcommand === "install") {
220
+ if (destination.state === "installed") {
221
+ output.log(formatInstallSuccess(destination, { alreadyInstalled: true, color }));
222
+ }
223
+ else {
224
+ if (destination.state !== "not-installed")
225
+ await rm(destination.path, { recursive: true, force: true });
226
+ await mkdir(path.dirname(destination.path), { recursive: true });
227
+ await cp(bundledSkillPath, destination.path, { recursive: true });
228
+ output.log(formatInstallSuccess(destination, { alreadyInstalled: false, color }));
229
+ }
230
+ }
231
+ else if (destination.state === "not-installed") {
232
+ output.log(`${destination.displayName}: not installed at ${destination.path}`);
233
+ }
234
+ else {
235
+ await rm(destination.path, { recursive: true, force: true });
236
+ output.log(`${destination.displayName}: uninstalled from ${destination.path}`);
237
+ }
238
+ }
239
+ return states.map(({ agent, path: destinationPath, state }) => ({ agent, path: destinationPath, state }));
240
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Help command handler
3
3
  */
4
+ import { CUA_MODELS } from '../core/cua-models.js';
4
5
  /**
5
6
  * Handle /help command
6
7
  * @param {string} args - Command arguments
@@ -32,7 +33,7 @@ export async function handleHelp(args, session, context) {
32
33
  addOutput({ type: 'info', text: ' --session-mode <mode> Browser session mode for web runs: persistent or fresh' });
33
34
  addOutput({ type: 'info', text: ' --config <file> Headless execution JSON config for prompt parity' });
34
35
  addOutput({ type: 'info', text: ' --llm-provider <mode> Headless AI provider: loadmill or openai' });
35
- addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4, loadmill-smart, loadmill-beacon, or gpt-5.4-mini' });
36
+ addOutput({ type: 'info', text: ` --cua-model <model> Headless CUA model override: ${CUA_MODELS.join(', ')}` });
36
37
  addOutput({ type: 'info', text: ' --aim-assist <mode> Headless Aim Assist mode: off, smart, or always' });
37
38
  addOutput({ type: 'info', text: ' --context <file> Optional app context file injected into execution runs' });
38
39
  addOutput({ type: 'info', text: ' --no-context Disable app context for headless execution' });
@@ -67,8 +67,7 @@ export async function handleRun(args, session, context) {
67
67
  if (context.setAgentWorking) {
68
68
  context.setAgentWorking(true, 'Executing test...');
69
69
  }
70
- // Reset session state to prevent context leak from design mode
71
- // Each test instruction should execute in isolation
70
+ // Reset session state to prevent context leak from design mode.
72
71
  session.updateResponseId(undefined);
73
72
  session.clearMessages();
74
73
  const appContext = await resolveExecutionAppContext({ contextPath: context.appContextPath });
@@ -89,7 +88,10 @@ export async function handleRun(args, session, context) {
89
88
  }
90
89
  // Set execution mode system prompt (replaces any design mode prompt)
91
90
  const availableSecretKeys = context.engine.getAvailableSecretKeys();
92
- const executionPrompt = buildExecutionModePrompt(session.deviceInfo, {}, appContextText, { availableSecretKeys });
91
+ const executionPrompt = buildExecutionModePrompt(session.deviceInfo, {}, appContextText, {
92
+ availableSecretKeys,
93
+ cuaModel: context.runtimeConfig?.cuaModel,
94
+ });
93
95
  session.setSystemPrompt(executionPrompt);
94
96
  // Create execution mode
95
97
  const executionMode = new ExecutionMode(session, context.engine, instructions);
@@ -1,9 +1,10 @@
1
1
  export const DEFAULT_OPENAI_CUA_MODEL = "gpt-5.4";
2
2
  export const DEFAULT_LOADMILL_CUA_MODEL = "loadmill-smart";
3
3
  export const DEFAULT_CUA_MODEL = DEFAULT_LOADMILL_CUA_MODEL;
4
- export const OPENAI_CUA_MODELS = ["gpt-5.4", "gpt-5.4-mini"];
5
- export const LOADMILL_CUA_MODELS = [DEFAULT_LOADMILL_CUA_MODEL, "loadmill-beacon"];
6
- export const CUA_MODELS = ["gpt-5.4", "loadmill-smart", "loadmill-beacon", "gpt-5.4-mini"];
4
+ export const CUA_CONVERSATION_POLICIES = Object.freeze({
5
+ CHAINED: "chained",
6
+ ISOLATED: "isolated",
7
+ });
7
8
  export const CUA_MODEL_OPTIONS = Object.freeze([
8
9
  Object.freeze({
9
10
  id: "loadmill-smart",
@@ -20,20 +21,34 @@ export const CUA_MODEL_OPTIONS = Object.freeze([
20
21
  label: "Loadmill - Beacon",
21
22
  }),
22
23
  Object.freeze({
23
- id: "gpt-5.4",
24
+ id: "gpt-5.6-terra",
24
25
  providerMode: "openai",
25
26
  providerLabel: "OpenAI",
26
- name: "GPT-5.4",
27
- label: "OpenAI GPT-5.4",
27
+ name: "GPT-5.6 Terra",
28
+ label: "OpenAI GPT-5.6 Terra",
28
29
  }),
29
30
  Object.freeze({
30
- id: "gpt-5.4-mini",
31
+ id: "gpt-5.6-luna",
31
32
  providerMode: "openai",
32
33
  providerLabel: "OpenAI",
33
- name: "GPT-5.4 mini",
34
- label: "OpenAI GPT-5.4 mini (faster)",
34
+ name: "GPT-5.6 Luna",
35
+ label: "OpenAI GPT-5.6 Luna",
36
+ }),
37
+ Object.freeze({
38
+ id: "gpt-5.4",
39
+ providerMode: "openai",
40
+ providerLabel: "OpenAI",
41
+ name: "GPT-5.4",
42
+ label: "OpenAI GPT-5.4",
35
43
  }),
36
44
  ]);
45
+ export const CUA_MODELS = Object.freeze(CUA_MODEL_OPTIONS.map((option) => option.id));
46
+ export const OPENAI_CUA_MODELS = Object.freeze(CUA_MODEL_OPTIONS
47
+ .filter((option) => option.providerMode === "openai")
48
+ .map((option) => option.id));
49
+ export const LOADMILL_CUA_MODELS = Object.freeze(CUA_MODEL_OPTIONS
50
+ .filter((option) => option.providerMode === "loadmill")
51
+ .map((option) => option.id));
37
52
  const VALID_CUA_MODELS = new Set(CUA_MODELS);
38
53
  const OPENAI_CUA_MODEL_SET = new Set(OPENAI_CUA_MODELS);
39
54
  const LOADMILL_CUA_MODEL_SET = new Set(LOADMILL_CUA_MODELS);
@@ -59,3 +74,8 @@ export function resolveDefaultCuaModelForProvider(providerMode) {
59
74
  ? DEFAULT_LOADMILL_CUA_MODEL
60
75
  : DEFAULT_OPENAI_CUA_MODEL;
61
76
  }
77
+ export function resolveConversationPolicy(cuaModel) {
78
+ return cuaModel === "loadmill-beacon" || cuaModel === "beacon"
79
+ ? CUA_CONVERSATION_POLICIES.ISOLATED
80
+ : CUA_CONVERSATION_POLICIES.CHAINED;
81
+ }
@@ -2,16 +2,22 @@ const TOKENS_PER_MILLION = 1_000_000;
2
2
  const OPENAI_GPT_5_4_PRICING = Object.freeze({
3
3
  inputPerMillionUsd: 2.5,
4
4
  });
5
- const OPENAI_GPT_5_4_MINI_PRICING = Object.freeze({
6
- inputPerMillionUsd: 0.75,
5
+ const OPENAI_GPT_5_6_TERRA_PRICING = Object.freeze({
6
+ inputPerMillionUsd: 2.5,
7
+ });
8
+ const OPENAI_GPT_5_6_LUNA_PRICING = Object.freeze({
9
+ inputPerMillionUsd: 1,
7
10
  });
8
11
  const MODEL_PRICING = Object.freeze({
12
+ "gpt-5.6-terra": Object.freeze({
13
+ ...OPENAI_GPT_5_6_TERRA_PRICING,
14
+ }),
15
+ "gpt-5.6-luna": Object.freeze({
16
+ ...OPENAI_GPT_5_6_LUNA_PRICING,
17
+ }),
9
18
  "gpt-5.4": Object.freeze({
10
19
  ...OPENAI_GPT_5_4_PRICING,
11
20
  }),
12
- "gpt-5.4-mini": Object.freeze({
13
- ...OPENAI_GPT_5_4_MINI_PRICING,
14
- }),
15
21
  // Loadmill Smart currently routes to GPT-5.4 behind the Loadmill LLM gateway.
16
22
  // Keep this explicit so pricing can be updated here if the backing model changes.
17
23
  "loadmill-smart": Object.freeze({
@@ -32,8 +38,11 @@ export function normalizeDroidCuaCostModel(model) {
32
38
  if (typeof model !== "string") {
33
39
  return null;
34
40
  }
35
- if (model === "gpt-5.4-mini" || model.startsWith("gpt-5.4-mini-")) {
36
- return "gpt-5.4-mini";
41
+ if (model === "gpt-5.6-terra" || model.startsWith("gpt-5.6-terra-")) {
42
+ return "gpt-5.6-terra";
43
+ }
44
+ if (model === "gpt-5.6-luna" || model.startsWith("gpt-5.6-luna-")) {
45
+ return "gpt-5.6-luna";
37
46
  }
38
47
  if (model === "gpt-5.4" || model.startsWith("gpt-5.4-")) {
39
48
  return "gpt-5.4";
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { getScreenshotAsBase64, getCurrentPlatform } from "../device/connection.js";
3
3
  import { closeCurrentTab as closeCurrentBrowserTab, getBrowserInfo, navigateToUrl as navigateBrowserToUrl } from "../browser/connection.js";
4
- import { handleModelAction } from "../device/actions.js";
4
+ import { closeKeyboard, handleModelAction } from "../device/actions.js";
5
5
  import { sendCUARequest } from "../device/openai.js";
6
6
  import { executeLoadmillInstruction } from "../device/loadmill.js";
7
7
  import { emitDesktopDebug } from "../utils/desktop-debug.js";
@@ -14,6 +14,7 @@ import { ScreenshotStore } from "./screenshot-store.js";
14
14
  import { handlePreviewClick } from "./tool-handlers/preview-click.js";
15
15
  import { handleNavigateToolCall } from "./tool-handlers/navigate.js";
16
16
  import { handleCloseCurrentTabToolCall } from "./tool-handlers/close-current-tab.js";
17
+ import { handleCloseKeyboardToolCall } from "./tool-handlers/close-keyboard.js";
17
18
  import { handleAssertionToolCall } from "./tool-handlers/assert.js";
18
19
  import { handleLoadmillToolCall } from "./tool-handlers/loadmill.js";
19
20
  import { handleTestDesignCompleteToolCall } from "./tool-handlers/test-design-complete.js";
@@ -176,6 +177,8 @@ function formatActionForRecovery(action) {
176
177
  }
177
178
  case "scroll":
178
179
  return "Scrolled";
180
+ case "close_keyboard":
181
+ return "Closed the keyboard";
179
182
  default:
180
183
  return null;
181
184
  }
@@ -240,6 +243,7 @@ export class ExecutionEngine {
240
243
  this.stepDelayMs = options.stepDelayMs ?? getConfiguredStepDelayMs();
241
244
  this.getScreenshotAsBase64 = options.getScreenshotAsBase64 || getScreenshotAsBase64;
242
245
  this.handleModelAction = options.handleModelAction || handleModelAction;
246
+ this.closeKeyboard = options.closeKeyboard || closeKeyboard;
243
247
  this.sendCUARequest = options.sendCUARequest || sendCUARequest;
244
248
  this.reviseTestScript = options.reviseTestScript || null;
245
249
  this.getCurrentPlatform = options.getCurrentPlatform || getCurrentPlatform;
@@ -323,6 +327,8 @@ export class ExecutionEngine {
323
327
  return await handleNavigateToolCall(handlerArgs);
324
328
  case "close_current_tab":
325
329
  return await handleCloseCurrentTabToolCall(handlerArgs);
330
+ case "close_keyboard":
331
+ return await handleCloseKeyboardToolCall(handlerArgs);
326
332
  case "loadmill":
327
333
  return await handleLoadmillToolCall(handlerArgs);
328
334
  case "type_sensitive_text":
@@ -90,11 +90,8 @@ export function inferLlmProviderModeFromCredentials(credentials = {}) {
90
90
  }
91
91
  export function resolveGatewayCuaModelAlias({ cuaModel } = {}) {
92
92
  const normalizedCuaModel = resolveCuaModel(cuaModel);
93
- if (normalizedCuaModel === "loadmill-beacon") {
94
- return LOADMILL_MODEL_ALIASES.BEACON;
95
- }
96
- return normalizedCuaModel === "gpt-5.4-mini"
97
- ? LOADMILL_MODEL_ALIASES.FAST
93
+ return normalizedCuaModel === "loadmill-beacon"
94
+ ? LOADMILL_MODEL_ALIASES.BEACON
98
95
  : LOADMILL_MODEL_ALIASES.SMART;
99
96
  }
100
97
  export function resolveEffectiveCuaModel({ llmProviderMode = null, cuaModel = null, } = {}) {
@@ -45,7 +45,7 @@ export class Session {
45
45
  this.messages = [{ role: "system", content: prompt }];
46
46
  }
47
47
  /**
48
- * Add a line to the transcript (for error recovery and logging)
48
+ * Add a line to the scenario transcript for assertions, recovery, and logging.
49
49
  */
50
50
  addToTranscript(line) {
51
51
  this.transcript.push(line);
@@ -57,7 +57,7 @@ export class Session {
57
57
  return this.transcript.join("\n");
58
58
  }
59
59
  /**
60
- * Clear the per-instruction transcript.
60
+ * Clear the scenario transcript at a run boundary.
61
61
  */
62
62
  clearTranscript() {
63
63
  this.transcript = [];
@@ -0,0 +1,103 @@
1
+ import { CAPTURE_SOURCES } from "../capture-sources.js";
2
+ import { KEYBOARD_CLOSE_RESULTS, buildKeyboardCloseResult } from "../../device/keyboard.js";
3
+ import { printCliOutput } from "../../utils/console-output.js";
4
+ import { emitDesktopDebug } from "../../utils/desktop-debug.js";
5
+ import { buildFunctionCallOutputItem, buildToolOutputContent, buildTextToolOutputItem, isDesignContext, parseJsonArguments, } from "./utils.js";
6
+ function parseDismissalPoint(argumentsText, deviceInfo = {}) {
7
+ const parsed = parseJsonArguments(argumentsText || "{}");
8
+ if (!parsed.ok || !parsed.value || typeof parsed.value !== "object" || Array.isArray(parsed.value)) {
9
+ return { ok: false, message: "Invalid close_keyboard arguments: expected a JSON object." };
10
+ }
11
+ const hasX = Object.prototype.hasOwnProperty.call(parsed.value, "x");
12
+ const hasY = Object.prototype.hasOwnProperty.call(parsed.value, "y");
13
+ if (!hasX && !hasY) {
14
+ return { ok: true, value: null };
15
+ }
16
+ if (!hasX || !hasY || !Number.isFinite(parsed.value.x) || !Number.isFinite(parsed.value.y)) {
17
+ return { ok: false, message: "Invalid close_keyboard arguments: x and y must both be finite numbers." };
18
+ }
19
+ const point = { x: parsed.value.x, y: parsed.value.y };
20
+ const width = Number(deviceInfo.scaled_width);
21
+ const height = Number(deviceInfo.scaled_height);
22
+ // We can validate coordinate shape and bounds, but only the model can judge
23
+ // whether a cloud-iOS point is a safe dismissal target in the current UI.
24
+ if (point.x < 0 || point.y < 0 ||
25
+ (Number.isFinite(width) && point.x >= width) ||
26
+ (Number.isFinite(height) && point.y >= height)) {
27
+ return { ok: false, message: "Invalid close_keyboard arguments: the dismissal point is outside the current screenshot." };
28
+ }
29
+ return { ok: true, value: point };
30
+ }
31
+ export async function handleCloseKeyboardToolCall({ engine, callId, argumentsText, context = null, stepContext = null }) {
32
+ const session = engine.session;
33
+ if (session?.targetKind === "browser" || session?.deviceInfo?.platform === "browser") {
34
+ return {
35
+ kind: "continue",
36
+ outputItem: buildFunctionCallOutputItem(callId, buildToolOutputContent({ text: "close_keyboard is only supported for mobile runs." })),
37
+ };
38
+ }
39
+ const parsedPoint = parseDismissalPoint(argumentsText, session.deviceInfo);
40
+ if (!parsedPoint.ok) {
41
+ return {
42
+ kind: "continue",
43
+ outputItem: buildTextToolOutputItem(callId, parsedPoint.message),
44
+ };
45
+ }
46
+ const dismissalPoint = parsedPoint.value;
47
+ let result;
48
+ try {
49
+ result = await engine.closeKeyboard(session.deviceId, {
50
+ dismissalPoint,
51
+ scale: session.deviceInfo?.scale || 1,
52
+ });
53
+ }
54
+ catch (error) {
55
+ result = buildKeyboardCloseResult(KEYBOARD_CLOSE_RESULTS.FAILED, error);
56
+ }
57
+ const addOutput = context?.addOutput || printCliOutput;
58
+ const outputText = result.status === KEYBOARD_CLOSE_RESULTS.FAILED
59
+ ? `${result.message}. The preceding text entry was unaffected.`
60
+ : result.message;
61
+ const scope = isDesignContext(context) ? "design" : "execution";
62
+ const ids = {
63
+ ...(scope === "design" ? { sessionId: context?.sessionId } : { runId: context?.runId }),
64
+ ...(stepContext?.stepId ? { stepId: stepContext.stepId } : {}),
65
+ ...(Number.isInteger(stepContext?.instructionIndex)
66
+ ? { instructionIndex: stepContext.instructionIndex }
67
+ : {}),
68
+ };
69
+ const payload = {
70
+ platform: session.deviceInfo?.platform,
71
+ toolName: "close_keyboard",
72
+ callId,
73
+ status: result.status,
74
+ failed: result.status === KEYBOARD_CLOSE_RESULTS.FAILED,
75
+ ...(dismissalPoint ? { dismissalPoint } : {}),
76
+ ...(result.error ? { error: result.error } : {}),
77
+ };
78
+ emitDesktopDebug("tool_call", scope, ids, payload);
79
+ addOutput({
80
+ type: "action",
81
+ text: result.message,
82
+ eventType: "tool_call",
83
+ runId: context?.runId,
84
+ stepId: stepContext?.stepId,
85
+ instructionIndex: stepContext?.instructionIndex,
86
+ payload,
87
+ });
88
+ session.addToTranscript(`[Close keyboard] ${outputText}`);
89
+ const screenshotBase64 = await engine.getScreenshotAsBase64(session.deviceId, session.deviceInfo);
90
+ const currentScreenshot = await engine.recordScreenshot(screenshotBase64, {
91
+ runId: context?.runId,
92
+ sessionId: context?.sessionId,
93
+ stepId: stepContext?.stepId,
94
+ instructionIndex: stepContext?.instructionIndex,
95
+ captureSource: CAPTURE_SOURCES.CALL_OUTPUT_POST_ACTION,
96
+ });
97
+ return {
98
+ kind: "continue",
99
+ outputItem: buildFunctionCallOutputItem(callId, buildToolOutputContent({ text: outputText, imageBase64: screenshotBase64 })),
100
+ currentScreenshot,
101
+ executedActions: [{ type: "close_keyboard", ...(dismissalPoint || {}) }],
102
+ };
103
+ }
@@ -20,3 +20,10 @@ export async function handleModelAction(deviceId, action, scale = 1.0, context =
20
20
  const backend = getDeviceBackend(platform);
21
21
  return backend.handleModelAction(deviceId, action, scale, context);
22
22
  }
23
+ export async function closeKeyboard(deviceId, options = {}) {
24
+ const platform = getCurrentPlatform();
25
+ if (!platform) {
26
+ throw new Error("No platform set. Call connectToDevice first.");
27
+ }
28
+ return getDeviceBackend(platform).closeKeyboard(deviceId, options);
29
+ }