@steipete/oracle 0.4.5 → 0.5.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 (45) hide show
  1. package/README.md +11 -9
  2. package/dist/bin/oracle-cli.js +16 -48
  3. package/dist/scripts/agent-send.js +147 -0
  4. package/dist/scripts/docs-list.js +110 -0
  5. package/dist/scripts/git-policy.js +125 -0
  6. package/dist/scripts/runner.js +1378 -0
  7. package/dist/scripts/test-browser.js +103 -0
  8. package/dist/scripts/test-remote-chrome.js +68 -0
  9. package/dist/src/browser/actions/attachments.js +47 -16
  10. package/dist/src/browser/actions/promptComposer.js +29 -18
  11. package/dist/src/browser/actions/remoteFileTransfer.js +36 -4
  12. package/dist/src/browser/chromeCookies.js +37 -6
  13. package/dist/src/browser/chromeLifecycle.js +166 -25
  14. package/dist/src/browser/config.js +25 -1
  15. package/dist/src/browser/constants.js +22 -3
  16. package/dist/src/browser/index.js +301 -21
  17. package/dist/src/browser/prompt.js +3 -1
  18. package/dist/src/browser/reattach.js +59 -0
  19. package/dist/src/browser/sessionRunner.js +15 -1
  20. package/dist/src/browser/windowsCookies.js +2 -1
  21. package/dist/src/cli/browserConfig.js +11 -0
  22. package/dist/src/cli/browserDefaults.js +41 -0
  23. package/dist/src/cli/detach.js +2 -2
  24. package/dist/src/cli/dryRun.js +4 -2
  25. package/dist/src/cli/engine.js +2 -2
  26. package/dist/src/cli/help.js +2 -2
  27. package/dist/src/cli/options.js +2 -1
  28. package/dist/src/cli/runOptions.js +1 -1
  29. package/dist/src/cli/sessionDisplay.js +98 -5
  30. package/dist/src/cli/sessionRunner.js +39 -6
  31. package/dist/src/cli/tui/index.js +15 -18
  32. package/dist/src/heartbeat.js +2 -2
  33. package/dist/src/oracle/background.js +10 -2
  34. package/dist/src/oracle/client.js +17 -0
  35. package/dist/src/oracle/config.js +10 -2
  36. package/dist/src/oracle/errors.js +24 -4
  37. package/dist/src/oracle/modelResolver.js +144 -0
  38. package/dist/src/oracle/oscProgress.js +1 -1
  39. package/dist/src/oracle/run.js +82 -34
  40. package/dist/src/oracle/runUtils.js +12 -8
  41. package/dist/src/remote/server.js +214 -23
  42. package/dist/src/sessionManager.js +5 -2
  43. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  44. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  45. package/package.json +14 -14
@@ -1,14 +1,15 @@
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
4
  import { resolveBrowserConfig } from './config.js';
5
- import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome, connectToRemoteChrome, } from './chromeLifecycle.js';
5
+ import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToChrome, connectToRemoteChrome, closeRemoteChromeTarget, } from './chromeLifecycle.js';
6
6
  import { syncCookies } from './cookies.js';
7
7
  import { navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, readAssistantSnapshot, } from './pageActions.js';
8
8
  import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
9
- import { estimateTokenCount, withRetries } from './utils.js';
9
+ import { estimateTokenCount, withRetries, delay } from './utils.js';
10
10
  import { formatElapsed } from '../oracle/format.js';
11
11
  import { CHATGPT_URL } from './constants.js';
12
+ import { BrowserAutomationError } from '../oracle/errors.js';
12
13
  export { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
13
14
  export { parseDuration, delay, normalizeChatgptUrl } from './utils.js';
14
15
  export async function runBrowserMode(options) {
@@ -25,6 +26,30 @@ export async function runBrowserMode(options) {
25
26
  if (logger.sessionLog === undefined && options.log?.sessionLog) {
26
27
  logger.sessionLog = options.log.sessionLog;
27
28
  }
29
+ const runtimeHintCb = options.runtimeHintCb;
30
+ let lastTargetId;
31
+ let lastUrl;
32
+ const emitRuntimeHint = async () => {
33
+ if (!runtimeHintCb || !chrome?.port) {
34
+ return;
35
+ }
36
+ const hint = {
37
+ chromePid: chrome.pid,
38
+ chromePort: chrome.port,
39
+ chromeHost,
40
+ chromeTargetId: lastTargetId,
41
+ tabUrl: lastUrl,
42
+ userDataDir,
43
+ controllerPid: process.pid,
44
+ };
45
+ try {
46
+ await runtimeHintCb(hint);
47
+ }
48
+ catch (error) {
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ logger(`Failed to persist runtime hint: ${message}`);
51
+ }
52
+ };
28
53
  if (config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === '1') {
29
54
  logger(`[browser-mode] config: ${JSON.stringify({
30
55
  ...config,
@@ -40,12 +65,31 @@ export async function runBrowserMode(options) {
40
65
  }
41
66
  return runRemoteBrowserMode(promptText, attachments, config, logger, options);
42
67
  }
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);
68
+ const manualLogin = Boolean(config.manualLogin);
69
+ const manualProfileDir = config.manualLoginProfileDir
70
+ ? path.resolve(config.manualLoginProfileDir)
71
+ : path.join(os.homedir(), '.oracle', 'browser-profile');
72
+ const userDataDir = manualLogin
73
+ ? manualProfileDir
74
+ : await mkdtemp(path.join(await resolveUserDataBaseDir(), 'oracle-browser-'));
75
+ if (manualLogin) {
76
+ await mkdir(userDataDir, { recursive: true });
77
+ logger(`Manual login mode enabled; reusing persistent profile at ${userDataDir}`);
78
+ }
79
+ else {
80
+ logger(`Created temporary Chrome profile at ${userDataDir}`);
81
+ }
82
+ const effectiveKeepBrowser = config.keepBrowser || manualLogin;
83
+ const reusedChrome = manualLogin ? await maybeReuseRunningChrome(userDataDir, logger) : null;
84
+ const chrome = reusedChrome ??
85
+ (await launchChrome({
86
+ ...config,
87
+ remoteChrome: config.remoteChrome,
88
+ }, userDataDir, logger));
89
+ const chromeHost = chrome.host ?? '127.0.0.1';
46
90
  let removeTerminationHooks = null;
47
91
  try {
48
- removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, config.keepBrowser, logger);
92
+ removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger);
49
93
  }
50
94
  catch {
51
95
  // ignore failure; cleanup still happens below
@@ -60,7 +104,16 @@ export async function runBrowserMode(options) {
60
104
  let stopThinkingMonitor = null;
61
105
  let appliedCookies = 0;
62
106
  try {
63
- client = await connectToChrome(chrome.port, logger);
107
+ try {
108
+ client = await connectToChrome(chrome.port, logger, chromeHost);
109
+ }
110
+ catch (error) {
111
+ const hint = describeDevtoolsFirewallHint(chromeHost, chrome.port);
112
+ if (hint) {
113
+ logger(hint);
114
+ }
115
+ throw error;
116
+ }
64
117
  const disconnectPromise = new Promise((_, reject) => {
65
118
  client?.on('disconnect', () => {
66
119
  connectionClosedUnexpectedly = true;
@@ -78,8 +131,11 @@ export async function runBrowserMode(options) {
78
131
  domainEnablers.push(DOM.enable());
79
132
  }
80
133
  await Promise.all(domainEnablers);
81
- await Network.clearBrowserCookies();
82
- if (config.cookieSync) {
134
+ if (!manualLogin) {
135
+ await Network.clearBrowserCookies();
136
+ }
137
+ const cookieSyncEnabled = config.cookieSync && !manualLogin;
138
+ if (cookieSyncEnabled) {
83
139
  if (!config.inlineCookies) {
84
140
  logger('Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; use --copy or --render for manual flow.');
85
141
  }
@@ -105,20 +161,57 @@ export async function runBrowserMode(options) {
105
161
  : 'No Chrome cookies found; continuing without session reuse');
106
162
  }
107
163
  else {
108
- logger('Skipping Chrome cookie sync (--browser-no-cookie-sync)');
164
+ logger(manualLogin
165
+ ? 'Skipping Chrome cookie sync (--browser-manual-login enabled); reuse the opened profile after signing in.'
166
+ : 'Skipping Chrome cookie sync (--browser-no-cookie-sync)');
109
167
  }
110
168
  const baseUrl = CHATGPT_URL;
111
169
  // First load the base ChatGPT homepage to satisfy potential interstitials,
112
170
  // then hop to the requested URL if it differs.
113
171
  await raceWithDisconnect(navigateToChatGPT(Page, Runtime, baseUrl, logger));
114
172
  await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
115
- await raceWithDisconnect(ensureLoggedIn(Runtime, logger, { appliedCookies }));
173
+ await raceWithDisconnect(waitForLogin({ runtime: Runtime, logger, appliedCookies, manualLogin, timeoutMs: config.timeoutMs }));
116
174
  if (config.url !== baseUrl) {
117
175
  await raceWithDisconnect(navigateToChatGPT(Page, Runtime, config.url, logger));
118
176
  await raceWithDisconnect(ensureNotBlocked(Runtime, config.headless, logger));
119
177
  }
120
178
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
121
179
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
180
+ const captureRuntimeSnapshot = async () => {
181
+ try {
182
+ if (client?.Target?.getTargetInfo) {
183
+ const info = await client.Target.getTargetInfo({});
184
+ lastTargetId = info?.targetInfo?.targetId ?? lastTargetId;
185
+ lastUrl = info?.targetInfo?.url ?? lastUrl;
186
+ }
187
+ }
188
+ catch {
189
+ // ignore
190
+ }
191
+ try {
192
+ const { result } = await Runtime.evaluate({
193
+ expression: 'location.href',
194
+ returnByValue: true,
195
+ });
196
+ if (typeof result?.value === 'string') {
197
+ lastUrl = result.value;
198
+ }
199
+ }
200
+ catch {
201
+ // ignore
202
+ }
203
+ if (chrome?.port) {
204
+ const suffix = lastTargetId ? ` target=${lastTargetId}` : '';
205
+ if (lastUrl) {
206
+ logger(`[reattach] chrome port=${chrome.port} host=${chromeHost} url=${lastUrl}${suffix}`);
207
+ }
208
+ else {
209
+ logger(`[reattach] chrome port=${chrome.port} host=${chromeHost}${suffix}`);
210
+ }
211
+ await emitRuntimeHint();
212
+ }
213
+ };
214
+ await captureRuntimeSnapshot();
122
215
  if (config.desiredModel) {
123
216
  await raceWithDisconnect(withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger), {
124
217
  retries: 2,
@@ -223,7 +316,11 @@ export async function runBrowserMode(options) {
223
316
  answerChars,
224
317
  chromePid: chrome.pid,
225
318
  chromePort: chrome.port,
319
+ chromeHost,
226
320
  userDataDir,
321
+ chromeTargetId: lastTargetId,
322
+ tabUrl: lastUrl,
323
+ controllerPid: process.pid,
227
324
  };
228
325
  }
229
326
  catch (error) {
@@ -242,9 +339,19 @@ export async function runBrowserMode(options) {
242
339
  logger(`Chrome window closed before completion: ${normalizedError.message}`);
243
340
  logger(normalizedError.stack);
244
341
  }
245
- throw new Error('Chrome window closed before oracle finished. Please keep it open until completion.', {
246
- cause: normalizedError,
247
- });
342
+ await emitRuntimeHint();
343
+ throw new BrowserAutomationError('Chrome window closed before oracle finished. Please keep it open until completion.', {
344
+ stage: 'connection-lost',
345
+ runtime: {
346
+ chromePid: chrome.pid,
347
+ chromePort: chrome.port,
348
+ chromeHost,
349
+ userDataDir,
350
+ chromeTargetId: lastTargetId,
351
+ tabUrl: lastUrl,
352
+ controllerPid: process.pid,
353
+ },
354
+ }, normalizedError);
248
355
  }
249
356
  finally {
250
357
  try {
@@ -256,7 +363,7 @@ export async function runBrowserMode(options) {
256
363
  // ignore
257
364
  }
258
365
  removeTerminationHooks?.();
259
- if (!config.keepBrowser) {
366
+ if (!effectiveKeepBrowser) {
260
367
  if (!connectionClosedUnexpectedly) {
261
368
  try {
262
369
  await chrome.kill();
@@ -276,6 +383,91 @@ export async function runBrowserMode(options) {
276
383
  }
277
384
  }
278
385
  }
386
+ async function waitForLogin({ runtime, logger, appliedCookies, manualLogin, timeoutMs, }) {
387
+ if (!manualLogin) {
388
+ await ensureLoggedIn(runtime, logger, { appliedCookies });
389
+ return;
390
+ }
391
+ const deadline = Date.now() + Math.min(timeoutMs ?? 1_200_000, 20 * 60_000);
392
+ let lastNotice = 0;
393
+ while (Date.now() < deadline) {
394
+ try {
395
+ await ensureLoggedIn(runtime, logger, { appliedCookies });
396
+ return;
397
+ }
398
+ catch (error) {
399
+ const message = error instanceof Error ? error.message : String(error);
400
+ const loginDetected = message?.toLowerCase().includes('login button');
401
+ const sessionMissing = message?.toLowerCase().includes('session not detected');
402
+ if (!loginDetected && !sessionMissing) {
403
+ throw error;
404
+ }
405
+ const now = Date.now();
406
+ if (now - lastNotice > 5000) {
407
+ logger('Manual login mode: please sign into chatgpt.com in the opened Chrome window; waiting for session to appear...');
408
+ lastNotice = now;
409
+ }
410
+ await delay(1000);
411
+ }
412
+ }
413
+ throw new Error('Manual login mode timed out waiting for ChatGPT session; please sign in and retry.');
414
+ }
415
+ async function maybeReuseRunningChrome(userDataDir, logger) {
416
+ const port = await readDevToolsPort(userDataDir);
417
+ if (!port)
418
+ return null;
419
+ const versionUrl = `http://127.0.0.1:${port}/json/version`;
420
+ try {
421
+ const controller = new AbortController();
422
+ const timeout = setTimeout(() => controller.abort(), 1500);
423
+ const response = await fetch(versionUrl, { signal: controller.signal });
424
+ clearTimeout(timeout);
425
+ if (!response.ok)
426
+ throw new Error(`HTTP ${response.status}`);
427
+ const pidPath = path.join(userDataDir, 'chrome.pid');
428
+ let pid;
429
+ try {
430
+ const rawPid = (await readFile(pidPath, 'utf8')).trim();
431
+ pid = Number.parseInt(rawPid, 10);
432
+ if (Number.isNaN(pid))
433
+ pid = undefined;
434
+ }
435
+ catch {
436
+ pid = undefined;
437
+ }
438
+ logger(`Found running Chrome for ${userDataDir}; reusing (DevTools port ${port}${pid ? `, pid ${pid}` : ''})`);
439
+ return {
440
+ port,
441
+ pid,
442
+ kill: async () => { },
443
+ process: undefined,
444
+ };
445
+ }
446
+ catch (error) {
447
+ const message = error instanceof Error ? error.message : String(error);
448
+ logger(`DevToolsActivePort found for ${userDataDir} but unreachable (${message}); launching new Chrome.`);
449
+ return null;
450
+ }
451
+ }
452
+ async function readDevToolsPort(userDataDir) {
453
+ const candidates = [
454
+ path.join(userDataDir, 'DevToolsActivePort'),
455
+ path.join(userDataDir, 'Default', 'DevToolsActivePort'),
456
+ ];
457
+ for (const candidate of candidates) {
458
+ try {
459
+ const raw = await readFile(candidate, 'utf8');
460
+ const firstLine = raw.split(/\r?\n/u)[0]?.trim();
461
+ const port = Number.parseInt(firstLine ?? '', 10);
462
+ if (Number.isFinite(port)) {
463
+ return port;
464
+ }
465
+ }
466
+ catch {
467
+ }
468
+ }
469
+ return null;
470
+ }
279
471
  async function runRemoteBrowserMode(promptText, attachments, config, logger, options) {
280
472
  const remoteChromeConfig = config.remoteChrome;
281
473
  if (!remoteChromeConfig) {
@@ -284,6 +476,26 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
284
476
  const { host, port } = remoteChromeConfig;
285
477
  logger(`Connecting to remote Chrome at ${host}:${port}`);
286
478
  let client = null;
479
+ let remoteTargetId = null;
480
+ let lastUrl;
481
+ const runtimeHintCb = options.runtimeHintCb;
482
+ const emitRuntimeHint = async () => {
483
+ if (!runtimeHintCb)
484
+ return;
485
+ try {
486
+ await runtimeHintCb({
487
+ chromePort: port,
488
+ chromeHost: host,
489
+ chromeTargetId: remoteTargetId ?? undefined,
490
+ tabUrl: lastUrl,
491
+ controllerPid: process.pid,
492
+ });
493
+ }
494
+ catch (error) {
495
+ const message = error instanceof Error ? error.message : String(error);
496
+ logger(`Failed to persist runtime hint: ${message}`);
497
+ }
498
+ };
287
499
  const startedAt = Date.now();
288
500
  let answerText = '';
289
501
  let answerMarkdown = '';
@@ -291,7 +503,10 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
291
503
  let connectionClosedUnexpectedly = false;
292
504
  let stopThinkingMonitor = null;
293
505
  try {
294
- client = await connectToRemoteChrome(host, port, logger);
506
+ const connection = await connectToRemoteChrome(host, port, logger, config.url);
507
+ client = connection.client;
508
+ remoteTargetId = connection.targetId ?? null;
509
+ await emitRuntimeHint();
295
510
  const markConnectionLost = () => {
296
511
  connectionClosedUnexpectedly = true;
297
512
  };
@@ -309,6 +524,19 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
309
524
  await ensureLoggedIn(Runtime, logger, { remoteSession: true });
310
525
  await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
311
526
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
527
+ try {
528
+ const { result } = await Runtime.evaluate({
529
+ expression: 'location.href',
530
+ returnByValue: true,
531
+ });
532
+ if (typeof result?.value === 'string') {
533
+ lastUrl = result.value;
534
+ }
535
+ await emitRuntimeHint();
536
+ }
537
+ catch {
538
+ // ignore
539
+ }
312
540
  if (config.desiredModel) {
313
541
  await withRetries(() => ensureModelSelection(Runtime, config.desiredModel, logger), {
314
542
  retries: 2,
@@ -369,7 +597,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
369
597
  answerChars,
370
598
  chromePid: undefined,
371
599
  chromePort: port,
600
+ chromeHost: host,
372
601
  userDataDir: undefined,
602
+ chromeTargetId: remoteTargetId ?? undefined,
603
+ tabUrl: lastUrl,
604
+ controllerPid: process.pid,
373
605
  };
374
606
  }
375
607
  catch (error) {
@@ -384,8 +616,15 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
384
616
  }
385
617
  throw normalizedError;
386
618
  }
387
- throw new Error('Remote Chrome connection lost before Oracle finished.', {
388
- cause: normalizedError,
619
+ throw new BrowserAutomationError('Remote Chrome connection lost before Oracle finished.', {
620
+ stage: 'connection-lost',
621
+ runtime: {
622
+ chromeHost: host,
623
+ chromePort: port,
624
+ chromeTargetId: remoteTargetId ?? undefined,
625
+ tabUrl: lastUrl,
626
+ controllerPid: process.pid,
627
+ },
389
628
  });
390
629
  }
391
630
  finally {
@@ -397,6 +636,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
397
636
  catch {
398
637
  // ignore
399
638
  }
639
+ await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
400
640
  // Don't kill remote Chrome - it's not ours to manage
401
641
  const totalSeconds = (Date.now() - startedAt) / 1000;
402
642
  logger(`Remote session complete • ${totalSeconds.toFixed(1)}s total`);
@@ -429,7 +669,7 @@ function startThinkingStatusMonitor(Runtime, logger, includeDiagnostics = false)
429
669
  let lastMessage = null;
430
670
  const startedAt = Date.now();
431
671
  const interval = setInterval(async () => {
432
- // biome-ignore lint/nursery/noUnnecessaryConditions: stop flag flips asynchronously
672
+ // stop flag flips asynchronously
433
673
  if (stopped || pending) {
434
674
  return;
435
675
  }
@@ -460,7 +700,7 @@ function startThinkingStatusMonitor(Runtime, logger, includeDiagnostics = false)
460
700
  }, 1500);
461
701
  interval.unref?.();
462
702
  return () => {
463
- // biome-ignore lint/nursery/noUnnecessaryConditions: multiple callers may race to stop
703
+ // multiple callers may race to stop
464
704
  if (stopped) {
465
705
  return;
466
706
  }
@@ -491,6 +731,46 @@ function sanitizeThinkingText(raw) {
491
731
  }
492
732
  return trimmed;
493
733
  }
734
+ function describeDevtoolsFirewallHint(host, port) {
735
+ if (!isWsl())
736
+ return null;
737
+ return [
738
+ `DevTools port ${host}:${port} is blocked from WSL.`,
739
+ '',
740
+ 'PowerShell (admin):',
741
+ `New-NetFirewallRule -DisplayName 'Chrome DevTools ${port}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${port}`,
742
+ "New-NetFirewallRule -DisplayName 'Chrome DevTools (chrome.exe)' -Direction Inbound -Action Allow -Program 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' -Protocol TCP",
743
+ '',
744
+ 'Re-run the same oracle command after adding the rule.',
745
+ ].join('\n');
746
+ }
747
+ function isWsl() {
748
+ if (process.platform !== 'linux')
749
+ return false;
750
+ if (process.env.WSL_DISTRO_NAME)
751
+ return true;
752
+ return os.release().toLowerCase().includes('microsoft');
753
+ }
754
+ async function resolveUserDataBaseDir() {
755
+ // On WSL, Chrome launched via Windows can choke on UNC paths; prefer a Windows-backed temp folder.
756
+ if (isWsl()) {
757
+ const candidates = [
758
+ '/mnt/c/Users/Public/AppData/Local/Temp',
759
+ '/mnt/c/Temp',
760
+ '/mnt/c/Windows/Temp',
761
+ ];
762
+ for (const candidate of candidates) {
763
+ try {
764
+ await mkdir(candidate, { recursive: true });
765
+ return candidate;
766
+ }
767
+ catch {
768
+ // try next
769
+ }
770
+ }
771
+ }
772
+ return os.tmpdir();
773
+ }
494
774
  function buildThinkingStatusExpression() {
495
775
  const selectors = [
496
776
  'span.loading-shimmer',
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { readFiles, createFileSections, MODEL_CONFIGS, TOKENIZER_OPTIONS, formatFileSection, } from '../oracle.js';
5
+ import { isKnownModel } from '../oracle/modelResolver.js';
5
6
  import { buildPromptMarkdown } from '../oracle/promptAssembly.js';
6
7
  import { buildAttachmentPlan } from './policies.js';
7
8
  export async function assembleBrowserPrompt(runOptions, deps = {}) {
@@ -48,7 +49,8 @@ export async function assembleBrowserPrompt(runOptions, deps = {}) {
48
49
  });
49
50
  }
50
51
  const inlineFileCount = attachmentPlan.inlineFileCount;
51
- const tokenizer = MODEL_CONFIGS[runOptions.model].tokenizer;
52
+ const modelConfig = isKnownModel(runOptions.model) ? MODEL_CONFIGS[runOptions.model] : MODEL_CONFIGS['gpt-5.1'];
53
+ const tokenizer = modelConfig.tokenizer;
52
54
  const tokenizerUserContent = inlineFileCount > 0 && attachmentPlan.inlineBlock
53
55
  ? [userPrompt, attachmentPlan.inlineBlock].filter((value) => Boolean(value?.trim())).join('\n\n').trim()
54
56
  : userPrompt;
@@ -0,0 +1,59 @@
1
+ import CDP from 'chrome-remote-interface';
2
+ import { waitForAssistantResponse, captureAssistantMarkdown } from './pageActions.js';
3
+ function pickTarget(targets, runtime) {
4
+ if (!Array.isArray(targets) || targets.length === 0) {
5
+ return undefined;
6
+ }
7
+ if (runtime.chromeTargetId) {
8
+ const byId = targets.find((t) => t.targetId === runtime.chromeTargetId);
9
+ if (byId)
10
+ return byId;
11
+ }
12
+ if (runtime.tabUrl) {
13
+ const byUrl = targets.find((t) => t.url?.startsWith(runtime.tabUrl)) ||
14
+ targets.find((t) => runtime.tabUrl.startsWith(t.url || ''));
15
+ if (byUrl)
16
+ return byUrl;
17
+ }
18
+ return targets.find((t) => t.type === 'page') ?? targets[0];
19
+ }
20
+ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
21
+ if (!runtime.chromePort) {
22
+ throw new Error('Missing chromePort; cannot reattach.');
23
+ }
24
+ const host = runtime.chromeHost ?? '127.0.0.1';
25
+ const listTargets = deps.listTargets ??
26
+ (async () => {
27
+ const targets = await CDP.List({ host, port: runtime.chromePort });
28
+ return targets;
29
+ });
30
+ const connect = deps.connect ?? ((options) => CDP(options));
31
+ const targetList = (await listTargets());
32
+ const target = pickTarget(targetList, runtime);
33
+ const client = (await connect({
34
+ host,
35
+ port: runtime.chromePort,
36
+ target: target?.targetId,
37
+ }));
38
+ const { Runtime, DOM } = client;
39
+ if (Runtime?.enable) {
40
+ await Runtime.enable();
41
+ }
42
+ if (DOM && typeof DOM.enable === 'function') {
43
+ await DOM.enable();
44
+ }
45
+ const waitForResponse = deps.waitForAssistantResponse ?? waitForAssistantResponse;
46
+ const captureMarkdown = deps.captureAssistantMarkdown ?? captureAssistantMarkdown;
47
+ const timeoutMs = config?.timeoutMs ?? 120_000;
48
+ const answer = await waitForResponse(Runtime, timeoutMs, logger);
49
+ const markdown = (await captureMarkdown(Runtime, answer.meta, logger)) ?? answer.text;
50
+ if (client && typeof client.close === 'function') {
51
+ try {
52
+ await client.close();
53
+ }
54
+ catch {
55
+ // ignore
56
+ }
57
+ }
58
+ return { answerText: answer.text, answerMarkdown: markdown };
59
+ }
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { formatElapsed } from '../oracle.js';
3
+ import { formatTokenCount } from '../oracle/runUtils.js';
3
4
  import { runBrowserMode } from '../browserMode.js';
4
5
  import { assembleBrowserPrompt } from './prompt.js';
5
6
  import { BrowserAutomationError } from '../oracle/errors.js';
@@ -46,6 +47,7 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
46
47
  if (runOptions.verbose) {
47
48
  log(chalk.dim('Chrome automation does not stream output; this may take a minute...'));
48
49
  }
50
+ const persistRuntimeHint = deps.persistRuntimeHint ?? (() => { });
49
51
  let browserResult;
50
52
  try {
51
53
  browserResult = await executeBrowser({
@@ -55,6 +57,9 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
55
57
  log: automationLogger,
56
58
  heartbeatIntervalMs: runOptions.heartbeatIntervalMs,
57
59
  verbose: runOptions.verbose,
60
+ runtimeHintCb: async (runtime) => {
61
+ await persistRuntimeHint({ ...runtime, controllerPid: runtime.controllerPid ?? process.pid });
62
+ },
58
63
  });
59
64
  }
60
65
  catch (error) {
@@ -76,7 +81,14 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
76
81
  reasoningTokens: 0,
77
82
  totalTokens: promptArtifacts.estimatedInputTokens + browserResult.answerTokens,
78
83
  };
79
- const tokensDisplay = `${usage.inputTokens}/${usage.outputTokens}/${usage.reasoningTokens}/${usage.totalTokens}`;
84
+ const tokensDisplay = [
85
+ usage.inputTokens,
86
+ usage.outputTokens,
87
+ usage.reasoningTokens,
88
+ usage.totalTokens,
89
+ ]
90
+ .map((value) => formatTokenCount(value))
91
+ .join('/');
80
92
  const tokensLabel = runOptions.verbose ? 'tokens (input/output/reasoning/total)' : 'tok(i/o/r/t)';
81
93
  const statsParts = [`${runOptions.model}[browser]`, `${tokensLabel}=${tokensDisplay}`];
82
94
  if (runOptions.file && runOptions.file.length > 0) {
@@ -89,7 +101,9 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
89
101
  runtime: {
90
102
  chromePid: browserResult.chromePid,
91
103
  chromePort: browserResult.chromePort,
104
+ chromeHost: browserResult.chromeHost,
92
105
  userDataDir: browserResult.userDataDir,
106
+ controllerPid: browserResult.controllerPid ?? process.pid,
93
107
  },
94
108
  answerText,
95
109
  };
@@ -65,7 +65,8 @@ export async function loadWindowsCookies(dbPath, filterNames) {
65
65
  }
66
66
  function decryptCookie(value, aesKey) {
67
67
  const prefix = value.slice(0, 3).toString();
68
- if (prefix === 'v10' || prefix === 'v11') {
68
+ // Chrome prefixes AES-GCM encrypted cookies with version markers like v10/v11/v20; treat all v** the same.
69
+ if (/^v\d{2}$/u.test(prefix)) {
69
70
  const iv = value.slice(3, 15);
70
71
  const tag = value.slice(value.length - 16);
71
72
  const data = value.slice(15, value.length - 16);
@@ -35,6 +35,7 @@ export async function buildBrowserConfig(options) {
35
35
  chromePath: options.browserChromePath ?? null,
36
36
  chromeCookiePath: options.browserCookiePath ?? null,
37
37
  url,
38
+ debugPort: selectBrowserPort(options),
38
39
  timeoutMs: options.browserTimeout ? parseDuration(options.browserTimeout, DEFAULT_BROWSER_TIMEOUT_MS) : undefined,
39
40
  inputTimeoutMs: options.browserInputTimeout
40
41
  ? parseDuration(options.browserInputTimeout, DEFAULT_BROWSER_INPUT_TIMEOUT_MS)
@@ -45,6 +46,7 @@ export async function buildBrowserConfig(options) {
45
46
  inlineCookiesSource: inline?.source ?? null,
46
47
  headless: undefined, // disable headless; Cloudflare blocks it
47
48
  keepBrowser: options.browserKeepBrowser ? true : undefined,
49
+ manualLogin: options.browserManualLogin ? true : undefined,
48
50
  hideWindow: options.browserHideWindow ? true : undefined,
49
51
  desiredModel: shouldUseOverride ? desiredModelOverride : mapModelToBrowserLabel(options.model),
50
52
  debug: options.verbose ? true : undefined,
@@ -53,6 +55,15 @@ export async function buildBrowserConfig(options) {
53
55
  remoteChrome,
54
56
  };
55
57
  }
58
+ function selectBrowserPort(options) {
59
+ const candidate = options.browserPort ?? options.browserDebugPort;
60
+ if (candidate === undefined || candidate === null)
61
+ return null;
62
+ if (!Number.isFinite(candidate) || candidate <= 0 || candidate > 65_535) {
63
+ throw new Error(`Invalid browser port: ${candidate}. Expected a number between 1 and 65535.`);
64
+ }
65
+ return candidate;
66
+ }
56
67
  export function mapModelToBrowserLabel(model) {
57
68
  return BROWSER_MODEL_LABELS[model] ?? DEFAULT_MODEL_TARGET;
58
69
  }