@steipete/oracle 0.8.5 → 0.8.6

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.
@@ -6,6 +6,7 @@ import { resolveBrowserConfig } from './config.js';
6
6
  import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToRemoteChrome, closeRemoteChromeTarget, connectWithNewTab, closeTab, } from './chromeLifecycle.js';
7
7
  import { syncCookies } from './cookies.js';
8
8
  import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, installJavaScriptDialogAutoDismissal, ensureModelSelection, submitPrompt, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from './pageActions.js';
9
+ import { INPUT_SELECTORS } from './constants.js';
9
10
  import { uploadAttachmentViaDataTransfer } from './actions/remoteFileTransfer.js';
10
11
  import { ensureThinkingTime } from './actions/thinkingTime.js';
11
12
  import { estimateTokenCount, withRetries, delay } from './utils.js';
@@ -13,7 +14,7 @@ import { formatElapsed } from '../oracle/format.js';
13
14
  import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR, DEFAULT_MODEL_STRATEGY } from './constants.js';
14
15
  import { BrowserAutomationError } from '../oracle/errors.js';
15
16
  import { alignPromptEchoPair, buildPromptEchoMatcher } from './reattachHelpers.js';
16
- import { cleanupStaleProfileState, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
17
+ import { cleanupStaleProfileState, acquireProfileRunLock, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from './profileState.js';
17
18
  export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from './constants.js';
18
19
  export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from './utils.js';
19
20
  export async function runBrowserMode(options) {
@@ -96,7 +97,9 @@ export async function runBrowserMode(options) {
96
97
  logger(`Created temporary Chrome profile at ${userDataDir}`);
97
98
  }
98
99
  const effectiveKeepBrowser = Boolean(config.keepBrowser);
99
- const reusedChrome = manualLogin ? await maybeReuseRunningChrome(userDataDir, logger) : null;
100
+ const reusedChrome = manualLogin
101
+ ? await maybeReuseRunningChrome(userDataDir, logger, { waitForPortMs: config.reuseChromeWaitMs })
102
+ : null;
100
103
  const chrome = reusedChrome ??
101
104
  (await launchChrome({
102
105
  ...config,
@@ -134,7 +137,12 @@ export async function runBrowserMode(options) {
134
137
  let appliedCookies = 0;
135
138
  try {
136
139
  try {
137
- const connection = await connectWithNewTab(chrome.port, logger, undefined, chromeHost);
140
+ const strictTabIsolation = Boolean(manualLogin && reusedChrome);
141
+ const connection = await connectWithNewTab(chrome.port, logger, undefined, chromeHost, {
142
+ fallbackToDefault: !strictTabIsolation,
143
+ retries: strictTabIsolation ? 3 : 0,
144
+ retryDelayMs: 500,
145
+ });
138
146
  client = connection.client;
139
147
  isolatedTargetId = connection.targetId ?? null;
140
148
  }
@@ -346,6 +354,23 @@ export async function runBrowserMode(options) {
346
354
  },
347
355
  }));
348
356
  }
357
+ const profileLockTimeoutMs = manualLogin ? (config.profileLockTimeoutMs ?? 0) : 0;
358
+ let profileLock = null;
359
+ const acquireProfileLockIfNeeded = async () => {
360
+ if (profileLockTimeoutMs <= 0)
361
+ return;
362
+ profileLock = await acquireProfileRunLock(userDataDir, {
363
+ timeoutMs: profileLockTimeoutMs,
364
+ logger,
365
+ });
366
+ };
367
+ const releaseProfileLockIfHeld = async () => {
368
+ if (!profileLock)
369
+ return;
370
+ const handle = profileLock;
371
+ profileLock = null;
372
+ await handle.release().catch(() => undefined);
373
+ };
349
374
  const submitOnce = async (prompt, submissionAttachments) => {
350
375
  const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
351
376
  const baselineAssistantText = typeof baselineSnapshot?.text === 'string' ? baselineSnapshot.text.trim() : '';
@@ -421,27 +446,33 @@ export async function runBrowserMode(options) {
421
446
  };
422
447
  let baselineTurns = null;
423
448
  let baselineAssistantText = null;
449
+ await acquireProfileLockIfNeeded();
424
450
  try {
425
- const submission = await raceWithDisconnect(submitOnce(promptText, attachments));
426
- baselineTurns = submission.baselineTurns;
427
- baselineAssistantText = submission.baselineAssistantText;
428
- }
429
- catch (error) {
430
- const isPromptTooLarge = error instanceof BrowserAutomationError &&
431
- error.details?.code === 'prompt-too-large';
432
- if (fallbackSubmission && isPromptTooLarge) {
433
- // Learned: when prompts truncate, retry with file uploads so the UI receives the full content.
434
- logger('[browser] Inline prompt too large; retrying with file uploads.');
435
- await raceWithDisconnect(clearPromptComposer(Runtime, logger));
436
- await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
437
- const submission = await raceWithDisconnect(submitOnce(fallbackSubmission.prompt, fallbackSubmission.attachments));
451
+ try {
452
+ const submission = await raceWithDisconnect(submitOnce(promptText, attachments));
438
453
  baselineTurns = submission.baselineTurns;
439
454
  baselineAssistantText = submission.baselineAssistantText;
440
455
  }
441
- else {
442
- throw error;
456
+ catch (error) {
457
+ const isPromptTooLarge = error instanceof BrowserAutomationError &&
458
+ error.details?.code === 'prompt-too-large';
459
+ if (fallbackSubmission && isPromptTooLarge) {
460
+ // Learned: when prompts truncate, retry with file uploads so the UI receives the full content.
461
+ logger('[browser] Inline prompt too large; retrying with file uploads.');
462
+ await raceWithDisconnect(clearPromptComposer(Runtime, logger));
463
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
464
+ const submission = await raceWithDisconnect(submitOnce(fallbackSubmission.prompt, fallbackSubmission.attachments));
465
+ baselineTurns = submission.baselineTurns;
466
+ baselineAssistantText = submission.baselineAssistantText;
467
+ }
468
+ else {
469
+ throw error;
470
+ }
443
471
  }
444
472
  }
473
+ finally {
474
+ await releaseProfileLockIfHeld();
475
+ }
445
476
  stopThinkingMonitor = startThinkingStatusMonitor(Runtime, logger, options.verbose ?? false);
446
477
  // Helper to normalize text for echo detection (collapse whitespace, lowercase)
447
478
  const normalizeForComparison = (text) => text.toLowerCase().replace(/\s+/g, ' ').trim();
@@ -468,7 +499,83 @@ export async function runBrowserMode(options) {
468
499
  }
469
500
  return null;
470
501
  };
471
- let answer = await raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined));
502
+ let answer;
503
+ const recheckDelayMs = Math.max(0, config.assistantRecheckDelayMs ?? 0);
504
+ const recheckTimeoutMs = Math.max(0, config.assistantRecheckTimeoutMs ?? 0);
505
+ const attemptAssistantRecheck = async () => {
506
+ if (!recheckDelayMs)
507
+ return null;
508
+ logger(`[browser] Assistant response timed out; waiting ${formatElapsed(recheckDelayMs)} before rechecking conversation.`);
509
+ await raceWithDisconnect(delay(recheckDelayMs));
510
+ await updateConversationHint('assistant-recheck', 15_000).catch(() => false);
511
+ await captureRuntimeSnapshot().catch(() => undefined);
512
+ const conversationUrl = await readConversationUrl(Runtime);
513
+ if (conversationUrl && isConversationUrl(conversationUrl)) {
514
+ logger(`[browser] Rechecking assistant response at ${conversationUrl}`);
515
+ await raceWithDisconnect(Page.navigate({ url: conversationUrl }));
516
+ await raceWithDisconnect(delay(1000));
517
+ }
518
+ // Validate session before attempting recheck - sessions can expire during the delay
519
+ const sessionValid = await validateChatGPTSession(Runtime, logger);
520
+ if (!sessionValid.valid) {
521
+ logger(`[browser] Session validation failed: ${sessionValid.reason}`);
522
+ // Update session metadata to indicate login is needed
523
+ await emitRuntimeHint();
524
+ throw new BrowserAutomationError(`ChatGPT session expired during recheck: ${sessionValid.reason}. ` +
525
+ `Conversation URL: ${conversationUrl || lastUrl || 'unknown'}. ` +
526
+ `Please sign in and retry.`, {
527
+ stage: 'assistant-recheck',
528
+ details: {
529
+ conversationUrl: conversationUrl || lastUrl || null,
530
+ sessionStatus: 'needs_login',
531
+ validationReason: sessionValid.reason,
532
+ },
533
+ runtime: {
534
+ chromePid: chrome.pid,
535
+ chromePort: chrome.port,
536
+ chromeHost,
537
+ userDataDir,
538
+ chromeTargetId: lastTargetId,
539
+ tabUrl: lastUrl,
540
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
541
+ controllerPid: process.pid,
542
+ },
543
+ });
544
+ }
545
+ const timeoutMs = recheckTimeoutMs > 0 ? recheckTimeoutMs : config.timeoutMs;
546
+ const rechecked = await raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined));
547
+ logger('Recovered assistant response after delayed recheck');
548
+ return rechecked;
549
+ };
550
+ try {
551
+ answer = await raceWithDisconnect(waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined));
552
+ }
553
+ catch (error) {
554
+ if (isAssistantResponseTimeoutError(error)) {
555
+ const rechecked = await attemptAssistantRecheck().catch(() => null);
556
+ if (rechecked) {
557
+ answer = rechecked;
558
+ }
559
+ else {
560
+ await updateConversationHint('assistant-timeout', 15_000).catch(() => false);
561
+ await captureRuntimeSnapshot().catch(() => undefined);
562
+ const runtime = {
563
+ chromePid: chrome.pid,
564
+ chromePort: chrome.port,
565
+ chromeHost,
566
+ userDataDir,
567
+ chromeTargetId: lastTargetId,
568
+ tabUrl: lastUrl,
569
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
570
+ controllerPid: process.pid,
571
+ };
572
+ throw new BrowserAutomationError('Assistant response timed out before completion; reattach later to capture the answer.', { stage: 'assistant-timeout', runtime }, error);
573
+ }
574
+ }
575
+ else {
576
+ throw error;
577
+ }
578
+ }
472
579
  // Ensure we store the final conversation URL even if the UI updated late.
473
580
  await updateConversationHint('post-response', 15_000);
474
581
  const baselineNormalized = baselineAssistantText ? normalizeForComparison(baselineAssistantText) : '';
@@ -658,7 +765,10 @@ export async function runBrowserMode(options) {
658
765
  catch {
659
766
  // ignore
660
767
  }
661
- if (!effectiveKeepBrowser && isolatedTargetId && chrome?.port) {
768
+ // Close the isolated tab once the response has been fully captured to prevent
769
+ // tab accumulation across repeated runs. Keep the tab open on incomplete runs
770
+ // so reattach can recover the response.
771
+ if (runStatus === 'complete' && isolatedTargetId && chrome?.port) {
662
772
  await closeTab(chrome.port, isolatedTargetId, logger, chromeHost).catch(() => undefined);
663
773
  }
664
774
  removeDialogHandler?.();
@@ -817,11 +927,20 @@ async function _assertNavigatedToHttp(runtime, _logger, timeoutMs = 10_000) {
817
927
  details: { url: lastUrl || '(empty)' },
818
928
  });
819
929
  }
820
- async function maybeReuseRunningChrome(userDataDir, logger) {
821
- const port = await readDevToolsPort(userDataDir);
930
+ async function maybeReuseRunningChrome(userDataDir, logger, options = {}) {
931
+ const waitForPortMs = Math.max(0, options.waitForPortMs ?? 0);
932
+ let port = await readDevToolsPort(userDataDir);
933
+ if (!port && waitForPortMs > 0) {
934
+ const deadline = Date.now() + waitForPortMs;
935
+ logger(`Waiting up to ${formatElapsed(waitForPortMs)} for shared Chrome to appear...`);
936
+ while (!port && Date.now() < deadline) {
937
+ await delay(250);
938
+ port = await readDevToolsPort(userDataDir);
939
+ }
940
+ }
822
941
  if (!port)
823
942
  return null;
824
- const probe = await verifyDevToolsReachable({ port });
943
+ const probe = await (options.probe ?? verifyDevToolsReachable)({ port });
825
944
  if (!probe.ok) {
826
945
  logger(`DevToolsActivePort found for ${userDataDir} but unreachable (${probe.error}); launching new Chrome.`);
827
946
  // Safe cleanup: remove stale DevToolsActivePort; only remove lock files if this was an Oracle-owned pid that died.
@@ -1023,7 +1142,87 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
1023
1142
  }
1024
1143
  return null;
1025
1144
  };
1026
- let answer = await waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined);
1145
+ let answer;
1146
+ const recheckDelayMs = Math.max(0, config.assistantRecheckDelayMs ?? 0);
1147
+ const recheckTimeoutMs = Math.max(0, config.assistantRecheckTimeoutMs ?? 0);
1148
+ const attemptAssistantRecheck = async () => {
1149
+ if (!recheckDelayMs)
1150
+ return null;
1151
+ logger(`[browser] Assistant response timed out; waiting ${formatElapsed(recheckDelayMs)} before rechecking conversation.`);
1152
+ await delay(recheckDelayMs);
1153
+ const conversationUrl = await readConversationUrl(Runtime);
1154
+ if (conversationUrl && isConversationUrl(conversationUrl)) {
1155
+ lastUrl = conversationUrl;
1156
+ logger(`[browser] Rechecking assistant response at ${conversationUrl}`);
1157
+ await Page.navigate({ url: conversationUrl });
1158
+ await delay(1000);
1159
+ }
1160
+ // Validate session before attempting recheck - sessions can expire during the delay
1161
+ const sessionValid = await validateChatGPTSession(Runtime, logger);
1162
+ if (!sessionValid.valid) {
1163
+ logger(`[browser] Session validation failed: ${sessionValid.reason}`);
1164
+ // Update session metadata to indicate login is needed
1165
+ await emitRuntimeHint();
1166
+ throw new BrowserAutomationError(`ChatGPT session expired during recheck: ${sessionValid.reason}. ` +
1167
+ `Conversation URL: ${conversationUrl || lastUrl || 'unknown'}. ` +
1168
+ `Please sign in and retry.`, {
1169
+ stage: 'assistant-recheck',
1170
+ details: {
1171
+ conversationUrl: conversationUrl || lastUrl || null,
1172
+ sessionStatus: 'needs_login',
1173
+ validationReason: sessionValid.reason,
1174
+ },
1175
+ runtime: {
1176
+ chromeHost: host,
1177
+ chromePort: port,
1178
+ chromeTargetId: remoteTargetId ?? undefined,
1179
+ tabUrl: lastUrl,
1180
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1181
+ controllerPid: process.pid,
1182
+ },
1183
+ });
1184
+ }
1185
+ await emitRuntimeHint();
1186
+ const timeoutMs = recheckTimeoutMs > 0 ? recheckTimeoutMs : config.timeoutMs;
1187
+ const rechecked = await waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logger, baselineTurns ?? undefined);
1188
+ logger('Recovered assistant response after delayed recheck');
1189
+ return rechecked;
1190
+ };
1191
+ try {
1192
+ answer = await waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined);
1193
+ }
1194
+ catch (error) {
1195
+ if (isAssistantResponseTimeoutError(error)) {
1196
+ const rechecked = await attemptAssistantRecheck().catch(() => null);
1197
+ if (rechecked) {
1198
+ answer = rechecked;
1199
+ }
1200
+ else {
1201
+ try {
1202
+ const conversationUrl = await readConversationUrl(Runtime);
1203
+ if (conversationUrl) {
1204
+ lastUrl = conversationUrl;
1205
+ }
1206
+ }
1207
+ catch {
1208
+ // ignore
1209
+ }
1210
+ await emitRuntimeHint();
1211
+ const runtime = {
1212
+ chromePort: port,
1213
+ chromeHost: host,
1214
+ chromeTargetId: remoteTargetId ?? undefined,
1215
+ tabUrl: lastUrl,
1216
+ conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1217
+ controllerPid: process.pid,
1218
+ };
1219
+ throw new BrowserAutomationError('Assistant response timed out before completion; reattach later to capture the answer.', { stage: 'assistant-timeout', runtime }, error);
1220
+ }
1221
+ }
1222
+ else {
1223
+ throw error;
1224
+ }
1225
+ }
1027
1226
  const baselineNormalized = baselineAssistantText ? normalizeForComparison(baselineAssistantText) : '';
1028
1227
  if (baselineNormalized) {
1029
1228
  const normalizedAnswer = normalizeForComparison(answer.text ?? '');
@@ -1178,6 +1377,9 @@ export { estimateTokenCount } from './utils.js';
1178
1377
  export { resolveBrowserConfig, DEFAULT_BROWSER_CONFIG } from './config.js';
1179
1378
  export { syncCookies } from './cookies.js';
1180
1379
  export { navigateToChatGPT, ensureNotBlocked, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, } from './pageActions.js';
1380
+ export async function maybeReuseRunningChromeForTest(userDataDir, logger, options = {}) {
1381
+ return maybeReuseRunningChrome(userDataDir, logger, options);
1382
+ }
1181
1383
  function isWebSocketClosureError(error) {
1182
1384
  const message = error.message.toLowerCase();
1183
1385
  return (message.includes('websocket connection closed') ||
@@ -1222,6 +1424,17 @@ function shouldReloadAfterAssistantError(error) {
1222
1424
  message.includes('timeout') ||
1223
1425
  message.includes('capture assistant response'));
1224
1426
  }
1427
+ function isAssistantResponseTimeoutError(error) {
1428
+ if (!(error instanceof Error))
1429
+ return false;
1430
+ const message = error.message.toLowerCase();
1431
+ if (!message)
1432
+ return false;
1433
+ return (message.includes('assistant-response') ||
1434
+ message.includes('assistant response') ||
1435
+ message.includes('watchdog') ||
1436
+ message.includes('capture assistant response'));
1437
+ }
1225
1438
  async function readConversationUrl(Runtime) {
1226
1439
  try {
1227
1440
  const currentUrl = await Runtime.evaluate({ expression: 'location.href', returnByValue: true });
@@ -1231,6 +1444,110 @@ async function readConversationUrl(Runtime) {
1231
1444
  return null;
1232
1445
  }
1233
1446
  }
1447
+ /**
1448
+ * Validates that the ChatGPT session is still active by checking for login CTAs
1449
+ * and textarea availability. Sessions can expire during long delays (e.g., recheck).
1450
+ *
1451
+ * @param Runtime - Chrome Runtime client
1452
+ * @param logger - Browser logger for diagnostics
1453
+ * @returns SessionValidationResult indicating if session is valid and reason if not
1454
+ */
1455
+ async function validateChatGPTSession(Runtime, logger) {
1456
+ try {
1457
+ const outcome = await Runtime.evaluate({
1458
+ expression: buildSessionValidationExpression(),
1459
+ awaitPromise: true,
1460
+ returnByValue: true,
1461
+ });
1462
+ const result = outcome.result?.value;
1463
+ if (!result) {
1464
+ return { valid: false, reason: 'Failed to evaluate session state' };
1465
+ }
1466
+ if (result.onAuthPage) {
1467
+ return { valid: false, reason: 'Redirected to auth page' };
1468
+ }
1469
+ if (result.hasLoginCta) {
1470
+ return { valid: false, reason: 'Login button detected on page' };
1471
+ }
1472
+ if (!result.hasTextarea) {
1473
+ return { valid: false, reason: 'Prompt textarea not available' };
1474
+ }
1475
+ return { valid: true };
1476
+ }
1477
+ catch (error) {
1478
+ const message = error instanceof Error ? error.message : String(error);
1479
+ logger(`[browser] Session validation error: ${message}`);
1480
+ return { valid: false, reason: `Validation error: ${message}` };
1481
+ }
1482
+ }
1483
+ function buildSessionValidationExpression() {
1484
+ const selectorLiteral = JSON.stringify(INPUT_SELECTORS);
1485
+ return `(async () => {
1486
+ const pageUrl = typeof location === 'object' && location?.href ? location.href : null;
1487
+ const onAuthPage =
1488
+ typeof location === 'object' &&
1489
+ typeof location.pathname === 'string' &&
1490
+ /^\\/(auth|login|signin)/i.test(location.pathname);
1491
+
1492
+ // Check for login CTAs (similar to ensureLoggedIn logic)
1493
+ const hasLoginCta = (() => {
1494
+ const candidates = Array.from(
1495
+ document.querySelectorAll(
1496
+ [
1497
+ 'a[href*="/auth/login"]',
1498
+ 'a[href*="/auth/signin"]',
1499
+ 'button[type="submit"]',
1500
+ 'button[data-testid*="login"]',
1501
+ 'button[data-testid*="log-in"]',
1502
+ 'button[data-testid*="sign-in"]',
1503
+ 'button[data-testid*="signin"]',
1504
+ 'button',
1505
+ 'a',
1506
+ ].join(','),
1507
+ ),
1508
+ );
1509
+ const textMatches = (text) => {
1510
+ if (!text) return false;
1511
+ const normalized = text.toLowerCase().trim();
1512
+ return ['log in', 'login', 'sign in', 'signin', 'continue with'].some((needle) =>
1513
+ normalized.startsWith(needle),
1514
+ );
1515
+ };
1516
+ for (const node of candidates) {
1517
+ if (!(node instanceof HTMLElement)) continue;
1518
+ const label =
1519
+ node.textContent?.trim() ||
1520
+ node.getAttribute('aria-label') ||
1521
+ node.getAttribute('title') ||
1522
+ '';
1523
+ if (textMatches(label)) {
1524
+ return true;
1525
+ }
1526
+ }
1527
+ return false;
1528
+ })();
1529
+
1530
+ // Check for textarea availability
1531
+ const hasTextarea = (() => {
1532
+ const selectors = ${selectorLiteral};
1533
+ for (const selector of selectors) {
1534
+ const node = document.querySelector(selector);
1535
+ if (node) {
1536
+ return true;
1537
+ }
1538
+ }
1539
+ return false;
1540
+ })();
1541
+
1542
+ return {
1543
+ valid: !onAuthPage && !hasLoginCta && hasTextarea,
1544
+ hasLoginCta,
1545
+ hasTextarea,
1546
+ onAuthPage,
1547
+ pageUrl,
1548
+ };
1549
+ })()`;
1550
+ }
1234
1551
  async function readConversationTurnCount(Runtime, logger) {
1235
1552
  const selectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
1236
1553
  const attempts = 4;
@@ -1,13 +1,16 @@
1
1
  import path from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
4
  import { execFile } from 'node:child_process';
4
5
  import { promisify } from 'node:util';
6
+ import { delay } from './utils.js';
5
7
  const DEVTOOLS_ACTIVE_PORT_FILENAME = 'DevToolsActivePort';
6
8
  const DEVTOOLS_ACTIVE_PORT_RELATIVE_PATHS = [
7
9
  DEVTOOLS_ACTIVE_PORT_FILENAME,
8
10
  path.join('Default', DEVTOOLS_ACTIVE_PORT_FILENAME),
9
11
  ];
10
12
  const CHROME_PID_FILENAME = 'chrome.pid';
13
+ const ORACLE_PROFILE_LOCK_FILENAME = 'oracle-automation.lock';
11
14
  const execFileAsync = promisify(execFile);
12
15
  export function getDevToolsActivePortPaths(userDataDir) {
13
16
  return DEVTOOLS_ACTIVE_PORT_RELATIVE_PATHS.map((relative) => path.join(userDataDir, relative));
@@ -81,6 +84,96 @@ export function isProcessAlive(pid) {
81
84
  return false;
82
85
  }
83
86
  }
87
+ function parseProfileRunLock(payload) {
88
+ if (!payload)
89
+ return null;
90
+ try {
91
+ const parsed = JSON.parse(payload);
92
+ if (!Number.isFinite(parsed.pid) || parsed.pid <= 0)
93
+ return null;
94
+ if (!parsed.lockId || typeof parsed.lockId !== 'string')
95
+ return null;
96
+ return parsed;
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ export async function acquireProfileRunLock(userDataDir, options) {
103
+ const timeoutMs = options.timeoutMs;
104
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
105
+ return null;
106
+ }
107
+ const pollMs = typeof options.pollMs === 'number' && Number.isFinite(options.pollMs) && options.pollMs > 0
108
+ ? options.pollMs
109
+ : 1000;
110
+ const lockPath = path.join(userDataDir, ORACLE_PROFILE_LOCK_FILENAME);
111
+ const lockId = randomUUID();
112
+ const startedAt = Date.now();
113
+ let warned = false;
114
+ for (;;) {
115
+ try {
116
+ const payload = {
117
+ pid: process.pid,
118
+ lockId,
119
+ createdAt: new Date().toISOString(),
120
+ sessionId: options.sessionId,
121
+ };
122
+ await mkdir(path.dirname(lockPath), { recursive: true });
123
+ await writeFile(lockPath, JSON.stringify(payload), { encoding: 'utf8', flag: 'wx' });
124
+ options.logger?.(`Acquired Oracle profile lock at ${lockPath}`);
125
+ return {
126
+ path: lockPath,
127
+ lockId,
128
+ release: async () => releaseProfileRunLock(lockPath, lockId, options.logger),
129
+ };
130
+ }
131
+ catch (error) {
132
+ const code = error.code;
133
+ if (code !== 'EEXIST') {
134
+ throw error;
135
+ }
136
+ let existing = parseProfileRunLock(await readFile(lockPath, 'utf8').catch(() => null));
137
+ if (!existing) {
138
+ // Likely partial write / corruption; re-read once, then delete (user preference: delete unreadable lockfiles).
139
+ await delay(200);
140
+ existing = parseProfileRunLock(await readFile(lockPath, 'utf8').catch(() => null));
141
+ if (!existing) {
142
+ options.logger?.('Oracle profile lock unreadable; deleting lockfile.');
143
+ await rm(lockPath, { force: true }).catch(() => undefined);
144
+ continue;
145
+ }
146
+ }
147
+ if (!existing || !isProcessAlive(existing.pid)) {
148
+ await rm(lockPath, { force: true }).catch(() => undefined);
149
+ continue;
150
+ }
151
+ if (!warned) {
152
+ const waited = Math.round(timeoutMs / 1000);
153
+ options.logger?.(`Oracle profile lock held by pid ${existing.pid}; waiting up to ${waited}s.`);
154
+ warned = true;
155
+ }
156
+ const elapsed = Date.now() - startedAt;
157
+ if (elapsed >= timeoutMs) {
158
+ throw new Error(`Oracle profile lock still held by pid ${existing.pid} after ${Math.round(elapsed / 1000)}s`);
159
+ }
160
+ await delay(Math.min(pollMs, timeoutMs - elapsed));
161
+ }
162
+ }
163
+ }
164
+ export async function releaseProfileRunLock(lockPath, lockId, logger) {
165
+ try {
166
+ const existing = parseProfileRunLock(await readFile(lockPath, 'utf8').catch(() => null));
167
+ if (!existing || existing.lockId !== lockId) {
168
+ return;
169
+ }
170
+ await rm(lockPath, { force: true });
171
+ logger?.(`Released Oracle profile lock ${lockPath}`);
172
+ }
173
+ catch {
174
+ // best effort
175
+ }
176
+ }
84
177
  export async function verifyDevToolsReachable({ port, host = '127.0.0.1', attempts = 3, timeoutMs = 3000, }) {
85
178
  const versionUrl = `http://${host}:${port}/json/version`;
86
179
  for (let attempt = 0; attempt < attempts; attempt++) {
@@ -5,6 +5,8 @@ import { normalizeBrowserModelStrategy } from '../browser/modelStrategy.js';
5
5
  import { getOracleHomeDir } from '../oracleHome.js';
6
6
  const DEFAULT_BROWSER_TIMEOUT_MS = 1_200_000;
7
7
  const DEFAULT_BROWSER_INPUT_TIMEOUT_MS = 60_000;
8
+ const DEFAULT_BROWSER_RECHECK_TIMEOUT_MS = 120_000;
9
+ const DEFAULT_BROWSER_AUTO_REATTACH_TIMEOUT_MS = 120_000;
8
10
  const DEFAULT_CHROME_PROFILE = 'Default';
9
11
  // Ordered array: most specific models first to ensure correct selection.
10
12
  // The browser label is passed to the model picker which fuzzy-matches against ChatGPT's UI.
@@ -82,6 +84,25 @@ export async function buildBrowserConfig(options) {
82
84
  inputTimeoutMs: options.browserInputTimeout
83
85
  ? parseDuration(options.browserInputTimeout, DEFAULT_BROWSER_INPUT_TIMEOUT_MS)
84
86
  : undefined,
87
+ assistantRecheckDelayMs: options.browserRecheckDelay
88
+ ? parseDuration(options.browserRecheckDelay, 0)
89
+ : undefined,
90
+ assistantRecheckTimeoutMs: options.browserRecheckTimeout
91
+ ? parseDuration(options.browserRecheckTimeout, DEFAULT_BROWSER_RECHECK_TIMEOUT_MS)
92
+ : undefined,
93
+ reuseChromeWaitMs: options.browserReuseWait ? parseDuration(options.browserReuseWait, 0) : undefined,
94
+ profileLockTimeoutMs: options.browserProfileLockTimeout
95
+ ? parseDuration(options.browserProfileLockTimeout, 0)
96
+ : undefined,
97
+ autoReattachDelayMs: options.browserAutoReattachDelay
98
+ ? parseDuration(options.browserAutoReattachDelay, 0)
99
+ : undefined,
100
+ autoReattachIntervalMs: options.browserAutoReattachInterval
101
+ ? parseDuration(options.browserAutoReattachInterval, 0)
102
+ : undefined,
103
+ autoReattachTimeoutMs: options.browserAutoReattachTimeout
104
+ ? parseDuration(options.browserAutoReattachTimeout, DEFAULT_BROWSER_AUTO_REATTACH_TIMEOUT_MS)
105
+ : undefined,
85
106
  cookieSyncWaitMs: options.browserCookieWait ? parseDuration(options.browserCookieWait, 0) : undefined,
86
107
  cookieSync: options.browserNoCookieSync ? false : undefined,
87
108
  cookieNames,
@@ -33,6 +33,27 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
33
33
  if (isUnset('browserInputTimeout') && typeof browser.inputTimeoutMs === 'number') {
34
34
  options.browserInputTimeout = String(browser.inputTimeoutMs);
35
35
  }
36
+ if (isUnset('browserRecheckDelay') && typeof browser.assistantRecheckDelayMs === 'number') {
37
+ options.browserRecheckDelay = String(browser.assistantRecheckDelayMs);
38
+ }
39
+ if (isUnset('browserRecheckTimeout') && typeof browser.assistantRecheckTimeoutMs === 'number') {
40
+ options.browserRecheckTimeout = String(browser.assistantRecheckTimeoutMs);
41
+ }
42
+ if (isUnset('browserReuseWait') && typeof browser.reuseChromeWaitMs === 'number') {
43
+ options.browserReuseWait = String(browser.reuseChromeWaitMs);
44
+ }
45
+ if (isUnset('browserProfileLockTimeout') && typeof browser.profileLockTimeoutMs === 'number') {
46
+ options.browserProfileLockTimeout = String(browser.profileLockTimeoutMs);
47
+ }
48
+ if (isUnset('browserAutoReattachDelay') && typeof browser.autoReattachDelayMs === 'number') {
49
+ options.browserAutoReattachDelay = String(browser.autoReattachDelayMs);
50
+ }
51
+ if (isUnset('browserAutoReattachInterval') && typeof browser.autoReattachIntervalMs === 'number') {
52
+ options.browserAutoReattachInterval = String(browser.autoReattachIntervalMs);
53
+ }
54
+ if (isUnset('browserAutoReattachTimeout') && typeof browser.autoReattachTimeoutMs === 'number') {
55
+ options.browserAutoReattachTimeout = String(browser.autoReattachTimeoutMs);
56
+ }
36
57
  if (isUnset('browserCookieWait') && typeof browser.cookieSyncWaitMs === 'number') {
37
58
  options.browserCookieWait = String(browser.cookieSyncWaitMs);
38
59
  }