@loadmill/droid-cua 2.8.0 → 2.10.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 (33) hide show
  1. package/README.md +7 -6
  2. package/build/index.js +89 -71
  3. package/build/src/brand/loadmill-mark.js +20 -0
  4. package/build/src/browser/playwright/connection.js +160 -12
  5. package/build/src/browser/playwright/welcome-page.js +161 -0
  6. package/build/src/cli/headless-execution-config.js +18 -58
  7. package/build/src/cli/headless-target.js +0 -9
  8. package/build/src/cli/ink-shell.js +5 -2
  9. package/build/src/commands/help.js +5 -8
  10. package/build/src/commands/loadmill.js +10 -1
  11. package/build/src/commands/run.js +17 -28
  12. package/build/src/core/aim-assist.js +5 -0
  13. package/build/src/core/app-context.js +57 -33
  14. package/build/src/core/context-optimization.js +1 -1
  15. package/build/src/core/cua-models.js +9 -0
  16. package/build/src/core/execution-engine.js +121 -199
  17. package/build/src/core/runtime-config.js +95 -0
  18. package/build/src/device/loadmill.js +15 -9
  19. package/build/src/device/openai.js +76 -142
  20. package/build/src/integrations/loadmill/client.js +149 -131
  21. package/build/src/integrations/loadmill/executor.js +17 -11
  22. package/build/src/integrations/loadmill/index.js +1 -1
  23. package/build/src/integrations/loadmill/interpreter.js +4 -14
  24. package/build/src/modes/design-mode-ink.js +5 -6
  25. package/build/src/modes/design-mode.js +5 -6
  26. package/build/src/modes/execution-mode.js +26 -0
  27. package/build/src/prompts/base.js +44 -30
  28. package/build/src/prompts/design.js +3 -4
  29. package/build/src/prompts/execution.js +13 -110
  30. package/build/src/utils/console-output.js +8 -0
  31. package/build/src/utils/cua-debug-tracer.js +0 -1
  32. package/build/src/utils/structured-debug-log-manager.js +4 -1
  33. package/package.json +1 -1
package/README.md CHANGED
@@ -40,6 +40,7 @@ Under the hood, `droid-cua` uses an AI agent to explore your app, execute action
40
40
 
41
41
  - Mac: [Download](https://github.com/loadmill/droid-cua-release/releases/latest/download/Loadmill-Droid-CUA.dmg)
42
42
  - Mac (Intel): [Download](https://github.com/loadmill/droid-cua-release/releases/latest/download/Loadmill-Droid-CUA-intel.dmg)
43
+ - Windows: [Download](https://github.com/loadmill/droid-cua-release/releases/latest/download/Loadmill-Droid-CUA-Setup.exe)
43
44
 
44
45
  **2. Launch the app**
45
46
 
@@ -67,6 +68,10 @@ You can also keep project run history in a results folder and review past runs f
67
68
  - **Android** - Physical devices and emulators
68
69
  - **iOS** - Simulators on macOS
69
70
 
71
+ Desktop app support:
72
+ - macOS: Android and iOS simulator workflows
73
+ - Windows: Android workflows only
74
+
70
75
  ## Requirements
71
76
 
72
77
  **All platforms:**
@@ -154,10 +159,9 @@ Example headless config:
154
159
  "executionModeInstructions": ""
155
160
  },
156
161
  "appContextEnabled": true,
157
- "appContextBudget": 300,
158
162
  "appContextPath": "../tests/context.md",
159
163
  "contextOptimizationEnabled": true,
160
- "contextOptimizationThreshold": 30000
164
+ "contextOptimizationThreshold": 20000
161
165
  }
162
166
  ```
163
167
 
@@ -177,7 +181,6 @@ Supported CLI options include:
177
181
  - `--config`
178
182
  - `--cua-model`
179
183
  - `--context`
180
- - `--app-context-budget`
181
184
  - `--no-context`
182
185
  - `--context-optimization-threshold`
183
186
  - `--no-context-optimization`
@@ -185,8 +188,6 @@ Supported CLI options include:
185
188
  - `--execution-prompt`
186
189
  - `--base-prompt-file`
187
190
  - `--execution-prompt-file`
188
- - `--strict`
189
- - `--no-strict`
190
191
  - `--record`
191
192
  - `--debug`
192
193
 
@@ -195,7 +196,7 @@ Config and precedence rules:
195
196
  - CLI flags override config file values.
196
197
  - `--context` overrides the config app-context path.
197
198
  - `--no-context` disables app context entirely.
198
- - Context Optimization is enabled by default with a `30000` input-token threshold.
199
+ - Context Optimization is enabled by default with a `20000` input-token threshold.
199
200
  - `--context-optimization-threshold <tokens>` sets the threshold and enables Context Optimization.
200
201
  - `--no-context-optimization` disables Context Optimization.
201
202
  - `--base-prompt` and `--execution-prompt` let you pass prompt customizations inline on the command line.
package/build/index.js CHANGED
@@ -11,16 +11,18 @@ import { ExecutionEngine } from "./src/core/execution-engine.js";
11
11
  import { buildBaseSystemPrompt, buildExecutionModePrompt } from "./src/core/prompts.js";
12
12
  import { startInkShell } from "./src/cli/ink-shell.js";
13
13
  import { ExecutionMode } from "./src/modes/execution-mode.js";
14
+ import { createOpenAiExecutor } from "./src/device/openai.js";
14
15
  import { logger } from "./src/utils/logger.js";
15
16
  import { selectDevice } from "./src/cli/device-selector.js";
16
- import { buildAppContextBriefing, DEFAULT_APP_CONTEXT_BUDGET } from "./src/core/app-context.js";
17
- import { CONTEXT_OPTIMIZATION_ENABLED_ENV, CONTEXT_OPTIMIZATION_THRESHOLD_ENV, } from "./src/core/context-optimization.js";
17
+ import { resolveExecutionAppContext } from "./src/core/app-context.js";
18
+ import { createRunContext, resolveRuntimeConfigFromEnv, resolveRuntimeCredentialsFromEnv, } from "./src/core/runtime-config.js";
18
19
  import { resolveHeadlessExecutionConfig } from "./src/cli/headless-execution-config.js";
19
20
  import { resolveHeadlessTarget } from "./src/cli/headless-target.js";
20
21
  import { printCliOutput } from "./src/utils/console-output.js";
21
22
  import { emitDesktopDebug } from "./src/utils/desktop-debug.js";
22
23
  import { createHeadlessDebugArtifacts } from "./src/cli/headless-debug.js";
23
24
  import { loadHeadlessInstructions } from "./src/cli/headless-instructions.js";
25
+ import { createLoadmillApiClient } from "./src/integrations/loadmill/client.js";
24
26
  dotenv.config();
25
27
  const args = minimist(process.argv.slice(2));
26
28
  const targetConfig = resolveHeadlessTarget(args);
@@ -30,14 +32,31 @@ const recordScreenshots = args["record"] || false;
30
32
  const instructionsFile = args.instructions || args.i || null;
31
33
  const appContextPath = typeof args.context === "string" ? args.context : null;
32
34
  const debugMode = args["debug"] || false;
33
- const strictFlag = args.strict === true;
34
- const noStrictFlag = args["no-strict"] === true || args.strict === false;
35
- if (strictFlag && noStrictFlag) {
36
- throw new Error("--strict and --no-strict cannot be used together.");
37
- }
38
35
  const screenshotDir = path.join("droid-cua-recording-" + Date.now());
39
36
  if (recordScreenshots)
40
37
  await mkdir(screenshotDir, { recursive: true });
38
+ function createCliRuntimeContext(configOverrides = {}) {
39
+ // CLI env/config is bootstrap input only. After this boundary the shared
40
+ // runtime uses explicit run-scoped config and credentials objects.
41
+ const context = createRunContext({
42
+ config: resolveRuntimeConfigFromEnv(process.env, configOverrides),
43
+ credentials: resolveRuntimeCredentialsFromEnv(process.env),
44
+ });
45
+ const openAi = createOpenAiExecutor({
46
+ client: context.clients.openai,
47
+ config: context.config,
48
+ });
49
+ const loadmillClient = createLoadmillApiClient({
50
+ token: context.credentials.loadmillApiToken,
51
+ baseUrl: context.credentials.loadmillBaseUrl,
52
+ });
53
+ return {
54
+ ...context,
55
+ openAi,
56
+ openAiClient: context.clients.openai,
57
+ loadmillClient,
58
+ };
59
+ }
41
60
  async function main() {
42
61
  const isHeadlessInstructionsRun = Boolean(instructionsFile);
43
62
  const isBrowserTarget = targetConfig.target === "web";
@@ -63,7 +82,6 @@ async function main() {
63
82
  let deviceInfo;
64
83
  if (isBrowserTarget) {
65
84
  targetId = await connectToBrowser({
66
- url: targetConfig.url,
67
85
  headless: true,
68
86
  sessionMode: targetConfig.sessionMode
69
87
  });
@@ -91,10 +109,12 @@ async function main() {
91
109
  let instructions = [];
92
110
  let executionMode = null;
93
111
  try {
112
+ const headlessConfig = await resolveHeadlessExecutionConfig(args);
94
113
  await headlessDebug.startExecutionSession(runId, {
95
114
  testName,
96
115
  platform: deviceInfo.platform,
97
- deviceName: deviceInfo.device_name
116
+ deviceName: deviceInfo.device_name,
117
+ aimAssistMode: headlessConfig.aimAssistMode
98
118
  });
99
119
  const currentLogFilePath = await headlessDebug.getCurrentLogFilePath();
100
120
  if (currentLogFilePath) {
@@ -102,71 +122,53 @@ async function main() {
102
122
  }
103
123
  console.log(`\nRunning test from: ${instructionsFile}\n`);
104
124
  instructions = await loadHeadlessInstructions(instructionsFile);
105
- const taskText = instructions.join("\n");
106
- const headlessConfig = await resolveHeadlessExecutionConfig(args);
107
- process.env.OPENAI_CUA_MODEL = headlessConfig.cuaModel;
108
- process.env[CONTEXT_OPTIMIZATION_ENABLED_ENV] = String(headlessConfig.contextOptimizationEnabled);
109
- process.env[CONTEXT_OPTIMIZATION_THRESHOLD_ENV] = String(headlessConfig.contextOptimizationThreshold);
110
- console.log(`Using CUA model: ${headlessConfig.cuaModel}`);
111
- let appContextBriefing = "";
112
- if (!headlessConfig.appContextEnabled) {
113
- emitDesktopDebug("app_context.status", "execution", { runId }, {
125
+ const appContext = await resolveExecutionAppContext({
126
+ enabled: headlessConfig.appContextEnabled,
127
+ contextPath: headlessConfig.appContextPath,
128
+ });
129
+ const appContextText = appContext.text;
130
+ const runtimeContext = createCliRuntimeContext({
131
+ cuaModel: headlessConfig.cuaModel,
132
+ promptCustomizations: headlessConfig.promptCustomizations,
133
+ contextOptimizationEnabled: headlessConfig.contextOptimizationEnabled,
134
+ contextOptimizationThreshold: headlessConfig.contextOptimizationThreshold,
135
+ appContext: {
136
+ enabled: headlessConfig.appContextEnabled,
137
+ path: appContext.contextPath,
138
+ status: appContext.status,
139
+ text: appContextText,
140
+ message: appContext.message,
141
+ },
142
+ });
143
+ console.log(`Using CUA model: ${runtimeContext.config.cuaModel}`);
144
+ if (appContext.status === "loaded" || appContext.status === "empty") {
145
+ emitDesktopDebug("app_context.loaded", "execution", { runId }, {
114
146
  source: "cli_context_flag",
115
- contextPath: null,
116
- budget: headlessConfig.appContextBudget,
117
- status: "disabled"
147
+ contextPath: appContext.contextPath,
148
+ status: appContext.status,
149
+ text: appContextText
118
150
  });
119
- }
120
- else if (headlessConfig.appContextPath) {
121
- try {
122
- const result = await buildAppContextBriefing({
123
- contextPath: headlessConfig.appContextPath,
124
- taskText,
125
- budget: headlessConfig.appContextBudget,
126
- });
127
- appContextBriefing = result.briefing;
128
- emitDesktopDebug("app_context.status", "execution", { runId }, {
129
- source: "cli_context_flag",
130
- contextPath: result.contextPath,
131
- budget: headlessConfig.appContextBudget,
132
- outputTokens: result.outputTokens,
133
- status: appContextBriefing.trim().length > 0 ? "loaded" : "empty"
134
- });
135
- if (appContextBriefing) {
136
- emitDesktopDebug("app_context.briefing.full", "execution", { runId }, {
137
- source: "cli_context_flag",
138
- contextPath: result.contextPath,
139
- budget: headlessConfig.appContextBudget,
140
- outputTokens: result.outputTokens,
141
- briefing: appContextBriefing
142
- });
143
- console.log(`Using app context briefing from: ${result.contextPath}`);
144
- }
145
- }
146
- catch (error) {
147
- const message = error instanceof Error ? error.message : "Unknown app context error";
148
- emitDesktopDebug("app_context.status", "execution", { runId }, {
149
- source: "cli_context_flag",
150
- contextPath: headlessConfig.appContextPath,
151
- budget: headlessConfig.appContextBudget,
152
- status: "failed",
153
- message
154
- });
155
- console.warn(`Warning: could not load app context from ${headlessConfig.appContextPath}. Running without briefing.`);
151
+ if (appContextText) {
152
+ console.log(`Using app context from: ${appContext.contextPath}`);
156
153
  }
157
154
  }
155
+ else if (appContext.status === "failed") {
156
+ emitDesktopDebug("app_context.loaded", "execution", { runId }, {
157
+ source: "cli_context_flag",
158
+ contextPath: appContext.contextPath,
159
+ status: "failed",
160
+ message: appContext.message
161
+ });
162
+ console.warn(`Warning: could not load app context from ${appContext.contextPath}. Running without it.`);
163
+ }
158
164
  else {
159
- emitDesktopDebug("app_context.status", "execution", { runId }, {
165
+ emitDesktopDebug("app_context.loaded", "execution", { runId }, {
160
166
  source: "cli_context_flag",
161
- contextPath: null,
162
- budget: headlessConfig.appContextBudget,
163
- status: "missing"
167
+ contextPath: appContext.contextPath,
168
+ status: appContext.status
164
169
  });
165
- if (isBrowserTarget && deviceInfo.currentUrl) {
166
- appContextBriefing = `Starting URL: ${deviceInfo.currentUrl}`;
167
- }
168
170
  }
169
- const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextBriefing, { strictMode: headlessConfig.strictMode });
171
+ const executionPrompt = buildExecutionModePrompt(deviceInfo, headlessConfig.promptCustomizations, appContextText, { aimAssistMode: headlessConfig.aimAssistMode });
170
172
  session.setSystemPrompt(executionPrompt);
171
173
  const screenshotRecorder = headlessDebug.createExecutionScreenshotRecorder({
172
174
  runId,
@@ -177,12 +179,22 @@ async function main() {
177
179
  ? {
178
180
  recordScreenshots: true,
179
181
  screenshotRecorder,
180
- strictMode: headlessConfig.strictMode
182
+ stepDelayMs: runtimeContext.config.stepDelayMs,
183
+ sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
184
+ ...requestArgs,
185
+ aimAssistMode: headlessConfig.aimAssistMode,
186
+ }),
187
+ reviseTestScript: runtimeContext.openAi.reviseTestScript,
181
188
  }
182
189
  : {
183
190
  recordScreenshots,
184
191
  screenshotDir,
185
- strictMode: headlessConfig.strictMode
192
+ stepDelayMs: runtimeContext.config.stepDelayMs,
193
+ sendCUARequest: (requestArgs) => runtimeContext.openAi.sendCUARequest({
194
+ ...requestArgs,
195
+ aimAssistMode: headlessConfig.aimAssistMode,
196
+ }),
197
+ reviseTestScript: runtimeContext.openAi.reviseTestScript,
186
198
  };
187
199
  if (isBrowserTarget) {
188
200
  engineOptions.getScreenshotAsBase64 = (browserSessionId, browserInfo) => getBrowserScreenshotAsBase64(browserSessionId, browserInfo);
@@ -193,7 +205,10 @@ async function main() {
193
205
  executionMode = new ExecutionMode(session, engine, instructions, true);
194
206
  const result = await executionMode.execute({
195
207
  runId,
196
- addOutput: printCliOutput
208
+ addOutput: printCliOutput,
209
+ openAiClient: runtimeContext.openAiClient,
210
+ loadmillClient: runtimeContext.loadmillClient,
211
+ runtimeCredentials: runtimeContext.credentials,
197
212
  });
198
213
  const stats = executionMode.stats || {};
199
214
  const durationMs = stats.startTime ? Math.max(0, Date.now() - stats.startTime) : 0;
@@ -238,15 +253,18 @@ async function main() {
238
253
  throw error;
239
254
  }
240
255
  }
256
+ const runtimeContext = createCliRuntimeContext();
241
257
  const engine = new ExecutionEngine(session, {
242
258
  recordScreenshots,
243
259
  screenshotDir,
244
- strictMode: strictFlag,
260
+ stepDelayMs: runtimeContext.config.stepDelayMs,
261
+ sendCUARequest: runtimeContext.openAi.sendCUARequest,
262
+ reviseTestScript: runtimeContext.openAi.reviseTestScript,
245
263
  });
246
264
  // Otherwise, start interactive Ink shell
247
265
  await startInkShell(session, engine, {
248
266
  appContextPath: appContextPath ? path.resolve(appContextPath) : null,
249
- appContextBudget: DEFAULT_APP_CONTEXT_BUDGET,
267
+ runtimeContext,
250
268
  });
251
269
  }
252
270
  main().catch((error) => {
@@ -0,0 +1,20 @@
1
+ export const LOADMILL_MARK_PATHS = [
2
+ {
3
+ d: `M808.982 297.016 644.14 29.3c-1.484-2.012-5.587-8.153-11.234-8.488-7.747-.46-9.872 3.935-11.428
4
+ 7.151l-.029.06-111.354 457.775c-1.141 5.191.769 9.126 4.5 12.835 3.731 3.709 10.086 1.879
5
+ 12.796.5l275.123-180.566c2.845-1.478 8.763-5.472 9.675-9.625.912-4.153-1.758-9.681-3.207-11.926ZM221.403
6
+ 695.361l265.91-167.74c2.219-1.152 8.607-4.858 13.825-2.672 7.156 3 7.129 7.881 7.109 11.453v.067L406.583
7
+ 996.493c-1.262 5.167-4.71 7.857-9.694 9.537-4.983 1.69-9.882-2.75-11.708-5.18L217.718 717.557c-1.903-2.58-5.457-8.772-4.447-12.902
8
+ 1.01-4.131 5.843-7.917 8.132-9.294Z`,
9
+ fill: "#52BAD3"
10
+ },
11
+ {
12
+ d: `M282.079 171.043 8.876 355.76c-1.948 1.568-7.91 5.926-8.008 11.582-.133 7.758 4.346 9.697 7.625
13
+ 11.116l.062.027 452.431 99.593c5.234.922 9.086-1.152 12.634-5.036 3.549-3.884 1.453-10.156-.039-12.806L303.882
14
+ 176.598c-1.596-2.78-5.835-8.525-10.023-9.261-4.188-.737-9.598 2.163-11.78 3.706ZM769.31 811.501
15
+ 554.939 562.32c-1.503-1.998-6.214-7.686-4.919-13.193 1.775-7.554 6.594-8.334 10.12-8.904l.066-.011
16
+ 462.134 22.113c5.3.392 8.52 3.347 11.01 7.984 2.49 4.636-1.08 10.201-3.18 12.405L791.81 811.468c-2.23
17
+ 2.303-7.75 6.832-11.99 6.518-4.241-.314-8.774-4.454-10.51-6.485Z`,
18
+ fill: "#5691E2"
19
+ }
20
+ ];
@@ -6,10 +6,23 @@ import { emitDesktopDebug } from "../../utils/desktop-debug.js";
6
6
  import { requireInstalledBrowser } from "./installed-browser.js";
7
7
  import { clearActiveBrowserSession, getActiveBrowserSession, setActiveBrowserSession } from "./session-state.js";
8
8
  import { normalizeAutomationUrl, normalizeOptionalAutomationUrl } from "./url.js";
9
+ import { getWelcomePageUrl, isWelcomePageUrl } from "./welcome-page.js";
9
10
  const DEFAULT_VIEWPORT = { width: 1440, height: 900 };
10
11
  let browserConnectionTestHooks = null;
12
+ let pendingBrowserSessionShutdown = null;
13
+ async function gotoWithTimeoutTolerance(page, url) {
14
+ try {
15
+ await page.goto(url, { waitUntil: "load" });
16
+ }
17
+ catch (error) {
18
+ if (!(error instanceof Error) || error.name !== "TimeoutError") {
19
+ throw error;
20
+ }
21
+ console.warn(`Page load did not finish within the timeout for ${url}. Continuing with the current page state.`);
22
+ }
23
+ }
11
24
  function normalizeKnownPageUrl(url) {
12
- if (typeof url !== "string" || url.trim().length === 0 || url.trim() === "about:blank") {
25
+ if (typeof url !== "string" || url.trim().length === 0 || url.trim() === "about:blank" || isWelcomePageUrl(url.trim())) {
13
26
  return null;
14
27
  }
15
28
  try {
@@ -19,6 +32,125 @@ function normalizeKnownPageUrl(url) {
19
32
  return null;
20
33
  }
21
34
  }
35
+ async function closeExtraPages(context, primaryPage) {
36
+ for (const page of context.pages()) {
37
+ if (page === primaryPage) {
38
+ continue;
39
+ }
40
+ try {
41
+ await page.close();
42
+ }
43
+ catch {
44
+ // Best-effort cleanup for restored tabs.
45
+ }
46
+ }
47
+ }
48
+ async function cleanupClosedSession(session) {
49
+ if (!session.cleanupOnDisconnect) {
50
+ return;
51
+ }
52
+ try {
53
+ await rm(session.userDataDir, { recursive: true, force: true });
54
+ }
55
+ catch {
56
+ // Best-effort cleanup when the browser disappears outside normal disconnect flow.
57
+ }
58
+ }
59
+ function isBenignCloseError(error) {
60
+ if (!(error instanceof Error)) {
61
+ return false;
62
+ }
63
+ const message = `${error.name} ${error.message}`.toLowerCase();
64
+ return (message.includes("target page, context or browser has been closed") ||
65
+ message.includes("browser has been closed") ||
66
+ message.includes("context closed") ||
67
+ message.includes("target closed") ||
68
+ message.includes("session closed") ||
69
+ message.includes("closed") ||
70
+ message.includes("disconnected"));
71
+ }
72
+ function isCurrentSession(session) {
73
+ try {
74
+ return getActiveBrowserSession().sessionId === session.sessionId;
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ async function waitForPendingBrowserSessionShutdown() {
81
+ if (!pendingBrowserSessionShutdown) {
82
+ return;
83
+ }
84
+ try {
85
+ await pendingBrowserSessionShutdown;
86
+ }
87
+ catch {
88
+ // Ignore teardown failures here. A follow-up launch attempt can surface the next actionable error.
89
+ }
90
+ }
91
+ async function closeBrowserSession(session, options = {}) {
92
+ if (session.shutdownPromise) {
93
+ return await session.shutdownPromise;
94
+ }
95
+ const { externalReason = null, clearActive = true } = options;
96
+ session.disconnecting = true;
97
+ const shutdownPromise = (async () => {
98
+ try {
99
+ await session.context.close();
100
+ }
101
+ catch (error) {
102
+ if (!isBenignCloseError(error)) {
103
+ throw error;
104
+ }
105
+ }
106
+ finally {
107
+ if (clearActive && isCurrentSession(session)) {
108
+ clearActiveBrowserSession();
109
+ }
110
+ await cleanupClosedSession(session);
111
+ }
112
+ if (externalReason && !session.externalDisconnectNotified && typeof session.onDisconnected === "function") {
113
+ session.externalDisconnectNotified = true;
114
+ await session.onDisconnected({
115
+ sessionId: session.sessionId,
116
+ browserName: session.installedBrowser?.browserName || "Browser",
117
+ browserFamily: session.installedBrowser?.browserFamily || null,
118
+ reason: externalReason,
119
+ });
120
+ }
121
+ })();
122
+ session.shutdownPromise = shutdownPromise;
123
+ pendingBrowserSessionShutdown = shutdownPromise.finally(() => {
124
+ if (pendingBrowserSessionShutdown === session.shutdownPromise) {
125
+ pendingBrowserSessionShutdown = null;
126
+ }
127
+ });
128
+ return await session.shutdownPromise;
129
+ }
130
+ function handleExternalSessionClosed(session, reason) {
131
+ if (session.disconnecting) {
132
+ return;
133
+ }
134
+ void closeBrowserSession(session, {
135
+ externalReason: reason,
136
+ clearActive: true,
137
+ });
138
+ }
139
+ async function launchPersistentContextWithRetry(chromium, userDataDir, launchOptions, sessionMode) {
140
+ try {
141
+ return await chromium.launchPersistentContext(userDataDir, launchOptions);
142
+ }
143
+ catch (error) {
144
+ const message = error instanceof Error ? error.message : String(error);
145
+ const isProfileLockError = sessionMode === "persistent" && message.includes("ProcessSingleton");
146
+ if (!isProfileLockError) {
147
+ throw error;
148
+ }
149
+ await waitForPendingBrowserSessionShutdown();
150
+ await new Promise((resolve) => setTimeout(resolve, 300));
151
+ return await chromium.launchPersistentContext(userDataDir, launchOptions);
152
+ }
153
+ }
22
154
  function buildSessionBrowserInfo(url, installedBrowser) {
23
155
  const currentUrl = normalizeKnownPageUrl(url);
24
156
  const browserInfo = {
@@ -52,6 +184,7 @@ function normalizeConnectOptions(targetOrOptions) {
52
184
  url: normalizeOptionalAutomationUrl(options.url),
53
185
  headless: options.headless === true,
54
186
  sessionMode: options.sessionMode === "persistent" ? "persistent" : "fresh",
187
+ onDisconnected: typeof options.onDisconnected === "function" ? options.onDisconnected : null,
55
188
  persistentProfileDir: typeof options.persistentProfileDir === "string" && options.persistentProfileDir.trim().length > 0
56
189
  ? path.resolve(options.persistentProfileDir)
57
190
  : null,
@@ -104,6 +237,7 @@ export async function connectToBrowser(targetOrOptions) {
104
237
  const options = normalizeConnectOptions(targetOrOptions);
105
238
  const installedBrowser = await resolveInstalledBrowserForLaunch();
106
239
  const chromium = await loadChromium();
240
+ await waitForPendingBrowserSessionShutdown();
107
241
  try {
108
242
  getActiveBrowserSession();
109
243
  await disconnect();
@@ -112,16 +246,20 @@ export async function connectToBrowser(targetOrOptions) {
112
246
  // no active session to close
113
247
  }
114
248
  const { userDataDir, cleanupOnDisconnect } = await buildUserDataDir(options);
115
- const context = await chromium.launchPersistentContext(userDataDir, {
249
+ const launchOptions = {
116
250
  executablePath: installedBrowser.executablePath,
117
251
  headless: options.headless,
118
252
  viewport: DEFAULT_VIEWPORT,
119
253
  screen: DEFAULT_VIEWPORT,
120
254
  acceptDownloads: false,
121
- });
255
+ };
256
+ const context = await launchPersistentContextWithRetry(chromium, userDataDir, launchOptions, options.sessionMode);
122
257
  const primaryPage = context.pages()[0] ?? (await context.newPage());
123
258
  const sessionId = `browser-${randomUUID()}`;
124
259
  const normalizedUrl = options.url;
260
+ const welcomePageUrl = await getWelcomePageUrl();
261
+ const initialUrl = normalizedUrl || welcomePageUrl;
262
+ await closeExtraPages(context, primaryPage);
125
263
  context.on("page", (page) => {
126
264
  if (page === primaryPage) {
127
265
  return;
@@ -132,18 +270,28 @@ export async function connectToBrowser(targetOrOptions) {
132
270
  url: page.url() || null,
133
271
  });
134
272
  });
135
- if (normalizedUrl) {
136
- await primaryPage.goto(normalizedUrl, { waitUntil: "load" });
137
- }
138
- setActiveBrowserSession({
273
+ await gotoWithTimeoutTolerance(primaryPage, initialUrl);
274
+ const session = {
139
275
  sessionId,
140
276
  context,
141
277
  page: primaryPage,
142
278
  url: normalizedUrl,
279
+ welcomePageUrl,
143
280
  sessionMode: options.sessionMode,
144
281
  userDataDir,
145
282
  cleanupOnDisconnect,
146
283
  installedBrowser,
284
+ onDisconnected: options.onDisconnected,
285
+ disconnecting: false,
286
+ externalDisconnectNotified: false,
287
+ shutdownPromise: null,
288
+ };
289
+ setActiveBrowserSession(session);
290
+ primaryPage.on?.("close", () => {
291
+ handleExternalSessionClosed(session, "page_closed");
292
+ });
293
+ context.on?.("close", () => {
294
+ handleExternalSessionClosed(session, "context_closed");
147
295
  });
148
296
  emitDesktopDebug("browser.connect", "device", {}, {
149
297
  platform: "browser",
@@ -169,7 +317,7 @@ export async function getScreenshotAsBase64(browserSessionId) {
169
317
  export async function navigateToUrl(browserSessionId, url) {
170
318
  const session = getActiveBrowserSession(browserSessionId);
171
319
  const normalizedUrl = normalizeAutomationUrl(url);
172
- await session.page.goto(normalizedUrl, { waitUntil: "load" });
320
+ await gotoWithTimeoutTolerance(session.page, normalizedUrl);
173
321
  session.url = normalizedUrl;
174
322
  emitDesktopDebug("browser.navigate", "device", { browserSessionId }, {
175
323
  platform: "browser",
@@ -183,13 +331,13 @@ export async function disconnect() {
183
331
  session = getActiveBrowserSession();
184
332
  }
185
333
  catch {
334
+ await waitForPendingBrowserSessionShutdown();
186
335
  return;
187
336
  }
188
337
  clearActiveBrowserSession();
189
- await session.context.close();
190
- if (session.cleanupOnDisconnect) {
191
- await rm(session.userDataDir, { recursive: true, force: true });
192
- }
338
+ await closeBrowserSession(session, {
339
+ clearActive: false,
340
+ });
193
341
  }
194
342
  export function __setBrowserConnectionTestHooks(hooks = null) {
195
343
  browserConnectionTestHooks = hooks;