@loadmill/droid-cua 2.2.2 → 2.4.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 (32) hide show
  1. package/README.md +69 -0
  2. package/build/index.js +177 -24
  3. package/build/src/cli/headless-debug.js +55 -0
  4. package/build/src/cli/headless-execution-config.js +203 -0
  5. package/build/src/cli/ink-shell.js +8 -2
  6. package/build/src/commands/help.js +13 -1
  7. package/build/src/commands/run.js +30 -1
  8. package/build/src/core/app-context.js +57 -0
  9. package/build/src/core/execution-engine.js +151 -20
  10. package/build/src/core/prompts.js +3 -247
  11. package/build/src/device/android/actions.js +2 -2
  12. package/build/src/device/assertions.js +4 -23
  13. package/build/src/device/cloud/browserstack/adapter.js +1 -0
  14. package/build/src/device/cloud/lambdatest/adapter.js +402 -0
  15. package/build/src/device/cloud/registry.js +2 -1
  16. package/build/src/device/interface.js +1 -1
  17. package/build/src/device/ios/actions.js +8 -2
  18. package/build/src/device/loadmill.js +4 -3
  19. package/build/src/device/openai.js +32 -26
  20. package/build/src/integrations/loadmill/interpreter.js +3 -56
  21. package/build/src/modes/design-mode-ink.js +12 -17
  22. package/build/src/modes/design-mode.js +12 -17
  23. package/build/src/modes/execution-mode.js +32 -22
  24. package/build/src/prompts/base.js +139 -0
  25. package/build/src/prompts/design.js +115 -0
  26. package/build/src/prompts/editor.js +19 -0
  27. package/build/src/prompts/execution.js +182 -0
  28. package/build/src/prompts/loadmill.js +60 -0
  29. package/build/src/utils/console-output.js +35 -0
  30. package/build/src/utils/run-screenshot-recorder.js +98 -0
  31. package/build/src/utils/structured-debug-log-manager.js +325 -0
  32. package/package.json +2 -1
package/README.md CHANGED
@@ -119,6 +119,11 @@ For CI, scripting, or advanced workflows, `droid-cua` also includes a CLI for ru
119
119
 
120
120
  Desktop projects can also keep run reports in a results folder, including JUnit XML output that the app can read back as project history.
121
121
 
122
+ The recommended workflow is:
123
+ - design and debug tests in the desktop app,
124
+ - commit the `.dcua` file plus a headless CLI config file,
125
+ - run the same test headlessly in CI with `--config` for prompt parity.
126
+
122
127
  Install:
123
128
  ```sh
124
129
  npm install -g @loadmill/droid-cua
@@ -134,15 +139,79 @@ droid-cua --avd adb:emulator-5554 --instructions tests/login.dcua
134
139
 
135
140
  # Headless iOS simulator run
136
141
  droid-cua --platform ios --avd "iPhone 16" --instructions tests/login.dcua
142
+
143
+ # Headless run with prompt-parity config
144
+ droid-cua --avd adb:emulator-5554 --instructions tests/login.dcua --config ci/droid-cua.json
145
+ ```
146
+
147
+ Example headless config:
148
+ ```json
149
+ {
150
+ "cuaModel": "gpt-5.4",
151
+ "promptCustomizations": {
152
+ "basePromptInstructions": "",
153
+ "designModeInstructions": "",
154
+ "executionModeInstructions": ""
155
+ },
156
+ "appContextEnabled": true,
157
+ "appContextBudget": 300,
158
+ "appContextPath": "../tests/context.md"
159
+ }
160
+ ```
161
+
162
+ Typical CI-style usage:
163
+ ```sh
164
+ droid-cua \
165
+ --avd adb:emulator-5554 \
166
+ --instructions tests/login.dcua \
167
+ --config ci/droid-cua.json \
168
+ --debug
137
169
  ```
138
170
 
139
171
  Supported CLI options include:
140
172
  - `--avd`
141
173
  - `--platform`
142
174
  - `--instructions`
175
+ - `--config`
176
+ - `--cua-model`
177
+ - `--context`
178
+ - `--app-context-budget`
179
+ - `--no-context`
180
+ - `--base-prompt`
181
+ - `--execution-prompt`
182
+ - `--base-prompt-file`
183
+ - `--execution-prompt-file`
143
184
  - `--record`
144
185
  - `--debug`
145
186
 
187
+ Config and precedence rules:
188
+ - Use `--config <file>` to supply prompt-affecting settings for headless runs.
189
+ - CLI flags override config file values.
190
+ - `--context` overrides the config app-context path.
191
+ - `--no-context` disables app context entirely.
192
+ - `--base-prompt` and `--execution-prompt` let you pass prompt customizations inline on the command line.
193
+ - `--base-prompt-file` and `--execution-prompt-file` override the corresponding prompt customizations from config.
194
+ - If both inline and file-based prompt overrides are provided, the inline prompt flags win.
195
+
196
+ Example without a config file:
197
+ ```sh
198
+ droid-cua \
199
+ --avd adb:emulator-5554 \
200
+ --instructions tests/login.dcua \
201
+ --context tests/context.md \
202
+ --base-prompt "stop and look at the screen after every action you take."
203
+ ```
204
+
205
+ Headless debug artifacts:
206
+ - `--debug` writes desktop-style structured JSONL artifacts under `logs/`.
207
+ - Each run creates `logs/execution-<runId>-<timestamp>.jsonl`.
208
+ - Each run also creates a sibling screenshot folder next to that JSONL file.
209
+ - Shared device events are written to `logs/device-events.jsonl`.
210
+ - `--debug` no longer creates the legacy `logs/debug-*.log` file for headless runs.
211
+ - If `--debug` and `--record` are both used, screenshots are written to both the debug artifacts folder and the legacy `droid-cua-recording-<timestamp>` folder.
212
+
213
+ Current headless behavior is documented in [docs/headless-cli-spec.md](docs/headless-cli-spec.md).
214
+
146
215
  ---
147
216
 
148
217
  <h2 id="license">📄 License</h2>
package/build/index.js CHANGED
@@ -5,24 +5,43 @@ import { mkdir, readFile } from "fs/promises";
5
5
  import { connectToDevice, getDeviceInfo } from "./src/device/connection.js";
6
6
  import { Session } from "./src/core/session.js";
7
7
  import { ExecutionEngine } from "./src/core/execution-engine.js";
8
- import { buildBaseSystemPrompt } from "./src/core/prompts.js";
8
+ import { buildBaseSystemPrompt, buildExecutionModePrompt } from "./src/core/prompts.js";
9
9
  import { startInkShell } from "./src/cli/ink-shell.js";
10
10
  import { ExecutionMode } from "./src/modes/execution-mode.js";
11
11
  import { logger } from "./src/utils/logger.js";
12
12
  import { selectDevice } from "./src/cli/device-selector.js";
13
+ import { buildAppContextBriefing, DEFAULT_APP_CONTEXT_BUDGET } from "./src/core/app-context.js";
14
+ import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
15
+ import { printCliOutput } from "./src/utils/console-output.js";
16
+ import { emitDesktopDebug } from "./src/utils/desktop-debug.js";
17
+ import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
13
18
  dotenv.config();
14
19
  const args = minimist(process.argv.slice(2));
15
20
  let avdName = args["avd"];
16
21
  let platform = args["platform"] || null; // 'ios' or 'android'
17
22
  const recordScreenshots = args["record"] || false;
18
23
  const instructionsFile = args.instructions || args.i || null;
24
+ const appContextPath = typeof args.context === "string" ? args.context : null;
19
25
  const debugMode = args["debug"] || false;
20
- // Initialize debug logging
21
- await logger.init(debugMode);
26
+ const strictFlag = args.strict === true;
27
+ const noStrictFlag = args["no-strict"] === true || args.strict === false;
28
+ if (strictFlag && noStrictFlag) {
29
+ throw new Error("--strict and --no-strict cannot be used together.");
30
+ }
22
31
  const screenshotDir = path.join("droid-cua-recording-" + Date.now());
23
32
  if (recordScreenshots)
24
33
  await mkdir(screenshotDir, { recursive: true });
25
34
  async function main() {
35
+ const isHeadlessInstructionsRun = Boolean(instructionsFile);
36
+ const headlessDebug = createHeadlessDebugArtifacts({
37
+ cwd: process.cwd(),
38
+ enabled: isHeadlessInstructionsRun && debugMode
39
+ });
40
+ if (isHeadlessInstructionsRun && debugMode) {
41
+ await headlessDebug.init();
42
+ }
43
+ // Initialize legacy plain-text debug logging only for non-headless flows.
44
+ await logger.init(debugMode && !isHeadlessInstructionsRun);
26
45
  // If no device specified, show interactive selection menu
27
46
  if (!avdName && !platform) {
28
47
  const selection = await selectDevice();
@@ -38,31 +57,165 @@ async function main() {
38
57
  const session = new Session(deviceId, deviceInfo);
39
58
  const initialSystemText = buildBaseSystemPrompt(deviceInfo);
40
59
  session.setSystemPrompt(initialSystemText);
41
- // Create execution engine
42
- const engine = new ExecutionEngine(session, {
43
- recordScreenshots,
44
- screenshotDir,
45
- });
46
60
  // If --instructions provided, run in headless mode
47
61
  if (instructionsFile) {
48
- console.log(`\nRunning test from: ${instructionsFile}\n`);
49
- // Read and parse the instructions file
50
- const content = await readFile(instructionsFile, "utf-8");
51
- const instructions = content
52
- .split("\n")
53
- .map(line => line.trim())
54
- .filter(line => line.length > 0);
55
- const executionMode = new ExecutionMode(session, engine, instructions, true); // true = headless mode
56
- const result = await executionMode.execute();
57
- if (result.success) {
58
- process.exit(0);
62
+ const runId = `run-${Date.now()}`;
63
+ const testName = path.basename(instructionsFile);
64
+ let instructions = [];
65
+ let executionMode = null;
66
+ try {
67
+ await headlessDebug.startExecutionSession(runId, {
68
+ testName,
69
+ platform: deviceInfo.platform,
70
+ deviceName: deviceInfo.device_name
71
+ });
72
+ const currentLogFilePath = await headlessDebug.getCurrentLogFilePath();
73
+ if (currentLogFilePath) {
74
+ console.log(`Debug logging enabled: ${currentLogFilePath}`);
75
+ }
76
+ console.log(`\nRunning test from: ${instructionsFile}\n`);
77
+ const content = await readFile(instructionsFile, "utf-8");
78
+ instructions = content
79
+ .split("\n")
80
+ .map(line => line.trim())
81
+ .filter(line => line.length > 0);
82
+ const taskText = instructions.join("\n");
83
+ const headlessConfig = await resolveHeadlessExecutionConfig(args);
84
+ process.env.OPENAI_CUA_MODEL = headlessConfig.cuaModel;
85
+ let appContextBriefing = "";
86
+ if (!headlessConfig.appContextEnabled) {
87
+ emitDesktopDebug("app_context.status", "execution", { runId }, {
88
+ source: "cli_context_flag",
89
+ contextPath: null,
90
+ budget: headlessConfig.appContextBudget,
91
+ status: "disabled"
92
+ });
93
+ }
94
+ else if (headlessConfig.appContextPath) {
95
+ try {
96
+ const result = await buildAppContextBriefing({
97
+ contextPath: headlessConfig.appContextPath,
98
+ taskText,
99
+ budget: headlessConfig.appContextBudget,
100
+ });
101
+ appContextBriefing = result.briefing;
102
+ emitDesktopDebug("app_context.status", "execution", { runId }, {
103
+ source: "cli_context_flag",
104
+ contextPath: result.contextPath,
105
+ budget: headlessConfig.appContextBudget,
106
+ outputTokens: result.outputTokens,
107
+ status: appContextBriefing.trim().length > 0 ? "loaded" : "empty"
108
+ });
109
+ if (appContextBriefing) {
110
+ emitDesktopDebug("app_context.briefing.full", "execution", { runId }, {
111
+ source: "cli_context_flag",
112
+ contextPath: result.contextPath,
113
+ budget: headlessConfig.appContextBudget,
114
+ outputTokens: result.outputTokens,
115
+ briefing: appContextBriefing
116
+ });
117
+ console.log(`Using app context briefing from: ${result.contextPath}`);
118
+ }
119
+ }
120
+ catch (error) {
121
+ const message = error instanceof Error ? error.message : "Unknown app context error";
122
+ emitDesktopDebug("app_context.status", "execution", { runId }, {
123
+ source: "cli_context_flag",
124
+ contextPath: headlessConfig.appContextPath,
125
+ budget: headlessConfig.appContextBudget,
126
+ status: "failed",
127
+ message
128
+ });
129
+ console.warn(`Warning: could not load app context from ${headlessConfig.appContextPath}. Running without briefing.`);
130
+ }
131
+ }
132
+ else {
133
+ emitDesktopDebug("app_context.status", "execution", { runId }, {
134
+ source: "cli_context_flag",
135
+ contextPath: null,
136
+ budget: headlessConfig.appContextBudget,
137
+ status: "missing"
138
+ });
139
+ }
140
+ const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextBriefing, { strictMode: headlessConfig.strictMode });
141
+ session.setSystemPrompt(executionPrompt);
142
+ const screenshotRecorder = headlessDebug.createExecutionScreenshotRecorder({
143
+ runId,
144
+ recordScreenshots,
145
+ screenshotDir
146
+ });
147
+ const engine = screenshotRecorder
148
+ ? new ExecutionEngine(session, {
149
+ recordScreenshots: true,
150
+ screenshotRecorder,
151
+ strictMode: headlessConfig.strictMode
152
+ })
153
+ : new ExecutionEngine(session, {
154
+ recordScreenshots,
155
+ screenshotDir,
156
+ strictMode: headlessConfig.strictMode
157
+ });
158
+ executionMode = new ExecutionMode(session, engine, instructions, true);
159
+ const result = await executionMode.execute({
160
+ runId,
161
+ addOutput: printCliOutput
162
+ });
163
+ const stats = executionMode.stats || {};
164
+ const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
165
+ await headlessDebug.endExecutionSession(runId, {
166
+ success: Boolean(result.success),
167
+ error: result.error ?? null,
168
+ durationMs,
169
+ instructionsTotal: instructions.length,
170
+ instructionsCompleted: stats.instructionsCompleted ?? 0,
171
+ actionsTotal: stats.actionCount ?? 0,
172
+ assertionsPassed: stats.assertionsPassed ?? 0,
173
+ assertionsFailed: stats.assertionsFailed ?? 0,
174
+ retries: stats.retryCount ?? 0
175
+ });
176
+ if (result.success) {
177
+ process.exit(0);
178
+ }
179
+ else {
180
+ console.error(`\nTest failed: ${result.error}`);
181
+ process.exit(1);
182
+ }
59
183
  }
60
- else {
61
- console.error(`\nTest failed: ${result.error}`);
62
- process.exit(1);
184
+ catch (error) {
185
+ const message = error instanceof Error ? error.message : "Failed to start execution.";
186
+ const stats = executionMode?.stats || {};
187
+ const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
188
+ await headlessDebug.endExecutionSession(runId, {
189
+ success: false,
190
+ error: message,
191
+ ...(stats.startTime
192
+ ? {
193
+ durationMs,
194
+ instructionsTotal: instructions.length,
195
+ instructionsCompleted: stats.instructionsCompleted ?? 0,
196
+ actionsTotal: stats.actionCount ?? 0,
197
+ assertionsPassed: stats.assertionsPassed ?? 0,
198
+ assertionsFailed: stats.assertionsFailed ?? 0,
199
+ retries: stats.retryCount ?? 0
200
+ }
201
+ : { reason: "start_failed" })
202
+ });
203
+ throw error;
63
204
  }
64
205
  }
206
+ const engine = new ExecutionEngine(session, {
207
+ recordScreenshots,
208
+ screenshotDir,
209
+ strictMode: strictFlag,
210
+ });
65
211
  // Otherwise, start interactive Ink shell
66
- await startInkShell(session, engine);
212
+ await startInkShell(session, engine, {
213
+ appContextPath: appContextPath ? path.resolve(appContextPath) : null,
214
+ appContextBudget: DEFAULT_APP_CONTEXT_BUDGET,
215
+ });
67
216
  }
68
- main();
217
+ main().catch((error) => {
218
+ const message = error instanceof Error ? error.message : String(error);
219
+ console.error(`\nTest failed: ${message}`);
220
+ process.exit(1);
221
+ });
@@ -0,0 +1,55 @@
1
+ import path from "node:path";
2
+ import { createStructuredDebugLogManager } from "../utils/structured-debug-log-manager.js";
3
+ import { createCompositeScreenshotRecorder, createDebugScreenshotRecorder } from "../utils/run-screenshot-recorder.js";
4
+ export function createHeadlessDebugArtifacts({ cwd = process.cwd(), enabled = false } = {}) {
5
+ const manager = createStructuredDebugLogManager({
6
+ enabled,
7
+ logsDirPath: path.join(cwd, "logs")
8
+ });
9
+ return {
10
+ async init() {
11
+ await manager.configure();
12
+ if (manager.isEnabled()) {
13
+ manager.installWorkspaceDebugBridge();
14
+ return await manager.getLogsDirPath();
15
+ }
16
+ return null;
17
+ },
18
+ isEnabled() {
19
+ return manager.isEnabled();
20
+ },
21
+ async startExecutionSession(runId, data = {}) {
22
+ await manager.startExecutionSession(runId, data);
23
+ return await manager.getCurrentLogFilePath();
24
+ },
25
+ async endExecutionSession(runId, data = {}) {
26
+ await manager.endExecutionSession(runId, data);
27
+ },
28
+ createExecutionScreenshotRecorder({ runId, recordScreenshots = false, screenshotDir = null }) {
29
+ const recorders = [];
30
+ const debugArtifactsDir = manager.getExecutionSessionArtifactsDir(runId);
31
+ if (debugArtifactsDir) {
32
+ recorders.push(createDebugScreenshotRecorder({ directoryPath: debugArtifactsDir }));
33
+ }
34
+ if (manager.isEnabled() && recordScreenshots && screenshotDir) {
35
+ recorders.push(createDebugScreenshotRecorder({ directoryPath: screenshotDir }));
36
+ }
37
+ if (recorders.length === 0) {
38
+ return null;
39
+ }
40
+ if (recorders.length === 1) {
41
+ return recorders[0];
42
+ }
43
+ return createCompositeScreenshotRecorder({ recorders });
44
+ },
45
+ getExecutionSessionArtifactsDir(runId) {
46
+ return manager.getExecutionSessionArtifactsDir(runId);
47
+ },
48
+ async getCurrentLogFilePath() {
49
+ return await manager.getCurrentLogFilePath();
50
+ },
51
+ async getLogsDirPath() {
52
+ return await manager.getLogsDirPath();
53
+ }
54
+ };
55
+ }
@@ -0,0 +1,203 @@
1
+ import path from "path";
2
+ import { readFile } from "fs/promises";
3
+ import { DEFAULT_APP_CONTEXT_BUDGET, MAX_APP_CONTEXT_BUDGET, MIN_APP_CONTEXT_BUDGET, } from "../core/app-context.js";
4
+ const VALID_CUA_MODELS = new Set(["gpt-5.4", "computer-use-preview"]);
5
+ function createEmptyPromptCustomizations() {
6
+ return {
7
+ basePromptInstructions: "",
8
+ designModeInstructions: "",
9
+ executionModeInstructions: "",
10
+ };
11
+ }
12
+ function isPlainObject(value) {
13
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
14
+ }
15
+ function assertNonEmptyString(value, label) {
16
+ if (typeof value !== "string" || value.trim().length === 0) {
17
+ throw new Error(`${label} must be a non-empty string.`);
18
+ }
19
+ }
20
+ function validateCuaModel(value, label) {
21
+ if (typeof value !== "string" || !VALID_CUA_MODELS.has(value)) {
22
+ throw new Error(`${label} must be one of: gpt-5.4, computer-use-preview.`);
23
+ }
24
+ return value;
25
+ }
26
+ function parseBooleanFlagState(args, key) {
27
+ return {
28
+ enabled: args[key] === true,
29
+ disabled: args[`no-${key}`] === true || args[key] === false,
30
+ };
31
+ }
32
+ function parseBudgetValue(rawValue, label) {
33
+ const numericValue = (() => {
34
+ if (typeof rawValue === "number") {
35
+ return rawValue;
36
+ }
37
+ if (typeof rawValue === "string" && /^-?\d+$/.test(rawValue.trim())) {
38
+ return Number.parseInt(rawValue, 10);
39
+ }
40
+ return Number.NaN;
41
+ })();
42
+ if (!Number.isInteger(numericValue)) {
43
+ throw new Error(`${label} must be an integer between ${MIN_APP_CONTEXT_BUDGET} and ${MAX_APP_CONTEXT_BUDGET}.`);
44
+ }
45
+ if (numericValue < MIN_APP_CONTEXT_BUDGET || numericValue > MAX_APP_CONTEXT_BUDGET) {
46
+ throw new Error(`${label} must be between ${MIN_APP_CONTEXT_BUDGET} and ${MAX_APP_CONTEXT_BUDGET}.`);
47
+ }
48
+ return numericValue;
49
+ }
50
+ function normalizePromptCustomizations(rawValue, label) {
51
+ if (rawValue == null) {
52
+ return createEmptyPromptCustomizations();
53
+ }
54
+ if (!isPlainObject(rawValue)) {
55
+ throw new Error(`${label} must be an object.`);
56
+ }
57
+ const normalized = createEmptyPromptCustomizations();
58
+ for (const key of Object.keys(normalized)) {
59
+ if (!(key in rawValue))
60
+ continue;
61
+ if (typeof rawValue[key] !== "string") {
62
+ throw new Error(`${label}.${key} must be a string.`);
63
+ }
64
+ normalized[key] = rawValue[key];
65
+ }
66
+ return normalized;
67
+ }
68
+ async function readJsonFile(filePath) {
69
+ const content = await readFile(filePath, "utf-8");
70
+ try {
71
+ return JSON.parse(content);
72
+ }
73
+ catch (error) {
74
+ const message = error instanceof Error ? error.message : "Invalid JSON.";
75
+ throw new Error(`Could not parse config file ${filePath}: ${message}`);
76
+ }
77
+ }
78
+ async function readTextFile(filePath, label) {
79
+ try {
80
+ return await readFile(filePath, "utf-8");
81
+ }
82
+ catch (error) {
83
+ const message = error instanceof Error ? error.message : "Unknown file read error.";
84
+ throw new Error(`Could not read ${label} at ${filePath}: ${message}`);
85
+ }
86
+ }
87
+ async function loadConfigFromFile(configPath) {
88
+ const absoluteConfigPath = path.resolve(configPath);
89
+ let rawConfig;
90
+ try {
91
+ rawConfig = await readJsonFile(absoluteConfigPath);
92
+ }
93
+ catch (error) {
94
+ const message = error instanceof Error ? error.message : "Unknown config read error.";
95
+ if (message.startsWith("Could not parse config file ")) {
96
+ throw error;
97
+ }
98
+ throw new Error(`Could not read config file ${absoluteConfigPath}: ${message}`);
99
+ }
100
+ if (!isPlainObject(rawConfig)) {
101
+ throw new Error(`Config file ${absoluteConfigPath} must contain a JSON object.`);
102
+ }
103
+ const configDir = path.dirname(absoluteConfigPath);
104
+ const normalized = {
105
+ configPath: absoluteConfigPath,
106
+ cuaModel: undefined,
107
+ promptCustomizations: createEmptyPromptCustomizations(),
108
+ appContextEnabled: undefined,
109
+ appContextBudget: undefined,
110
+ appContextPath: undefined,
111
+ strictMode: undefined,
112
+ };
113
+ if ("cuaModel" in rawConfig) {
114
+ normalized.cuaModel = validateCuaModel(rawConfig.cuaModel, "config.cuaModel");
115
+ }
116
+ if ("promptCustomizations" in rawConfig) {
117
+ normalized.promptCustomizations = normalizePromptCustomizations(rawConfig.promptCustomizations, "config.promptCustomizations");
118
+ }
119
+ if ("appContextEnabled" in rawConfig) {
120
+ if (typeof rawConfig.appContextEnabled !== "boolean") {
121
+ throw new Error("config.appContextEnabled must be a boolean.");
122
+ }
123
+ normalized.appContextEnabled = rawConfig.appContextEnabled;
124
+ }
125
+ if ("appContextBudget" in rawConfig) {
126
+ normalized.appContextBudget = parseBudgetValue(rawConfig.appContextBudget, "config.appContextBudget");
127
+ }
128
+ if ("appContextPath" in rawConfig && rawConfig.appContextPath != null) {
129
+ assertNonEmptyString(rawConfig.appContextPath, "config.appContextPath");
130
+ normalized.appContextPath = path.resolve(configDir, rawConfig.appContextPath);
131
+ }
132
+ if ("strictMode" in rawConfig) {
133
+ if (typeof rawConfig.strictMode !== "boolean") {
134
+ throw new Error("config.strictMode must be a boolean.");
135
+ }
136
+ normalized.strictMode = rawConfig.strictMode;
137
+ }
138
+ return normalized;
139
+ }
140
+ export async function resolveHeadlessExecutionConfig(args, options = {}) {
141
+ const cwd = typeof options.cwd === "string" ? options.cwd : process.cwd();
142
+ const configPath = typeof args.config === "string" ? args.config : null;
143
+ const explicitContextPath = typeof args.context === "string" ? path.resolve(cwd, args.context) : null;
144
+ const noContext = args["no-context"] === true || args.context === false;
145
+ const { enabled: strictEnabledByFlag, disabled: strictDisabledByFlag } = parseBooleanFlagState(args, "strict");
146
+ const basePromptText = typeof args["base-prompt"] === "string" ? args["base-prompt"] : null;
147
+ const executionPromptText = typeof args["execution-prompt"] === "string" ? args["execution-prompt"] : null;
148
+ const basePromptFilePath = typeof args["base-prompt-file"] === "string" ? path.resolve(cwd, args["base-prompt-file"]) : null;
149
+ const executionPromptFilePath = typeof args["execution-prompt-file"] === "string" ? path.resolve(cwd, args["execution-prompt-file"]) : null;
150
+ if (explicitContextPath && noContext) {
151
+ throw new Error("--context and --no-context cannot be used together.");
152
+ }
153
+ if (strictEnabledByFlag && strictDisabledByFlag) {
154
+ throw new Error("--strict and --no-strict cannot be used together.");
155
+ }
156
+ const fileConfig = configPath ? await loadConfigFromFile(configPath) : null;
157
+ const promptCustomizations = {
158
+ ...createEmptyPromptCustomizations(),
159
+ ...(fileConfig?.promptCustomizations || {}),
160
+ };
161
+ const resolved = {
162
+ configPath: fileConfig?.configPath || null,
163
+ cuaModel: fileConfig?.cuaModel || "gpt-5.4",
164
+ promptCustomizations,
165
+ appContextEnabled: fileConfig?.appContextEnabled ?? true,
166
+ appContextBudget: fileConfig?.appContextBudget ?? DEFAULT_APP_CONTEXT_BUDGET,
167
+ appContextPath: fileConfig?.appContextPath || null,
168
+ strictMode: fileConfig?.strictMode ?? false,
169
+ };
170
+ if (typeof args["cua-model"] === "string") {
171
+ resolved.cuaModel = validateCuaModel(args["cua-model"], "--cua-model");
172
+ }
173
+ if (args["app-context-budget"] != null) {
174
+ resolved.appContextBudget = parseBudgetValue(args["app-context-budget"], "--app-context-budget");
175
+ }
176
+ if (basePromptFilePath) {
177
+ resolved.promptCustomizations.basePromptInstructions = await readTextFile(basePromptFilePath, "--base-prompt-file");
178
+ }
179
+ if (executionPromptFilePath) {
180
+ resolved.promptCustomizations.executionModeInstructions = await readTextFile(executionPromptFilePath, "--execution-prompt-file");
181
+ }
182
+ if (basePromptText !== null) {
183
+ resolved.promptCustomizations.basePromptInstructions = basePromptText;
184
+ }
185
+ if (executionPromptText !== null) {
186
+ resolved.promptCustomizations.executionModeInstructions = executionPromptText;
187
+ }
188
+ if (explicitContextPath) {
189
+ resolved.appContextEnabled = true;
190
+ resolved.appContextPath = explicitContextPath;
191
+ }
192
+ if (noContext) {
193
+ resolved.appContextEnabled = false;
194
+ resolved.appContextPath = null;
195
+ }
196
+ if (strictEnabledByFlag) {
197
+ resolved.strictMode = true;
198
+ }
199
+ else if (strictDisabledByFlag) {
200
+ resolved.strictMode = false;
201
+ }
202
+ return resolved;
203
+ }
@@ -7,9 +7,10 @@ import { routeCommand } from '../commands/index.js';
7
7
  * Start the Ink-based conversational shell
8
8
  * @param {Object} session - Session object with device info
9
9
  * @param {Object} executionEngine - Execution engine instance
10
+ * @param {{ appContextPath?: string | null, appContextBudget?: number }} [options]
10
11
  * @returns {Promise<void>}
11
12
  */
12
- export async function startInkShell(session, executionEngine) {
13
+ export async function startInkShell(session, executionEngine, options = {}) {
13
14
  let shouldExit = false;
14
15
  const handleInput = async (input, context) => {
15
16
  // Check if there's an active design mode - route input to it
@@ -29,7 +30,12 @@ export async function startInkShell(session, executionEngine) {
29
30
  }
30
31
  if (parsed.type === 'command') {
31
32
  // Route to command handler
32
- const shouldContinue = await routeCommand(parsed.command, parsed.args, session, { ...context, engine: executionEngine });
33
+ const shouldContinue = await routeCommand(parsed.command, parsed.args, session, {
34
+ ...context,
35
+ engine: executionEngine,
36
+ appContextPath: options.appContextPath ?? null,
37
+ appContextBudget: options.appContextBudget
38
+ });
33
39
  if (!shouldContinue) {
34
40
  shouldExit = true;
35
41
  context.exit();
@@ -19,8 +19,19 @@ export async function handleHelp(args, session, context) {
19
19
  addOutput({ type: 'info', text: ' --avd <name> Device name (Android device ID/serial or iOS Simulator)' });
20
20
  addOutput({ type: 'info', text: ' --platform <platform> Force platform: android or ios' });
21
21
  addOutput({ type: 'info', text: ' --instructions <file> Run test file in headless mode' });
22
+ addOutput({ type: 'info', text: ' --config <file> Headless execution JSON config for prompt parity' });
23
+ addOutput({ type: 'info', text: ' --cua-model <model> Headless CUA model override: gpt-5.4 or computer-use-preview' });
24
+ addOutput({ type: 'info', text: ' --context <file> Optional app context file used to brief execution runs' });
25
+ addOutput({ type: 'info', text: ' --app-context-budget Headless app context token budget override' });
26
+ addOutput({ type: 'info', text: ' --no-context Disable app context for headless execution' });
27
+ addOutput({ type: 'info', text: ' --base-prompt <text> Headless base prompt customization text' });
28
+ addOutput({ type: 'info', text: ' --execution-prompt <text> Headless execution prompt customization text' });
29
+ addOutput({ type: 'info', text: ' --base-prompt-file Headless base prompt customization file' });
30
+ addOutput({ type: 'info', text: ' --execution-prompt-file Headless execution prompt customization file' });
31
+ addOutput({ type: 'info', text: ' --strict Strict Mode: re-observe after the first action in a chain' });
32
+ addOutput({ type: 'info', text: ' --no-strict Disable Strict Mode for headless config-driven runs' });
22
33
  addOutput({ type: 'info', text: ' --record Record screenshots during execution' });
23
- addOutput({ type: 'info', text: ' --debug Enable debug logging' });
34
+ addOutput({ type: 'info', text: ' --debug Enable structured JSONL debug artifacts' });
24
35
  addOutput({ type: 'info', text: '' });
25
36
  addOutput({ type: 'info', text: 'Interactive commands:' });
26
37
  addOutput({ type: 'info', text: ' /help Show this help message' });
@@ -57,6 +68,7 @@ export async function handleHelp(args, session, context) {
57
68
  addOutput({ type: 'info', text: ' droid-cua --avd avd:Pixel_8_API_35 (Launch Android AVD then connect)' });
58
69
  addOutput({ type: 'info', text: ' droid-cua --avd "iPhone 16" (iOS Simulator, auto-detected)' });
59
70
  addOutput({ type: 'info', text: ' droid-cua --platform ios --avd MySim (Force iOS platform)' });
71
+ addOutput({ type: 'info', text: ' droid-cua --instructions tests/login.dcua --context app/context.md' });
60
72
  addOutput({ type: 'info', text: ' /create login-test (design a new test)' });
61
73
  addOutput({ type: 'info', text: ' /list (see all tests)' });
62
74
  addOutput({ type: 'info', text: ' /view login-test (view test contents)' });