@steipete/oracle 0.15.2 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +1 -1
  3. package/dist/bin/oracle-mcp.js +0 -0
  4. package/dist/src/browser/actions/assistantResponse.js +218 -126
  5. package/dist/src/browser/actions/modelSelection.js +133 -22
  6. package/dist/src/browser/actions/navigation.js +235 -14
  7. package/dist/src/browser/actions/thinkingStatus.js +228 -0
  8. package/dist/src/browser/actions/thinkingTime.js +68 -11
  9. package/dist/src/browser/cdpLiveness.js +78 -0
  10. package/dist/src/browser/chromeLifecycle.js +112 -30
  11. package/dist/src/browser/config.js +14 -1
  12. package/dist/src/browser/constants.js +5 -1
  13. package/dist/src/browser/controlPlan.js +2 -2
  14. package/dist/src/browser/conversationUrl.js +16 -0
  15. package/dist/src/browser/conversationUrlMonitor.js +2 -4
  16. package/dist/src/browser/cookies.js +6 -4
  17. package/dist/src/browser/index.js +213 -57
  18. package/dist/src/browser/liveTabs.js +115 -33
  19. package/dist/src/browser/pageActions.js +1 -1
  20. package/dist/src/browser/projectSourcesRunner.js +4 -4
  21. package/dist/src/browser/reattach.js +2 -2
  22. package/dist/src/browser/reattachHelpers.js +3 -5
  23. package/dist/src/browser/reattachability.js +2 -1
  24. package/dist/src/browser/recoverConversation.js +90 -29
  25. package/dist/src/browser/tabLeaseRegistry.js +9 -3
  26. package/dist/src/cli/browserConfig.js +5 -1
  27. package/dist/src/cli/browserDefaults.js +4 -1
  28. package/dist/src/cli/browserTabs.js +54 -33
  29. package/dist/src/cli/options.js +24 -0
  30. package/dist/src/cli/runOptions.js +6 -1
  31. package/dist/src/cli/sessionDisplay.js +38 -14
  32. package/dist/src/cli/sessionRunner.js +49 -2
  33. package/dist/src/oracle/config.js +25 -0
  34. package/dist/src/oracle/geminiModels.js +2 -0
  35. package/dist/src/remote/server.js +8 -0
  36. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  37. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  38. package/package.json +13 -14
  39. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  40. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  41. package/dist/bin/oracle.js +0 -569
  42. package/dist/docs-site/.nojekyll +0 -0
  43. package/dist/docs-site/CNAME +0 -1
  44. package/dist/docs-site/RELEASING.html +0 -410
  45. package/dist/docs-site/agents.html +0 -374
  46. package/dist/docs-site/anthropic.html +0 -368
  47. package/dist/docs-site/bridge.html +0 -416
  48. package/dist/docs-site/browser-mode.html +0 -594
  49. package/dist/docs-site/chromium-forks.html +0 -347
  50. package/dist/docs-site/cli-reference.html +0 -346
  51. package/dist/docs-site/configuration.html +0 -462
  52. package/dist/docs-site/favicon.svg +0 -14
  53. package/dist/docs-site/followup.html +0 -375
  54. package/dist/docs-site/gemini.html +0 -383
  55. package/dist/docs-site/grok.html +0 -325
  56. package/dist/docs-site/index.html +0 -360
  57. package/dist/docs-site/install.html +0 -335
  58. package/dist/docs-site/linux.html +0 -321
  59. package/dist/docs-site/llms.txt +0 -43
  60. package/dist/docs-site/manual-tests.html +0 -596
  61. package/dist/docs-site/mcp.html +0 -391
  62. package/dist/docs-site/multimodel.html +0 -364
  63. package/dist/docs-site/mythical-pro-agents.html +0 -360
  64. package/dist/docs-site/notifier.html +0 -338
  65. package/dist/docs-site/openai-endpoints.html +0 -399
  66. package/dist/docs-site/openrouter.html +0 -344
  67. package/dist/docs-site/quickstart.html +0 -369
  68. package/dist/docs-site/refactor/ux.html +0 -532
  69. package/dist/docs-site/sessions.html +0 -388
  70. package/dist/docs-site/social-card.png +0 -0
  71. package/dist/docs-site/social-card.svg +0 -79
  72. package/dist/docs-site/spec.html +0 -363
  73. package/dist/docs-site/testing.html +0 -320
  74. package/dist/docs-site/tui-debug.html +0 -326
  75. package/dist/docs-site/windows-work.html +0 -323
  76. package/dist/docs-site/windows.html +0 -320
  77. package/dist/src/browser/chromeCookies.js +0 -312
  78. package/dist/src/browser/keytarShim.js +0 -56
  79. package/dist/src/browser/windowsCookies.js +0 -219
@@ -4,9 +4,9 @@ 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, hideChromeWindow, 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
- import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js";
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";
11
11
  import { uploadAttachmentViaDataTransfer } from "./actions/remoteFileTransfer.js";
12
12
  import { ensureThinkingTime } from "./actions/thinkingTime.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";
@@ -281,6 +283,25 @@ async function createAssistantTimeoutError(params) {
281
283
  }
282
284
  return warningError;
283
285
  }
286
+ /**
287
+ * Make the page behave like a focused foreground tab.
288
+ *
289
+ * The send button is activated with trusted CDP input events dispatched at
290
+ * viewport coordinates. Chrome delivers those only to a window that is being
291
+ * composited, so a hidden (`--browser-hide-window`), minimized, or occluded
292
+ * window swallows the click while the automation still believes it clicked.
293
+ * Soft-fails: focus emulation is an optimization, never a hard requirement.
294
+ */
295
+ async function enableFocusEmulation(client, logger, label) {
296
+ try {
297
+ await client.Emulation.setFocusEmulationEnabled({ enabled: true });
298
+ logger(`[browser] Focus emulation enabled for ${label}`);
299
+ }
300
+ catch (error) {
301
+ const message = error instanceof Error ? error.message : String(error);
302
+ logger(`[browser] Focus emulation unavailable: ${message}`);
303
+ }
304
+ }
284
305
  function listIgnoredRemoteChromeFlags(config) {
285
306
  return [
286
307
  config.headless ? "--browser-headless" : null,
@@ -356,13 +377,13 @@ async function pollGeneratedImageOrTextAssistantResponse(Runtime, timeoutMs, min
356
377
  }
357
378
  return null;
358
379
  }
359
- function isImageOnlyUiChromeText(text) {
380
+ export function isImageOnlyUiChromeText(text) {
360
381
  const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
361
382
  return (normalized.length === 0 ||
362
383
  normalized === "edit" ||
363
384
  normalized === "stopped thinking" ||
364
385
  normalized === "stopped thinking edit" ||
365
- /^thought for \d+(?:\.\d+)?\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\s+edit$/.test(normalized));
386
+ /^(?:reasoning\s+|pro thinking\s+)?thought for \d+(?:\.\d+)?\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\s+edit$/.test(normalized));
366
387
  }
367
388
  function normalizeBrowserFollowUpPrompts(values) {
368
389
  return (values ?? []).map((entry) => entry.trim()).filter(Boolean);
@@ -516,7 +537,17 @@ async function closeRemoteConnectionAfterRun(options) {
516
537
  }
517
538
  }
518
539
  function shouldCloseOwnedRunTargetAfterRun(options) {
519
- return options.runStatus === "complete" && options.ownsTarget && !options.keepBrowser;
540
+ return (options.runStatus === "complete" &&
541
+ options.ownsTarget &&
542
+ (Boolean(options.closeOwnedTabOnComplete) || !options.keepBrowser));
543
+ }
544
+ function shouldCleanupBlankTabsAfterLastLease(options) {
545
+ return (options.runStatus === "complete" &&
546
+ options.ownsTarget &&
547
+ !options.connectionClosedUnexpectedly &&
548
+ options.manualLogin &&
549
+ options.keepBrowser &&
550
+ Boolean(options.chromePort));
520
551
  }
521
552
  function buildSkippedModelSelectionEvidence(desiredModel, strategy) {
522
553
  return {
@@ -777,20 +808,54 @@ export async function runBrowserMode(options) {
777
808
  const disconnectPromise = new Promise((_, reject) => {
778
809
  client?.on("disconnect", () => {
779
810
  connectionClosedUnexpectedly = true;
780
- logger("Chrome window closed; attempting to abort run.");
781
- reject(new Error("Chrome window closed before oracle finished. Please keep it open until completion."));
811
+ void (async () => {
812
+ const liveness = await probeChromeTargetLiveness({
813
+ host: chromeHost,
814
+ port: chrome.port,
815
+ targetId: lastTargetId ?? isolatedTargetId,
816
+ });
817
+ const recoverable = isRecoverableChromeDisconnect(liveness);
818
+ if (recoverable) {
819
+ logger("CDP client disconnected; Chrome/target still reachable. Leaving run recoverable for reattach.");
820
+ }
821
+ else {
822
+ logger("Chrome window closed; attempting to abort run.");
823
+ }
824
+ reject(new BrowserAutomationError(connectionLostUserMessage({ recoverable }), {
825
+ stage: "connection-lost",
826
+ recoverableDisconnect: recoverable,
827
+ disconnectCause: recoverable ? "cdp-client-disconnect" : "chrome-closed",
828
+ runtime: {
829
+ chromePid: chrome.pid,
830
+ chromePort: chrome.port,
831
+ chromeHost,
832
+ userDataDir,
833
+ chromeTargetId: lastTargetId ?? isolatedTargetId ?? undefined,
834
+ tabUrl: liveness.matchedUrl ?? lastUrl,
835
+ conversationId: (liveness.matchedUrl ?? lastUrl)
836
+ ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
837
+ : undefined,
838
+ promptSubmitted,
839
+ controllerPid: process.pid,
840
+ },
841
+ }));
842
+ })();
782
843
  });
783
844
  });
784
845
  const raceWithDisconnect = (promise) => Promise.race([promise, disconnectPromise]);
785
846
  const { Network, Page, Runtime, Input, DOM, Target } = client;
786
- if (!config.headless && config.hideWindow) {
787
- await hideChromeWindow(chrome, logger);
788
- }
789
847
  const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
790
848
  if (DOM && typeof DOM.enable === "function") {
791
849
  domainEnablers.push(DOM.enable());
792
850
  }
793
851
  await Promise.all(domainEnablers);
852
+ if (!config.headless && config.hideWindow) {
853
+ await positionChromeWindowOffscreen(client, logger);
854
+ }
855
+ // The send button is clicked with trusted CDP input events at viewport
856
+ // coordinates, which ChatGPT silently drops when the window is hidden or
857
+ // occluded. Emulate focus so the page behaves like a foreground tab.
858
+ await enableFocusEmulation(client, logger, "local target");
794
859
  removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
795
860
  if (!profileIsPreSigned) {
796
861
  await Network.clearBrowserCookies();
@@ -911,6 +976,18 @@ export async function runBrowserMode(options) {
911
976
  }));
912
977
  }
913
978
  }
979
+ const chatMode = await raceWithDisconnect(ensureChatMode(Runtime, Input, config.inputTimeoutMs, logger, {
980
+ resetWorkConversation: config.browserTabRef && !isResumingConversation
981
+ ? async () => {
982
+ await navigateToChatGPT(Page, Runtime, config.url, logger);
983
+ await ensureNotBlocked(Runtime, config.headless, logger);
984
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
985
+ }
986
+ : undefined,
987
+ }));
988
+ if (chatMode === "switched") {
989
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
990
+ }
914
991
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
915
992
  const captureRuntimeSnapshot = async () => {
916
993
  try {
@@ -1692,19 +1769,34 @@ export async function runBrowserMode(options) {
1692
1769
  throw normalizedError;
1693
1770
  }
1694
1771
  if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") && normalizedError.stack) {
1695
- logger(`Chrome window closed before completion: ${normalizedError.message}`);
1772
+ logger(`Chrome connection lost before completion: ${normalizedError.message}`);
1696
1773
  logger(normalizedError.stack);
1697
1774
  }
1698
1775
  await emitRuntimeHint();
1699
- throw new BrowserAutomationError("Chrome window closed before oracle finished. Please keep it open until completion.", {
1776
+ if (normalizedError instanceof BrowserAutomationError &&
1777
+ normalizedError.details?.stage === "connection-lost") {
1778
+ throw normalizedError;
1779
+ }
1780
+ const liveness = await probeChromeTargetLiveness({
1781
+ host: chromeHost,
1782
+ port: chrome.port,
1783
+ targetId: lastTargetId ?? isolatedTargetId,
1784
+ });
1785
+ const recoverable = isRecoverableChromeDisconnect(liveness);
1786
+ throw new BrowserAutomationError(connectionLostUserMessage({ recoverable }), {
1700
1787
  stage: "connection-lost",
1788
+ recoverableDisconnect: recoverable,
1789
+ disconnectCause: recoverable ? "cdp-client-disconnect" : "chrome-closed",
1701
1790
  runtime: {
1702
1791
  chromePid: chrome.pid,
1703
1792
  chromePort: chrome.port,
1704
1793
  chromeHost,
1705
1794
  userDataDir,
1706
1795
  chromeTargetId: lastTargetId,
1707
- tabUrl: lastUrl,
1796
+ tabUrl: liveness.matchedUrl ?? lastUrl,
1797
+ conversationId: (liveness.matchedUrl ?? lastUrl)
1798
+ ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
1799
+ : undefined,
1708
1800
  promptSubmitted,
1709
1801
  controllerPid: process.pid,
1710
1802
  },
@@ -1723,15 +1815,12 @@ export async function runBrowserMode(options) {
1723
1815
  // Close the isolated tab once the response has been fully captured to prevent
1724
1816
  // tab accumulation across repeated runs. Keep the tab open on incomplete runs
1725
1817
  // so reattach can recover the response.
1726
- if (shouldCloseOwnedRunTargetAfterRun({
1818
+ const shouldCloseOwnedRunTarget = shouldCloseOwnedRunTargetAfterRun({
1727
1819
  runStatus,
1728
1820
  ownsTarget,
1729
1821
  keepBrowser: effectiveKeepBrowser,
1730
- }) &&
1731
- isolatedTargetId &&
1732
- chrome?.port) {
1733
- await closeTab(chrome.port, isolatedTargetId, logger, chromeHost).catch(() => undefined);
1734
- }
1822
+ closeOwnedTabOnComplete: options.closeOwnedTabOnComplete,
1823
+ });
1735
1824
  let keepBrowserOpen = shouldKeepLocalBrowserOpen({
1736
1825
  effectiveKeepBrowser,
1737
1826
  preserveBrowserOnError,
@@ -1749,18 +1838,6 @@ export async function runBrowserMode(options) {
1749
1838
  }
1750
1839
  return otherActiveBrowserTabLeases;
1751
1840
  };
1752
- if (runStatus === "complete" &&
1753
- manualLogin &&
1754
- !connectionClosedUnexpectedly &&
1755
- chrome?.port &&
1756
- ownsTarget) {
1757
- const otherLeasesActive = await hasOtherActiveLeases().catch(() => true);
1758
- if (!otherLeasesActive) {
1759
- await closeBlankChromeTabs(chrome.port, logger, chromeHost, {
1760
- excludeTargetIds: [isolatedTargetId, lastTargetId],
1761
- }).catch(() => undefined);
1762
- }
1763
- }
1764
1841
  if (!keepBrowserOpen && manualLogin && tabLease) {
1765
1842
  const cleanupLockTimeoutMs = Math.max(0, config.profileLockTimeoutMs ?? 0);
1766
1843
  if (cleanupLockTimeoutMs > 0) {
@@ -1778,10 +1855,55 @@ export async function runBrowserMode(options) {
1778
1855
  terminatedRecordedChrome = await terminateRecordedChromeForProfile(userDataDir, logger).catch(() => false);
1779
1856
  }
1780
1857
  }
1858
+ const closeOwnedRunTarget = async () => {
1859
+ if (!shouldCloseOwnedRunTarget || !isolatedTargetId || !chrome?.port) {
1860
+ return;
1861
+ }
1862
+ const safeToClose = !effectiveKeepBrowser ||
1863
+ Boolean(await ensureChromePageTargetAfterClose(chrome.port, isolatedTargetId, logger, chromeHost));
1864
+ if (!safeToClose) {
1865
+ logger(`[browser] Leaving completed browser tab open because Chrome has no replacement page target.`);
1866
+ return;
1867
+ }
1868
+ const closeConfirmed = await closeTab(chrome.port, isolatedTargetId, logger, chromeHost);
1869
+ if (!closeConfirmed && effectiveKeepBrowser) {
1870
+ const replacementTargetId = await createChromePageTarget(chrome.port, logger, chromeHost);
1871
+ if (!replacementTargetId) {
1872
+ logger(`[browser] Chrome page retention could not be verified after closing ${isolatedTargetId}.`);
1873
+ }
1874
+ }
1875
+ };
1876
+ const cleanupBlankTabs = async () => {
1877
+ if (!shouldCleanupBlankTabsAfterLastLease({
1878
+ runStatus,
1879
+ ownsTarget,
1880
+ connectionClosedUnexpectedly,
1881
+ manualLogin,
1882
+ keepBrowser: effectiveKeepBrowser,
1883
+ chromePort: chrome?.port,
1884
+ }) ||
1885
+ !chrome?.port) {
1886
+ return;
1887
+ }
1888
+ await closeBlankChromeTabs(chrome.port, logger, chromeHost, {
1889
+ excludeTargetIds: [isolatedTargetId, lastTargetId],
1890
+ preserveOneBlank: true,
1891
+ });
1892
+ };
1781
1893
  if (tabLease) {
1782
1894
  const handle = tabLease;
1783
1895
  tabLease = null;
1784
- await handle.release().catch(() => undefined);
1896
+ const onRelease = async ({ isLastLease }) => {
1897
+ await closeOwnedRunTarget();
1898
+ if (isLastLease) {
1899
+ await cleanupBlankTabs();
1900
+ }
1901
+ };
1902
+ await handle.release({ onRelease }).catch(() => undefined);
1903
+ }
1904
+ else {
1905
+ await closeOwnedRunTarget();
1906
+ await cleanupBlankTabs();
1785
1907
  }
1786
1908
  removeDialogHandler?.();
1787
1909
  removeTerminationHooks?.();
@@ -1961,7 +2083,7 @@ function detachKeptChromeProcess(chrome) {
1961
2083
  // Best-effort only; cleanup should not mask the original browser result.
1962
2084
  }
1963
2085
  }
1964
- async function acquireManualLoginChromeForRun(userDataDir, config, logger, sessionId, deps = {}) {
2086
+ export async function acquireManualLoginChromeForRun(userDataDir, config, logger, sessionId, deps = {}) {
1965
2087
  const maybeReuse = deps.maybeReuse ?? maybeReuseRunningChrome;
1966
2088
  const launch = deps.launch ?? launchChrome;
1967
2089
  const lockTimeoutMs = Math.max(0, config.profileLockTimeoutMs ?? 0);
@@ -2176,14 +2298,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2176
2298
  }
2177
2299
  await Promise.all(domainEnablers);
2178
2300
  removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
2179
- try {
2180
- await client.Emulation.setFocusEmulationEnabled({ enabled: true });
2181
- logger("[browser] Focus emulation enabled for remote target");
2182
- }
2183
- catch (error) {
2184
- const message = error instanceof Error ? error.message : String(error);
2185
- logger(`[browser] Focus emulation unavailable: ${message}`);
2186
- }
2301
+ await enableFocusEmulation(client, logger, "remote target");
2187
2302
  const activeConversationUrlMonitor = createConversationUrlMonitor({
2188
2303
  readUrl: async () => {
2189
2304
  const { result } = await Runtime.evaluate({
@@ -2222,6 +2337,18 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2222
2337
  expectedConversationUrl: config.resumeConversationUrl,
2223
2338
  });
2224
2339
  }
2340
+ const chatMode = await ensureChatMode(Runtime, Input, config.inputTimeoutMs, logger, {
2341
+ resetWorkConversation: attachedExistingTab && !config.resumeConversationUrl
2342
+ ? async () => {
2343
+ await navigateToChatGPT(Page, Runtime, config.url, logger);
2344
+ await ensureNotBlocked(Runtime, config.headless, logger);
2345
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
2346
+ }
2347
+ : undefined,
2348
+ });
2349
+ if (chatMode === "switched") {
2350
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
2351
+ }
2225
2352
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
2226
2353
  try {
2227
2354
  const { result } = await Runtime.evaluate({
@@ -2829,15 +2956,27 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2829
2956
  }
2830
2957
  throw normalizedError;
2831
2958
  }
2832
- throw new BrowserAutomationError("Remote Chrome connection lost before Oracle finished.", {
2959
+ const liveness = await probeChromeTargetLiveness({
2960
+ host,
2961
+ port,
2962
+ targetId: remoteTargetId,
2963
+ browserWSEndpoint,
2964
+ });
2965
+ const recoverable = isRecoverableChromeDisconnect(liveness);
2966
+ throw new BrowserAutomationError(connectionLostUserMessage({ recoverable, remote: true }), {
2833
2967
  stage: "connection-lost",
2968
+ recoverableDisconnect: recoverable,
2969
+ disconnectCause: recoverable ? "cdp-client-disconnect" : "chrome-closed",
2834
2970
  runtime: {
2835
2971
  chromeHost: host,
2836
2972
  chromePort: port,
2837
2973
  chromeBrowserWSEndpoint: browserWSEndpoint,
2838
2974
  chromeProfileRoot,
2839
2975
  chromeTargetId: remoteTargetId ?? undefined,
2840
- tabUrl: lastUrl,
2976
+ tabUrl: liveness.matchedUrl ?? lastUrl,
2977
+ conversationId: (liveness.matchedUrl ?? lastUrl)
2978
+ ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
2979
+ : undefined,
2841
2980
  promptSubmitted,
2842
2981
  controllerPid: process.pid,
2843
2982
  },
@@ -2857,17 +2996,40 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2857
2996
  // ignore
2858
2997
  }
2859
2998
  removeDialogHandler?.();
2999
+ const keepRemoteBrowser = Boolean(config.keepBrowser);
3000
+ const shouldCloseOwnedRemoteTarget = shouldCloseOwnedRunTargetAfterRun({
3001
+ runStatus,
3002
+ ownsTarget,
3003
+ keepBrowser: keepRemoteBrowser,
3004
+ closeOwnedTabOnComplete: options.closeOwnedTabOnComplete,
3005
+ });
3006
+ const closeOwnedRemoteTarget = async () => {
3007
+ if (!shouldCloseOwnedRemoteTarget || !remoteTargetId) {
3008
+ return;
3009
+ }
3010
+ const safeToClose = !keepRemoteBrowser ||
3011
+ Boolean(await ensureChromePageTargetAfterClose(port, remoteTargetId, logger, host));
3012
+ if (!safeToClose) {
3013
+ logger(`[browser] Leaving completed remote browser tab open because Chrome has no replacement page target.`);
3014
+ return;
3015
+ }
3016
+ const closeConfirmed = await closeTab(port, remoteTargetId, logger, host);
3017
+ if (!closeConfirmed && keepRemoteBrowser) {
3018
+ const replacementTargetId = await createChromePageTarget(port, logger, host);
3019
+ if (!replacementTargetId) {
3020
+ logger(`[browser] Remote Chrome page retention could not be verified after closing ${remoteTargetId}.`);
3021
+ }
3022
+ }
3023
+ };
2860
3024
  if (tabLease) {
2861
3025
  const handle = tabLease;
2862
3026
  tabLease = null;
2863
- await handle.release().catch(() => undefined);
3027
+ await handle
3028
+ .release({ onRelease: async () => closeOwnedRemoteTarget() })
3029
+ .catch(() => undefined);
2864
3030
  }
2865
- if (shouldCloseOwnedRunTargetAfterRun({
2866
- runStatus,
2867
- ownsTarget,
2868
- keepBrowser: Boolean(config.keepBrowser),
2869
- })) {
2870
- await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
3031
+ else {
3032
+ await closeOwnedRemoteTarget();
2871
3033
  }
2872
3034
  // Don't kill remote Chrome - it's not ours to manage
2873
3035
  const totalSeconds = (Date.now() - startedAt) / 1000;
@@ -2890,6 +3052,7 @@ export const __test__ = {
2890
3052
  isImageOnlyUiChromeText,
2891
3053
  listIgnoredRemoteChromeFlags,
2892
3054
  resolveManualLoginWaitMs,
3055
+ shouldCleanupBlankTabsAfterLastLease,
2893
3056
  shouldCloseOwnedRunTargetAfterRun,
2894
3057
  shouldKeepLocalBrowserOpen,
2895
3058
  };
@@ -3089,9 +3252,6 @@ async function readConversationTurnCount(Runtime, logger) {
3089
3252
  }
3090
3253
  return null;
3091
3254
  }
3092
- function isConversationUrl(url) {
3093
- return /\/c\/[a-z0-9-]+/i.test(url);
3094
- }
3095
3255
  function describeDevtoolsFirewallHint(host, port) {
3096
3256
  if (!isWsl())
3097
3257
  return null;
@@ -3112,10 +3272,6 @@ function isWsl() {
3112
3272
  return true;
3113
3273
  return os.release().toLowerCase().includes("microsoft");
3114
3274
  }
3115
- function extractConversationIdFromUrl(url) {
3116
- const match = url.match(/\/c\/([a-zA-Z0-9-]+)/);
3117
- return match?.[1];
3118
- }
3119
3275
  async function resolveUserDataBaseDir() {
3120
3276
  // On WSL, Chrome launched via Windows can choke on UNC paths; prefer a Windows-backed temp folder.
3121
3277
  if (isWsl()) {