@loadmill/droid-cua 2.6.0 → 2.8.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 (38) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +11 -2
  3. package/build/index.js +55 -20
  4. package/build/src/browser/actions.js +9 -0
  5. package/build/src/browser/connection.js +37 -0
  6. package/build/src/browser/factory.js +22 -0
  7. package/build/src/browser/interface.js +14 -0
  8. package/build/src/browser/navigation.js +11 -0
  9. package/build/src/browser/normalize.js +36 -0
  10. package/build/src/browser/playwright/actions.js +199 -0
  11. package/build/src/browser/playwright/connection.js +196 -0
  12. package/build/src/browser/playwright/installed-browser.js +131 -0
  13. package/build/src/browser/playwright/session-state.js +19 -0
  14. package/build/src/browser/playwright/url.js +79 -0
  15. package/build/src/cli/headless-execution-config.js +59 -3
  16. package/build/src/cli/headless-instructions.js +12 -0
  17. package/build/src/cli/headless-target.js +64 -0
  18. package/build/src/commands/help.js +10 -2
  19. package/build/src/core/capture-sources.js +76 -0
  20. package/build/src/core/context-optimization.js +36 -0
  21. package/build/src/core/execution-engine.js +45 -12
  22. package/build/src/core/prompts.js +1 -1
  23. package/build/src/core/session.js +18 -4
  24. package/build/src/device/action-event-meta.js +18 -0
  25. package/build/src/device/android/actions.js +6 -13
  26. package/build/src/device/cloud/actions.js +9 -14
  27. package/build/src/device/cloud/connection.js +8 -0
  28. package/build/src/device/ios/actions.js +6 -13
  29. package/build/src/device/ios/appium-server.js +4 -3
  30. package/build/src/device/ios/connection.js +8 -0
  31. package/build/src/device/ios/node-cli-env.js +28 -0
  32. package/build/src/device/openai.js +47 -2
  33. package/build/src/modes/execution-mode.js +113 -21
  34. package/build/src/prompts/base.js +95 -21
  35. package/build/src/prompts/design.js +6 -11
  36. package/build/src/prompts/execution.js +26 -4
  37. package/build/src/utils/run-screenshot-recorder.js +1 -1
  38. package/package.json +7 -1
@@ -0,0 +1,196 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { randomUUID } from "node:crypto";
5
+ import { emitDesktopDebug } from "../../utils/desktop-debug.js";
6
+ import { requireInstalledBrowser } from "./installed-browser.js";
7
+ import { clearActiveBrowserSession, getActiveBrowserSession, setActiveBrowserSession } from "./session-state.js";
8
+ import { normalizeAutomationUrl, normalizeOptionalAutomationUrl } from "./url.js";
9
+ const DEFAULT_VIEWPORT = { width: 1440, height: 900 };
10
+ let browserConnectionTestHooks = null;
11
+ function normalizeKnownPageUrl(url) {
12
+ if (typeof url !== "string" || url.trim().length === 0 || url.trim() === "about:blank") {
13
+ return null;
14
+ }
15
+ try {
16
+ return normalizeOptionalAutomationUrl(url);
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function buildSessionBrowserInfo(url, installedBrowser) {
23
+ const currentUrl = normalizeKnownPageUrl(url);
24
+ const browserInfo = {
25
+ viewportWidth: DEFAULT_VIEWPORT.width,
26
+ viewportHeight: DEFAULT_VIEWPORT.height,
27
+ scaledWidth: DEFAULT_VIEWPORT.width,
28
+ scaledHeight: DEFAULT_VIEWPORT.height,
29
+ scale: 1,
30
+ platform: "browser",
31
+ browserName: installedBrowser?.browserName || "Browser",
32
+ browserFamily: installedBrowser?.browserFamily || null,
33
+ model: installedBrowser?.browserName || "Browser",
34
+ };
35
+ if (currentUrl) {
36
+ browserInfo.currentUrl = currentUrl;
37
+ }
38
+ return browserInfo;
39
+ }
40
+ function normalizeConnectOptions(targetOrOptions) {
41
+ if (typeof targetOrOptions === "string") {
42
+ return {
43
+ url: normalizeOptionalAutomationUrl(targetOrOptions),
44
+ headless: false,
45
+ sessionMode: "fresh",
46
+ persistentProfileDir: null,
47
+ freshProfileDir: null,
48
+ };
49
+ }
50
+ const options = targetOrOptions && typeof targetOrOptions === "object" ? targetOrOptions : {};
51
+ return {
52
+ url: normalizeOptionalAutomationUrl(options.url),
53
+ headless: options.headless === true,
54
+ sessionMode: options.sessionMode === "persistent" ? "persistent" : "fresh",
55
+ persistentProfileDir: typeof options.persistentProfileDir === "string" && options.persistentProfileDir.trim().length > 0
56
+ ? path.resolve(options.persistentProfileDir)
57
+ : null,
58
+ freshProfileDir: typeof options.freshProfileDir === "string" && options.freshProfileDir.trim().length > 0
59
+ ? path.resolve(options.freshProfileDir)
60
+ : null,
61
+ };
62
+ }
63
+ async function buildUserDataDir(options) {
64
+ if (options.sessionMode === "persistent") {
65
+ if (!options.persistentProfileDir) {
66
+ throw new Error("Persistent browser sessions require a profile directory.");
67
+ }
68
+ return {
69
+ userDataDir: options.persistentProfileDir,
70
+ cleanupOnDisconnect: false,
71
+ };
72
+ }
73
+ if (options.freshProfileDir) {
74
+ return {
75
+ userDataDir: options.freshProfileDir,
76
+ cleanupOnDisconnect: true,
77
+ };
78
+ }
79
+ return {
80
+ userDataDir: await mkdtemp(path.join(os.tmpdir(), "droid-cua-browser-profile-")),
81
+ cleanupOnDisconnect: true,
82
+ };
83
+ }
84
+ async function loadChromium() {
85
+ if (typeof browserConnectionTestHooks?.loadChromium === "function") {
86
+ return await browserConnectionTestHooks.loadChromium();
87
+ }
88
+ try {
89
+ const playwright = await import("playwright-core");
90
+ return playwright.chromium;
91
+ }
92
+ catch (error) {
93
+ const message = error instanceof Error ? error.message : "playwright-core is unavailable";
94
+ throw new Error(`Browser automation requires the optional playwright-core dependency. Install project dependencies and try again. (${message})`);
95
+ }
96
+ }
97
+ async function resolveInstalledBrowserForLaunch() {
98
+ if (typeof browserConnectionTestHooks?.resolveInstalledBrowser === "function") {
99
+ return await browserConnectionTestHooks.resolveInstalledBrowser();
100
+ }
101
+ return await requireInstalledBrowser();
102
+ }
103
+ export async function connectToBrowser(targetOrOptions) {
104
+ const options = normalizeConnectOptions(targetOrOptions);
105
+ const installedBrowser = await resolveInstalledBrowserForLaunch();
106
+ const chromium = await loadChromium();
107
+ try {
108
+ getActiveBrowserSession();
109
+ await disconnect();
110
+ }
111
+ catch {
112
+ // no active session to close
113
+ }
114
+ const { userDataDir, cleanupOnDisconnect } = await buildUserDataDir(options);
115
+ const context = await chromium.launchPersistentContext(userDataDir, {
116
+ executablePath: installedBrowser.executablePath,
117
+ headless: options.headless,
118
+ viewport: DEFAULT_VIEWPORT,
119
+ screen: DEFAULT_VIEWPORT,
120
+ acceptDownloads: false,
121
+ });
122
+ const primaryPage = context.pages()[0] ?? (await context.newPage());
123
+ const sessionId = `browser-${randomUUID()}`;
124
+ const normalizedUrl = options.url;
125
+ context.on("page", (page) => {
126
+ if (page === primaryPage) {
127
+ return;
128
+ }
129
+ console.warn("New tab or popup opened. Automation will remain attached to the original page only.");
130
+ emitDesktopDebug("browser.popup", "device", { sessionId }, {
131
+ stage: "opened",
132
+ url: page.url() || null,
133
+ });
134
+ });
135
+ if (normalizedUrl) {
136
+ await primaryPage.goto(normalizedUrl, { waitUntil: "load" });
137
+ }
138
+ setActiveBrowserSession({
139
+ sessionId,
140
+ context,
141
+ page: primaryPage,
142
+ url: normalizedUrl,
143
+ sessionMode: options.sessionMode,
144
+ userDataDir,
145
+ cleanupOnDisconnect,
146
+ installedBrowser,
147
+ });
148
+ emitDesktopDebug("browser.connect", "device", {}, {
149
+ platform: "browser",
150
+ stage: "success",
151
+ browserSessionId: sessionId,
152
+ browserFamily: installedBrowser.browserFamily,
153
+ browserName: installedBrowser.browserName,
154
+ url: normalizedUrl ?? undefined,
155
+ sessionMode: options.sessionMode,
156
+ });
157
+ return sessionId;
158
+ }
159
+ export async function getBrowserInfo(browserSessionId) {
160
+ const session = getActiveBrowserSession(browserSessionId);
161
+ const currentUrl = normalizeKnownPageUrl(session.page.url()) || session.url || null;
162
+ return buildSessionBrowserInfo(currentUrl, session.installedBrowser);
163
+ }
164
+ export async function getScreenshotAsBase64(browserSessionId) {
165
+ const session = getActiveBrowserSession(browserSessionId);
166
+ const imageBuffer = await session.page.screenshot({ type: "png" });
167
+ return imageBuffer.toString("base64");
168
+ }
169
+ export async function navigateToUrl(browserSessionId, url) {
170
+ const session = getActiveBrowserSession(browserSessionId);
171
+ const normalizedUrl = normalizeAutomationUrl(url);
172
+ await session.page.goto(normalizedUrl, { waitUntil: "load" });
173
+ session.url = normalizedUrl;
174
+ emitDesktopDebug("browser.navigate", "device", { browserSessionId }, {
175
+ platform: "browser",
176
+ url: normalizedUrl,
177
+ });
178
+ return normalizedUrl;
179
+ }
180
+ export async function disconnect() {
181
+ let session;
182
+ try {
183
+ session = getActiveBrowserSession();
184
+ }
185
+ catch {
186
+ return;
187
+ }
188
+ clearActiveBrowserSession();
189
+ await session.context.close();
190
+ if (session.cleanupOnDisconnect) {
191
+ await rm(session.userDataDir, { recursive: true, force: true });
192
+ }
193
+ }
194
+ export function __setBrowserConnectionTestHooks(hooks = null) {
195
+ browserConnectionTestHooks = hooks;
196
+ }
@@ -0,0 +1,131 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { access } from "node:fs/promises";
4
+ import { constants as fsConstants } from "node:fs";
5
+ export const CHROME_INSTALL_URL = "https://www.google.com/chrome/";
6
+ export const EDGE_INSTALL_URL = "https://www.microsoft.com/edge/download";
7
+ function readEnvValue(env, ...keys) {
8
+ for (const key of keys) {
9
+ const value = env?.[key];
10
+ if (typeof value === "string" && value.trim()) {
11
+ return value.trim();
12
+ }
13
+ }
14
+ return null;
15
+ }
16
+ function normalizeWindowsBaseDirs(env, homeDir) {
17
+ const programFiles = readEnvValue(env, "PROGRAMFILES", "ProgramFiles") || "C:\\Program Files";
18
+ const programFilesX86 = readEnvValue(env, "PROGRAMFILES(X86)") || "C:\\Program Files (x86)";
19
+ const localAppData = readEnvValue(env, "LOCALAPPDATA")
20
+ || (homeDir ? path.join(homeDir, "AppData", "Local") : null)
21
+ || "C:\\Users\\Default\\AppData\\Local";
22
+ return { programFiles, programFilesX86, localAppData };
23
+ }
24
+ function uniquePaths(paths = []) {
25
+ return [...new Set(paths.filter((value) => typeof value === "string" && value.trim().length > 0))];
26
+ }
27
+ export function listInstalledBrowserCandidates({ platform = process.platform, env = process.env, homeDir = os.homedir(), } = {}) {
28
+ if (platform === "darwin") {
29
+ const appRoots = uniquePaths([
30
+ "/Applications",
31
+ homeDir ? path.join(homeDir, "Applications") : null,
32
+ ]);
33
+ return [
34
+ {
35
+ browserFamily: "chrome",
36
+ browserName: "Google Chrome",
37
+ candidates: appRoots.map((root) => path.join(root, "Google Chrome.app", "Contents", "MacOS", "Google Chrome")),
38
+ },
39
+ {
40
+ browserFamily: "edge",
41
+ browserName: "Microsoft Edge",
42
+ candidates: appRoots.map((root) => path.join(root, "Microsoft Edge.app", "Contents", "MacOS", "Microsoft Edge")),
43
+ },
44
+ ];
45
+ }
46
+ if (platform === "win32") {
47
+ const { programFiles, programFilesX86, localAppData } = normalizeWindowsBaseDirs(env, homeDir);
48
+ return [
49
+ {
50
+ browserFamily: "chrome",
51
+ browserName: "Google Chrome",
52
+ candidates: uniquePaths([
53
+ path.win32.join(programFiles, "Google", "Chrome", "Application", "chrome.exe"),
54
+ path.win32.join(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
55
+ path.win32.join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
56
+ ]),
57
+ },
58
+ {
59
+ browserFamily: "edge",
60
+ browserName: "Microsoft Edge",
61
+ candidates: uniquePaths([
62
+ path.win32.join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
63
+ path.win32.join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
64
+ path.win32.join(localAppData, "Microsoft", "Edge", "Application", "msedge.exe"),
65
+ ]),
66
+ },
67
+ ];
68
+ }
69
+ return [];
70
+ }
71
+ async function pathExistsAndIsRunnable(filePath, platform, accessFn) {
72
+ const accessMode = platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK;
73
+ try {
74
+ await accessFn(filePath, accessMode);
75
+ return true;
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ function buildMissingBrowserMessage(platform) {
82
+ if (platform !== "darwin" && platform !== "win32") {
83
+ return "Installed browser detection is currently supported only on macOS and Windows. Install Google Chrome or Microsoft Edge on a supported machine.";
84
+ }
85
+ return `No supported browser was found. Install Google Chrome or Microsoft Edge and try again. Chrome: ${CHROME_INSTALL_URL} Edge: ${EDGE_INSTALL_URL}`;
86
+ }
87
+ export async function resolveInstalledBrowser({ platform = process.platform, env = process.env, homeDir = os.homedir(), accessFn = access, } = {}) {
88
+ const browserGroups = listInstalledBrowserCandidates({ platform, env, homeDir });
89
+ for (const browser of browserGroups) {
90
+ for (const executablePath of browser.candidates) {
91
+ if (await pathExistsAndIsRunnable(executablePath, platform, accessFn)) {
92
+ return {
93
+ browserFamily: browser.browserFamily,
94
+ browserName: browser.browserName,
95
+ executablePath,
96
+ };
97
+ }
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+ export async function getInstalledBrowserAvailability(options = {}) {
103
+ const resolved = await resolveInstalledBrowser(options);
104
+ if (!resolved) {
105
+ return {
106
+ available: false,
107
+ browserFamily: null,
108
+ browserName: null,
109
+ executablePath: null,
110
+ message: buildMissingBrowserMessage(options.platform ?? process.platform),
111
+ };
112
+ }
113
+ return {
114
+ available: true,
115
+ browserFamily: resolved.browserFamily,
116
+ browserName: resolved.browserName,
117
+ executablePath: resolved.executablePath,
118
+ message: null,
119
+ };
120
+ }
121
+ export async function requireInstalledBrowser(options = {}) {
122
+ const availability = await getInstalledBrowserAvailability(options);
123
+ if (!availability.available || !availability.browserName || !availability.executablePath) {
124
+ throw new Error(availability.message || buildMissingBrowserMessage(options.platform ?? process.platform));
125
+ }
126
+ return {
127
+ browserFamily: availability.browserFamily,
128
+ browserName: availability.browserName,
129
+ executablePath: availability.executablePath,
130
+ };
131
+ }
@@ -0,0 +1,19 @@
1
+ let activeSession = null;
2
+ export function setActiveBrowserSession(session) {
3
+ activeSession = session;
4
+ }
5
+ export function clearActiveBrowserSession() {
6
+ activeSession = null;
7
+ }
8
+ export function getActiveBrowserSession(browserSessionId = null) {
9
+ if (!activeSession) {
10
+ throw new Error("Not connected to a browser session. Call connectToBrowser first.");
11
+ }
12
+ if (browserSessionId && activeSession.sessionId !== browserSessionId) {
13
+ throw new Error("Requested browser session is no longer active.");
14
+ }
15
+ return activeSession;
16
+ }
17
+ export function getActiveBrowserPage(browserSessionId = null) {
18
+ return getActiveBrowserSession(browserSessionId).page;
19
+ }
@@ -0,0 +1,79 @@
1
+ const HTTP_SCHEME_RE = /^https?:\/\//i;
2
+ const SCHEME_RE = /^[a-z][a-z\d+\-.]*:\/\//i;
3
+ const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1)(:\d+)?$/i;
4
+ const EXPLICIT_PORT_RE = /^[^/]+:\d+$/;
5
+ const URL_ERROR_MESSAGE = "Browser URL must be a valid host or http:// / https:// URL.";
6
+ function hasUnsupportedBareScheme(rawValue) {
7
+ if (rawValue.includes("://")) {
8
+ return false;
9
+ }
10
+ const authority = rawValue.split(/[/?#]/, 1)[0]?.trim() || "";
11
+ const colonIndex = authority.indexOf(":");
12
+ if (colonIndex <= 0 || LOCAL_HOST_RE.test(authority) || EXPLICIT_PORT_RE.test(authority)) {
13
+ return false;
14
+ }
15
+ const prefix = authority.slice(0, colonIndex);
16
+ const suffix = authority.slice(colonIndex + 1);
17
+ return /^[a-z][a-z\d+\-.]*$/i.test(prefix) && !prefix.includes(".") && !/^\d+$/.test(suffix);
18
+ }
19
+ function inferDefaultScheme(rawValue) {
20
+ const authority = rawValue.split(/[/?#]/, 1)[0]?.trim() || "";
21
+ if (LOCAL_HOST_RE.test(authority) || EXPLICIT_PORT_RE.test(authority)) {
22
+ return "http://";
23
+ }
24
+ return "https://";
25
+ }
26
+ function parseUrlCandidate(rawValue) {
27
+ if (rawValue == null) {
28
+ return null;
29
+ }
30
+ if (typeof rawValue !== "string") {
31
+ throw new Error("Browser URL must be a string.");
32
+ }
33
+ const trimmed = rawValue.trim();
34
+ if (!trimmed) {
35
+ return null;
36
+ }
37
+ if (hasUnsupportedBareScheme(trimmed)) {
38
+ throw new Error("Browser URL must use http:// or https://.");
39
+ }
40
+ if (SCHEME_RE.test(trimmed) && !HTTP_SCHEME_RE.test(trimmed)) {
41
+ throw new Error("Browser URL must use http:// or https://.");
42
+ }
43
+ const candidate = HTTP_SCHEME_RE.test(trimmed)
44
+ ? trimmed
45
+ : `${inferDefaultScheme(trimmed)}${trimmed}`;
46
+ let parsedUrl;
47
+ try {
48
+ parsedUrl = new URL(candidate);
49
+ }
50
+ catch {
51
+ throw new Error(URL_ERROR_MESSAGE);
52
+ }
53
+ if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
54
+ throw new Error("Browser URL must use http:// or https://.");
55
+ }
56
+ if (!parsedUrl.hostname) {
57
+ throw new Error(URL_ERROR_MESSAGE);
58
+ }
59
+ return parsedUrl;
60
+ }
61
+ export function normalizeOptionalAutomationUrl(rawValue) {
62
+ const parsedUrl = parseUrlCandidate(rawValue);
63
+ return parsedUrl ? parsedUrl.toString() : null;
64
+ }
65
+ export function normalizeAutomationUrl(rawValue) {
66
+ const normalizedUrl = normalizeOptionalAutomationUrl(rawValue);
67
+ if (!normalizedUrl) {
68
+ throw new Error("Browser URL is required.");
69
+ }
70
+ return normalizedUrl;
71
+ }
72
+ export function formatAutomationDisplayName(rawUrl) {
73
+ const normalizedUrl = normalizeOptionalAutomationUrl(rawUrl);
74
+ if (!normalizedUrl) {
75
+ return "Browser";
76
+ }
77
+ const parsedUrl = new URL(normalizedUrl);
78
+ return parsedUrl.host || parsedUrl.origin || normalizedUrl;
79
+ }
@@ -1,7 +1,9 @@
1
1
  import path from "path";
2
2
  import { readFile } from "fs/promises";
3
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"]);
4
+ import { DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD, MAX_CONTEXT_OPTIMIZATION_THRESHOLD, MIN_CONTEXT_OPTIMIZATION_THRESHOLD, } from "../core/context-optimization.js";
5
+ const VALID_CUA_MODELS = new Set(["gpt-5.4", "gpt-5.4-mini", "computer-use-preview"]);
6
+ const VALID_CUA_MODEL_LIST = ["gpt-5.4", "gpt-5.4-mini", "computer-use-preview"];
5
7
  function createEmptyPromptCustomizations() {
6
8
  return {
7
9
  basePromptInstructions: "",
@@ -19,10 +21,17 @@ function assertNonEmptyString(value, label) {
19
21
  }
20
22
  function validateCuaModel(value, label) {
21
23
  if (typeof value !== "string" || !VALID_CUA_MODELS.has(value)) {
22
- throw new Error(`${label} must be one of: gpt-5.4, computer-use-preview.`);
24
+ throw new Error(`${label} must be one of: ${VALID_CUA_MODEL_LIST.join(", ")}.`);
23
25
  }
24
26
  return value;
25
27
  }
28
+ function readEnvDefaultCuaModel() {
29
+ const rawValue = process.env.OPENAI_CUA_MODEL;
30
+ if (typeof rawValue !== "string" || rawValue.trim().length === 0) {
31
+ return "gpt-5.4";
32
+ }
33
+ return VALID_CUA_MODELS.has(rawValue) ? rawValue : "gpt-5.4";
34
+ }
26
35
  function parseBooleanFlagState(args, key) {
27
36
  return {
28
37
  enabled: args[key] === true,
@@ -47,6 +56,24 @@ function parseBudgetValue(rawValue, label) {
47
56
  }
48
57
  return numericValue;
49
58
  }
59
+ function parseContextOptimizationThreshold(rawValue, label) {
60
+ const numericValue = (() => {
61
+ if (typeof rawValue === "number") {
62
+ return rawValue;
63
+ }
64
+ if (typeof rawValue === "string" && /^-?\d+$/.test(rawValue.trim())) {
65
+ return Number.parseInt(rawValue, 10);
66
+ }
67
+ return Number.NaN;
68
+ })();
69
+ if (!Number.isInteger(numericValue)) {
70
+ throw new Error(`${label} must be an integer between ${MIN_CONTEXT_OPTIMIZATION_THRESHOLD} and ${MAX_CONTEXT_OPTIMIZATION_THRESHOLD}.`);
71
+ }
72
+ if (numericValue < MIN_CONTEXT_OPTIMIZATION_THRESHOLD || numericValue > MAX_CONTEXT_OPTIMIZATION_THRESHOLD) {
73
+ throw new Error(`${label} must be between ${MIN_CONTEXT_OPTIMIZATION_THRESHOLD} and ${MAX_CONTEXT_OPTIMIZATION_THRESHOLD}.`);
74
+ }
75
+ return numericValue;
76
+ }
50
77
  function normalizePromptCustomizations(rawValue, label) {
51
78
  if (rawValue == null) {
52
79
  return createEmptyPromptCustomizations();
@@ -108,6 +135,8 @@ async function loadConfigFromFile(configPath) {
108
135
  appContextEnabled: undefined,
109
136
  appContextBudget: undefined,
110
137
  appContextPath: undefined,
138
+ contextOptimizationEnabled: undefined,
139
+ contextOptimizationThreshold: undefined,
111
140
  strictMode: undefined,
112
141
  };
113
142
  if ("cuaModel" in rawConfig) {
@@ -129,6 +158,15 @@ async function loadConfigFromFile(configPath) {
129
158
  assertNonEmptyString(rawConfig.appContextPath, "config.appContextPath");
130
159
  normalized.appContextPath = path.resolve(configDir, rawConfig.appContextPath);
131
160
  }
161
+ if ("contextOptimizationEnabled" in rawConfig) {
162
+ if (typeof rawConfig.contextOptimizationEnabled !== "boolean") {
163
+ throw new Error("config.contextOptimizationEnabled must be a boolean.");
164
+ }
165
+ normalized.contextOptimizationEnabled = rawConfig.contextOptimizationEnabled;
166
+ }
167
+ if ("contextOptimizationThreshold" in rawConfig) {
168
+ normalized.contextOptimizationThreshold = parseContextOptimizationThreshold(rawConfig.contextOptimizationThreshold, "config.contextOptimizationThreshold");
169
+ }
132
170
  if ("strictMode" in rawConfig) {
133
171
  if (typeof rawConfig.strictMode !== "boolean") {
134
172
  throw new Error("config.strictMode must be a boolean.");
@@ -142,6 +180,9 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
142
180
  const configPath = typeof args.config === "string" ? args.config : null;
143
181
  const explicitContextPath = typeof args.context === "string" ? path.resolve(cwd, args.context) : null;
144
182
  const noContext = args["no-context"] === true || args.context === false;
183
+ const contextOptimizationEnabledByFlag = args["context-optimization"] === true;
184
+ const noContextOptimization = args["no-context-optimization"] === true || args["context-optimization"] === false;
185
+ const hasContextOptimizationThresholdFlag = args["context-optimization-threshold"] != null;
145
186
  const { enabled: strictEnabledByFlag, disabled: strictDisabledByFlag } = parseBooleanFlagState(args, "strict");
146
187
  const basePromptText = typeof args["base-prompt"] === "string" ? args["base-prompt"] : null;
147
188
  const executionPromptText = typeof args["execution-prompt"] === "string" ? args["execution-prompt"] : null;
@@ -153,6 +194,12 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
153
194
  if (strictEnabledByFlag && strictDisabledByFlag) {
154
195
  throw new Error("--strict and --no-strict cannot be used together.");
155
196
  }
197
+ if (contextOptimizationEnabledByFlag) {
198
+ throw new Error("--context-optimization is not supported; Context Optimization is enabled by default.");
199
+ }
200
+ if (noContextOptimization && hasContextOptimizationThresholdFlag) {
201
+ throw new Error("--context-optimization-threshold and --no-context-optimization cannot be used together.");
202
+ }
156
203
  const fileConfig = configPath ? await loadConfigFromFile(configPath) : null;
157
204
  const promptCustomizations = {
158
205
  ...createEmptyPromptCustomizations(),
@@ -160,11 +207,13 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
160
207
  };
161
208
  const resolved = {
162
209
  configPath: fileConfig?.configPath || null,
163
- cuaModel: fileConfig?.cuaModel || "gpt-5.4",
210
+ cuaModel: fileConfig?.cuaModel || readEnvDefaultCuaModel(),
164
211
  promptCustomizations,
165
212
  appContextEnabled: fileConfig?.appContextEnabled ?? true,
166
213
  appContextBudget: fileConfig?.appContextBudget ?? DEFAULT_APP_CONTEXT_BUDGET,
167
214
  appContextPath: fileConfig?.appContextPath || null,
215
+ contextOptimizationEnabled: fileConfig?.contextOptimizationEnabled ?? true,
216
+ contextOptimizationThreshold: fileConfig?.contextOptimizationThreshold ?? DEFAULT_CONTEXT_OPTIMIZATION_THRESHOLD,
168
217
  strictMode: fileConfig?.strictMode ?? false,
169
218
  };
170
219
  if (typeof args["cua-model"] === "string") {
@@ -173,6 +222,10 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
173
222
  if (args["app-context-budget"] != null) {
174
223
  resolved.appContextBudget = parseBudgetValue(args["app-context-budget"], "--app-context-budget");
175
224
  }
225
+ if (hasContextOptimizationThresholdFlag) {
226
+ resolved.contextOptimizationEnabled = true;
227
+ resolved.contextOptimizationThreshold = parseContextOptimizationThreshold(args["context-optimization-threshold"], "--context-optimization-threshold");
228
+ }
176
229
  if (basePromptFilePath) {
177
230
  resolved.promptCustomizations.basePromptInstructions = await readTextFile(basePromptFilePath, "--base-prompt-file");
178
231
  }
@@ -193,6 +246,9 @@ export async function resolveHeadlessExecutionConfig(args, options = {}) {
193
246
  resolved.appContextEnabled = false;
194
247
  resolved.appContextPath = null;
195
248
  }
249
+ if (noContextOptimization) {
250
+ resolved.contextOptimizationEnabled = false;
251
+ }
196
252
  if (strictEnabledByFlag) {
197
253
  resolved.strictMode = true;
198
254
  }
@@ -0,0 +1,12 @@
1
+ import { readFile } from "fs/promises";
2
+ import { parseTestScript } from "../test-store/test-script.js";
3
+ /**
4
+ * Load runnable instructions from a headless .dcua script file.
5
+ *
6
+ * @param {string} instructionsPath
7
+ * @returns {Promise<string[]>}
8
+ */
9
+ export async function loadHeadlessInstructions(instructionsPath) {
10
+ const content = await readFile(instructionsPath, "utf-8");
11
+ return parseTestScript(content);
12
+ }
@@ -0,0 +1,64 @@
1
+ function normalizeTarget(value) {
2
+ if (value == null || value === "") {
3
+ return "mobile";
4
+ }
5
+ if (typeof value !== "string") {
6
+ throw new Error("--target must be one of: mobile, web.");
7
+ }
8
+ const normalized = value.trim().toLowerCase();
9
+ if (normalized === "mobile" || normalized === "web") {
10
+ return normalized;
11
+ }
12
+ throw new Error("--target must be one of: mobile, web.");
13
+ }
14
+ function normalizeSessionMode(value) {
15
+ if (value == null || value === "") {
16
+ return "persistent";
17
+ }
18
+ if (typeof value !== "string") {
19
+ throw new Error("--session-mode must be one of: persistent, fresh.");
20
+ }
21
+ const normalized = value.trim().toLowerCase();
22
+ if (normalized === "persistent" || normalized === "fresh") {
23
+ return normalized;
24
+ }
25
+ throw new Error("--session-mode must be one of: persistent, fresh.");
26
+ }
27
+ function hasNonEmptyString(value) {
28
+ return typeof value === "string" && value.trim().length > 0;
29
+ }
30
+ export function resolveHeadlessTarget(args = {}) {
31
+ const target = normalizeTarget(args.target);
32
+ const sessionMode = normalizeSessionMode(args["session-mode"]);
33
+ const url = hasNonEmptyString(args.url) ? args.url.trim() : null;
34
+ const avdName = hasNonEmptyString(args.avd) ? args.avd.trim() : null;
35
+ const platform = hasNonEmptyString(args.platform) ? args.platform.trim() : null;
36
+ if (target === "web") {
37
+ if (!url) {
38
+ throw new Error("--target web requires --url.");
39
+ }
40
+ if (avdName || platform) {
41
+ throw new Error("--avd and --platform cannot be used with --target web.");
42
+ }
43
+ return {
44
+ target,
45
+ url,
46
+ sessionMode,
47
+ avdName: null,
48
+ platform: null,
49
+ };
50
+ }
51
+ if (hasNonEmptyString(args.url)) {
52
+ throw new Error("--url can only be used with --target web.");
53
+ }
54
+ if (args["session-mode"] != null) {
55
+ throw new Error("--session-mode can only be used with --target web.");
56
+ }
57
+ return {
58
+ target,
59
+ url: null,
60
+ sessionMode: null,
61
+ avdName,
62
+ platform,
63
+ };
64
+ }