@steipete/oracle 0.16.0 → 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.
package/README.md CHANGED
@@ -294,7 +294,7 @@ Browser automation can open or control Chrome, so dry-runs and live runs print a
294
294
  | `--followup-model <model>` | For multi-model OpenAI/Azure parent sessions, choose which model response to continue from. |
295
295
  | `--base-url <url>` | Point API runs at LiteLLM/Azure/OpenRouter/etc. |
296
296
  | `--chatgpt-url <url>` | Target a ChatGPT workspace/folder or Temporary Chat URL (browser). |
297
- | `--browser-model-strategy <select\|current\|ignore>` | Control ChatGPT model selection in browser mode (current keeps the active model; ignore skips the picker). |
297
+ | `--browser-model-strategy <select\|current\|ignore>` | Control ChatGPT model selection in browser mode. Explicit `current` keeps the active model without inheriting configured thinking time; pass `--browser-thinking-time` to change effort. `ignore` skips the picker. |
298
298
  | `--browser-manual-login` | Skip cookie copy; reuse a persistent automation profile and wait for manual ChatGPT login. |
299
299
  | `--browser-attach-running` | Reuse your current local browser session through local `DevToolsActivePort` discovery; Oracle opens a dedicated tab instead of launching Chrome (defaults to `127.0.0.1:9222`, or combine with `--remote-chrome <host:port>` to hint a different local endpoint). |
300
300
  | `--browser-tab <ref>` | Reuse an existing ChatGPT tab by `current`, target id, URL, or title substring instead of opening a new tab. |
File without changes
File without changes
@@ -0,0 +1,78 @@
1
+ import { listRemoteChromeTargets } from "./chromeLifecycle.js";
2
+ import { verifyDevToolsReachable } from "./profileState.js";
3
+ /**
4
+ * Probe whether Chrome's DevTools endpoint (and optionally a specific target)
5
+ * is still reachable after a CDP client WebSocket disconnect.
6
+ */
7
+ export async function probeChromeTargetLiveness(options) {
8
+ const host = options.host || "127.0.0.1";
9
+ const port = options.port;
10
+ if (!Number.isFinite(port) || port <= 0) {
11
+ return { endpointReachable: false, targetFound: null, error: "missing debug port" };
12
+ }
13
+ const verifyEndpoint = options.verifyEndpoint ?? verifyDevToolsReachable;
14
+ const endpoint = await verifyEndpoint({ host, port, attempts: 2, timeoutMs: 1500 });
15
+ if (!endpoint.ok) {
16
+ return {
17
+ endpointReachable: false,
18
+ targetFound: null,
19
+ error: endpoint.error,
20
+ };
21
+ }
22
+ const targetId = options.targetId?.trim();
23
+ if (!targetId) {
24
+ return { endpointReachable: true, targetFound: null };
25
+ }
26
+ try {
27
+ const listTargets = options.listTargets ?? listRemoteChromeTargets;
28
+ const targets = await listTargets({
29
+ host,
30
+ port,
31
+ browserWSEndpoint: options.browserWSEndpoint,
32
+ });
33
+ const match = targets.find((target) => {
34
+ const id = target.targetId ?? target.id;
35
+ return id === targetId;
36
+ });
37
+ if (!match) {
38
+ return { endpointReachable: true, targetFound: false };
39
+ }
40
+ return {
41
+ endpointReachable: true,
42
+ targetFound: true,
43
+ matchedUrl: typeof match.url === "string" ? match.url : undefined,
44
+ };
45
+ }
46
+ catch (error) {
47
+ const message = error instanceof Error ? error.message : String(error);
48
+ // Endpoint answered /json/version; treat list failures as still recoverable.
49
+ return { endpointReachable: true, targetFound: null, error: message };
50
+ }
51
+ }
52
+ export function isRecoverableChromeDisconnect(liveness) {
53
+ if (!liveness.endpointReachable) {
54
+ return false;
55
+ }
56
+ // Confirmed live target → recoverable.
57
+ if (liveness.targetFound === true) {
58
+ return true;
59
+ }
60
+ // Confirmed missing target → not recoverable.
61
+ if (liveness.targetFound === false) {
62
+ return false;
63
+ }
64
+ // targetFound === null:
65
+ // - no target id was provided (endpoint-only check) → recoverable
66
+ // - target list failed after a specific id was requested (error set) → fail closed
67
+ return !liveness.error;
68
+ }
69
+ export function connectionLostUserMessage(options) {
70
+ if (options.recoverable) {
71
+ return options.remote
72
+ ? "Remote Chrome DevTools client disconnected before oracle finished; the browser target appears still alive."
73
+ : "Chrome DevTools client disconnected before oracle finished; the browser target appears still alive.";
74
+ }
75
+ return options.remote
76
+ ? "Remote Chrome connection lost before Oracle finished."
77
+ : "Chrome window closed before oracle finished. Please keep it open until completion.";
78
+ }
@@ -406,13 +406,79 @@ export async function closeTab(port, targetId, logger, host) {
406
406
  const effectiveHost = host ?? "127.0.0.1";
407
407
  try {
408
408
  await CDP.Close({ host: effectiveHost, port, id: targetId });
409
- logger(`Closed isolated browser tab (target=${targetId})`);
409
+ for (let attempt = 0; attempt < 40; attempt += 1) {
410
+ await delay(25);
411
+ let targets;
412
+ try {
413
+ targets = (await CDP.List({ host: effectiveHost, port }));
414
+ }
415
+ catch {
416
+ continue;
417
+ }
418
+ if (!targets.some((target) => (target.targetId ?? target.id) === targetId)) {
419
+ logger(`Closed isolated browser tab (target=${targetId})`);
420
+ return true;
421
+ }
422
+ }
423
+ logger(`Browser tab close was not confirmed (target=${targetId})`);
424
+ return false;
410
425
  }
411
426
  catch (error) {
427
+ try {
428
+ const targets = (await CDP.List({ host: effectiveHost, port }));
429
+ if (!targets.some((target) => (target.targetId ?? target.id) === targetId)) {
430
+ logger(`Closed isolated browser tab (target=${targetId})`);
431
+ return true;
432
+ }
433
+ }
434
+ catch {
435
+ // Preserve the original close error below.
436
+ }
412
437
  const message = error instanceof Error ? error.message : String(error);
413
438
  logger(`Failed to close browser tab ${targetId}: ${message}`);
439
+ return false;
414
440
  }
415
441
  }
442
+ export async function createChromePageTarget(port, logger, host) {
443
+ const effectiveHost = host ?? "127.0.0.1";
444
+ try {
445
+ const created = (await CDP.New({
446
+ host: effectiveHost,
447
+ port,
448
+ url: "about:blank",
449
+ }));
450
+ const createdTargetId = created.targetId ?? created.id;
451
+ if (!createdTargetId) {
452
+ logger("Failed to create a replacement Chrome tab.");
453
+ return undefined;
454
+ }
455
+ logger(`Opened replacement Chrome tab (target=${createdTargetId})`);
456
+ return createdTargetId;
457
+ }
458
+ catch (error) {
459
+ const message = error instanceof Error ? error.message : String(error);
460
+ logger(`Failed to create a replacement Chrome tab: ${message}`);
461
+ return undefined;
462
+ }
463
+ }
464
+ export async function ensureChromePageTargetAfterClose(port, closingTargetId, logger, host) {
465
+ const effectiveHost = host ?? "127.0.0.1";
466
+ try {
467
+ const targets = (await CDP.List({ host: effectiveHost, port }));
468
+ const existingPageTargetId = targets
469
+ .filter((target) => target.type === "page")
470
+ .map((target) => target.targetId ?? target.id)
471
+ .find((targetId) => Boolean(targetId) && targetId !== closingTargetId);
472
+ if (existingPageTargetId) {
473
+ return existingPageTargetId;
474
+ }
475
+ }
476
+ catch (error) {
477
+ const message = error instanceof Error ? error.message : String(error);
478
+ logger(`Failed to inspect Chrome tabs before closing ${closingTargetId}: ${message}`);
479
+ }
480
+ return await createChromePageTarget(port, logger, host);
481
+ }
416
482
  export async function closeBlankChromeTabs(port, logger, host, options) {
417
483
  const effectiveHost = host ?? "127.0.0.1";
418
484
  const excluded = new Set([...(options?.excludeTargetIds ?? [])].filter((targetId) => typeof targetId === "string" && targetId.length > 0));
@@ -425,10 +491,20 @@ export async function closeBlankChromeTabs(port, logger, host, options) {
425
491
  logger(`Failed to inspect blank Chrome tabs: ${message}`);
426
492
  return;
427
493
  }
494
+ const preservedBlankTargetId = options?.preserveOneBlank
495
+ ? targets
496
+ .filter(isBlankPageTarget)
497
+ .map((target) => target.targetId ?? target.id)
498
+ .filter((targetId) => Boolean(targetId))
499
+ .sort()[0]
500
+ : undefined;
428
501
  let closed = 0;
429
502
  for (const target of targets) {
430
503
  const targetId = target.targetId ?? target.id;
431
- if (!targetId || excluded.has(targetId) || !isBlankPageTarget(target)) {
504
+ if (!targetId ||
505
+ targetId === preservedBlankTargetId ||
506
+ excluded.has(targetId) ||
507
+ !isBlankPageTarget(target)) {
432
508
  continue;
433
509
  }
434
510
  try {
@@ -487,6 +563,11 @@ function buildChromeFlags(headless, debugBindAddress, hideWindow = false) {
487
563
  // off-screen avoids desktop disruption while preserving normal rendering.
488
564
  flags.push("--window-position=-32000,-32000");
489
565
  }
566
+ // Opt-in only: container/CI Chromium often cannot use the sandbox. Callers must
567
+ // set ORACLE_CHROME_NO_SANDBOX=1 explicitly (never default this on).
568
+ if (process.env.ORACLE_CHROME_NO_SANDBOX === "1") {
569
+ flags.push("--no-sandbox", "--disable-dev-shm-usage");
570
+ }
490
571
  return flags;
491
572
  }
492
573
  export function buildChromeFlagsForTest(headless, debugBindAddress, hideWindow = false) {
@@ -0,0 +1,16 @@
1
+ const CONVERSATION_ID_PATH = /\/c\/([a-zA-Z0-9-]+)(?=[/?#]|$)/;
2
+ /**
3
+ * Extract a durable ChatGPT conversation id from a URL.
4
+ *
5
+ * ChatGPT can briefly expose client-created routes such as `/c/WEB:<request-id>`
6
+ * before replacing them with the persisted conversation URL. Those transient
7
+ * routes must not be used to scope assistant-response capture or reattachment.
8
+ */
9
+ export function extractStableConversationIdFromUrl(url) {
10
+ if (!url)
11
+ return undefined;
12
+ return url.match(CONVERSATION_ID_PATH)?.[1];
13
+ }
14
+ export function isStableConversationUrl(url) {
15
+ return extractStableConversationIdFromUrl(url) !== undefined;
16
+ }
@@ -1,4 +1,5 @@
1
1
  import { delay } from "./utils.js";
2
+ import { isStableConversationUrl } from "./conversationUrl.js";
2
3
  export function createConversationUrlMonitor(options) {
3
4
  const pollIntervalMs = options.pollIntervalMs ?? 250;
4
5
  const wait = options.wait ?? delay;
@@ -14,7 +15,7 @@ export function createConversationUrlMonitor(options) {
14
15
  if (stopped) {
15
16
  return false;
16
17
  }
17
- if (url && isConversationUrl(url)) {
18
+ if (url && isStableConversationUrl(url)) {
18
19
  options.logger(`[browser] conversation url (${label}) = ${url}`);
19
20
  const persist = options.persistUrl(url);
20
21
  activePersists.add(persist);
@@ -59,6 +60,3 @@ export function createConversationUrlMonitor(options) {
59
60
  },
60
61
  };
61
62
  }
62
- function isConversationUrl(url) {
63
- return /\/c\/[a-z0-9-]+/i.test(url);
64
- }
@@ -244,12 +244,14 @@ function normalizeExpiration(expires) {
244
244
  if (value <= 0) {
245
245
  return undefined;
246
246
  }
247
- if (value > 1_000_000_000_000) {
248
- // Learned: Chrome may store WebKit microseconds since 1601; convert to Unix seconds.
247
+ // Units by magnitude (do not treat Unix seconds ~1.7e9 as milliseconds):
248
+ // - >= 1e15: Chrome/WebKit FILETIME microseconds since 1601
249
+ // - >= 1e12: Unix milliseconds
250
+ // - else: Unix seconds (sweet-cookie / Chromium Cookie.expires)
251
+ if (value >= 1_000_000_000_000_000) {
249
252
  return Math.round(value / 1_000_000 - 11644473600);
250
253
  }
251
- if (value > 1_000_000_000) {
252
- // Likely milliseconds; normalize to seconds for CDP.
254
+ if (value >= 1_000_000_000_000) {
253
255
  return Math.round(value / 1000);
254
256
  }
255
257
  return Math.round(value);
@@ -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";
@@ -535,7 +537,17 @@ async function closeRemoteConnectionAfterRun(options) {
535
537
  }
536
538
  }
537
539
  function shouldCloseOwnedRunTargetAfterRun(options) {
538
- 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));
539
551
  }
540
552
  function buildSkippedModelSelectionEvidence(desiredModel, strategy) {
541
553
  return {
@@ -796,8 +808,38 @@ export async function runBrowserMode(options) {
796
808
  const disconnectPromise = new Promise((_, reject) => {
797
809
  client?.on("disconnect", () => {
798
810
  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."));
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
+ })();
801
843
  });
802
844
  });
803
845
  const raceWithDisconnect = (promise) => Promise.race([promise, disconnectPromise]);
@@ -1727,19 +1769,34 @@ export async function runBrowserMode(options) {
1727
1769
  throw normalizedError;
1728
1770
  }
1729
1771
  if ((config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") && normalizedError.stack) {
1730
- logger(`Chrome window closed before completion: ${normalizedError.message}`);
1772
+ logger(`Chrome connection lost before completion: ${normalizedError.message}`);
1731
1773
  logger(normalizedError.stack);
1732
1774
  }
1733
1775
  await emitRuntimeHint();
1734
- 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 }), {
1735
1787
  stage: "connection-lost",
1788
+ recoverableDisconnect: recoverable,
1789
+ disconnectCause: recoverable ? "cdp-client-disconnect" : "chrome-closed",
1736
1790
  runtime: {
1737
1791
  chromePid: chrome.pid,
1738
1792
  chromePort: chrome.port,
1739
1793
  chromeHost,
1740
1794
  userDataDir,
1741
1795
  chromeTargetId: lastTargetId,
1742
- tabUrl: lastUrl,
1796
+ tabUrl: liveness.matchedUrl ?? lastUrl,
1797
+ conversationId: (liveness.matchedUrl ?? lastUrl)
1798
+ ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
1799
+ : undefined,
1743
1800
  promptSubmitted,
1744
1801
  controllerPid: process.pid,
1745
1802
  },
@@ -1758,15 +1815,12 @@ export async function runBrowserMode(options) {
1758
1815
  // Close the isolated tab once the response has been fully captured to prevent
1759
1816
  // tab accumulation across repeated runs. Keep the tab open on incomplete runs
1760
1817
  // so reattach can recover the response.
1761
- if (shouldCloseOwnedRunTargetAfterRun({
1818
+ const shouldCloseOwnedRunTarget = shouldCloseOwnedRunTargetAfterRun({
1762
1819
  runStatus,
1763
1820
  ownsTarget,
1764
1821
  keepBrowser: effectiveKeepBrowser,
1765
- }) &&
1766
- isolatedTargetId &&
1767
- chrome?.port) {
1768
- await closeTab(chrome.port, isolatedTargetId, logger, chromeHost).catch(() => undefined);
1769
- }
1822
+ closeOwnedTabOnComplete: options.closeOwnedTabOnComplete,
1823
+ });
1770
1824
  let keepBrowserOpen = shouldKeepLocalBrowserOpen({
1771
1825
  effectiveKeepBrowser,
1772
1826
  preserveBrowserOnError,
@@ -1784,18 +1838,6 @@ export async function runBrowserMode(options) {
1784
1838
  }
1785
1839
  return otherActiveBrowserTabLeases;
1786
1840
  };
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
1841
  if (!keepBrowserOpen && manualLogin && tabLease) {
1800
1842
  const cleanupLockTimeoutMs = Math.max(0, config.profileLockTimeoutMs ?? 0);
1801
1843
  if (cleanupLockTimeoutMs > 0) {
@@ -1813,10 +1855,55 @@ export async function runBrowserMode(options) {
1813
1855
  terminatedRecordedChrome = await terminateRecordedChromeForProfile(userDataDir, logger).catch(() => false);
1814
1856
  }
1815
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
+ };
1816
1893
  if (tabLease) {
1817
1894
  const handle = tabLease;
1818
1895
  tabLease = null;
1819
- 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();
1820
1907
  }
1821
1908
  removeDialogHandler?.();
1822
1909
  removeTerminationHooks?.();
@@ -2869,15 +2956,27 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2869
2956
  }
2870
2957
  throw normalizedError;
2871
2958
  }
2872
- 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 }), {
2873
2967
  stage: "connection-lost",
2968
+ recoverableDisconnect: recoverable,
2969
+ disconnectCause: recoverable ? "cdp-client-disconnect" : "chrome-closed",
2874
2970
  runtime: {
2875
2971
  chromeHost: host,
2876
2972
  chromePort: port,
2877
2973
  chromeBrowserWSEndpoint: browserWSEndpoint,
2878
2974
  chromeProfileRoot,
2879
2975
  chromeTargetId: remoteTargetId ?? undefined,
2880
- tabUrl: lastUrl,
2976
+ tabUrl: liveness.matchedUrl ?? lastUrl,
2977
+ conversationId: (liveness.matchedUrl ?? lastUrl)
2978
+ ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
2979
+ : undefined,
2881
2980
  promptSubmitted,
2882
2981
  controllerPid: process.pid,
2883
2982
  },
@@ -2897,17 +2996,40 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2897
2996
  // ignore
2898
2997
  }
2899
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
+ };
2900
3024
  if (tabLease) {
2901
3025
  const handle = tabLease;
2902
3026
  tabLease = null;
2903
- await handle.release().catch(() => undefined);
3027
+ await handle
3028
+ .release({ onRelease: async () => closeOwnedRemoteTarget() })
3029
+ .catch(() => undefined);
2904
3030
  }
2905
- if (shouldCloseOwnedRunTargetAfterRun({
2906
- runStatus,
2907
- ownsTarget,
2908
- keepBrowser: Boolean(config.keepBrowser),
2909
- })) {
2910
- await closeRemoteChromeTarget(host, port, remoteTargetId ?? undefined, logger);
3031
+ else {
3032
+ await closeOwnedRemoteTarget();
2911
3033
  }
2912
3034
  // Don't kill remote Chrome - it's not ours to manage
2913
3035
  const totalSeconds = (Date.now() - startedAt) / 1000;
@@ -2930,6 +3052,7 @@ export const __test__ = {
2930
3052
  isImageOnlyUiChromeText,
2931
3053
  listIgnoredRemoteChromeFlags,
2932
3054
  resolveManualLoginWaitMs,
3055
+ shouldCleanupBlankTabsAfterLastLease,
2933
3056
  shouldCloseOwnedRunTargetAfterRun,
2934
3057
  shouldKeepLocalBrowserOpen,
2935
3058
  };
@@ -3129,9 +3252,6 @@ async function readConversationTurnCount(Runtime, logger) {
3129
3252
  }
3130
3253
  return null;
3131
3254
  }
3132
- function isConversationUrl(url) {
3133
- return /\/c\/[a-z0-9-]+/i.test(url);
3134
- }
3135
3255
  function describeDevtoolsFirewallHint(host, port) {
3136
3256
  if (!isWsl())
3137
3257
  return null;
@@ -3152,10 +3272,6 @@ function isWsl() {
3152
3272
  return true;
3153
3273
  return os.release().toLowerCase().includes("microsoft");
3154
3274
  }
3155
- function extractConversationIdFromUrl(url) {
3156
- const match = url.match(/\/c\/([a-zA-Z0-9-]+)/);
3157
- return match?.[1];
3158
- }
3159
3275
  async function resolveUserDataBaseDir() {
3160
3276
  // On WSL, Chrome launched via Windows can choke on UNC paths; prefer a Windows-backed temp folder.
3161
3277
  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);
@@ -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;
@@ -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
  }
@@ -11,6 +11,7 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
11
11
  };
12
12
  const attachRunningRequested = options.browserAttachRunning === true ||
13
13
  (isUnset("browserAttachRunning") && browser.attachRunning === true);
14
+ const currentModelRequestedByCli = options.browserModelStrategy === "current" && getSource("browserModelStrategy") === "cli";
14
15
  const configuredChatgptUrl = browser.chatgptUrl ?? browser.url;
15
16
  const cliChatgptSet = options.chatgptUrl !== undefined || options.browserUrl !== undefined;
16
17
  if (isUnset("chatgptUrl") && !cliChatgptSet && configuredChatgptUrl !== undefined) {
@@ -89,7 +90,9 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
89
90
  if (isUnset("browserModelStrategy") && browser.modelStrategy !== undefined) {
90
91
  options.browserModelStrategy = browser.modelStrategy;
91
92
  }
92
- if (isUnset("browserThinkingTime") && browser.thinkingTime !== undefined) {
93
+ if (!currentModelRequestedByCli &&
94
+ isUnset("browserThinkingTime") &&
95
+ browser.thinkingTime !== undefined) {
93
96
  options.browserThinkingTime = normalizeThinkingTimeLevel(browser.thinkingTime) ?? undefined;
94
97
  }
95
98
  if (isUnset("browserResearch") && browser.researchMode !== undefined) {
@@ -469,6 +469,41 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
469
469
  response: { status: "running", incompleteReason: "chrome-disconnected" },
470
470
  });
471
471
  logBrowserReattachGuidance(recoverableRuntime);
472
+ // Only auto-reattach when liveness classified the target as still alive.
473
+ // Closed-Chrome disconnects stay running + guidance but must not enter a
474
+ // futile resume loop (fail closed on availability).
475
+ const recoverableDisconnect = userError.details
476
+ ?.recoverableDisconnect === true;
477
+ if (!recoverableDisconnect) {
478
+ log(dim("Skipping auto-reattach: disconnect classified as non-recoverable."));
479
+ return;
480
+ }
481
+ // Connection-lost should attempt the same recovery path as assistant-timeout.
482
+ // When auto-reattach interval is unset, still try a single resume so a live
483
+ // Chrome/target can be harvested instead of leaving the session permanently running.
484
+ const configuredIntervalMs = browserConfig?.autoReattachIntervalMs ?? 0;
485
+ const connectionLostIntervalMs = configuredIntervalMs > 0
486
+ ? configuredIntervalMs
487
+ : Math.max(1_000, Math.min(browserConfig?.timeoutMs ?? 30_000, 30_000));
488
+ const success = await autoReattachUntilComplete({
489
+ sessionMeta,
490
+ runtime: recoverableRuntime ?? undefined,
491
+ browserConfig: {
492
+ ...browserConfig,
493
+ autoReattachIntervalMs: connectionLostIntervalMs,
494
+ autoReattachDelayMs: browserConfig?.autoReattachDelayMs ?? 0,
495
+ autoReattachTimeoutMs: browserConfig?.autoReattachTimeoutMs ?? browserConfig?.timeoutMs ?? 120_000,
496
+ },
497
+ browserMetadata: currentBrowser,
498
+ runOptions,
499
+ modelForStatus,
500
+ notificationSettings,
501
+ log,
502
+ maxAttempts: configuredIntervalMs > 0 ? undefined : 1,
503
+ });
504
+ if (success) {
505
+ return;
506
+ }
472
507
  return;
473
508
  }
474
509
  if (assistantTimeout && mode === "browser" && browserCanReattach) {
@@ -833,7 +868,7 @@ async function writeAssistantOutput(targetPath, content, log) {
833
868
  log(dim(`write-output failed (${reason}); session completed anyway.`));
834
869
  }
835
870
  }
836
- async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig, browserMetadata, runOptions, modelForStatus, notificationSettings, log, }) {
871
+ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig, browserMetadata, runOptions, modelForStatus, notificationSettings, log, maxAttempts, }) {
837
872
  if (!runtime || !browserConfig) {
838
873
  log(dim("Auto-reattach disabled: missing runtime or browser config."));
839
874
  return false;
@@ -848,11 +883,19 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
848
883
  120_000;
849
884
  const maxTotalMs = 2 * 60 * 60 * 1000; // 2h hard cap; avoid infinite polling by default.
850
885
  const maxDeadline = Date.now() + maxTotalMs;
886
+ const attemptLimit = typeof maxAttempts === "number" && maxAttempts > 0
887
+ ? Math.floor(maxAttempts)
888
+ : Number.POSITIVE_INFINITY;
851
889
  if (delayMs > 0) {
852
890
  log(dim(`Auto-reattach starting in ${formatElapsed(delayMs)}...`));
853
891
  await wait(delayMs);
854
892
  }
855
- log(dim(`Auto-reattach will stop after ${formatElapsed(maxTotalMs)} if no answer is captured.`));
893
+ if (Number.isFinite(attemptLimit)) {
894
+ log(dim(`Auto-reattach will try up to ${attemptLimit} attempt(s).`));
895
+ }
896
+ else {
897
+ log(dim(`Auto-reattach will stop after ${formatElapsed(maxTotalMs)} if no answer is captured.`));
898
+ }
856
899
  const logger = ((message) => {
857
900
  if (message) {
858
901
  log(dim(message));
@@ -943,6 +986,10 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
943
986
  const message = error instanceof Error ? error.message : String(error);
944
987
  log(dim(`Auto-reattach attempt ${attempt} failed: ${message}`));
945
988
  }
989
+ if (attempt >= attemptLimit) {
990
+ log(dim(`Auto-reattach stopped after ${attempt} attempt(s) without capturing an answer.`));
991
+ return false;
992
+ }
946
993
  const remainingAfterAttemptMs = maxDeadline - Date.now();
947
994
  if (remainingAfterAttemptMs <= 0) {
948
995
  log(dim(`Auto-reattach stopped after ${formatElapsed(maxTotalMs)} without capturing an answer.`));
@@ -189,6 +189,9 @@ export async function createRemoteServer(options = {}, deps = {}) {
189
189
  }
190
190
  });
191
191
  automationLogger.verbose = Boolean(payload.options.verbose);
192
+ // Preserve an explicit request to leave the completed conversation tab
193
+ // open before the service forces `keepBrowser` for process lifetime.
194
+ const clientRequestedKeepBrowser = payload.browserConfig?.keepBrowser === true;
192
195
  // Remote runs always rely on the host's own Chrome profile; ignore any inline cookie transfer.
193
196
  if (payload.browserConfig) {
194
197
  payload.browserConfig.inlineCookies = null;
@@ -212,6 +215,11 @@ export async function createRemoteServer(options = {}, deps = {}) {
212
215
  attachments,
213
216
  fallbackSubmission,
214
217
  config: payload.browserConfig,
218
+ // `keepBrowser` above preserves the authenticated shared Chrome
219
+ // process. This separate service policy closes only a successfully
220
+ // captured tab owned by this run, preventing one renderer leak per
221
+ // request while incomplete/reattachable tabs remain untouched.
222
+ closeOwnedTabOnComplete: Boolean(options.manualLoginDefault && !clientRequestedKeepBrowser),
215
223
  log: automationLogger,
216
224
  heartbeatIntervalMs: payload.options.heartbeatIntervalMs,
217
225
  verbose: payload.options.verbose,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.6 Sol, GPT-5.6, GPT-5.5 Pro, GPT-5.5, GPT-5.4, GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "keywords": [],
6
6
  "homepage": "https://askoracle.sh",
@@ -58,14 +58,14 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@anthropic-ai/tokenizer": "^0.0.4",
61
- "@google/genai": "^2.10.0",
61
+ "@google/genai": "^2.13.0",
62
62
  "@google/generative-ai": "^0.24.1",
63
63
  "@modelcontextprotocol/sdk": "^1.29.0",
64
64
  "@steipete/sweet-cookie": "^0.4.0",
65
65
  "chalk": "^5.6.2",
66
66
  "chrome-launcher": "^1.2.1",
67
67
  "chrome-remote-interface": "^0.34.0",
68
- "clipboardy": "^5.3.1",
68
+ "clipboardy": "^5.3.2",
69
69
  "commander": "^15.0.0",
70
70
  "dotenv": "^17.4.2",
71
71
  "fast-glob": "^3.3.3",
@@ -74,7 +74,7 @@
74
74
  "json5": "^2.2.3",
75
75
  "kleur": "^4.1.5",
76
76
  "markdansi": "0.3.2",
77
- "openai": "^6.45.0",
77
+ "openai": "^6.48.0",
78
78
  "osc-progress": "^0.3.2",
79
79
  "qs": "^6.15.3",
80
80
  "shiki": "^4.3.1",
@@ -88,13 +88,13 @@
88
88
  "@types/inquirer": "^9.0.10",
89
89
  "@types/node": "^26.1.1",
90
90
  "@vitest/coverage-v8": "4.1.10",
91
- "devtools-protocol": "0.0.1658499",
91
+ "devtools-protocol": "0.0.1666840",
92
92
  "es-toolkit": "^1.49.0",
93
93
  "esbuild": "^0.28.1",
94
- "oxfmt": "0.58.0",
95
- "oxlint": "^1.73.0",
94
+ "oxfmt": "0.60.0",
95
+ "oxlint": "^1.75.0",
96
96
  "puppeteer-core": "^25.3.0",
97
- "tsx": "^4.23.0",
97
+ "tsx": "^4.23.1",
98
98
  "typescript": "^7.0.2",
99
99
  "vitest": "^4.1.10"
100
100
  },