@steipete/oracle 0.4.5 → 0.5.1

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 (48) hide show
  1. package/README.md +11 -9
  2. package/dist/.DS_Store +0 -0
  3. package/dist/bin/oracle-cli.js +16 -48
  4. package/dist/scripts/agent-send.js +147 -0
  5. package/dist/scripts/docs-list.js +110 -0
  6. package/dist/scripts/git-policy.js +125 -0
  7. package/dist/scripts/runner.js +1378 -0
  8. package/dist/scripts/test-browser.js +103 -0
  9. package/dist/scripts/test-remote-chrome.js +68 -0
  10. package/dist/src/browser/actions/attachments.js +47 -16
  11. package/dist/src/browser/actions/promptComposer.js +67 -18
  12. package/dist/src/browser/actions/remoteFileTransfer.js +36 -4
  13. package/dist/src/browser/chromeCookies.js +44 -6
  14. package/dist/src/browser/chromeLifecycle.js +166 -25
  15. package/dist/src/browser/config.js +25 -1
  16. package/dist/src/browser/constants.js +22 -3
  17. package/dist/src/browser/index.js +384 -22
  18. package/dist/src/browser/profileSync.js +141 -0
  19. package/dist/src/browser/prompt.js +3 -1
  20. package/dist/src/browser/reattach.js +59 -0
  21. package/dist/src/browser/sessionRunner.js +15 -1
  22. package/dist/src/browser/windowsCookies.js +2 -1
  23. package/dist/src/cli/browserConfig.js +11 -0
  24. package/dist/src/cli/browserDefaults.js +41 -0
  25. package/dist/src/cli/detach.js +2 -2
  26. package/dist/src/cli/dryRun.js +4 -2
  27. package/dist/src/cli/engine.js +2 -2
  28. package/dist/src/cli/help.js +2 -2
  29. package/dist/src/cli/options.js +2 -1
  30. package/dist/src/cli/runOptions.js +1 -1
  31. package/dist/src/cli/sessionDisplay.js +102 -104
  32. package/dist/src/cli/sessionRunner.js +39 -6
  33. package/dist/src/cli/sessionTable.js +88 -0
  34. package/dist/src/cli/tui/index.js +19 -89
  35. package/dist/src/heartbeat.js +2 -2
  36. package/dist/src/oracle/background.js +10 -2
  37. package/dist/src/oracle/client.js +107 -0
  38. package/dist/src/oracle/config.js +10 -2
  39. package/dist/src/oracle/errors.js +24 -4
  40. package/dist/src/oracle/modelResolver.js +144 -0
  41. package/dist/src/oracle/oscProgress.js +1 -1
  42. package/dist/src/oracle/run.js +83 -34
  43. package/dist/src/oracle/runUtils.js +12 -8
  44. package/dist/src/remote/server.js +214 -23
  45. package/dist/src/sessionManager.js +5 -2
  46. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  47. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  48. package/package.json +14 -14
@@ -1,14 +1,16 @@
1
- import { mkdtemp, rm } from 'node:fs/promises';
1
+ import { mkdtemp, rm, mkdir, readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
+ import net from 'node:net';
4
5
  import { resolveBrowserConfig } from './config.js';
5
- import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome, connectToRemoteChrome, } from './chromeLifecycle.js';
6
+ import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome, connectToRemoteChrome, closeRemoteChromeTarget, } from './chromeLifecycle.js';
6
7
  import { syncCookies } from './cookies.js';
7
8
  import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, readAssistantSnapshot, } from './pageActions.js';
8
9
  import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
9
- import { estimateTokenCount, withRetries } from './utils.js';
10
+ import { estimateTokenCount, withRetries, delay } from './utils.js';
10
11
  import { formatElapsed } from '../oracle/format.js';
11
12
  import { CHATGPT_URL } from './constants.js';
13
+ import { BrowserAutomationError } from '../oracle/errors.js';
12
14
  export { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
13
15
  export { parseDuration, delay, normalizeChatgptUrl } from './utils.js';
14
16
  export async function runBrowserMode(options) {
@@ -17,7 +19,7 @@ export async function runBrowserMode(options) {
17
19
  throw new Error('Prompt text is required when using browser mode.');
18
20
  }
19
21
  const attachments = options.attachments ?? [];
20
- const config = resolveBrowserConfig(options.config);
22
+ let config = resolveBrowserConfig(options.config);
21
23
  const logger = options.log ?? ((_message) => { });
22
24
  if (logger.verbose === undefined) {
23
25
  logger.verbose = Boolean(config.debug);
@@ -25,12 +27,44 @@ export async function runBrowserMode(options) {
25
27
  if (logger.sessionLog === undefined && options.log?.sessionLog) {
26
28
  logger.sessionLog = options.log.sessionLog;
27
29
  }
30
+ const runtimeHintCb = options.runtimeHintCb;
31
+ let lastTargetId;
32
+ let lastUrl;
33
+ const emitRuntimeHint = async () => {
34
+ if (!runtimeHintCb || !chrome?.port) {
35
+ return;
36
+ }
37
+ const hint = {
38
+ chromePid: chrome.pid,
39
+ chromePort: chrome.port,
40
+ chromeHost,
41
+ chromeTargetId: lastTargetId,
42
+ tabUrl: lastUrl,
43
+ userDataDir,
44
+ controllerPid: process.pid,
45
+ };
46
+ try {
47
+ await runtimeHintCb(hint);
48
+ }
49
+ catch (error) {
50
+ const message = error instanceof Error ? error.message : String(error);
51
+ logger(`Failed to persist runtime hint: ${message}`);
52
+ }
53
+ };
28
54
  if (config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === '1') {
29
55
  logger(`[browser-mode] config: ${JSON.stringify({
30
56
  ...config,
31
57
  promptLength: promptText.length,
32
58
  })}`);
33
59
  }
60
+ if (!config.remoteChrome && !config.manualLogin) {
61
+ const preferredPort = config.debugPort ?? DEFAULT_DEBUG_PORT;
62
+ const availablePort = await pickAvailableDebugPort(preferredPort, logger);
63
+ if (availablePort !== preferredPort) {
64
+ logger(`DevTools port ${preferredPort} busy; using ${availablePort} to avoid attaching to stray Chrome.`);
65
+ }
66
+ config = { ...config, debugPort: availablePort };
67
+ }
34
68
  // Remote Chrome mode - connect to existing browser
35
69
  if (config.remoteChrome) {
36
70
  // Warn about ignored local-only options
@@ -40,12 +74,31 @@ export async function runBrowserMode(options) {
40
74
  }
41
75
  return runRemoteBrowserMode(promptText, attachments, config, logger, options);
42
76
  }
43
- const userDataDir = await mkdtemp(path.join(os.tmpdir(), 'oracle-browser-'));
44
- logger(`Created temporary Chrome profile at ${userDataDir}`);
45
- const chrome = await launchChrome(config, userDataDir, logger);
77
+ const manualLogin = Boolean(config.manualLogin);
78
+ const manualProfileDir = config.manualLoginProfileDir
79
+ ? path.resolve(config.manualLoginProfileDir)
80
+ : path.join(os.homedir(), '.oracle', 'browser-profile');
81
+ const userDataDir = manualLogin
82
+ ? manualProfileDir
83
+ : await mkdtemp(path.join(await resolveUserDataBaseDir(), 'oracle-browser-'));
84
+ if (manualLogin) {
85
+ await mkdir(userDataDir, { recursive: true });
86
+ logger(`Manual login mode enabled; reusing persistent profile at ${userDataDir}`);
87
+ }
88
+ else {
89
+ logger(`Created temporary Chrome profile at ${userDataDir}`);
90
+ }
91
+ const effectiveKeepBrowser = config.keepBrowser || manualLogin;
92
+ const reusedChrome = manualLogin ? await maybeReuseRunningChrome(userDataDir, logger) : null;
93
+ const chrome = reusedChrome ??
94
+ (await launchChrome({
95
+ ...config,
96
+ remoteChrome: config.remoteChrome,
97
+ }, userDataDir, logger));
98
+ const chromeHost = chrome.host ?? '127.0.0.1';
46
99
  let removeTerminationHooks = null;
47
100
  try {
48
- removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, config.keepBrowser, logger);
101
+ removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger);
49
102
  }
50
103
  catch {
51
104
  // ignore failure; cleanup still happens below
@@ -60,7 +113,16 @@ export async function runBrowserMode(options) {
60
113
  let stopThinkingMonitor = null;
61
114
  let appliedCookies = 0;
62
115
  try {
63
- client = await connectToChrome(chrome.port, logger);
116
+ try {
117
+ client = await connectToChrome(chrome.port, logger, chromeHost);
118
+ }
119
+ catch (error) {
120
+ const hint = describeDevtoolsFirewallHint(chromeHost, chrome.port);
121
+ if (hint) {
122
+ logger(hint);
123
+ }
124
+ throw error;
125
+ }
64
126
  const disconnectPromise = new Promise((_, reject) => {
65
127
  client?.on('disconnect', () => {
66
128
  connectionClosedUnexpectedly = true;
@@ -78,8 +140,11 @@ export async function runBrowserMode(options) {
78
140
  domainEnablers.push(DOM.enable());
79
141
  }
80
142
  await Promise.all(domainEnablers);
81
- await Network.clearBrowserCookies();
82
- if (config.cookieSync) {
143
+ if (!manualLogin) {
144
+ await Network.clearBrowserCookies();
145
+ }
146
+ const cookieSyncEnabled = config.cookieSync && !manualLogin;
147
+ if (cookieSyncEnabled) {
83
148
  if (!config.inlineCookies) {
84
149
  logger('Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; use --copy or --render for manual flow.');
85
150
  }
@@ -105,20 +170,68 @@ export async function runBrowserMode(options) {
105
170
  : 'No Chrome cookies found; continuing without session reuse');
106
171
  }
107
172
  else {
108
- logger('Skipping Chrome cookie sync (--browser-no-cookie-sync)');
173
+ logger(manualLogin
174
+ ? 'Skipping Chrome cookie sync (--browser-manual-login enabled); reuse the opened profile after signing in.'
175
+ : 'Skipping Chrome cookie sync (--browser-no-cookie-sync)');
176
+ }
177
+ if (cookieSyncEnabled && !manualLogin && (appliedCookies ?? 0) === 0 && !config.inlineCookies) {
178
+ throw new BrowserAutomationError('No ChatGPT cookies were applied from your Chrome profile; cannot proceed in browser mode. ' +
179
+ 'Make sure ChatGPT is signed in in the selected profile or rebuild the keytar native module if it failed to load.', {
180
+ stage: 'execute-browser',
181
+ details: {
182
+ profile: config.chromeProfile ?? 'Default',
183
+ cookiePath: config.chromeCookiePath ?? null,
184
+ hint: 'Rebuild keytar: PYTHON=/usr/bin/python3 /Users/steipete/Projects/oracle/runner npx node-gyp rebuild (run inside the keytar path from the error), then retry.',
185
+ },
186
+ });
109
187
  }
110
188
  const baseUrl = CHATGPT_URL;
111
189
  // First load the base ChatGPT homepage to satisfy potential interstitials,
112
190
  // then hop to the requested URL if it differs.
113
191
  await raceWithDisconnect(navigateToChatGPT(Page, Runtime, baseUrl, logger));
114
192
  await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
115
- await raceWithDisconnect(ensureLoggedIn(Runtime, logger, { appliedCookies }));
193
+ await raceWithDisconnect(waitForLogin({ runtime: Runtime, logger, appliedCookies, manualLogin, timeoutMs: config.timeoutMs }));
116
194
  if (config.url !== baseUrl) {
117
195
  await raceWithDisconnect(navigateToChatGPT(Page, Runtime, config.url, logger));
118
196
  await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
119
197
  }
120
198
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
121
199
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
200
+ const captureRuntimeSnapshot = async () => {
201
+ try {
202
+ if (client?.Target?.getTargetInfo) {
203
+ const info = await client.Target.getTargetInfo({});
204
+ lastTargetId = info?.targetInfo?.targetId ?? lastTargetId;
205
+ lastUrl = info?.targetInfo?.url ?? lastUrl;
206
+ }
207
+ }
208
+ catch {
209
+ // ignore
210
+ }
211
+ try {
212
+ const { result } = await Runtime.evaluate({
213
+ expression: 'location.href',
214
+ returnByValue: true,
215
+ });
216
+ if (typeof result?.value === 'string') {
217
+ lastUrl = result.value;
218
+ }
219
+ }
220
+ catch {
221
+ // ignore
222
+ }
223
+ if (chrome?.port) {
224
+ const suffix = lastTargetId ? ` target=${lastTargetId}` : '';
225
+ if (lastUrl) {
226
+ logger(`[reattach] chrome port=${chrome.port} host=${chromeHost} url=${lastUrl}${suffix}`);
227
+ }
228
+ else {
229
+ logger(`[reattach] chrome port=${chrome.port} host=${chromeHost}${suffix}`);
230
+ }
231
+ await emitRuntimeHint();
232
+ }
233
+ };
234
+ await captureRuntimeSnapshot();
122
235
  if (config.desiredModel) {
123
236
  await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger), {
124
237
  retries: 2,
@@ -223,7 +336,11 @@ export async function runBrowserMode(options) {
223
336
  answerChars,
224
337
  chromePid: chrome.pid,
225
338
  chromePort: chrome.port,
339
+ chromeHost,
226
340
  userDataDir,
341
+ chromeTargetId: lastTargetId,
342
+ tabUrl: lastUrl,
343
+ controllerPid: process.pid,
227
344
  };
228
345
  }
229
346
  catch (error) {
@@ -242,9 +359,19 @@ export async function runBrowserMode(options) {
242
359
  logger(`Chrome window closed before completion: ${normalizedError.message}`);
243
360
  logger(normalizedError.stack);
244
361
  }
245
- throw new Error('Chrome window closed before oracle finished. Please keep it open until completion.', {
246
- cause: normalizedError,
247
- });
362
+ await emitRuntimeHint();
363
+ throw new BrowserAutomationError('Chrome window closed before oracle finished. Please keep it open until completion.', {
364
+ stage: 'connection-lost',
365
+ runtime: {
366
+ chromePid: chrome.pid,
367
+ chromePort: chrome.port,
368
+ chromeHost,
369
+ userDataDir,
370
+ chromeTargetId: lastTargetId,
371
+ tabUrl: lastUrl,
372
+ controllerPid: process.pid,
373
+ },
374
+ }, normalizedError);
248
375
  }
249
376
  finally {
250
377
  try {
@@ -256,7 +383,7 @@ export async function runBrowserMode(options) {
256
383
  // ignore
257
384
  }
258
385
  removeTerminationHooks?.();
259
- if (!config.keepBrowser) {
386
+ if (!effectiveKeepBrowser) {
260
387
  if (!connectionClosedUnexpectedly) {
261
388
  try {
262
389
  await chrome.kill();
@@ -276,6 +403,153 @@ export async function runBrowserMode(options) {
276
403
  }
277
404
  }
278
405
  }
406
+ const DEFAULT_DEBUG_PORT = 9222;
407
+ async function pickAvailableDebugPort(preferredPort, logger) {
408
+ const start = Number.isFinite(preferredPort) && preferredPort > 0 ? preferredPort : DEFAULT_DEBUG_PORT;
409
+ for (let offset = 0; offset < 10; offset++) {
410
+ const candidate = start + offset;
411
+ if (await isPortAvailable(candidate)) {
412
+ return candidate;
413
+ }
414
+ }
415
+ const fallback = await findEphemeralPort();
416
+ logger(`DevTools ports ${start}-${start + 9} are occupied; falling back to ${fallback}.`);
417
+ return fallback;
418
+ }
419
+ async function isPortAvailable(port) {
420
+ return new Promise((resolve) => {
421
+ const server = net.createServer();
422
+ server.once('error', () => resolve(false));
423
+ server.once('listening', () => {
424
+ server.close(() => resolve(true));
425
+ });
426
+ server.listen(port, '127.0.0.1');
427
+ });
428
+ }
429
+ async function findEphemeralPort() {
430
+ return new Promise((resolve, reject) => {
431
+ const server = net.createServer();
432
+ server.once('error', (error) => {
433
+ server.close();
434
+ reject(error);
435
+ });
436
+ server.listen(0, '127.0.0.1', () => {
437
+ const address = server.address();
438
+ if (address && typeof address === 'object') {
439
+ const port = address.port;
440
+ server.close(() => resolve(port));
441
+ }
442
+ else {
443
+ server.close(() => reject(new Error('Failed to acquire ephemeral port')));
444
+ }
445
+ });
446
+ });
447
+ }
448
+ async function waitForLogin({ runtime, logger, appliedCookies, manualLogin, timeoutMs, }) {
449
+ if (!manualLogin) {
450
+ await ensureLoggedIn(runtime, logger, { appliedCookies });
451
+ return;
452
+ }
453
+ const deadline = Date.now() + Math.min(timeoutMs ?? 1_200_000, 20 * 60_000);
454
+ let lastNotice = 0;
455
+ while (Date.now() < deadline) {
456
+ try {
457
+ await ensureLoggedIn(runtime, logger, { appliedCookies });
458
+ return;
459
+ }
460
+ catch (error) {
461
+ const message = error instanceof Error ? error.message : String(error);
462
+ const loginDetected = message?.toLowerCase().includes('login button');
463
+ const sessionMissing = message?.toLowerCase().includes('session not detected');
464
+ if (!loginDetected && !sessionMissing) {
465
+ throw error;
466
+ }
467
+ const now = Date.now();
468
+ if (now - lastNotice > 5000) {
469
+ logger('Manual login mode: please sign into chatgpt.com in the opened Chrome window; waiting for session to appear...');
470
+ lastNotice = now;
471
+ }
472
+ await delay(1000);
473
+ }
474
+ }
475
+ throw new Error('Manual login mode timed out waiting for ChatGPT session; please sign in and retry.');
476
+ }
477
+ async function _assertNavigatedToHttp(runtime, _logger, timeoutMs = 10_000) {
478
+ const deadline = Date.now() + timeoutMs;
479
+ let lastUrl = '';
480
+ while (Date.now() < deadline) {
481
+ const { result } = await runtime.evaluate({
482
+ expression: 'typeof location === "object" && location.href ? location.href : ""',
483
+ returnByValue: true,
484
+ });
485
+ const url = typeof result?.value === 'string' ? result.value : '';
486
+ lastUrl = url;
487
+ if (/^https?:\/\//i.test(url)) {
488
+ return url;
489
+ }
490
+ await delay(250);
491
+ }
492
+ throw new BrowserAutomationError('ChatGPT session not detected; page never left new tab.', {
493
+ stage: 'execute-browser',
494
+ details: { url: lastUrl || '(empty)' },
495
+ });
496
+ }
497
+ async function maybeReuseRunningChrome(userDataDir, logger) {
498
+ const port = await readDevToolsPort(userDataDir);
499
+ if (!port)
500
+ return null;
501
+ const versionUrl = `http://127.0.0.1:${port}/json/version`;
502
+ try {
503
+ const controller = new AbortController();
504
+ const timeout = setTimeout(() => controller.abort(), 1500);
505
+ const response = await fetch(versionUrl, { signal: controller.signal });
506
+ clearTimeout(timeout);
507
+ if (!response.ok)
508
+ throw new Error(`HTTP ${response.status}`);
509
+ const pidPath = path.join(userDataDir, 'chrome.pid');
510
+ let pid;
511
+ try {
512
+ const rawPid = (await readFile(pidPath, 'utf8')).trim();
513
+ pid = Number.parseInt(rawPid, 10);
514
+ if (Number.isNaN(pid))
515
+ pid = undefined;
516
+ }
517
+ catch {
518
+ pid = undefined;
519
+ }
520
+ logger(`Found running Chrome for ${userDataDir}; reusing (DevTools port ${port}${pid ? `, pid ${pid}` : ''})`);
521
+ return {
522
+ port,
523
+ pid,
524
+ kill: async () => { },
525
+ process: undefined,
526
+ };
527
+ }
528
+ catch (error) {
529
+ const message = error instanceof Error ? error.message : String(error);
530
+ logger(`DevToolsActivePort found for ${userDataDir} but unreachable (${message}); launching new Chrome.`);
531
+ return null;
532
+ }
533
+ }
534
+ async function readDevToolsPort(userDataDir) {
535
+ const candidates = [
536
+ path.join(userDataDir, 'DevToolsActivePort'),
537
+ path.join(userDataDir, 'Default', 'DevToolsActivePort'),
538
+ ];
539
+ for (const candidate of candidates) {
540
+ try {
541
+ const raw = await readFile(candidate, 'utf8');
542
+ const firstLine = raw.split(/\r?\n/u)[0]?.trim();
543
+ const port = Number.parseInt(firstLine ?? '', 10);
544
+ if (Number.isFinite(port)) {
545
+ return port;
546
+ }
547
+ }
548
+ catch {
549
+ }
550
+ }
551
+ return null;
552
+ }
279
553
  async function runRemoteBrowserMode(promptText, attachments, config, logger, options) {
280
554
  const remoteChromeConfig = config.remoteChrome;
281
555
  if (!remoteChromeConfig) {
@@ -284,6 +558,26 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
284
558
  const { host, port } = remoteChromeConfig;
285
559
  logger(`Connecting to remote Chrome at ${host}:${port}`);
286
560
  let client = null;
561
+ let remoteTargetId = null;
562
+ let lastUrl;
563
+ const runtimeHintCb = options.runtimeHintCb;
564
+ const emitRuntimeHint = async () => {
565
+ if (!runtimeHintCb)
566
+ return;
567
+ try {
568
+ await runtimeHintCb({
569
+ chromePort: port,
570
+ chromeHost: host,
571
+ chromeTargetId: remoteTargetId ?? undefined,
572
+ tabUrl: lastUrl,
573
+ controllerPid: process.pid,
574
+ });
575
+ }
576
+ catch (error) {
577
+ const message = error instanceof Error ? error.message : String(error);
578
+ logger(`Failed to persist runtime hint: ${message}`);
579
+ }
580
+ };
287
581
  const startedAt = Date.now();
288
582
  let answerText = '';
289
583
  let answerMarkdown = '';
@@ -291,7 +585,10 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
291
585
  let connectionClosedUnexpectedly = false;
292
586
  let stopThinkingMonitor = null;
293
587
  try {
294
- client = await connectToRemoteChrome(host, port, logger);
588
+ const connection = await connectToRemoteChrome(host, port, logger, config.url);
589
+ client = connection.client;
590
+ remoteTargetId = connection.targetId ?? null;
591
+ await emitRuntimeHint();
295
592
  const markConnectionLost = () => {
296
593
  connectionClosedUnexpectedly = true;
297
594
  };
@@ -309,6 +606,19 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
309
606
  await ensureLoggedIn(Runtime, logger, { remoteSession: true });
310
607
  await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
311
608
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
609
+ try {
610
+ const { result } = await Runtime.evaluate({
611
+ expression: 'location.href',
612
+ returnByValue: true,
613
+ });
614
+ if (typeof result?.value === 'string') {
615
+ lastUrl = result.value;
616
+ }
617
+ await emitRuntimeHint();
618
+ }
619
+ catch {
620
+ // ignore
621
+ }
312
622
  if (config.desiredModel) {
313
623
  await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger), {
314
624
  retries: 2,
@@ -369,7 +679,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
369
679
  answerChars,
370
680
  chromePid: undefined,
371
681
  chromePort: port,
682
+ chromeHost: host,
372
683
  userDataDir: undefined,
684
+ chromeTargetId: remoteTargetId ?? undefined,
685
+ tabUrl: lastUrl,
686
+ controllerPid: process.pid,
373
687
  };
374
688
  }
375
689
  catch (error) {
@@ -384,8 +698,15 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
384
698
  }
385
699
  throw normalizedError;
386
700
  }
387
- throw new Error('Remote Chrome connection lost before Oracle finished.', {
388
- cause: normalizedError,
701
+ throw new BrowserAutomationError('Remote Chrome connection lost before Oracle finished.', {
702
+ stage: 'connection-lost',
703
+ runtime: {
704
+ chromeHost: host,
705
+ chromePort: port,
706
+ chromeTargetId: remoteTargetId ?? undefined,
707
+ tabUrl: lastUrl,
708
+ controllerPid: process.pid,
709
+ },
389
710
  });
390
711
  }
391
712
  finally {
@@ -397,6 +718,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
397
718
  catch {
398
719
  // ignore
399
720
  }
721
+ await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
400
722
  // Don't kill remote Chrome - it's not ours to manage
401
723
  const totalSeconds = (Date.now() - startedAt) / 1000;
402
724
  logger(`Remote session complete • ${totalSeconds.toFixed(1)}s total`);
@@ -429,7 +751,7 @@ function startThinkingStatusMonitor(Runtime, logger, includeDiagnostics = false)
429
751
  let lastMessage = null;
430
752
  const startedAt = Date.now();
431
753
  const interval = setInterval(async () => {
432
- // biome-ignore lint/nursery/noUnnecessaryConditions: stop flag flips asynchronously
754
+ // stop flag flips asynchronously
433
755
  if (stopped || pending) {
434
756
  return;
435
757
  }
@@ -460,7 +782,7 @@ function startThinkingStatusMonitor(Runtime, logger, includeDiagnostics = false)
460
782
  }, 1500);
461
783
  interval.unref?.();
462
784
  return () => {
463
- // biome-ignore lint/nursery/noUnnecessaryConditions: multiple callers may race to stop
785
+ // multiple callers may race to stop
464
786
  if (stopped) {
465
787
  return;
466
788
  }
@@ -491,6 +813,46 @@ function sanitizeThinkingText(raw) {
491
813
  }
492
814
  return trimmed;
493
815
  }
816
+ function describeDevtoolsFirewallHint(host, port) {
817
+ if (!isWsl())
818
+ return null;
819
+ return [
820
+ `DevTools port ${host}:${port} is blocked from WSL.`,
821
+ '',
822
+ 'PowerShell (admin):',
823
+ `New-NetFirewallRule -DisplayName 'Chrome DevTools ${port}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${port}`,
824
+ "New-NetFirewallRule -DisplayName 'Chrome DevTools (chrome.exe)' -Direction Inbound -Action Allow -Program 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' -Protocol TCP",
825
+ '',
826
+ 'Re-run the same oracle command after adding the rule.',
827
+ ].join('\n');
828
+ }
829
+ function isWsl() {
830
+ if (process.platform !== 'linux')
831
+ return false;
832
+ if (process.env.WSL_DISTRO_NAME)
833
+ return true;
834
+ return os.release().toLowerCase().includes('microsoft');
835
+ }
836
+ async function resolveUserDataBaseDir() {
837
+ // On WSL, Chrome launched via Windows can choke on UNC paths; prefer a Windows-backed temp folder.
838
+ if (isWsl()) {
839
+ const candidates = [
840
+ '/mnt/c/Users/Public/AppData/Local/Temp',
841
+ '/mnt/c/Temp',
842
+ '/mnt/c/Windows/Temp',
843
+ ];
844
+ for (const candidate of candidates) {
845
+ try {
846
+ await mkdir(candidate, { recursive: true });
847
+ return candidate;
848
+ }
849
+ catch {
850
+ // try next
851
+ }
852
+ }
853
+ }
854
+ return os.tmpdir();
855
+ }
494
856
  function buildThinkingStatusExpression() {
495
857
  const selectors = [
496
858
  'span.loading-shimmer',