@steipete/oracle 0.16.0 → 0.17.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 (80) hide show
  1. package/README.md +72 -337
  2. package/dist/bin/oracle-cli.js +184 -55
  3. package/dist/bin/oracle-mcp.js +0 -0
  4. package/dist/docs-site/.nojekyll +0 -0
  5. package/dist/docs-site/CNAME +1 -0
  6. package/dist/docs-site/RELEASING.html +410 -0
  7. package/dist/docs-site/agents.html +374 -0
  8. package/dist/docs-site/anthropic.html +368 -0
  9. package/dist/docs-site/bridge.html +416 -0
  10. package/dist/docs-site/browser-mode.html +594 -0
  11. package/dist/docs-site/chromium-forks.html +347 -0
  12. package/dist/docs-site/cli-reference.html +346 -0
  13. package/dist/docs-site/configuration.html +462 -0
  14. package/dist/docs-site/favicon.svg +14 -0
  15. package/dist/docs-site/followup.html +375 -0
  16. package/dist/docs-site/gemini.html +383 -0
  17. package/dist/docs-site/grok.html +325 -0
  18. package/dist/docs-site/index.html +360 -0
  19. package/dist/docs-site/install.html +335 -0
  20. package/dist/docs-site/linux.html +321 -0
  21. package/dist/docs-site/llms.txt +43 -0
  22. package/dist/docs-site/manual-tests.html +596 -0
  23. package/dist/docs-site/mcp.html +391 -0
  24. package/dist/docs-site/multimodel.html +364 -0
  25. package/dist/docs-site/mythical-pro-agents.html +360 -0
  26. package/dist/docs-site/notifier.html +338 -0
  27. package/dist/docs-site/openai-endpoints.html +410 -0
  28. package/dist/docs-site/openrouter.html +344 -0
  29. package/dist/docs-site/quickstart.html +369 -0
  30. package/dist/docs-site/refactor/ux.html +532 -0
  31. package/dist/docs-site/sessions.html +389 -0
  32. package/dist/docs-site/social-card.png +0 -0
  33. package/dist/docs-site/social-card.svg +79 -0
  34. package/dist/docs-site/spec.html +363 -0
  35. package/dist/docs-site/testing.html +320 -0
  36. package/dist/docs-site/tui-debug.html +326 -0
  37. package/dist/docs-site/windows-work.html +324 -0
  38. package/dist/docs-site/windows.html +320 -0
  39. package/dist/scripts/test-browser.js +1 -25
  40. package/dist/src/browser/actions/modelSelection.js +36 -34
  41. package/dist/src/browser/actions/navigation.js +8 -3
  42. package/dist/src/browser/actions/thinkingTime.js +39 -3
  43. package/dist/src/browser/cdpLiveness.js +78 -0
  44. package/dist/src/browser/chromeLifecycle.js +85 -39
  45. package/dist/src/browser/conversationUrl.js +16 -0
  46. package/dist/src/browser/conversationUrlMonitor.js +2 -4
  47. package/dist/src/browser/cookies.js +6 -4
  48. package/dist/src/browser/index.js +181 -50
  49. package/dist/src/browser/liveTabs.js +2 -2
  50. package/dist/src/browser/modelDisplay.js +67 -0
  51. package/dist/src/browser/reattach.js +14 -1
  52. package/dist/src/browser/reattachHelpers.js +3 -5
  53. package/dist/src/browser/reattachability.js +2 -1
  54. package/dist/src/browser/recoverConversation.js +2 -1
  55. package/dist/src/browser/sessionRunner.js +9 -10
  56. package/dist/src/browser/tabLeaseRegistry.js +9 -3
  57. package/dist/src/browser/wslHost.js +50 -0
  58. package/dist/src/cli/browserConfig.js +31 -11
  59. package/dist/src/cli/browserDefaults.js +4 -1
  60. package/dist/src/cli/detach.js +21 -4
  61. package/dist/src/cli/dryRun.js +13 -2
  62. package/dist/src/cli/engine.js +2 -2
  63. package/dist/src/cli/options.js +4 -0
  64. package/dist/src/cli/sessionDisplay.js +60 -8
  65. package/dist/src/cli/sessionLifecycle.js +2 -1
  66. package/dist/src/cli/sessionRunner.js +159 -62
  67. package/dist/src/cli/sessionTable.js +5 -1
  68. package/dist/src/cli/tui/index.js +12 -4
  69. package/dist/src/duration.js +3 -0
  70. package/dist/src/gemini-web/client.js +19 -1
  71. package/dist/src/oracle/modelResolver.js +8 -1
  72. package/dist/src/oracle/request.js +9 -2
  73. package/dist/src/oracle/run.js +43 -3
  74. package/dist/src/remote/server.js +8 -0
  75. package/dist/src/sessionManager.js +41 -12
  76. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  77. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  78. package/package.json +16 -16
  79. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  80. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -4,7 +4,7 @@ import os from "node:os";
4
4
  import net from "node:net";
5
5
  import { resolveBrowserConfig } from "./config.js";
6
6
  import { copyChromeProfile } from "./profileCopy.js";
7
- import { launchChrome, registerTerminationHooks, positionChromeWindowOffscreen, connectToRemoteChrome, connectWithNewTab, closeTab, closeRemoteChromeTarget, closeBlankChromeTabs, } from "./chromeLifecycle.js";
7
+ import { launchChrome, registerTerminationHooks, positionChromeWindowOffscreen, connectToRemoteChrome, connectWithNewTab, closeTab, createChromePageTarget, ensureChromePageTargetAfterClose, closeBlankChromeTabs, } from "./chromeLifecycle.js";
8
8
  import { clearStaleChatGptConversationCookies, syncCookies } from "./cookies.js";
9
9
  import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureChatMode, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js";
10
10
  import { INPUT_SELECTORS } from "./constants.js";
@@ -19,6 +19,7 @@ import { BrowserAutomationError } from "../oracle/errors.js";
19
19
  import { alignPromptEchoPair, buildPromptEchoMatcher } from "./reattachHelpers.js";
20
20
  import { buildConversationTurnCountExpression } from "./conversationTurns.js";
21
21
  import { cleanupStaleProfileState, acquireProfileRunLock, findRunningChromeDebugTargetForProfile, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, terminateRecordedChromeForProfile, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from "./profileState.js";
22
+ import { connectionLostUserMessage, isRecoverableChromeDisconnect, probeChromeTargetLiveness, } from "./cdpLiveness.js";
22
23
  import { acquireBrowserTabLease, hasOtherActiveBrowserTabLeases, } from "./tabLeaseRegistry.js";
23
24
  import { appendArtifacts, saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "./artifacts.js";
24
25
  import { collectGeneratedImageArtifacts } from "./chatgptImages.js";
@@ -32,6 +33,7 @@ import { archiveChatGptConversation, resolveBrowserArchiveDecision, } from "./ac
32
33
  import { assertManualLoginProfileReadyForRun, defaultManualLoginProfileDir, formatManualLoginSetupCommand, isManualLoginProfileInitialized, resolveManualLoginWaitMs, } from "./manualLoginProfile.js";
33
34
  import { describeBrowserControlPlan, formatBrowserControlPlan } from "./controlPlan.js";
34
35
  import { createConversationUrlMonitor, } from "./conversationUrlMonitor.js";
36
+ import { extractStableConversationIdFromUrl as extractConversationIdFromUrl, isStableConversationUrl as isConversationUrl, } from "./conversationUrl.js";
35
37
  export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "./constants.js";
36
38
  export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from "./utils.js";
37
39
  export { formatThinkingLog, formatThinkingWaitingLog, buildThinkingStatusExpressionForTest, readThinkingStatusForTest, sanitizeThinkingText, startThinkingStatusMonitorForTest, } from "./actions/thinkingStatus.js";
@@ -69,6 +71,9 @@ function classifyPreservedBrowserError(error, headless) {
69
71
  function shouldPreserveBrowserOnError(error, headless) {
70
72
  return classifyPreservedBrowserError(error, headless) !== null;
71
73
  }
74
+ function normalizeAuthenticatedModelSelectionError(error) {
75
+ return error instanceof Error ? error : new Error(String(error));
76
+ }
72
77
  function shouldKeepLocalBrowserOpen(options) {
73
78
  if (options.usingCopiedProfile)
74
79
  return false;
@@ -535,7 +540,17 @@ async function closeRemoteConnectionAfterRun(options) {
535
540
  }
536
541
  }
537
542
  function shouldCloseOwnedRunTargetAfterRun(options) {
538
- return options.runStatus === "complete" && options.ownsTarget && !options.keepBrowser;
543
+ return (options.runStatus === "complete" &&
544
+ options.ownsTarget &&
545
+ (Boolean(options.closeOwnedTabOnComplete) || !options.keepBrowser));
546
+ }
547
+ function shouldCleanupBlankTabsAfterLastLease(options) {
548
+ return (options.runStatus === "complete" &&
549
+ options.ownsTarget &&
550
+ !options.connectionClosedUnexpectedly &&
551
+ options.manualLogin &&
552
+ options.keepBrowser &&
553
+ Boolean(options.chromePort));
539
554
  }
540
555
  function buildSkippedModelSelectionEvidence(desiredModel, strategy) {
541
556
  return {
@@ -796,8 +811,38 @@ export async function runBrowserMode(options) {
796
811
  const disconnectPromise = new Promise((_, reject) => {
797
812
  client?.on("disconnect", () => {
798
813
  connectionClosedUnexpectedly = true;
799
- logger("Chrome window closed; attempting to abort run.");
800
- reject(new Error("Chrome window closed before oracle finished. Please keep it open until completion."));
814
+ void (async () => {
815
+ const liveness = await probeChromeTargetLiveness({
816
+ host: chromeHost,
817
+ port: chrome.port,
818
+ targetId: lastTargetId ?? isolatedTargetId,
819
+ });
820
+ const recoverable = isRecoverableChromeDisconnect(liveness);
821
+ if (recoverable) {
822
+ logger("CDP client disconnected; Chrome/target still reachable. Leaving run recoverable for reattach.");
823
+ }
824
+ else {
825
+ logger("Chrome window closed; attempting to abort run.");
826
+ }
827
+ reject(new BrowserAutomationError(connectionLostUserMessage({ recoverable }), {
828
+ stage: "connection-lost",
829
+ recoverableDisconnect: recoverable,
830
+ disconnectCause: recoverable ? "cdp-client-disconnect" : "chrome-closed",
831
+ runtime: {
832
+ chromePid: chrome.pid,
833
+ chromePort: chrome.port,
834
+ chromeHost,
835
+ userDataDir,
836
+ chromeTargetId: lastTargetId ?? isolatedTargetId ?? undefined,
837
+ tabUrl: liveness.matchedUrl ?? lastUrl,
838
+ conversationId: (liveness.matchedUrl ?? lastUrl)
839
+ ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
840
+ : undefined,
841
+ promptSubmitted,
842
+ controllerPid: process.pid,
843
+ },
844
+ }));
845
+ })();
801
846
  });
802
847
  });
803
848
  const raceWithDisconnect = (promise) => Promise.race([promise, disconnectPromise]);
@@ -1012,11 +1057,9 @@ export async function runBrowserMode(options) {
1012
1057
  }
1013
1058
  },
1014
1059
  })).catch((error) => {
1015
- const base = error instanceof Error ? error.message : String(error);
1016
- const hint = appliedCookies === 0
1017
- ? " No cookies were applied; log in to ChatGPT in Chrome or provide inline cookies (--browser-inline-cookies[(-file)] or ORACLE_BROWSER_COOKIES_JSON)."
1018
- : "";
1019
- throw new Error(`${base}${hint}`);
1060
+ // Login has already been verified above. Preserve the picker failure instead of
1061
+ // misdiagnosing an unavailable model as missing cookies.
1062
+ throw normalizeAuthenticatedModelSelectionError(error);
1020
1063
  });
1021
1064
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
1022
1065
  logger(`Prompt textarea ready (after model switch, ${promptText.length.toLocaleString()} chars queued)`);
@@ -1300,7 +1343,11 @@ export async function runBrowserMode(options) {
1300
1343
  if (conversationUrl && isConversationUrl(conversationUrl)) {
1301
1344
  logger(`[browser] Rechecking assistant response at ${conversationUrl}`);
1302
1345
  await raceWithDisconnect(Page.navigate({ url: conversationUrl }));
1303
- await raceWithDisconnect(delay(1000));
1346
+ await raceWithDisconnect(waitForResumedConversationHydration(Runtime, recheckTimeoutMs || 30_000, logger, {
1347
+ requirePriorTurns: true,
1348
+ requirePromptReady: false,
1349
+ expectedConversationUrl: conversationUrl,
1350
+ }));
1304
1351
  }
1305
1352
  // Validate session before attempting recheck - sessions can expire during the delay
1306
1353
  const sessionValid = await validateChatGPTSession(Runtime, logger);
@@ -1727,19 +1774,34 @@ export async function runBrowserMode(options) {
1727
1774
  throw normalizedError;
1728
1775
  }
1729
1776
  if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") && normalizedError.stack) {
1730
- logger(`Chrome window closed before completion: ${normalizedError.message}`);
1777
+ logger(`Chrome connection lost before completion: ${normalizedError.message}`);
1731
1778
  logger(normalizedError.stack);
1732
1779
  }
1733
1780
  await emitRuntimeHint();
1734
- throw new BrowserAutomationError("Chrome window closed before oracle finished. Please keep it open until completion.", {
1781
+ if (normalizedError instanceof BrowserAutomationError &&
1782
+ normalizedError.details?.stage === "connection-lost") {
1783
+ throw normalizedError;
1784
+ }
1785
+ const liveness = await probeChromeTargetLiveness({
1786
+ host: chromeHost,
1787
+ port: chrome.port,
1788
+ targetId: lastTargetId ?? isolatedTargetId,
1789
+ });
1790
+ const recoverable = isRecoverableChromeDisconnect(liveness);
1791
+ throw new BrowserAutomationError(connectionLostUserMessage({ recoverable }), {
1735
1792
  stage: "connection-lost",
1793
+ recoverableDisconnect: recoverable,
1794
+ disconnectCause: recoverable ? "cdp-client-disconnect" : "chrome-closed",
1736
1795
  runtime: {
1737
1796
  chromePid: chrome.pid,
1738
1797
  chromePort: chrome.port,
1739
1798
  chromeHost,
1740
1799
  userDataDir,
1741
1800
  chromeTargetId: lastTargetId,
1742
- tabUrl: lastUrl,
1801
+ tabUrl: liveness.matchedUrl ?? lastUrl,
1802
+ conversationId: (liveness.matchedUrl ?? lastUrl)
1803
+ ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
1804
+ : undefined,
1743
1805
  promptSubmitted,
1744
1806
  controllerPid: process.pid,
1745
1807
  },
@@ -1758,15 +1820,12 @@ export async function runBrowserMode(options) {
1758
1820
  // Close the isolated tab once the response has been fully captured to prevent
1759
1821
  // tab accumulation across repeated runs. Keep the tab open on incomplete runs
1760
1822
  // so reattach can recover the response.
1761
- if (shouldCloseOwnedRunTargetAfterRun({
1823
+ const shouldCloseOwnedRunTarget = shouldCloseOwnedRunTargetAfterRun({
1762
1824
  runStatus,
1763
1825
  ownsTarget,
1764
1826
  keepBrowser: effectiveKeepBrowser,
1765
- }) &&
1766
- isolatedTargetId &&
1767
- chrome?.port) {
1768
- await closeTab(chrome.port, isolatedTargetId, logger, chromeHost).catch(() => undefined);
1769
- }
1827
+ closeOwnedTabOnComplete: options.closeOwnedTabOnComplete,
1828
+ });
1770
1829
  let keepBrowserOpen = shouldKeepLocalBrowserOpen({
1771
1830
  effectiveKeepBrowser,
1772
1831
  preserveBrowserOnError,
@@ -1784,18 +1843,6 @@ export async function runBrowserMode(options) {
1784
1843
  }
1785
1844
  return otherActiveBrowserTabLeases;
1786
1845
  };
1787
- if (runStatus === "complete" &&
1788
- manualLogin &&
1789
- !connectionClosedUnexpectedly &&
1790
- chrome?.port &&
1791
- ownsTarget) {
1792
- const otherLeasesActive = await hasOtherActiveLeases().catch(() => true);
1793
- if (!otherLeasesActive) {
1794
- await closeBlankChromeTabs(chrome.port, logger, chromeHost, {
1795
- excludeTargetIds: [isolatedTargetId, lastTargetId],
1796
- }).catch(() => undefined);
1797
- }
1798
- }
1799
1846
  if (!keepBrowserOpen && manualLogin && tabLease) {
1800
1847
  const cleanupLockTimeoutMs = Math.max(0, config.profileLockTimeoutMs ?? 0);
1801
1848
  if (cleanupLockTimeoutMs > 0) {
@@ -1813,10 +1860,55 @@ export async function runBrowserMode(options) {
1813
1860
  terminatedRecordedChrome = await terminateRecordedChromeForProfile(userDataDir, logger).catch(() => false);
1814
1861
  }
1815
1862
  }
1863
+ const closeOwnedRunTarget = async () => {
1864
+ if (!shouldCloseOwnedRunTarget || !isolatedTargetId || !chrome?.port) {
1865
+ return;
1866
+ }
1867
+ const safeToClose = !effectiveKeepBrowser ||
1868
+ Boolean(await ensureChromePageTargetAfterClose(chrome.port, isolatedTargetId, logger, chromeHost));
1869
+ if (!safeToClose) {
1870
+ logger(`[browser] Leaving completed browser tab open because Chrome has no replacement page target.`);
1871
+ return;
1872
+ }
1873
+ const closeConfirmed = await closeTab(chrome.port, isolatedTargetId, logger, chromeHost);
1874
+ if (!closeConfirmed && effectiveKeepBrowser) {
1875
+ const replacementTargetId = await createChromePageTarget(chrome.port, logger, chromeHost);
1876
+ if (!replacementTargetId) {
1877
+ logger(`[browser] Chrome page retention could not be verified after closing ${isolatedTargetId}.`);
1878
+ }
1879
+ }
1880
+ };
1881
+ const cleanupBlankTabs = async () => {
1882
+ if (!shouldCleanupBlankTabsAfterLastLease({
1883
+ runStatus,
1884
+ ownsTarget,
1885
+ connectionClosedUnexpectedly,
1886
+ manualLogin,
1887
+ keepBrowser: effectiveKeepBrowser,
1888
+ chromePort: chrome?.port,
1889
+ }) ||
1890
+ !chrome?.port) {
1891
+ return;
1892
+ }
1893
+ await closeBlankChromeTabs(chrome.port, logger, chromeHost, {
1894
+ excludeTargetIds: [isolatedTargetId, lastTargetId],
1895
+ preserveOneBlank: true,
1896
+ });
1897
+ };
1816
1898
  if (tabLease) {
1817
1899
  const handle = tabLease;
1818
1900
  tabLease = null;
1819
- await handle.release().catch(() => undefined);
1901
+ const onRelease = async ({ isLastLease }) => {
1902
+ await closeOwnedRunTarget();
1903
+ if (isLastLease) {
1904
+ await cleanupBlankTabs();
1905
+ }
1906
+ };
1907
+ await handle.release({ onRelease }).catch(() => undefined);
1908
+ }
1909
+ else {
1910
+ await closeOwnedRunTarget();
1911
+ await cleanupBlankTabs();
1820
1912
  }
1821
1913
  removeDialogHandler?.();
1822
1914
  removeTerminationHooks?.();
@@ -2520,7 +2612,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2520
2612
  lastUrl = conversationUrl;
2521
2613
  logger(`[browser] Rechecking assistant response at ${conversationUrl}`);
2522
2614
  await Page.navigate({ url: conversationUrl });
2523
- await delay(1000);
2615
+ await waitForResumedConversationHydration(Runtime, recheckTimeoutMs || 30_000, logger, {
2616
+ requirePriorTurns: true,
2617
+ requirePromptReady: false,
2618
+ expectedConversationUrl: conversationUrl,
2619
+ });
2524
2620
  }
2525
2621
  // Validate session before attempting recheck - sessions can expire during the delay
2526
2622
  const sessionValid = await validateChatGPTSession(Runtime, logger);
@@ -2869,15 +2965,27 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2869
2965
  }
2870
2966
  throw normalizedError;
2871
2967
  }
2872
- throw new BrowserAutomationError("Remote Chrome connection lost before Oracle finished.", {
2968
+ const liveness = await probeChromeTargetLiveness({
2969
+ host,
2970
+ port,
2971
+ targetId: remoteTargetId,
2972
+ browserWSEndpoint,
2973
+ });
2974
+ const recoverable = isRecoverableChromeDisconnect(liveness);
2975
+ throw new BrowserAutomationError(connectionLostUserMessage({ recoverable, remote: true }), {
2873
2976
  stage: "connection-lost",
2977
+ recoverableDisconnect: recoverable,
2978
+ disconnectCause: recoverable ? "cdp-client-disconnect" : "chrome-closed",
2874
2979
  runtime: {
2875
2980
  chromeHost: host,
2876
2981
  chromePort: port,
2877
2982
  chromeBrowserWSEndpoint: browserWSEndpoint,
2878
2983
  chromeProfileRoot,
2879
2984
  chromeTargetId: remoteTargetId ?? undefined,
2880
- tabUrl: lastUrl,
2985
+ tabUrl: liveness.matchedUrl ?? lastUrl,
2986
+ conversationId: (liveness.matchedUrl ?? lastUrl)
2987
+ ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
2988
+ : undefined,
2881
2989
  promptSubmitted,
2882
2990
  controllerPid: process.pid,
2883
2991
  },
@@ -2897,17 +3005,40 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2897
3005
  // ignore
2898
3006
  }
2899
3007
  removeDialogHandler?.();
3008
+ const keepRemoteBrowser = Boolean(config.keepBrowser);
3009
+ const shouldCloseOwnedRemoteTarget = shouldCloseOwnedRunTargetAfterRun({
3010
+ runStatus,
3011
+ ownsTarget,
3012
+ keepBrowser: keepRemoteBrowser,
3013
+ closeOwnedTabOnComplete: options.closeOwnedTabOnComplete,
3014
+ });
3015
+ const closeOwnedRemoteTarget = async () => {
3016
+ if (!shouldCloseOwnedRemoteTarget || !remoteTargetId) {
3017
+ return;
3018
+ }
3019
+ const safeToClose = !keepRemoteBrowser ||
3020
+ Boolean(await ensureChromePageTargetAfterClose(port, remoteTargetId, logger, host));
3021
+ if (!safeToClose) {
3022
+ logger(`[browser] Leaving completed remote browser tab open because Chrome has no replacement page target.`);
3023
+ return;
3024
+ }
3025
+ const closeConfirmed = await closeTab(port, remoteTargetId, logger, host);
3026
+ if (!closeConfirmed && keepRemoteBrowser) {
3027
+ const replacementTargetId = await createChromePageTarget(port, logger, host);
3028
+ if (!replacementTargetId) {
3029
+ logger(`[browser] Remote Chrome page retention could not be verified after closing ${remoteTargetId}.`);
3030
+ }
3031
+ }
3032
+ };
2900
3033
  if (tabLease) {
2901
3034
  const handle = tabLease;
2902
3035
  tabLease = null;
2903
- await handle.release().catch(() => undefined);
3036
+ await handle
3037
+ .release({ onRelease: async () => closeOwnedRemoteTarget() })
3038
+ .catch(() => undefined);
2904
3039
  }
2905
- if (shouldCloseOwnedRunTargetAfterRun({
2906
- runStatus,
2907
- ownsTarget,
2908
- keepBrowser: Boolean(config.keepBrowser),
2909
- })) {
2910
- await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
3040
+ else {
3041
+ await closeOwnedRemoteTarget();
2911
3042
  }
2912
3043
  // Don't kill remote Chrome - it's not ours to manage
2913
3044
  const totalSeconds = (Date.now() - startedAt) / 1000;
@@ -2929,9 +3060,12 @@ export const __test__ = {
2929
3060
  isManualLoginProfileInitialized,
2930
3061
  isImageOnlyUiChromeText,
2931
3062
  listIgnoredRemoteChromeFlags,
3063
+ normalizeAuthenticatedModelSelectionError,
2932
3064
  resolveManualLoginWaitMs,
3065
+ shouldCleanupBlankTabsAfterLastLease,
2933
3066
  shouldCloseOwnedRunTargetAfterRun,
2934
3067
  shouldKeepLocalBrowserOpen,
3068
+ waitForAssistantResponseWithReload,
2935
3069
  };
2936
3070
  export { syncCookies } from "./cookies.js";
2937
3071
  export { navigateToChatGPT, ensureNotBlocked, ensurePromptReady, ensureModelSelection, submitPrompt, waitForAssistantResponse, captureAssistantMarkdown, uploadAttachmentFile, waitForAttachmentCompletion, } from "./pageActions.js";
@@ -2963,7 +3097,11 @@ async function waitForAssistantResponseWithReload(Runtime, Page, timeoutMs, logg
2963
3097
  }
2964
3098
  logger("Assistant response stalled; reloading conversation and retrying once");
2965
3099
  await Page.navigate({ url: conversationUrl });
2966
- await delay(1000);
3100
+ await waitForResumedConversationHydration(Runtime, timeoutMs, logger, {
3101
+ requirePriorTurns: true,
3102
+ requirePromptReady: false,
3103
+ expectedConversationUrl: conversationUrl,
3104
+ });
2967
3105
  return await waitForAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex, expectedConversationId);
2968
3106
  }
2969
3107
  }
@@ -3129,9 +3267,6 @@ async function readConversationTurnCount(Runtime, logger) {
3129
3267
  }
3130
3268
  return null;
3131
3269
  }
3132
- function isConversationUrl(url) {
3133
- return /\/c\/[a-z0-9-]+/i.test(url);
3134
- }
3135
3270
  function describeDevtoolsFirewallHint(host, port) {
3136
3271
  if (!isWsl())
3137
3272
  return null;
@@ -3152,10 +3287,6 @@ function isWsl() {
3152
3287
  return true;
3153
3288
  return os.release().toLowerCase().includes("microsoft");
3154
3289
  }
3155
- function extractConversationIdFromUrl(url) {
3156
- const match = url.match(/\/c\/([a-zA-Z0-9-]+)/);
3157
- return match?.[1];
3158
- }
3159
3290
  async function resolveUserDataBaseDir() {
3160
3291
  // On WSL, Chrome launched via Windows can choke on UNC paths; prefer a Windows-backed temp folder.
3161
3292
  if (isWsl()) {
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
3
3
  import { ANSWER_SELECTORS, ASSISTANT_ROLE_SELECTOR, INPUT_SELECTORS, MODEL_BUTTON_SELECTOR, SEND_BUTTON_SELECTORS, STOP_BUTTON_SELECTOR, } from "./constants.js";
4
4
  import { captureAssistantMarkdown, readAssistantSnapshot } from "./actions/assistantResponse.js";
5
5
  import { buildConversationTurnListExpression } from "./conversationTurns.js";
6
+ import { extractStableConversationIdFromUrl } from "./conversationUrl.js";
6
7
  import { delay } from "./utils.js";
7
8
  export const DEFAULT_REMOTE_CHROME_HOST = "127.0.0.1";
8
9
  export const DEFAULT_REMOTE_CHROME_PORT = 9222;
@@ -481,8 +482,7 @@ export async function harvestChatGptTab(options = {}) {
481
482
  }
482
483
  }
483
484
  export function extractConversationIdFromUrl(url) {
484
- const match = normalizeUrl(url).match(/\/c\/([^/?#]+)/);
485
- return match?.[1] ?? undefined;
485
+ return extractStableConversationIdFromUrl(normalizeUrl(url));
486
486
  }
487
487
  export function formatBrowserTabState(tab) {
488
488
  return tab.state ?? classifyTabState(tab);
@@ -0,0 +1,67 @@
1
+ function cleanLabel(value) {
2
+ const label = value?.trim();
3
+ return label ? label : null;
4
+ }
5
+ function sameLabel(left, right) {
6
+ return left.localeCompare(right, undefined, { sensitivity: "accent" }) === 0;
7
+ }
8
+ /**
9
+ * Describe what a browser run will try to select without presenting the target as observed fact.
10
+ */
11
+ export function formatBrowserModelTarget({ model, desiredModel, modelStrategy, }) {
12
+ const requested = cleanLabel(model) ?? "n/a";
13
+ if (modelStrategy === "current" || modelStrategy === "ignore") {
14
+ return `picker=${modelStrategy}; requested=${requested}`;
15
+ }
16
+ const target = cleanLabel(desiredModel);
17
+ if (!target) {
18
+ return requested;
19
+ }
20
+ return `target=${target}; requested=${requested}`;
21
+ }
22
+ /**
23
+ * Prefer picker evidence only when Oracle verified it. Otherwise retain the requested CLI key.
24
+ * In particular, a bare `Pro` picker label must not be expanded to a server-side model version.
25
+ */
26
+ export function resolveBrowserModelDisplayName({ model, evidence, }) {
27
+ const verifiedLabel = evidence?.verified ? cleanLabel(evidence.resolvedLabel) : null;
28
+ return verifiedLabel ?? cleanLabel(model) ?? "n/a";
29
+ }
30
+ export function formatBrowserModelWithRequestedKey(input) {
31
+ const displayName = resolveBrowserModelDisplayName(input);
32
+ const requested = cleanLabel(input.model);
33
+ if (!requested || sameLabel(displayName, requested)) {
34
+ return displayName;
35
+ }
36
+ return `${displayName} (requested ${requested})`;
37
+ }
38
+ export function resolveSessionBrowserModelDisplayName(metadata, model = metadata.model) {
39
+ const sessionModel = cleanLabel(metadata.model);
40
+ const requestedModel = cleanLabel(model);
41
+ const evidenceApplies = requestedModel === null
42
+ ? sessionModel === null
43
+ : sessionModel !== null && sameLabel(requestedModel, sessionModel);
44
+ return resolveBrowserModelDisplayName({
45
+ model,
46
+ evidence: evidenceApplies ? metadata.browser?.modelSelection : undefined,
47
+ });
48
+ }
49
+ export function formatSessionBrowserModelWithRequestedKey(metadata, model = metadata.model) {
50
+ const sessionModel = cleanLabel(metadata.model);
51
+ const requestedModel = cleanLabel(model);
52
+ const evidenceApplies = requestedModel === null
53
+ ? sessionModel === null
54
+ : sessionModel !== null && sameLabel(requestedModel, sessionModel);
55
+ return formatBrowserModelWithRequestedKey({
56
+ model,
57
+ evidence: evidenceApplies ? metadata.browser?.modelSelection : undefined,
58
+ });
59
+ }
60
+ export function formatBrowserModelSelectionEvidence(evidence, model) {
61
+ const requestedKey = cleanLabel(model) ?? "(none)";
62
+ const target = cleanLabel(evidence.requestedModel) ?? "(none)";
63
+ const resolvedLabel = cleanLabel(evidence.resolvedLabel) ?? "(unavailable)";
64
+ const strategy = evidence.strategy ?? "(default)";
65
+ const verified = evidence.verified ? "yes" : "no";
66
+ return `requestedKey=${requestedKey}; target=${target}; resolvedLabel=${resolvedLabel}; status=${evidence.status}; strategy=${strategy}; verified=${verified}; source=${evidence.source}; capturedAt=${evidence.capturedAt}`;
67
+ }
@@ -2,7 +2,7 @@ import CDP from "chrome-remote-interface";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { mkdtemp, mkdir, rm } from "node:fs/promises";
5
- import { waitForAssistantResponse, captureAssistantMarkdown, navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, } from "./pageActions.js";
5
+ import { waitForAssistantResponse, captureAssistantMarkdown, navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, waitForResumedConversationHydration, } from "./pageActions.js";
6
6
  import { launchChrome, connectToChrome, positionChromeWindowOffscreen, connectToRemoteChromeTarget, listRemoteChromeTargets, } from "./chromeLifecycle.js";
7
7
  import { resolveBrowserConfig } from "./config.js";
8
8
  import { clearStaleChatGptConversationCookies, syncCookies } from "./cookies.js";
@@ -98,6 +98,13 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
98
98
  const pingTimeoutMs = Math.min(5_000, Math.max(1_500, Math.floor(timeoutMs * 0.05)));
99
99
  await withTimeout(Runtime.evaluate({ expression: "1+1", returnByValue: true }), pingTimeoutMs, "Reattach target did not respond");
100
100
  await ensureConversationOpen();
101
+ const waitForHydration = deps.waitForConversationHydration ?? waitForResumedConversationHydration;
102
+ const expectedConversationUrl = buildConversationUrl(runtime, resolveBrowserConfig(config ?? {}).url);
103
+ await waitForHydration(Runtime, timeoutMs, logger, {
104
+ requirePriorTurns: true,
105
+ requirePromptReady: false,
106
+ expectedConversationUrl: expectedConversationUrl ?? undefined,
107
+ });
101
108
  const minTurnIndex = (await readPromptPreviewTurnIndex(Runtime, deps.promptPreview)) ??
102
109
  (deps.promptPreview ? null : await readConversationTurnIndex(Runtime, logger));
103
110
  if (config?.researchMode === "deep") {
@@ -226,6 +233,12 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
226
233
  }
227
234
  await waitForLocationChange(Runtime, 15_000);
228
235
  }
236
+ const waitForHydration = deps.waitForConversationHydration ?? waitForResumedConversationHydration;
237
+ await waitForHydration(Runtime, resolved.inputTimeoutMs, logger, {
238
+ requirePriorTurns: true,
239
+ requirePromptReady: false,
240
+ expectedConversationUrl: conversationUrl ?? undefined,
241
+ });
229
242
  const waitForResponse = deps.waitForAssistantResponse ?? waitForAssistantResponse;
230
243
  const captureMarkdown = deps.captureAssistantMarkdown ?? captureAssistantMarkdown;
231
244
  const timeoutMs = resolved.timeoutMs ?? 120_000;
@@ -1,5 +1,6 @@
1
1
  import { CONVERSATION_TURN_SELECTOR } from "./constants.js";
2
2
  import { buildConversationTurnCountExpression } from "./conversationTurns.js";
3
+ import { extractStableConversationIdFromUrl } from "./conversationUrl.js";
3
4
  import { delay } from "./utils.js";
4
5
  import { readAssistantSnapshot } from "./pageActions.js";
5
6
  export function pickTarget(targets, runtime) {
@@ -29,14 +30,11 @@ export function pickTarget(targets, runtime) {
29
30
  return targets.find((t) => t.type === "page") ?? targets[0];
30
31
  }
31
32
  export function extractConversationIdFromUrl(url) {
32
- if (!url)
33
- return undefined;
34
- const match = url.match(/\/c\/([a-zA-Z0-9-]+)/);
35
- return match?.[1];
33
+ return extractStableConversationIdFromUrl(url);
36
34
  }
37
35
  export function buildConversationUrl(runtime, baseUrl) {
38
36
  if (runtime.tabUrl) {
39
- if (runtime.tabUrl.includes("/c/")) {
37
+ if (extractConversationIdFromUrl(runtime.tabUrl)) {
40
38
  return runtime.tabUrl;
41
39
  }
42
40
  return null;
@@ -1,3 +1,4 @@
1
+ import { isStableConversationUrl } from "./conversationUrl.js";
1
2
  /**
2
3
  * True when the URL points at a specific ChatGPT conversation (`/c/<id>`) on
3
4
  * chatgpt.com or chat.openai.com. Rejects home, project shell, and external
@@ -17,7 +18,7 @@ export function isRecoverableChatGptConversationUrl(candidate) {
17
18
  if (url.hostname !== "chatgpt.com" && url.hostname !== "chat.openai.com") {
18
19
  return false;
19
20
  }
20
- return /(?:^|\/)c\/[^/]+/.test(url.pathname);
21
+ return isStableConversationUrl(url.pathname);
21
22
  }
22
23
  catch {
23
24
  return false;
@@ -67,7 +67,8 @@ export function isRecoveredConversationHarvestReady(harvested) {
67
67
  (typeof harvested.lastAssistantTurnIndex === "number" &&
68
68
  typeof harvested.lastUserTurnIndex === "number" &&
69
69
  harvested.lastAssistantTurnIndex > harvested.lastUserTurnIndex);
70
- return (harvested.stopExists === true ||
70
+ const hasHydratedUserTurn = typeof harvested.lastUserTurnIndex === "number" && harvested.lastUserTurnIndex >= 0;
71
+ return ((harvested.stopExists === true && hasHydratedUserTurn) ||
71
72
  ((harvested.assistantCount ?? 0) > 0 &&
72
73
  assistantFollowsLatestUser &&
73
74
  latestAssistant.trim().length > 0 &&
@@ -5,6 +5,7 @@ import { runBrowserMode } from "../browserMode.js";
5
5
  import { assembleBrowserPrompt } from "./prompt.js";
6
6
  import { BrowserAutomationError } from "../oracle/errors.js";
7
7
  import { appendArtifacts, saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "./artifacts.js";
8
+ import { formatBrowserModelSelectionEvidence, formatBrowserModelTarget, resolveBrowserModelDisplayName, } from "./modelDisplay.js";
8
9
  const LARGE_PRO_FAST_INPUT_TOKEN_THRESHOLD = 25_000;
9
10
  const LARGE_PRO_FAST_ELAPSED_MS_THRESHOLD = 120_000;
10
11
  function buildUnavailableModelSelectionEvidence(browserConfig) {
@@ -21,13 +22,6 @@ function buildUnavailableModelSelectionEvidence(browserConfig) {
21
22
  capturedAt: new Date().toISOString(),
22
23
  };
23
24
  }
24
- function formatModelSelectionEvidence(evidence) {
25
- const requested = evidence.requestedModel ?? "(none)";
26
- const resolved = evidence.resolvedLabel ?? "(unavailable)";
27
- const strategy = evidence.strategy ?? "(default)";
28
- const verified = evidence.verified ? "yes" : "no";
29
- return `[browser] Model selection evidence: requested=${requested}; resolved=${resolved}; status=${evidence.status}; strategy=${strategy}; verified=${verified}.`;
30
- }
31
25
  function isRequestedProBrowserRun(runOptions, browserConfig, evidence) {
32
26
  const candidates = [
33
27
  runOptions.model,
@@ -87,7 +81,12 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
87
81
  if (promptArtifacts.bundled) {
88
82
  log(chalk.dim(`Packed ${promptArtifacts.bundled.originalCount} files into 1 bundle (contents counted in token estimate).`));
89
83
  }
90
- const headerLine = `Launching browser mode (${runOptions.model}) with ~${promptArtifacts.estimatedInputTokens.toLocaleString()} tokens.`;
84
+ const launchModel = formatBrowserModelTarget({
85
+ model: runOptions.model,
86
+ desiredModel: browserConfig.desiredModel,
87
+ modelStrategy: browserConfig.modelStrategy,
88
+ });
89
+ const headerLine = `Launching browser mode (${launchModel}) with ~${promptArtifacts.estimatedInputTokens.toLocaleString()} tokens.`;
91
90
  const automationLogger = ((message) => {
92
91
  if (typeof message !== "string")
93
92
  return;
@@ -150,7 +149,7 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
150
149
  }
151
150
  const modelSelection = browserResult.modelSelection ?? buildUnavailableModelSelectionEvidence(browserConfig);
152
151
  if (modelSelection) {
153
- log(formatModelSelectionEvidence(modelSelection));
152
+ log(`[browser] Model selection evidence: ${formatBrowserModelSelectionEvidence(modelSelection, runOptions.model)}`);
154
153
  }
155
154
  const warnings = buildBrowserRunWarnings({
156
155
  runOptions,
@@ -199,7 +198,7 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
199
198
  })();
200
199
  const { line1, line2 } = formatFinishLine({
201
200
  elapsedMs: browserResult.tookMs,
202
- model: `${runOptions.model}[browser]`,
201
+ model: `${resolveBrowserModelDisplayName({ model: runOptions.model, evidence: modelSelection })}[browser]`,
203
202
  tokensPart,
204
203
  detailParts: [
205
204
  runOptions.file && runOptions.file.length > 0 ? `files=${runOptions.file.length}` : null,
@@ -61,7 +61,7 @@ export async function acquireBrowserTabLease(profileDir, options, deps = {}) {
61
61
  options.logger?.(`[browser] Acquired ChatGPT browser slot ${leaseId.slice(0, 8)} (${maxConcurrentTabs} max).`);
62
62
  return {
63
63
  id: leaseId,
64
- release: async () => releaseBrowserTabLease(profileDir, leaseId, options.logger),
64
+ release: async (releaseOptions) => releaseBrowserTabLease(profileDir, leaseId, options.logger, releaseOptions),
65
65
  update: async (patch) => updateBrowserTabLease(profileDir, leaseId, patch),
66
66
  };
67
67
  }
@@ -86,11 +86,17 @@ export async function updateBrowserTabLease(profileDir, leaseId, patch) {
86
86
  await writeRegistry(profileDir, { version: 1, leases });
87
87
  });
88
88
  }
89
- export async function releaseBrowserTabLease(profileDir, leaseId, logger) {
89
+ export async function releaseBrowserTabLease(profileDir, leaseId, logger, options = {}) {
90
90
  await withRegistryLock(profileDir, async () => {
91
91
  const registry = await readRegistry(profileDir);
92
- const leases = registry.leases.filter((lease) => lease.id !== leaseId);
92
+ const active = pruneStaleLeases(registry.leases, {
93
+ nowMs: Date.now(),
94
+ staleMs: DEFAULT_STALE_MS,
95
+ isProcessAlive,
96
+ });
97
+ const leases = active.filter((lease) => lease.id !== leaseId);
93
98
  await writeRegistry(profileDir, { version: 1, leases });
99
+ await options.onRelease?.({ isLastLease: leases.length === 0 });
94
100
  }).catch(() => undefined);
95
101
  logger?.(`[browser] Released ChatGPT browser slot ${leaseId.slice(0, 8)}.`);
96
102
  }