@steipete/oracle 0.17.3 → 0.19.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 (161) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +75 -64
  3. package/dist/bin/oracle-mcp.js +0 -0
  4. package/dist/src/browser/actions/archiveConversation.js +31 -7
  5. package/dist/src/browser/actions/assistantResponse.js +43 -1
  6. package/dist/src/browser/actions/attachmentContext.js +239 -0
  7. package/dist/src/browser/actions/attachmentDataTransfer.js +19 -1
  8. package/dist/src/browser/actions/attachmentEvidence.js +78 -0
  9. package/dist/src/browser/actions/attachmentProgress.js +41 -0
  10. package/dist/src/browser/actions/attachmentPrompt.js +130 -0
  11. package/dist/src/browser/actions/attachments.js +306 -355
  12. package/dist/src/browser/actions/deepResearch.js +2 -1
  13. package/dist/src/browser/actions/modelSelection.js +13 -3
  14. package/dist/src/browser/actions/navigation.js +1 -1
  15. package/dist/src/browser/actions/promptComposer.js +361 -186
  16. package/dist/src/browser/actions/remoteFileTransfer.js +4 -2
  17. package/dist/src/browser/actions/thinkingStatus.js +2 -5
  18. package/dist/src/browser/actions/thinkingTime.js +267 -15
  19. package/dist/src/browser/attachRunning.js +46 -2
  20. package/dist/src/browser/attachmentValidation.js +53 -0
  21. package/dist/src/browser/cancellation.js +105 -0
  22. package/dist/src/browser/chatgptImages.js +2 -0
  23. package/dist/src/browser/chatgptThrottle.js +104 -0
  24. package/dist/src/browser/chromeLifecycle.js +267 -43
  25. package/dist/src/browser/config.js +15 -3
  26. package/dist/src/browser/detect.js +1 -1
  27. package/dist/src/browser/index.js +447 -263
  28. package/dist/src/browser/modelDisplay.js +9 -0
  29. package/dist/src/browser/pageActions.js +2 -2
  30. package/dist/src/browser/policies.js +6 -2
  31. package/dist/src/browser/profileState.js +114 -9
  32. package/dist/src/browser/projectSourcesRunner.js +88 -17
  33. package/dist/src/browser/prompt.js +272 -113
  34. package/dist/src/browser/providers/chatgptDomProvider.js +2 -0
  35. package/dist/src/browser/reattach.js +14 -4
  36. package/dist/src/browser/sessionRunner.js +71 -11
  37. package/dist/src/browser/tabLeaseRegistry.js +414 -38
  38. package/dist/src/browser/utils.js +20 -2
  39. package/dist/src/cli/browserConfig.js +40 -15
  40. package/dist/src/cli/browserDefaults.js +13 -0
  41. package/dist/src/cli/browserTabs.js +12 -29
  42. package/dist/src/cli/detachedSession.js +69 -0
  43. package/dist/src/cli/dryRun.js +58 -47
  44. package/dist/src/cli/harvestIntegrity.js +140 -0
  45. package/dist/src/cli/projectSources.js +5 -3
  46. package/dist/src/cli/serveBrowserConfig.js +24 -0
  47. package/dist/src/cli/sessionDisplay.js +9 -2
  48. package/dist/src/cli/sessionRunner.js +91 -7
  49. package/dist/src/config.js +1 -0
  50. package/dist/src/gemini-web/client.js +67 -21
  51. package/dist/src/gemini-web/executor.js +4 -0
  52. package/dist/src/gemini-web/http.js +15 -0
  53. package/dist/src/gemini-web/models.js +15 -11
  54. package/dist/src/mcp/server.js +11 -14
  55. package/dist/src/mcp/tools/chatgptImage.js +4 -4
  56. package/dist/src/mcp/tools/consult.js +97 -26
  57. package/dist/src/mcp/tools/projectSources.js +4 -6
  58. package/dist/src/mcp/tools/sessionResources.js +1 -1
  59. package/dist/src/mcp/tools/sessions.js +2 -2
  60. package/dist/src/mcp/tools/wait.js +182 -0
  61. package/dist/src/mcp/types.js +7 -0
  62. package/dist/src/oracle/errors.js +11 -0
  63. package/dist/src/oracle/providerRoutePlan.js +3 -2
  64. package/dist/src/remote/client.js +70 -6
  65. package/dist/src/remote/health.js +6 -2
  66. package/dist/src/remote/runSlots.js +83 -0
  67. package/dist/src/remote/server.js +351 -72
  68. package/dist/src/sessionManager.js +36 -1
  69. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  70. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  71. package/package.json +25 -23
  72. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  73. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  74. package/dist/bin/oracle.js +0 -683
  75. package/dist/docs-site/.nojekyll +0 -0
  76. package/dist/docs-site/CNAME +0 -1
  77. package/dist/docs-site/RELEASING.html +0 -410
  78. package/dist/docs-site/agents.html +0 -374
  79. package/dist/docs-site/anthropic.html +0 -368
  80. package/dist/docs-site/bridge.html +0 -400
  81. package/dist/docs-site/browser-mode.html +0 -588
  82. package/dist/docs-site/chromium-forks.html +0 -347
  83. package/dist/docs-site/cli-reference.html +0 -346
  84. package/dist/docs-site/configuration.html +0 -436
  85. package/dist/docs-site/favicon.svg +0 -14
  86. package/dist/docs-site/followup.html +0 -368
  87. package/dist/docs-site/gemini.html +0 -379
  88. package/dist/docs-site/grok.html +0 -325
  89. package/dist/docs-site/index.html +0 -360
  90. package/dist/docs-site/install.html +0 -335
  91. package/dist/docs-site/linux.html +0 -321
  92. package/dist/docs-site/llms.txt +0 -43
  93. package/dist/docs-site/manual-tests.html +0 -595
  94. package/dist/docs-site/mcp.html +0 -368
  95. package/dist/docs-site/multimodel.html +0 -364
  96. package/dist/docs-site/mythical-pro-agents.html +0 -360
  97. package/dist/docs-site/notifier.html +0 -338
  98. package/dist/docs-site/openai-endpoints.html +0 -387
  99. package/dist/docs-site/openrouter.html +0 -344
  100. package/dist/docs-site/quickstart.html +0 -369
  101. package/dist/docs-site/refactor/ux.html +0 -532
  102. package/dist/docs-site/sessions.html +0 -388
  103. package/dist/docs-site/social-card.png +0 -0
  104. package/dist/docs-site/social-card.svg +0 -79
  105. package/dist/docs-site/spec.html +0 -363
  106. package/dist/docs-site/testing.html +0 -320
  107. package/dist/docs-site/tui-debug.html +0 -326
  108. package/dist/docs-site/windows-work.html +0 -323
  109. package/dist/docs-site/windows.html +0 -320
  110. package/dist/markdansi/types/index.js +0 -4
  111. package/dist/oracle/bin/oracle-cli.js +0 -472
  112. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  113. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  114. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  115. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  116. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  117. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  118. package/dist/oracle/src/browser/config.js +0 -33
  119. package/dist/oracle/src/browser/constants.js +0 -40
  120. package/dist/oracle/src/browser/cookies.js +0 -210
  121. package/dist/oracle/src/browser/domDebug.js +0 -36
  122. package/dist/oracle/src/browser/index.js +0 -331
  123. package/dist/oracle/src/browser/pageActions.js +0 -5
  124. package/dist/oracle/src/browser/prompt.js +0 -88
  125. package/dist/oracle/src/browser/promptSummary.js +0 -20
  126. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  127. package/dist/oracle/src/browser/types.js +0 -1
  128. package/dist/oracle/src/browser/utils.js +0 -62
  129. package/dist/oracle/src/browserMode.js +0 -1
  130. package/dist/oracle/src/cli/browserConfig.js +0 -44
  131. package/dist/oracle/src/cli/dryRun.js +0 -59
  132. package/dist/oracle/src/cli/engine.js +0 -17
  133. package/dist/oracle/src/cli/errorUtils.js +0 -9
  134. package/dist/oracle/src/cli/help.js +0 -70
  135. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  136. package/dist/oracle/src/cli/options.js +0 -103
  137. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  138. package/dist/oracle/src/cli/rootAlias.js +0 -30
  139. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  140. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  141. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  142. package/dist/oracle/src/heartbeat.js +0 -43
  143. package/dist/oracle/src/oracle/client.js +0 -48
  144. package/dist/oracle/src/oracle/config.js +0 -29
  145. package/dist/oracle/src/oracle/errors.js +0 -101
  146. package/dist/oracle/src/oracle/files.js +0 -220
  147. package/dist/oracle/src/oracle/format.js +0 -33
  148. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  149. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  150. package/dist/oracle/src/oracle/request.js +0 -48
  151. package/dist/oracle/src/oracle/run.js +0 -444
  152. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  153. package/dist/oracle/src/oracle/types.js +0 -1
  154. package/dist/oracle/src/oracle.js +0 -9
  155. package/dist/oracle/src/sessionManager.js +0 -205
  156. package/dist/oracle/src/version.js +0 -39
  157. package/dist/scripts/chrome/browser-tools.js +0 -295
  158. package/dist/src/browser/chromeCookies.js +0 -312
  159. package/dist/src/browser/keytarShim.js +0 -56
  160. package/dist/src/browser/profileSync.js +0 -141
  161. package/dist/src/browser/windowsCookies.js +0 -219
@@ -4,23 +4,26 @@ 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, createChromePageTarget, ensureChromePageTargetAfterClose, closeBlankChromeTabs, } from "./chromeLifecycle.js";
7
+ import { BrowserCancellation, withoutBrowserCancellation } from "./cancellation.js";
8
+ import { launchChrome, registerTerminationHooks, positionChromeWindowOffscreen, positionChromeWindowOnscreen, connectToRemoteChrome, connectWithNewTab, closeTab, createChromePageTarget, ensureChromePageTargetAfterClose, closeBlankChromeTabs, } from "./chromeLifecycle.js";
8
9
  import { clearStaleChatGptConversationCookies, syncCookies } from "./cookies.js";
9
- import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureChatMode, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js";
10
+ import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureChatMode, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, captureComposerNavigationUrl, assertComposerPlusStayedInPlace, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js";
10
11
  import { INPUT_SELECTORS } from "./constants.js";
11
12
  import { uploadAttachmentViaDataTransfer } from "./actions/remoteFileTransfer.js";
12
13
  import { ensureThinkingTime } from "./actions/thinkingTime.js";
14
+ import { throwIfAssistantUiError } from "./actions/assistantResponse.js";
13
15
  import { startThinkingStatusMonitor } from "./actions/thinkingStatus.js";
14
16
  import { activateDeepResearch, captureDeepResearchTargetKeys, waitForDeepResearchCompletion, waitForResearchPlanAutoConfirm, } from "./actions/deepResearch.js";
15
17
  import { estimateTokenCount, withRetries, delay } from "./utils.js";
16
18
  import { formatElapsed } from "../oracle/format.js";
17
19
  import { CHATGPT_URL, DEFAULT_MODEL_STRATEGY } from "./constants.js";
18
- import { BrowserAutomationError } from "../oracle/errors.js";
20
+ import { BrowserAutomationError, BrowserRunCancelledError } from "../oracle/errors.js";
21
+ import { buildAttachmentBasenameCollisionDetails, findAttachmentBasenameCollisions, formatAttachmentBasenameCollisionMessage, } from "./attachmentValidation.js";
19
22
  import { alignPromptEchoPair, buildPromptEchoMatcher } from "./reattachHelpers.js";
20
23
  import { buildConversationTurnCountExpression } from "./conversationTurns.js";
21
24
  import { cleanupStaleProfileState, acquireProfileRunLock, findRunningChromeDebugTargetForProfile, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, terminateRecordedChromeForProfile, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from "./profileState.js";
22
25
  import { connectionLostUserMessage, isRecoverableChromeDisconnect, probeChromeTargetLiveness, } from "./cdpLiveness.js";
23
- import { acquireBrowserTabLease, hasOtherActiveBrowserTabLeases, } from "./tabLeaseRegistry.js";
26
+ import { acquireBrowserTabLease } from "./tabLeaseRegistry.js";
24
27
  import { appendArtifacts, saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "./artifacts.js";
25
28
  import { collectGeneratedImageArtifacts } from "./chatgptImages.js";
26
29
  import { collectChatGptFileArtifacts } from "./chatgptFiles.js";
@@ -32,7 +35,7 @@ import { captureBrowserDiagnostics } from "./domDebug.js";
32
35
  import { archiveChatGptConversation, resolveBrowserArchiveDecision, } from "./actions/archiveConversation.js";
33
36
  import { assertManualLoginProfileReadyForRun, defaultManualLoginProfileDir, formatManualLoginSetupCommand, isManualLoginProfileInitialized, resolveManualLoginWaitMs, } from "./manualLoginProfile.js";
34
37
  import { describeBrowserControlPlan, formatBrowserControlPlan } from "./controlPlan.js";
35
- import { shouldSyncBrowserCookies } from "./policies.js";
38
+ import { CHROME_COOKIE_SYNC_WARNING, shouldSyncBrowserCookies } from "./policies.js";
36
39
  import { createConversationUrlMonitor, } from "./conversationUrlMonitor.js";
37
40
  import { extractStableConversationIdFromUrl as extractConversationIdFromUrl, isStableConversationUrl as isConversationUrl, } from "./conversationUrl.js";
38
41
  export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "./constants.js";
@@ -58,7 +61,7 @@ function isReattachableCaptureError(error) {
58
61
  if (!(error instanceof BrowserAutomationError))
59
62
  return false;
60
63
  const stage = error.details?.stage;
61
- return stage === "assistant-timeout" || stage === "assistant-recheck";
64
+ return (stage === "assistant-timeout" || stage === "assistant-recheck" || stage === "assistant-ui-error");
62
65
  }
63
66
  function classifyPreservedBrowserError(error, headless) {
64
67
  if (headless)
@@ -86,6 +89,11 @@ export function shouldPreserveBrowserOnErrorForTest(error, headless) {
86
89
  export function classifyPreservedBrowserErrorForTest(error, headless) {
87
90
  return classifyPreservedBrowserError(error, headless);
88
91
  }
92
+ function shouldApplyThinkingTimeSelection(config) {
93
+ // Deep Research uses the same effort picker, so research mode must not
94
+ // suppress an explicitly configured thinking-time selection.
95
+ return config.thinkingTime !== undefined;
96
+ }
89
97
  const MAX_CHATGPT_UI_WARNING_CHARS = 300;
90
98
  const MAX_CHATGPT_UI_WARNINGS = 3;
91
99
  function classifyChatGptUiWarningText(text) {
@@ -318,6 +326,17 @@ function hasBrowserErrorCode(error, code) {
318
326
  return (error instanceof BrowserAutomationError &&
319
327
  error.details?.code === code);
320
328
  }
329
+ function assertUniqueAttachmentBasenames(attachments, options) {
330
+ const collisions = findAttachmentBasenameCollisions(attachments);
331
+ if (collisions.length === 0)
332
+ return;
333
+ const collisionDetails = buildAttachmentBasenameCollisionDetails(collisions, (attachment) => attachment.displayPath || attachment.path);
334
+ throw new BrowserAutomationError(formatAttachmentBasenameCollisionMessage(options.subject, collisionDetails.collisions), {
335
+ stage: options.stage,
336
+ code: "attachment-basename-collision",
337
+ ...collisionDetails,
338
+ });
339
+ }
321
340
  async function saveOptionalArtifact(operation, logger) {
322
341
  try {
323
342
  return await operation();
@@ -357,10 +376,12 @@ async function pollGeneratedImageOrTextAssistantResponse(Runtime, timeoutMs, min
357
376
  const deadline = Date.now() + timeoutMs;
358
377
  while (Date.now() < deadline) {
359
378
  let snapshot = await readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId).catch(() => null);
379
+ throwIfAssistantUiError(snapshot);
360
380
  if (!snapshot && typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex)) {
361
381
  const relaxedSnapshot = await readAssistantSnapshot(Runtime, undefined, expectedConversationId).catch(() => null);
362
382
  const relaxedHtml = typeof relaxedSnapshot?.html === "string" ? relaxedSnapshot.html : "";
363
- if (relaxedHtml.includes("/backend-api/estuary/content?id=file_")) {
383
+ if (!relaxedSnapshot?.uiError &&
384
+ relaxedHtml.includes("/backend-api/estuary/content?id=file_")) {
364
385
  snapshot = relaxedSnapshot;
365
386
  }
366
387
  }
@@ -491,6 +512,13 @@ async function runSubmissionWithRecovery({ prompt, attachments, fallbackSubmissi
491
512
  if (fallbackSubmission && isPromptTooLarge && !usedFallbackSubmission) {
492
513
  usedFallbackSubmission = true;
493
514
  logger("[browser] Inline prompt too large; retrying with file uploads.");
515
+ if (fallbackSubmission.prepare) {
516
+ await fallbackSubmission.prepare();
517
+ }
518
+ assertUniqueAttachmentBasenames(fallbackSubmission.attachments, {
519
+ stage: "upload-fallback",
520
+ subject: "The inline prompt was too large, but its upload fallback",
521
+ });
494
522
  await prepareFallbackSubmission();
495
523
  currentPrompt = fallbackSubmission.prompt;
496
524
  currentAttachments = fallbackSubmission.attachments;
@@ -541,9 +569,11 @@ async function closeRemoteConnectionAfterRun(options) {
541
569
  }
542
570
  }
543
571
  function shouldCloseOwnedRunTargetAfterRun(options) {
544
- return (options.runStatus === "complete" &&
545
- options.ownsTarget &&
546
- (Boolean(options.closeOwnedTabOnComplete) || !options.keepBrowser));
572
+ return (options.ownsTarget &&
573
+ ((options.runStatus === "cancelled" &&
574
+ (options.closeOwnedTabOnCancel ?? !options.keepBrowser)) ||
575
+ (options.runStatus === "complete" &&
576
+ (Boolean(options.closeOwnedTabOnComplete) || !options.keepBrowser))));
547
577
  }
548
578
  function shouldCleanupBlankTabsAfterLastLease(options) {
549
579
  return (options.runStatus === "complete" &&
@@ -553,6 +583,79 @@ function shouldCleanupBlankTabsAfterLastLease(options) {
553
583
  options.keepBrowser &&
554
584
  Boolean(options.chromePort));
555
585
  }
586
+ async function releaseLocalBrowserTabLease(options) {
587
+ let decisionObserved = false;
588
+ let keepBrowserOpen = false;
589
+ let terminationHandled = false;
590
+ let otherLeasesRemain = false;
591
+ let releaseError;
592
+ try {
593
+ await options.lease.release({
594
+ onRelease: async ({ isLastLease }) => {
595
+ decisionObserved = true;
596
+ if (!isLastLease) {
597
+ // Record this before any best-effort tab cleanup so a cleanup failure can
598
+ // never fall through into terminating Chrome used by another lease.
599
+ keepBrowserOpen = true;
600
+ otherLeasesRemain = true;
601
+ }
602
+ await options.closeOwnedRunTarget().catch(() => undefined);
603
+ if (!isLastLease) {
604
+ return;
605
+ }
606
+ await options.cleanupBlankTabs().catch(() => undefined);
607
+ if (options.terminateSharedChrome) {
608
+ options.logger(`[browser] ChatGPT browser slot ${options.lease.id.slice(0, 8)} is final; ` +
609
+ `terminating shared Chrome (${formatBrowserLeaseDiagnostics(options)}).`);
610
+ const terminated = await options.terminateSharedChrome().catch(() => false);
611
+ if (terminated) {
612
+ terminationHandled = true;
613
+ }
614
+ else {
615
+ // A reused Chrome handle may have a no-op kill implementation. Never
616
+ // claim cleanup or fall through into an unverified lock-free kill.
617
+ keepBrowserOpen = true;
618
+ options.logger("[browser] Could not verify shared Chrome termination; leaving it available for reuse.");
619
+ }
620
+ }
621
+ },
622
+ });
623
+ }
624
+ catch (error) {
625
+ releaseError = error instanceof Error ? error : new Error(String(error));
626
+ if (!terminationHandled)
627
+ keepBrowserOpen = true;
628
+ options.logger(`[browser] Failed to release the ChatGPT browser slot registry lock; restart Oracle/Codex MCP before another browser run: ${releaseError.message}`);
629
+ }
630
+ if (!decisionObserved) {
631
+ options.logger("[browser] Could not verify final ChatGPT tab lease; leaving shared Chrome running.");
632
+ return {
633
+ keepBrowserOpen: true,
634
+ terminationHandled: false,
635
+ ...(releaseError ? { releaseError } : {}),
636
+ };
637
+ }
638
+ if (otherLeasesRemain) {
639
+ options.logger(`[browser] Other ChatGPT tab leases still active; leaving shared Chrome running; ` +
640
+ `browser slot ${options.lease.id.slice(0, 8)} is non-final ` +
641
+ `(${formatBrowserLeaseDiagnostics(options)}).`);
642
+ }
643
+ return {
644
+ keepBrowserOpen,
645
+ terminationHandled,
646
+ ...(releaseError ? { releaseError } : {}),
647
+ };
648
+ }
649
+ function formatBrowserLeaseDiagnostics(options) {
650
+ return [
651
+ `session=${options.sessionId ?? "unknown"}`,
652
+ `controllerPid=${process.pid}`,
653
+ `chromePid=${options.chromePid ?? "unknown"}`,
654
+ `chromePort=${options.chromePort ?? "unknown"}`,
655
+ `target=${options.chromeTargetId ?? "unknown"}`,
656
+ `launch=${options.launchDisposition ?? "unknown"}`,
657
+ ].join("; ");
658
+ }
556
659
  function buildSkippedModelSelectionEvidence(desiredModel, strategy) {
557
660
  return {
558
661
  requestedModel: desiredModel ?? null,
@@ -565,11 +668,25 @@ function buildSkippedModelSelectionEvidence(desiredModel, strategy) {
565
668
  };
566
669
  }
567
670
  export async function runBrowserMode(options) {
671
+ const cancellation = new BrowserCancellation(options.signal, options.log);
672
+ try {
673
+ cancellation.check();
674
+ return await cancellation.run(() => runBrowserModeInternal(options, cancellation));
675
+ }
676
+ finally {
677
+ cancellation.dispose();
678
+ }
679
+ }
680
+ async function runBrowserModeInternal(options, cancellation) {
681
+ const attachments = options.attachments ?? [];
682
+ assertUniqueAttachmentBasenames(attachments, {
683
+ stage: "upload",
684
+ subject: "Browser upload",
685
+ });
568
686
  const promptText = options.prompt?.trim();
569
687
  if (!promptText) {
570
688
  throw new Error("Prompt text is required when using browser mode.");
571
689
  }
572
- const attachments = options.attachments ?? [];
573
690
  const fallbackSubmission = options.fallbackSubmission;
574
691
  let config = resolveBrowserConfig(options.config);
575
692
  const usingCopiedProfile = Boolean(config.copyProfileSource);
@@ -596,6 +713,7 @@ export async function runBrowserMode(options) {
596
713
  let lastUrl;
597
714
  let promptSubmitted = false;
598
715
  let modelSelectionEvidence;
716
+ let thinkingSelectionEvidence;
599
717
  let tabLease = null;
600
718
  let conversationUrlMonitor = null;
601
719
  const emitRuntimeHint = async () => {
@@ -646,7 +764,7 @@ export async function runBrowserMode(options) {
646
764
  logger(line);
647
765
  }
648
766
  if (config.attachRunning) {
649
- const attached = await resolveAttachRunningConnection(config, logger);
767
+ const attached = await cancellation.call(() => resolveAttachRunningConnection(config, logger));
650
768
  config = {
651
769
  ...config,
652
770
  remoteChrome: { host: attached.host, port: attached.port },
@@ -656,7 +774,7 @@ export async function runBrowserMode(options) {
656
774
  }
657
775
  if (!config.remoteChrome && !config.manualLogin) {
658
776
  const preferredPort = config.debugPort ?? DEFAULT_DEBUG_PORT;
659
- const availablePort = await pickAvailableDebugPort(preferredPort, logger);
777
+ const availablePort = await cancellation.call(() => pickAvailableDebugPort(preferredPort, logger));
660
778
  if (availablePort !== preferredPort) {
661
779
  logger(`DevTools port ${preferredPort} busy; using ${availablePort} to avoid attaching to stray Chrome.`);
662
780
  }
@@ -669,7 +787,7 @@ export async function runBrowserMode(options) {
669
787
  if (ignoredFlags.length > 0) {
670
788
  logger(`Note: --remote-chrome ignores local Chrome flags (${ignoredFlags.join(", ")}).`);
671
789
  }
672
- return runRemoteBrowserMode(promptText, attachments, config, logger, options);
790
+ return runRemoteBrowserMode(promptText, attachments, config, logger, options, cancellation);
673
791
  }
674
792
  const manualLogin = Boolean(config.manualLogin);
675
793
  if (manualLogin && usingCopiedProfile) {
@@ -683,70 +801,82 @@ export async function runBrowserMode(options) {
683
801
  : defaultManualLoginProfileDir();
684
802
  const userDataDir = manualLogin
685
803
  ? manualProfileDir
686
- : await mkdtemp(path.join(await resolveUserDataBaseDir(), "oracle-browser-"));
804
+ : await cancellation.acquire(async () => mkdtemp(path.join(await resolveUserDataBaseDir(), "oracle-browser-")), (dir) => rm(dir, { recursive: true, force: true }));
687
805
  const effectiveKeepBrowser = Boolean(config.keepBrowser);
688
- if (manualLogin) {
689
- // Learned: manual login reuses a persistent profile so cookies/SSO survive.
690
- await mkdir(userDataDir, { recursive: true });
691
- logger(`Manual login mode enabled; reusing persistent profile at ${userDataDir}`);
692
- await assertManualLoginProfileReadyForRun({
693
- userDataDir,
694
- keepBrowser: effectiveKeepBrowser,
695
- });
696
- }
697
- else if (config.copyProfileSource) {
698
- const copiedProfileDirectory = await copyChromeProfile(config.copyProfileSource, userDataDir, config.chromeProfile);
699
- config = { ...config, chromeProfile: copiedProfileDirectory };
700
- logger(`Seeded temporary Chrome profile ${copiedProfileDirectory} from ${config.copyProfileSource} (copy-profile mode; signed-in session reused without manual login)`);
806
+ try {
807
+ if (manualLogin) {
808
+ // Learned: manual login reuses a persistent profile so cookies/SSO survive.
809
+ await cancellation.call(() => mkdir(userDataDir, { recursive: true }));
810
+ logger(`Manual login mode enabled; reusing persistent profile at ${userDataDir}`);
811
+ await cancellation.call(() => assertManualLoginProfileReadyForRun({ userDataDir, keepBrowser: effectiveKeepBrowser }));
812
+ }
813
+ else if (config.copyProfileSource) {
814
+ const copying = copyChromeProfile(config.copyProfileSource, userDataDir, config.chromeProfile);
815
+ const copiedProfileDirectory = await cancellation.race(copying.finally(async () => {
816
+ if (options.signal?.aborted)
817
+ await withoutBrowserCancellation(() => rm(userDataDir, { recursive: true, force: true }));
818
+ }));
819
+ config = { ...config, chromeProfile: copiedProfileDirectory };
820
+ logger(`Seeded temporary Chrome profile ${copiedProfileDirectory} from ${config.copyProfileSource} (copy-profile mode; signed-in session reused without manual login)`);
821
+ }
822
+ else {
823
+ logger(`Created temporary Chrome profile at ${userDataDir}`);
824
+ }
701
825
  }
702
- else {
703
- logger(`Created temporary Chrome profile at ${userDataDir}`);
826
+ catch (error) {
827
+ if (!manualLogin)
828
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
829
+ throw error;
704
830
  }
705
831
  if (manualLogin) {
706
- tabLease = await acquireBrowserTabLease(userDataDir, {
832
+ tabLease = await cancellation.acquire(() => acquireBrowserTabLease(userDataDir, {
707
833
  maxConcurrentTabs: config.maxConcurrentTabs,
708
834
  timeoutMs: config.timeoutMs,
709
835
  logger,
710
836
  sessionId: options.sessionId,
711
- });
837
+ signal: options.signal,
838
+ }), (lease) => lease.release());
712
839
  }
713
840
  let acquiredChrome;
714
841
  try {
715
- acquiredChrome = manualLogin
716
- ? await acquireManualLoginChromeForRun(userDataDir, config, logger, options.sessionId)
717
- : {
718
- chrome: await launchChrome({
719
- ...config,
720
- remoteChrome: config.remoteChrome,
721
- }, userDataDir, logger),
722
- reusedChrome: null,
723
- };
724
- }
725
- catch (error) {
726
- if (tabLease) {
727
- const handle = tabLease;
728
- tabLease = null;
729
- await handle.release().catch(() => undefined);
842
+ if (manualLogin) {
843
+ acquiredChrome = await cancellation.acquire(() => acquireManualLoginChromeForRun(userDataDir, config, logger, options.sessionId), async ({ chrome }) => {
844
+ detachKeptChromeProcess(chrome);
845
+ });
730
846
  }
731
- if (usingCopiedProfile) {
732
- await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
847
+ else {
848
+ const launched = await cancellation.acquire(() => launchChrome({ ...config, remoteChrome: config.remoteChrome }, userDataDir, logger), async (chrome) => {
849
+ try {
850
+ await chrome.kill();
851
+ }
852
+ finally {
853
+ await rm(userDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
854
+ }
855
+ });
856
+ acquiredChrome = { chrome: launched, reusedChrome: null };
733
857
  }
858
+ }
859
+ catch (error) {
860
+ await withoutBrowserCancellation(async () => {
861
+ if (tabLease) {
862
+ const handle = tabLease;
863
+ tabLease = null;
864
+ await handle.release().catch(() => undefined);
865
+ }
866
+ if (!manualLogin)
867
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
868
+ });
734
869
  throw error;
735
870
  }
736
871
  const { chrome, reusedChrome } = acquiredChrome;
737
872
  const chromeHost = chrome.host ?? "127.0.0.1";
738
- if (tabLease) {
739
- await tabLease.update({
740
- chromeHost,
741
- chromePort: chrome.port,
742
- });
743
- }
744
873
  let removeTerminationHooks = null;
745
874
  try {
746
875
  removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger, {
747
876
  isInFlight: () => runStatus !== "complete",
748
877
  emitRuntimeHint,
749
878
  preserveUserDataDir: manualLogin,
879
+ preserveSharedChromeOnSignal: manualLogin,
750
880
  // copy-profile is a throwaway copy of a signed-in profile; never leave it on disk.
751
881
  forceProfileCleanup: usingCopiedProfile,
752
882
  });
@@ -768,14 +898,17 @@ export async function runBrowserMode(options) {
768
898
  let appliedCookies = 0;
769
899
  let preserveBrowserOnError = false;
770
900
  try {
901
+ if (tabLease)
902
+ await cancellation.call(() => tabLease.update({ chromeHost, chromePort: chrome.port }));
771
903
  try {
772
904
  if (config.browserTabRef) {
773
- const attached = await connectToExistingChatGptTab({
905
+ const tabRef = config.browserTabRef;
906
+ const attached = await cancellation.acquire(() => connectToExistingChatGptTab({
774
907
  host: chromeHost,
775
908
  port: chrome.port,
776
- ref: config.browserTabRef,
777
- });
778
- client = attached.client;
909
+ ref: tabRef,
910
+ }), (attached) => attached.client.close());
911
+ client = cancellation.client(attached.client);
779
912
  isolatedTargetId = attached.targetId ?? null;
780
913
  lastTargetId = attached.targetId ?? undefined;
781
914
  lastUrl = attached.tab.url || lastUrl;
@@ -785,12 +918,16 @@ export async function runBrowserMode(options) {
785
918
  else {
786
919
  const strictTabIsolation = Boolean(manualLogin && reusedChrome);
787
920
  const devtoolsRetries = manualLogin ? 6 : 0;
788
- const connection = await connectWithNewTab(chrome.port, logger, "about:blank", chromeHost, {
921
+ const connection = await cancellation.acquire(() => connectWithNewTab(chrome.port, logger, "about:blank", chromeHost, {
789
922
  fallbackToDefault: !strictTabIsolation,
790
923
  retries: devtoolsRetries,
791
924
  retryDelayMs: 500,
925
+ }), async (connection) => {
926
+ await connection.client.close().catch(() => undefined);
927
+ if (connection.targetId)
928
+ await closeTab(chrome.port, connection.targetId, logger, chromeHost);
792
929
  });
793
- client = connection.client;
930
+ client = cancellation.client(connection.client);
794
931
  isolatedTargetId = connection.targetId ?? null;
795
932
  ownsTarget = true;
796
933
  }
@@ -846,7 +983,7 @@ export async function runBrowserMode(options) {
846
983
  })();
847
984
  });
848
985
  });
849
- const raceWithDisconnect = (promise) => Promise.race([promise, disconnectPromise]);
986
+ const raceWithDisconnect = (promise) => cancellation.race(Promise.race([promise, disconnectPromise]));
850
987
  const { Network, Page, Runtime, Input, DOM, Target } = client;
851
988
  const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
852
989
  if (DOM && typeof DOM.enable === "function") {
@@ -854,7 +991,12 @@ export async function runBrowserMode(options) {
854
991
  }
855
992
  await Promise.all(domainEnablers);
856
993
  if (!config.headless && config.hideWindow) {
857
- await positionChromeWindowOffscreen(client, logger);
994
+ await positionChromeWindowOffscreen(client, userDataDir, logger);
995
+ }
996
+ else if (!config.headless) {
997
+ // Persistent profiles can retain bounds from a prior hidden run. Visible
998
+ // local runs must actively restore the Oracle-owned Chrome window.
999
+ await positionChromeWindowOnscreen(client, userDataDir, logger);
858
1000
  }
859
1001
  // The send button is clicked with trusted CDP input events at viewport
860
1002
  // coordinates, which ChatGPT silently drops when the window is hidden or
@@ -874,6 +1016,7 @@ export async function runBrowserMode(options) {
874
1016
  logger("Manual login mode: seeding persistent profile with cookies from your Chrome profile.");
875
1017
  }
876
1018
  if (!config.inlineCookies) {
1019
+ logger(CHROME_COOKIE_SYNC_WARNING);
877
1020
  logger("Heads-up: macOS may prompt for your Keychain password to read Chrome cookies; use --copy or --render for manual flow.");
878
1021
  }
879
1022
  else {
@@ -902,7 +1045,7 @@ export async function runBrowserMode(options) {
902
1045
  else {
903
1046
  logger(manualLogin
904
1047
  ? "Skipping Chrome cookie sync (--browser-manual-login enabled); reuse the opened profile after signing in."
905
- : "Skipping Chrome cookie sync (--browser-no-cookie-sync)");
1048
+ : "Skipping Chrome cookie copy (disabled by default; use --browser-cookie-sync to opt in).");
906
1049
  }
907
1050
  await clearStaleChatGptConversationCookies(Network, Target, logger, {
908
1051
  preserveConversationIds: [
@@ -1075,16 +1218,14 @@ export async function runBrowserMode(options) {
1075
1218
  : "Model picker: skipped (strategy=ignore)");
1076
1219
  }
1077
1220
  const deepResearch = config.researchMode === "deep";
1078
- // Handle thinking time selection if specified. Deep Research owns its own effort flow.
1079
- const thinkingTime = config.thinkingTime;
1080
- if (thinkingTime && !deepResearch) {
1221
+ if (shouldApplyThinkingTimeSelection(config)) {
1081
1222
  const thinkingTargetModel = modelStrategy === "select" ? config.desiredModel : null;
1082
- await raceWithDisconnect(withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger, thinkingTargetModel), {
1223
+ thinkingSelectionEvidence = await raceWithDisconnect(withRetries(() => ensureThinkingTime(Runtime, config.thinkingTime, logger, thinkingTargetModel), {
1083
1224
  retries: 2,
1084
1225
  delayMs: 300,
1085
1226
  onRetry: (attempt, error) => {
1086
1227
  if (options.verbose) {
1087
- logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
1228
+ logger(`[retry] Thinking time (${config.thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
1088
1229
  }
1089
1230
  },
1090
1231
  }));
@@ -1094,9 +1235,12 @@ export async function runBrowserMode(options) {
1094
1235
  const acquireProfileLockIfNeeded = async () => {
1095
1236
  if (profileLockTimeoutMs <= 0)
1096
1237
  return;
1097
- profileLock = await acquireProfileRunLock(userDataDir, {
1238
+ profileLock = await cancellation.acquire(() => acquireProfileRunLock(userDataDir, {
1098
1239
  timeoutMs: profileLockTimeoutMs,
1099
1240
  logger,
1241
+ signal: options.signal,
1242
+ }), async (lock) => {
1243
+ await lock?.release();
1100
1244
  });
1101
1245
  };
1102
1246
  const releaseProfileLockIfHeld = async () => {
@@ -1104,7 +1248,7 @@ export async function runBrowserMode(options) {
1104
1248
  return;
1105
1249
  const handle = profileLock;
1106
1250
  profileLock = null;
1107
- await handle.release().catch(() => undefined);
1251
+ await withoutBrowserCancellation(() => handle.release()).catch(() => undefined);
1108
1252
  };
1109
1253
  const submitOnce = async (prompt, submissionAttachments) => {
1110
1254
  const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
@@ -1115,17 +1259,20 @@ export async function runBrowserMode(options) {
1115
1259
  generatedBundle: a.generatedBundle === true,
1116
1260
  }));
1117
1261
  let inputOnlyAttachments = false;
1262
+ let attachmentNavigationUrl;
1118
1263
  await raceWithDisconnect(clearPromptComposer(Runtime, logger));
1119
1264
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
1120
1265
  if (submissionAttachments.length > 0) {
1121
1266
  if (!DOM) {
1122
1267
  throw new Error("Chrome DOM domain unavailable while uploading attachments.");
1123
1268
  }
1269
+ attachmentNavigationUrl = await raceWithDisconnect(captureComposerNavigationUrl(Runtime));
1124
1270
  await clearComposerAttachments(Runtime, 5_000, logger);
1125
1271
  for (let attachmentIndex = 0; attachmentIndex < submissionAttachments.length; attachmentIndex += 1) {
1126
1272
  const attachment = submissionAttachments[attachmentIndex];
1273
+ await raceWithDisconnect(assertComposerPlusStayedInPlace(Runtime, attachmentNavigationUrl));
1127
1274
  logger(`Uploading attachment: ${attachment.displayPath}`);
1128
- const uiConfirmed = await uploadAttachmentFile({ runtime: Runtime, dom: DOM, input: Input }, attachment, logger, { expectedCount: attachmentIndex + 1 });
1275
+ const uiConfirmed = await uploadAttachmentFile({ runtime: Runtime, dom: DOM, input: Input }, attachment, logger, { expectedCount: attachmentIndex + 1, navigationUrl: attachmentNavigationUrl });
1129
1276
  if (!uiConfirmed) {
1130
1277
  inputOnlyAttachments = true;
1131
1278
  }
@@ -1157,12 +1304,14 @@ export async function runBrowserMode(options) {
1157
1304
  const providerState = {
1158
1305
  runtime: Runtime,
1159
1306
  input: Input,
1307
+ page: Page,
1160
1308
  logger,
1161
1309
  timeoutMs: config.timeoutMs,
1162
1310
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
1163
1311
  attachmentTimeoutMs: config.attachmentTimeoutMs ?? undefined,
1164
1312
  baselineTurns: baselineTurns ?? undefined,
1165
1313
  attachmentNames: attachmentExpectations,
1314
+ attachmentNavigationUrl,
1166
1315
  onPromptSubmitted: markPromptSubmitted,
1167
1316
  };
1168
1317
  const deepResearchTargetBaseline = deepResearch && client
@@ -1277,6 +1426,7 @@ export async function runBrowserMode(options) {
1277
1426
  artifacts: savedArtifacts,
1278
1427
  archive,
1279
1428
  modelSelection: modelSelectionEvidence,
1429
+ thinkingSelection: thinkingSelectionEvidence,
1280
1430
  tookMs: durationMs,
1281
1431
  answerTokens: tokens,
1282
1432
  answerChars: researchResult.text.length,
@@ -1301,6 +1451,7 @@ export async function runBrowserMode(options) {
1301
1451
  const deadline = Date.now() + timeoutMs;
1302
1452
  while (Date.now() < deadline) {
1303
1453
  const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
1454
+ throwIfAssistantUiError(snapshot);
1304
1455
  const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
1305
1456
  if (text) {
1306
1457
  const normalized = normalizeForComparison(text);
@@ -1528,6 +1679,7 @@ export async function runBrowserMode(options) {
1528
1679
  let stableCount = 0;
1529
1680
  while (Date.now() < deadline) {
1530
1681
  const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
1682
+ throwIfAssistantUiError(snapshot);
1531
1683
  const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
1532
1684
  const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
1533
1685
  if (!isStillEcho) {
@@ -1557,6 +1709,7 @@ export async function runBrowserMode(options) {
1557
1709
  let stableCycles = 0;
1558
1710
  while (Date.now() < deadline) {
1559
1711
  const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
1712
+ throwIfAssistantUiError(snapshot);
1560
1713
  const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
1561
1714
  if (text && text.length > bestText.length) {
1562
1715
  bestText = text;
@@ -1711,6 +1864,7 @@ export async function runBrowserMode(options) {
1711
1864
  savedFiles: fileArtifacts.savedFiles,
1712
1865
  archive,
1713
1866
  modelSelection: modelSelectionEvidence,
1867
+ thinkingSelection: thinkingSelectionEvidence,
1714
1868
  tookMs: durationMs,
1715
1869
  answerTokens,
1716
1870
  answerChars,
@@ -1726,6 +1880,10 @@ export async function runBrowserMode(options) {
1726
1880
  };
1727
1881
  }
1728
1882
  catch (error) {
1883
+ if (options.signal?.aborted || error instanceof BrowserRunCancelledError) {
1884
+ runStatus = "cancelled";
1885
+ throw new BrowserRunCancelledError();
1886
+ }
1729
1887
  const normalizedError = error instanceof Error ? error : new Error(String(error));
1730
1888
  const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError);
1731
1889
  connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed;
@@ -1812,150 +1970,154 @@ export async function runBrowserMode(options) {
1812
1970
  }, normalizedError);
1813
1971
  }
1814
1972
  finally {
1815
- await conversationUrlMonitor?.stop();
1816
- try {
1817
- if (!connectionClosedUnexpectedly) {
1818
- await client?.close();
1819
- }
1820
- }
1821
- catch {
1822
- // ignore
1823
- }
1824
- // Close the isolated tab once the response has been fully captured to prevent
1825
- // tab accumulation across repeated runs. Keep the tab open on incomplete runs
1826
- // so reattach can recover the response.
1827
- const shouldCloseOwnedRunTarget = shouldCloseOwnedRunTargetAfterRun({
1828
- runStatus,
1829
- ownsTarget,
1830
- keepBrowser: effectiveKeepBrowser,
1831
- closeOwnedTabOnComplete: options.closeOwnedTabOnComplete,
1832
- });
1833
- let keepBrowserOpen = shouldKeepLocalBrowserOpen({
1834
- effectiveKeepBrowser,
1835
- preserveBrowserOnError,
1836
- usingCopiedProfile,
1837
- });
1838
- let cleanupProfileLock = null;
1839
- let terminatedRecordedChrome = false;
1840
- let otherActiveBrowserTabLeases = null;
1841
- const hasOtherActiveLeases = async () => {
1842
- if (!manualLogin || !tabLease) {
1843
- return false;
1844
- }
1845
- if (otherActiveBrowserTabLeases === null) {
1846
- otherActiveBrowserTabLeases = await hasOtherActiveBrowserTabLeases(userDataDir, tabLease.id);
1847
- }
1848
- return otherActiveBrowserTabLeases;
1849
- };
1850
- if (!keepBrowserOpen && manualLogin && tabLease) {
1851
- const cleanupLockTimeoutMs = Math.max(0, config.profileLockTimeoutMs ?? 0);
1852
- if (cleanupLockTimeoutMs > 0) {
1853
- cleanupProfileLock = await acquireProfileRunLock(userDataDir, {
1854
- timeoutMs: cleanupLockTimeoutMs,
1855
- logger,
1856
- sessionId: options.sessionId,
1857
- }).catch(() => null);
1858
- }
1859
- keepBrowserOpen = await hasOtherActiveLeases().catch(() => false);
1860
- if (keepBrowserOpen) {
1861
- logger("[browser] Other ChatGPT tab leases still active; leaving shared Chrome running.");
1862
- }
1863
- else if (reusedChrome && !connectionClosedUnexpectedly) {
1864
- terminatedRecordedChrome = await terminateRecordedChromeForProfile(userDataDir, logger).catch(() => false);
1865
- }
1866
- }
1867
- const closeOwnedRunTarget = async () => {
1868
- if (!shouldCloseOwnedRunTarget || !isolatedTargetId || !chrome?.port) {
1869
- return;
1870
- }
1871
- const safeToClose = !effectiveKeepBrowser ||
1872
- Boolean(await ensureChromePageTargetAfterClose(chrome.port, isolatedTargetId, logger, chromeHost));
1873
- if (!safeToClose) {
1874
- logger(`[browser] Leaving completed browser tab open because Chrome has no replacement page target.`);
1875
- return;
1876
- }
1877
- const closeConfirmed = await closeTab(chrome.port, isolatedTargetId, logger, chromeHost);
1878
- if (!closeConfirmed && effectiveKeepBrowser) {
1879
- const replacementTargetId = await createChromePageTarget(chrome.port, logger, chromeHost);
1880
- if (!replacementTargetId) {
1881
- logger(`[browser] Chrome page retention could not be verified after closing ${isolatedTargetId}.`);
1973
+ await withoutBrowserCancellation(async () => {
1974
+ stopThinkingMonitor?.();
1975
+ await conversationUrlMonitor?.stop();
1976
+ try {
1977
+ if (!connectionClosedUnexpectedly) {
1978
+ await client?.close();
1882
1979
  }
1883
1980
  }
1884
- };
1885
- const cleanupBlankTabs = async () => {
1886
- if (!shouldCleanupBlankTabsAfterLastLease({
1981
+ catch {
1982
+ // ignore
1983
+ }
1984
+ // Close the isolated tab once the response has been fully captured to prevent
1985
+ // tab accumulation across repeated runs. Keep the tab open on incomplete runs
1986
+ // so reattach can recover the response.
1987
+ const shouldCloseOwnedRunTarget = shouldCloseOwnedRunTargetAfterRun({
1887
1988
  runStatus,
1888
1989
  ownsTarget,
1889
- connectionClosedUnexpectedly,
1890
- manualLogin,
1891
1990
  keepBrowser: effectiveKeepBrowser,
1892
- chromePort: chrome?.port,
1893
- }) ||
1894
- !chrome?.port) {
1895
- return;
1896
- }
1897
- await closeBlankChromeTabs(chrome.port, logger, chromeHost, {
1898
- excludeTargetIds: [isolatedTargetId, lastTargetId],
1899
- preserveOneBlank: true,
1991
+ closeOwnedTabOnComplete: options.closeOwnedTabOnComplete,
1992
+ closeOwnedTabOnCancel: options.closeOwnedTabOnCancel,
1900
1993
  });
1901
- };
1902
- if (tabLease) {
1903
- const handle = tabLease;
1904
- tabLease = null;
1905
- const onRelease = async ({ isLastLease }) => {
1906
- await closeOwnedRunTarget();
1907
- if (isLastLease) {
1908
- await cleanupBlankTabs();
1994
+ let keepBrowserOpen = (manualLogin && runStatus === "cancelled") ||
1995
+ shouldKeepLocalBrowserOpen({
1996
+ effectiveKeepBrowser,
1997
+ preserveBrowserOnError,
1998
+ usingCopiedProfile,
1999
+ });
2000
+ let cleanupProfileLock = null;
2001
+ let browserTerminationHandledByLease = false;
2002
+ let tabLeaseReleaseError;
2003
+ if (!keepBrowserOpen && manualLogin && tabLease) {
2004
+ const cleanupLockTimeoutMs = Math.max(0, config.profileLockTimeoutMs ?? 0);
2005
+ if (cleanupLockTimeoutMs > 0) {
2006
+ cleanupProfileLock = await acquireProfileRunLock(userDataDir, {
2007
+ timeoutMs: cleanupLockTimeoutMs,
2008
+ logger,
2009
+ sessionId: options.sessionId,
2010
+ }).catch(() => null);
1909
2011
  }
1910
- };
1911
- await handle.release({ onRelease }).catch(() => undefined);
1912
- }
1913
- else {
1914
- await closeOwnedRunTarget();
1915
- await cleanupBlankTabs();
1916
- }
1917
- removeDialogHandler?.();
1918
- removeTerminationHooks?.();
1919
- if (!keepBrowserOpen) {
1920
- if (!connectionClosedUnexpectedly) {
1921
- try {
1922
- if (!terminatedRecordedChrome) {
1923
- await chrome.kill();
1924
- }
2012
+ }
2013
+ const closeOwnedRunTarget = async () => {
2014
+ if (!shouldCloseOwnedRunTarget || !isolatedTargetId || !chrome?.port) {
2015
+ return;
1925
2016
  }
1926
- catch {
1927
- // ignore kill failures
2017
+ const safeToClose = !keepBrowserOpen ||
2018
+ Boolean(await ensureChromePageTargetAfterClose(chrome.port, isolatedTargetId, logger, chromeHost));
2019
+ if (!safeToClose) {
2020
+ logger(`[browser] Leaving completed browser tab open because Chrome has no replacement page target.`);
2021
+ return;
1928
2022
  }
1929
- }
1930
- if (manualLogin) {
1931
- const shouldCleanup = await shouldCleanupManualLoginProfileState(userDataDir, logger.verbose ? logger : undefined, {
2023
+ const closeConfirmed = await closeTab(chrome.port, isolatedTargetId, logger, chromeHost);
2024
+ if (!closeConfirmed && keepBrowserOpen) {
2025
+ const replacementTargetId = await createChromePageTarget(chrome.port, logger, chromeHost);
2026
+ if (!replacementTargetId) {
2027
+ logger(`[browser] Chrome page retention could not be verified after closing ${isolatedTargetId}.`);
2028
+ }
2029
+ }
2030
+ };
2031
+ const cleanupBlankTabs = async () => {
2032
+ if (!shouldCleanupBlankTabsAfterLastLease({
2033
+ runStatus,
2034
+ ownsTarget,
1932
2035
  connectionClosedUnexpectedly,
1933
- host: chromeHost,
2036
+ manualLogin,
2037
+ keepBrowser: effectiveKeepBrowser,
2038
+ chromePort: chrome?.port,
2039
+ }) ||
2040
+ !chrome?.port) {
2041
+ return;
2042
+ }
2043
+ await closeBlankChromeTabs(chrome.port, logger, chromeHost, {
2044
+ excludeTargetIds: [isolatedTargetId, lastTargetId],
2045
+ preserveOneBlank: true,
2046
+ });
2047
+ };
2048
+ if (tabLease) {
2049
+ const handle = tabLease;
2050
+ tabLease = null;
2051
+ const terminateSharedChrome = !keepBrowserOpen && manualLogin && !connectionClosedUnexpectedly
2052
+ ? async () => terminateRecordedChromeForProfile(userDataDir, logger).catch(() => false)
2053
+ : undefined;
2054
+ const releaseResult = await releaseLocalBrowserTabLease({
2055
+ lease: handle,
2056
+ closeOwnedRunTarget,
2057
+ cleanupBlankTabs,
2058
+ terminateSharedChrome,
2059
+ sessionId: options.sessionId,
2060
+ chromePid: chrome.pid,
2061
+ chromePort: chrome.port,
2062
+ chromeTargetId: isolatedTargetId,
2063
+ launchDisposition: reusedChrome ? "reused" : "launched",
2064
+ logger,
1934
2065
  });
1935
- if (shouldCleanup) {
1936
- // Preserve the persistent manual-login profile, but clear stale reattach hints.
1937
- await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "never" }).catch(() => undefined);
2066
+ keepBrowserOpen ||= releaseResult.keepBrowserOpen;
2067
+ browserTerminationHandledByLease = releaseResult.terminationHandled;
2068
+ tabLeaseReleaseError = releaseResult.releaseError;
2069
+ }
2070
+ else {
2071
+ await closeOwnedRunTarget();
2072
+ await cleanupBlankTabs();
2073
+ }
2074
+ removeDialogHandler?.();
2075
+ removeTerminationHooks?.();
2076
+ if (!keepBrowserOpen) {
2077
+ if (!connectionClosedUnexpectedly) {
2078
+ try {
2079
+ if (!browserTerminationHandledByLease) {
2080
+ await chrome.kill();
2081
+ }
2082
+ }
2083
+ catch {
2084
+ // ignore kill failures
2085
+ }
2086
+ }
2087
+ if (manualLogin) {
2088
+ const shouldCleanup = await shouldCleanupManualLoginProfileState(userDataDir, logger.verbose ? logger : undefined, {
2089
+ connectionClosedUnexpectedly,
2090
+ host: chromeHost,
2091
+ });
2092
+ if (shouldCleanup) {
2093
+ // Preserve the persistent manual-login profile, but clear stale reattach hints.
2094
+ await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "never" }).catch(() => undefined);
2095
+ }
2096
+ }
2097
+ else {
2098
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
2099
+ }
2100
+ if (!connectionClosedUnexpectedly) {
2101
+ const totalSeconds = (Date.now() - startedAt) / 1000;
2102
+ logger(`Cleanup ${runStatus} • ${totalSeconds.toFixed(1)}s total`);
1938
2103
  }
1939
2104
  }
1940
2105
  else {
1941
- await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
2106
+ detachKeptChromeProcess(chrome);
2107
+ if (!connectionClosedUnexpectedly) {
2108
+ logger(`Chrome left running on port ${chrome.port} with profile ${userDataDir}`);
2109
+ }
1942
2110
  }
1943
- if (!connectionClosedUnexpectedly) {
1944
- const totalSeconds = (Date.now() - startedAt) / 1000;
1945
- logger(`Cleanup ${runStatus} • ${totalSeconds.toFixed(1)}s total`);
2111
+ if (cleanupProfileLock) {
2112
+ const handle = cleanupProfileLock;
2113
+ cleanupProfileLock = null;
2114
+ await handle.release().catch(() => undefined);
1946
2115
  }
1947
- }
1948
- else {
1949
- detachKeptChromeProcess(chrome);
1950
- if (!connectionClosedUnexpectedly) {
1951
- logger(`Chrome left running on port ${chrome.port} with profile ${userDataDir}`);
2116
+ if (tabLeaseReleaseError) {
2117
+ // oxlint-disable-next-line eslint/no-unsafe-finally -- This cleanup failure must override a successful browser result or a live MCP owner can remain locked.
2118
+ throw new Error("Failed to release the ChatGPT browser slot registry lock; restart Oracle/Codex MCP before another browser run.", { cause: tabLeaseReleaseError });
1952
2119
  }
1953
- }
1954
- if (cleanupProfileLock) {
1955
- const handle = cleanupProfileLock;
1956
- cleanupProfileLock = null;
1957
- await handle.release().catch(() => undefined);
1958
- }
2120
+ });
1959
2121
  }
1960
2122
  }
1961
2123
  const DEFAULT_DEBUG_PORT = 9222;
@@ -2189,7 +2351,7 @@ async function maybeReuseRunningChrome(userDataDir, logger, options = {}) {
2189
2351
  process: undefined,
2190
2352
  };
2191
2353
  }
2192
- async function runRemoteBrowserMode(promptText, attachments, config, logger, options) {
2354
+ async function runRemoteBrowserMode(promptText, attachments, config, logger, options, cancellation) {
2193
2355
  const remoteChromeConfig = config.remoteChrome;
2194
2356
  if (!remoteChromeConfig) {
2195
2357
  throw new Error("Remote Chrome configuration missing. Pass --remote-chrome <host:port> to use this mode.");
@@ -2202,6 +2364,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2202
2364
  let lastUrl;
2203
2365
  let promptSubmitted = false;
2204
2366
  let modelSelectionEvidence;
2367
+ let thinkingSelectionEvidence;
2205
2368
  let attachedExistingTab = false;
2206
2369
  let ownsTarget = true;
2207
2370
  let conversationUrlMonitor = null;
@@ -2258,22 +2421,24 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2258
2421
  : resolveRemoteTabLeaseProfileDir(config);
2259
2422
  if (remoteLeaseProfileDir) {
2260
2423
  await mkdir(remoteLeaseProfileDir, { recursive: true });
2261
- tabLease = await acquireBrowserTabLease(remoteLeaseProfileDir, {
2424
+ tabLease = await cancellation.acquire(() => acquireBrowserTabLease(remoteLeaseProfileDir, {
2262
2425
  maxConcurrentTabs: config.maxConcurrentTabs,
2263
2426
  timeoutMs: config.timeoutMs,
2264
2427
  logger,
2265
2428
  sessionId: options.sessionId,
2266
2429
  chromeHost: host,
2267
2430
  chromePort: port,
2268
- });
2431
+ signal: options.signal,
2432
+ }), (lease) => lease.release());
2269
2433
  }
2270
2434
  if (config.browserTabRef) {
2271
- const attached = await connectToExistingChatGptTab({
2435
+ const tabRef = config.browserTabRef;
2436
+ const attached = await cancellation.acquire(() => connectToExistingChatGptTab({
2272
2437
  host,
2273
2438
  port,
2274
- ref: config.browserTabRef,
2275
- });
2276
- client = attached.client;
2439
+ ref: tabRef,
2440
+ }), (attached) => attached.client.close());
2441
+ client = cancellation.client(attached.client);
2277
2442
  remoteTargetId = attached.targetId ?? null;
2278
2443
  lastUrl = attached.tab.url || lastUrl;
2279
2444
  attachedExistingTab = true;
@@ -2281,10 +2446,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2281
2446
  logger(`Attached to existing remote ChatGPT tab ${attached.targetId}${attached.tab.url ? ` (${attached.tab.url})` : ""}`);
2282
2447
  }
2283
2448
  else {
2284
- connection = await connectToRemoteChrome(host, port, logger, "about:blank", browserWSEndpoint, {
2285
- approvalWaitMs: config.attachRunning && browserWSEndpoint ? 20_000 : undefined,
2286
- });
2287
- client = connection.client;
2449
+ connection = await cancellation.acquire(() => connectToRemoteChrome(host, port, logger, "about:blank", browserWSEndpoint, {
2450
+ approvalWaitMs: browserWSEndpoint ? config.approvalWaitMs : undefined,
2451
+ fallbackToDefault: false,
2452
+ }), (connection) => connection.close());
2453
+ client = cancellation.client(connection.client);
2288
2454
  remoteTargetId = connection.targetId ?? null;
2289
2455
  ownsTarget = true;
2290
2456
  }
@@ -2393,16 +2559,14 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2393
2559
  : "Model picker: skipped (strategy=ignore)");
2394
2560
  }
2395
2561
  const deepResearch = config.researchMode === "deep";
2396
- // Handle thinking time selection if specified. Deep Research owns its own effort flow.
2397
- const thinkingTime = config.thinkingTime;
2398
- if (thinkingTime && !deepResearch) {
2562
+ if (shouldApplyThinkingTimeSelection(config)) {
2399
2563
  const thinkingTargetModel = modelStrategy === "select" ? config.desiredModel : null;
2400
- await withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger, thinkingTargetModel), {
2564
+ thinkingSelectionEvidence = await withRetries(() => ensureThinkingTime(Runtime, config.thinkingTime, logger, thinkingTargetModel), {
2401
2565
  retries: 2,
2402
2566
  delayMs: 300,
2403
2567
  onRetry: (attempt, error) => {
2404
2568
  if (options.verbose) {
2405
- logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
2569
+ logger(`[retry] Thinking time (${config.thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
2406
2570
  }
2407
2571
  },
2408
2572
  });
@@ -2415,17 +2579,20 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2415
2579
  name: path.basename(a.path),
2416
2580
  generatedBundle: a.generatedBundle === true,
2417
2581
  }));
2582
+ let attachmentNavigationUrl;
2418
2583
  await clearPromptComposer(Runtime, logger);
2419
2584
  await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
2420
2585
  if (submissionAttachments.length > 0) {
2421
2586
  if (!DOM) {
2422
2587
  throw new Error("Chrome DOM domain unavailable while uploading attachments.");
2423
2588
  }
2589
+ attachmentNavigationUrl = await captureComposerNavigationUrl(Runtime);
2424
2590
  await clearComposerAttachments(Runtime, 5_000, logger);
2425
2591
  // Use remote file transfer for remote Chrome (reads local files and injects via CDP)
2426
2592
  for (const attachment of submissionAttachments) {
2593
+ await assertComposerPlusStayedInPlace(Runtime, attachmentNavigationUrl);
2427
2594
  logger(`Uploading attachment: ${attachment.displayPath}`);
2428
- await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM }, attachment, logger);
2595
+ await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM, navigationUrl: attachmentNavigationUrl }, attachment, logger);
2429
2596
  await delay(500);
2430
2597
  }
2431
2598
  // Scale timeout based on number of files: base 30s + 15s per additional file
@@ -2453,12 +2620,14 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2453
2620
  const providerState = {
2454
2621
  runtime: Runtime,
2455
2622
  input: Input,
2623
+ page: Page,
2456
2624
  logger,
2457
2625
  timeoutMs: config.timeoutMs,
2458
2626
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
2459
2627
  attachmentTimeoutMs: config.attachmentTimeoutMs ?? undefined,
2460
2628
  baselineTurns: baselineTurns ?? undefined,
2461
2629
  attachmentNames: attachmentExpectations,
2630
+ attachmentNavigationUrl,
2462
2631
  onPromptSubmitted: markPromptSubmitted,
2463
2632
  };
2464
2633
  const deepResearchTargetBaseline = deepResearch && client
@@ -2549,6 +2718,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2549
2718
  artifacts: savedArtifacts,
2550
2719
  archive,
2551
2720
  modelSelection: modelSelectionEvidence,
2721
+ thinkingSelection: thinkingSelectionEvidence,
2552
2722
  tookMs: durationMs,
2553
2723
  answerTokens: tokens,
2554
2724
  answerChars: researchResult.text.length,
@@ -2571,6 +2741,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2571
2741
  const deadline = Date.now() + timeoutMs;
2572
2742
  while (Date.now() < deadline) {
2573
2743
  const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
2744
+ throwIfAssistantUiError(snapshot);
2574
2745
  const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
2575
2746
  if (text) {
2576
2747
  const normalized = normalizeForComparison(text);
@@ -2792,6 +2963,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2792
2963
  let stableCount = 0;
2793
2964
  while (Date.now() < deadline) {
2794
2965
  const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
2966
+ throwIfAssistantUiError(snapshot);
2795
2967
  const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
2796
2968
  const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
2797
2969
  if (!isStillEcho) {
@@ -2955,10 +3127,15 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2955
3127
  savedFiles: fileArtifacts.savedFiles,
2956
3128
  archive,
2957
3129
  modelSelection: modelSelectionEvidence,
3130
+ thinkingSelection: thinkingSelectionEvidence,
2958
3131
  controllerPid: process.pid,
2959
3132
  };
2960
3133
  }
2961
3134
  catch (error) {
3135
+ if (options.signal?.aborted || error instanceof BrowserRunCancelledError) {
3136
+ runStatus = "cancelled";
3137
+ throw new BrowserRunCancelledError();
3138
+ }
2962
3139
  const normalizedError = error instanceof Error ? error : new Error(String(error));
2963
3140
  const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError);
2964
3141
  connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed;
@@ -2996,57 +3173,61 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2996
3173
  });
2997
3174
  }
2998
3175
  finally {
2999
- await conversationUrlMonitor?.stop();
3000
- try {
3001
- await closeRemoteConnectionAfterRun({
3002
- connectionClosedUnexpectedly,
3003
- connection,
3004
- client,
3005
- runStatus,
3006
- });
3007
- }
3008
- catch {
3009
- // ignore
3010
- }
3011
- removeDialogHandler?.();
3012
- const keepRemoteBrowser = Boolean(config.keepBrowser);
3013
- const shouldCloseOwnedRemoteTarget = shouldCloseOwnedRunTargetAfterRun({
3014
- runStatus,
3015
- ownsTarget,
3016
- keepBrowser: keepRemoteBrowser,
3017
- closeOwnedTabOnComplete: options.closeOwnedTabOnComplete,
3018
- });
3019
- const closeOwnedRemoteTarget = async () => {
3020
- if (!shouldCloseOwnedRemoteTarget || !remoteTargetId) {
3021
- return;
3176
+ await withoutBrowserCancellation(async () => {
3177
+ stopThinkingMonitor?.();
3178
+ await conversationUrlMonitor?.stop();
3179
+ try {
3180
+ await closeRemoteConnectionAfterRun({
3181
+ connectionClosedUnexpectedly,
3182
+ connection,
3183
+ client,
3184
+ runStatus,
3185
+ });
3022
3186
  }
3023
- const safeToClose = !keepRemoteBrowser ||
3024
- Boolean(await ensureChromePageTargetAfterClose(port, remoteTargetId, logger, host));
3025
- if (!safeToClose) {
3026
- logger(`[browser] Leaving completed remote browser tab open because Chrome has no replacement page target.`);
3027
- return;
3187
+ catch {
3188
+ // ignore
3028
3189
  }
3029
- const closeConfirmed = await closeTab(port, remoteTargetId, logger, host);
3030
- if (!closeConfirmed && keepRemoteBrowser) {
3031
- const replacementTargetId = await createChromePageTarget(port, logger, host);
3032
- if (!replacementTargetId) {
3033
- logger(`[browser] Remote Chrome page retention could not be verified after closing ${remoteTargetId}.`);
3190
+ removeDialogHandler?.();
3191
+ const keepRemoteBrowser = Boolean(config.keepBrowser);
3192
+ const shouldCloseOwnedRemoteTarget = shouldCloseOwnedRunTargetAfterRun({
3193
+ runStatus,
3194
+ ownsTarget,
3195
+ keepBrowser: keepRemoteBrowser,
3196
+ closeOwnedTabOnComplete: options.closeOwnedTabOnComplete,
3197
+ closeOwnedTabOnCancel: options.closeOwnedTabOnCancel,
3198
+ });
3199
+ const closeOwnedRemoteTarget = async () => {
3200
+ if (!shouldCloseOwnedRemoteTarget || !remoteTargetId) {
3201
+ return;
3034
3202
  }
3203
+ const safeToClose = !keepRemoteBrowser ||
3204
+ Boolean(await ensureChromePageTargetAfterClose(port, remoteTargetId, logger, host));
3205
+ if (!safeToClose) {
3206
+ logger(`[browser] Leaving completed remote browser tab open because Chrome has no replacement page target.`);
3207
+ return;
3208
+ }
3209
+ const closeConfirmed = await closeTab(port, remoteTargetId, logger, host);
3210
+ if (!closeConfirmed && keepRemoteBrowser) {
3211
+ const replacementTargetId = await createChromePageTarget(port, logger, host);
3212
+ if (!replacementTargetId) {
3213
+ logger(`[browser] Remote Chrome page retention could not be verified after closing ${remoteTargetId}.`);
3214
+ }
3215
+ }
3216
+ };
3217
+ if (tabLease) {
3218
+ const handle = tabLease;
3219
+ tabLease = null;
3220
+ await handle
3221
+ .release({ onRelease: async () => closeOwnedRemoteTarget() })
3222
+ .catch(() => undefined);
3035
3223
  }
3036
- };
3037
- if (tabLease) {
3038
- const handle = tabLease;
3039
- tabLease = null;
3040
- await handle
3041
- .release({ onRelease: async () => closeOwnedRemoteTarget() })
3042
- .catch(() => undefined);
3043
- }
3044
- else {
3045
- await closeOwnedRemoteTarget();
3046
- }
3047
- // Don't kill remote Chrome - it's not ours to manage
3048
- const totalSeconds = (Date.now() - startedAt) / 1000;
3049
- logger(`Remote session complete • ${totalSeconds.toFixed(1)}s total`);
3224
+ else {
3225
+ await closeOwnedRemoteTarget();
3226
+ }
3227
+ // Don't kill remote Chrome - it's not ours to manage
3228
+ const totalSeconds = (Date.now() - startedAt) / 1000;
3229
+ logger(`Remote session complete ${totalSeconds.toFixed(1)}s total`);
3230
+ });
3050
3231
  }
3051
3232
  }
3052
3233
  export { estimateTokenCount } from "./utils.js";
@@ -3065,10 +3246,13 @@ export const __test__ = {
3065
3246
  isImageOnlyUiChromeText,
3066
3247
  listIgnoredRemoteChromeFlags,
3067
3248
  normalizeAuthenticatedModelSelectionError,
3249
+ pollGeneratedImageOrTextAssistantResponse,
3068
3250
  resolveManualLoginWaitMs,
3251
+ shouldApplyThinkingTimeSelection,
3069
3252
  shouldCleanupBlankTabsAfterLastLease,
3070
3253
  shouldCloseOwnedRunTargetAfterRun,
3071
3254
  shouldKeepLocalBrowserOpen,
3255
+ releaseLocalBrowserTabLease,
3072
3256
  waitForAssistantResponseWithReload,
3073
3257
  };
3074
3258
  export { syncCookies } from "./cookies.js";