@steipete/oracle 0.18.0 → 0.20.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 (169) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +76 -76
  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 +170 -45
  13. package/dist/src/browser/actions/modelSelection.js +91 -4
  14. package/dist/src/browser/actions/navigation.js +1 -1
  15. package/dist/src/browser/actions/promptComposer.js +364 -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 +156 -16
  19. package/dist/src/browser/actions/webSearch.js +96 -0
  20. package/dist/src/browser/attachRunning.js +46 -2
  21. package/dist/src/browser/attachmentValidation.js +53 -0
  22. package/dist/src/browser/cancellation.js +105 -0
  23. package/dist/src/browser/chatgptImages.js +2 -0
  24. package/dist/src/browser/chatgptThrottle.js +104 -0
  25. package/dist/src/browser/chromeLifecycle.js +272 -47
  26. package/dist/src/browser/config.js +14 -1
  27. package/dist/src/browser/detect.js +1 -1
  28. package/dist/src/browser/index.js +525 -274
  29. package/dist/src/browser/liveTabs.js +3 -4
  30. package/dist/src/browser/modelDisplay.js +9 -0
  31. package/dist/src/browser/pageActions.js +2 -2
  32. package/dist/src/browser/profileState.js +114 -9
  33. package/dist/src/browser/projectSourcesRunner.js +83 -15
  34. package/dist/src/browser/prompt.js +272 -113
  35. package/dist/src/browser/providers/chatgptDomProvider.js +3 -0
  36. package/dist/src/browser/reattach.js +31 -4
  37. package/dist/src/browser/recoveryTarget.js +131 -0
  38. package/dist/src/browser/sessionRunner.js +73 -11
  39. package/dist/src/browser/tabLeaseRegistry.js +434 -38
  40. package/dist/src/browser/targetClaim.js +54 -0
  41. package/dist/src/browser/utils.js +20 -2
  42. package/dist/src/cli/browserConfig.js +36 -4
  43. package/dist/src/cli/browserDefaults.js +10 -0
  44. package/dist/src/cli/browserTabs.js +15 -29
  45. package/dist/src/cli/detachedSession.js +69 -0
  46. package/dist/src/cli/dryRun.js +58 -48
  47. package/dist/src/cli/harvestIntegrity.js +140 -0
  48. package/dist/src/cli/options.js +15 -0
  49. package/dist/src/cli/recoveredBrowserHarvest.js +50 -0
  50. package/dist/src/cli/runOptions.js +19 -4
  51. package/dist/src/cli/serveBrowserConfig.js +24 -0
  52. package/dist/src/cli/sessionDisplay.js +13 -7
  53. package/dist/src/cli/sessionRunner.js +95 -12
  54. package/dist/src/config.js +1 -0
  55. package/dist/src/gemini-web/client.js +67 -21
  56. package/dist/src/gemini-web/executor.js +4 -0
  57. package/dist/src/gemini-web/http.js +15 -0
  58. package/dist/src/gemini-web/models.js +15 -11
  59. package/dist/src/mcp/server.js +11 -14
  60. package/dist/src/mcp/tools/chatgptImage.js +4 -4
  61. package/dist/src/mcp/tools/consult.js +86 -24
  62. package/dist/src/mcp/tools/projectSources.js +4 -6
  63. package/dist/src/mcp/tools/sessionResources.js +1 -1
  64. package/dist/src/mcp/tools/sessions.js +2 -2
  65. package/dist/src/mcp/tools/wait.js +182 -0
  66. package/dist/src/mcp/types.js +8 -1
  67. package/dist/src/oracle/config.js +14 -0
  68. package/dist/src/oracle/errors.js +11 -0
  69. package/dist/src/oracle/geminiModels.js +1 -0
  70. package/dist/src/oracle/providerRoutePlan.js +3 -2
  71. package/dist/src/oracle/run.js +27 -4
  72. package/dist/src/remote/client.js +73 -6
  73. package/dist/src/remote/health.js +6 -2
  74. package/dist/src/remote/runSlots.js +83 -0
  75. package/dist/src/remote/server.js +346 -71
  76. package/dist/src/sessionManager.js +36 -1
  77. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  78. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  79. package/package.json +25 -23
  80. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  81. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  82. package/dist/bin/oracle.js +0 -683
  83. package/dist/docs-site/.nojekyll +0 -0
  84. package/dist/docs-site/CNAME +0 -1
  85. package/dist/docs-site/RELEASING.html +0 -410
  86. package/dist/docs-site/agents.html +0 -374
  87. package/dist/docs-site/anthropic.html +0 -368
  88. package/dist/docs-site/bridge.html +0 -400
  89. package/dist/docs-site/browser-mode.html +0 -588
  90. package/dist/docs-site/chromium-forks.html +0 -347
  91. package/dist/docs-site/cli-reference.html +0 -346
  92. package/dist/docs-site/configuration.html +0 -436
  93. package/dist/docs-site/favicon.svg +0 -14
  94. package/dist/docs-site/followup.html +0 -368
  95. package/dist/docs-site/gemini.html +0 -379
  96. package/dist/docs-site/grok.html +0 -325
  97. package/dist/docs-site/index.html +0 -360
  98. package/dist/docs-site/install.html +0 -335
  99. package/dist/docs-site/linux.html +0 -321
  100. package/dist/docs-site/llms.txt +0 -43
  101. package/dist/docs-site/manual-tests.html +0 -595
  102. package/dist/docs-site/mcp.html +0 -368
  103. package/dist/docs-site/multimodel.html +0 -364
  104. package/dist/docs-site/mythical-pro-agents.html +0 -360
  105. package/dist/docs-site/notifier.html +0 -338
  106. package/dist/docs-site/openai-endpoints.html +0 -387
  107. package/dist/docs-site/openrouter.html +0 -344
  108. package/dist/docs-site/quickstart.html +0 -369
  109. package/dist/docs-site/refactor/ux.html +0 -532
  110. package/dist/docs-site/sessions.html +0 -388
  111. package/dist/docs-site/social-card.png +0 -0
  112. package/dist/docs-site/social-card.svg +0 -79
  113. package/dist/docs-site/spec.html +0 -363
  114. package/dist/docs-site/testing.html +0 -320
  115. package/dist/docs-site/tui-debug.html +0 -326
  116. package/dist/docs-site/windows-work.html +0 -323
  117. package/dist/docs-site/windows.html +0 -320
  118. package/dist/markdansi/types/index.js +0 -4
  119. package/dist/oracle/bin/oracle-cli.js +0 -472
  120. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  121. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  122. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  123. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  124. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  125. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  126. package/dist/oracle/src/browser/config.js +0 -33
  127. package/dist/oracle/src/browser/constants.js +0 -40
  128. package/dist/oracle/src/browser/cookies.js +0 -210
  129. package/dist/oracle/src/browser/domDebug.js +0 -36
  130. package/dist/oracle/src/browser/index.js +0 -331
  131. package/dist/oracle/src/browser/pageActions.js +0 -5
  132. package/dist/oracle/src/browser/prompt.js +0 -88
  133. package/dist/oracle/src/browser/promptSummary.js +0 -20
  134. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  135. package/dist/oracle/src/browser/types.js +0 -1
  136. package/dist/oracle/src/browser/utils.js +0 -62
  137. package/dist/oracle/src/browserMode.js +0 -1
  138. package/dist/oracle/src/cli/browserConfig.js +0 -44
  139. package/dist/oracle/src/cli/dryRun.js +0 -59
  140. package/dist/oracle/src/cli/engine.js +0 -17
  141. package/dist/oracle/src/cli/errorUtils.js +0 -9
  142. package/dist/oracle/src/cli/help.js +0 -70
  143. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  144. package/dist/oracle/src/cli/options.js +0 -103
  145. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  146. package/dist/oracle/src/cli/rootAlias.js +0 -30
  147. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  148. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  149. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  150. package/dist/oracle/src/heartbeat.js +0 -43
  151. package/dist/oracle/src/oracle/client.js +0 -48
  152. package/dist/oracle/src/oracle/config.js +0 -29
  153. package/dist/oracle/src/oracle/errors.js +0 -101
  154. package/dist/oracle/src/oracle/files.js +0 -220
  155. package/dist/oracle/src/oracle/format.js +0 -33
  156. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  157. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  158. package/dist/oracle/src/oracle/request.js +0 -48
  159. package/dist/oracle/src/oracle/run.js +0 -444
  160. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  161. package/dist/oracle/src/oracle/types.js +0 -1
  162. package/dist/oracle/src/oracle.js +0 -9
  163. package/dist/oracle/src/sessionManager.js +0 -205
  164. package/dist/oracle/src/version.js +0 -39
  165. package/dist/scripts/chrome/browser-tools.js +0 -295
  166. package/dist/src/browser/chromeCookies.js +0 -312
  167. package/dist/src/browser/keytarShim.js +0 -56
  168. package/dist/src/browser/profileSync.js +0 -141
  169. package/dist/src/browser/windowsCookies.js +0 -219
@@ -2,25 +2,30 @@ import { mkdtemp, rm, mkdir } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
4
  import net from "node:net";
5
+ import { randomUUID } from "node:crypto";
6
+ import { claimBrowserTarget } from "./targetClaim.js";
5
7
  import { resolveBrowserConfig } from "./config.js";
6
8
  import { copyChromeProfile } from "./profileCopy.js";
7
- import { launchChrome, registerTerminationHooks, positionChromeWindowOffscreen, connectToRemoteChrome, connectWithNewTab, closeTab, createChromePageTarget, ensureChromePageTargetAfterClose, closeBlankChromeTabs, } from "./chromeLifecycle.js";
9
+ import { BrowserCancellation, withoutBrowserCancellation } from "./cancellation.js";
10
+ import { launchChrome, registerTerminationHooks, positionChromeWindowOffscreen, positionChromeWindowOnscreen, connectToRemoteChrome, connectWithNewTab, closeTab, createChromePageTarget, ensureChromePageTargetAfterClose, closeBlankChromeTabs, } from "./chromeLifecycle.js";
8
11
  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";
12
+ import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureChatMode, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, captureComposerNavigationUrl, assertComposerPlusStayedInPlace, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js";
10
13
  import { INPUT_SELECTORS } from "./constants.js";
11
14
  import { uploadAttachmentViaDataTransfer } from "./actions/remoteFileTransfer.js";
12
15
  import { ensureThinkingTime } from "./actions/thinkingTime.js";
16
+ import { throwIfAssistantUiError } from "./actions/assistantResponse.js";
13
17
  import { startThinkingStatusMonitor } from "./actions/thinkingStatus.js";
14
18
  import { activateDeepResearch, captureDeepResearchTargetKeys, waitForDeepResearchCompletion, waitForResearchPlanAutoConfirm, } from "./actions/deepResearch.js";
15
19
  import { estimateTokenCount, withRetries, delay } from "./utils.js";
16
20
  import { formatElapsed } from "../oracle/format.js";
17
21
  import { CHATGPT_URL, DEFAULT_MODEL_STRATEGY } from "./constants.js";
18
- import { BrowserAutomationError } from "../oracle/errors.js";
22
+ import { BrowserAutomationError, BrowserRunCancelledError } from "../oracle/errors.js";
23
+ import { buildAttachmentBasenameCollisionDetails, findAttachmentBasenameCollisions, formatAttachmentBasenameCollisionMessage, } from "./attachmentValidation.js";
19
24
  import { alignPromptEchoPair, buildPromptEchoMatcher } from "./reattachHelpers.js";
20
25
  import { buildConversationTurnCountExpression } from "./conversationTurns.js";
21
26
  import { cleanupStaleProfileState, acquireProfileRunLock, findRunningChromeDebugTargetForProfile, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, terminateRecordedChromeForProfile, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from "./profileState.js";
22
27
  import { connectionLostUserMessage, isRecoverableChromeDisconnect, probeChromeTargetLiveness, } from "./cdpLiveness.js";
23
- import { acquireBrowserTabLease, hasOtherActiveBrowserTabLeases, } from "./tabLeaseRegistry.js";
28
+ import { acquireBrowserTabLease } from "./tabLeaseRegistry.js";
24
29
  import { appendArtifacts, saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "./artifacts.js";
25
30
  import { collectGeneratedImageArtifacts } from "./chatgptImages.js";
26
31
  import { collectChatGptFileArtifacts } from "./chatgptFiles.js";
@@ -58,7 +63,7 @@ function isReattachableCaptureError(error) {
58
63
  if (!(error instanceof BrowserAutomationError))
59
64
  return false;
60
65
  const stage = error.details?.stage;
61
- return stage === "assistant-timeout" || stage === "assistant-recheck";
66
+ return (stage === "assistant-timeout" || stage === "assistant-recheck" || stage === "assistant-ui-error");
62
67
  }
63
68
  function classifyPreservedBrowserError(error, headless) {
64
69
  if (headless)
@@ -86,6 +91,11 @@ export function shouldPreserveBrowserOnErrorForTest(error, headless) {
86
91
  export function classifyPreservedBrowserErrorForTest(error, headless) {
87
92
  return classifyPreservedBrowserError(error, headless);
88
93
  }
94
+ function shouldApplyThinkingTimeSelection(config) {
95
+ // Deep Research uses the same effort picker, so research mode must not
96
+ // suppress an explicitly configured thinking-time selection.
97
+ return config.thinkingTime !== undefined;
98
+ }
89
99
  const MAX_CHATGPT_UI_WARNING_CHARS = 300;
90
100
  const MAX_CHATGPT_UI_WARNINGS = 3;
91
101
  function classifyChatGptUiWarningText(text) {
@@ -318,6 +328,17 @@ function hasBrowserErrorCode(error, code) {
318
328
  return (error instanceof BrowserAutomationError &&
319
329
  error.details?.code === code);
320
330
  }
331
+ function assertUniqueAttachmentBasenames(attachments, options) {
332
+ const collisions = findAttachmentBasenameCollisions(attachments);
333
+ if (collisions.length === 0)
334
+ return;
335
+ const collisionDetails = buildAttachmentBasenameCollisionDetails(collisions, (attachment) => attachment.displayPath || attachment.path);
336
+ throw new BrowserAutomationError(formatAttachmentBasenameCollisionMessage(options.subject, collisionDetails.collisions), {
337
+ stage: options.stage,
338
+ code: "attachment-basename-collision",
339
+ ...collisionDetails,
340
+ });
341
+ }
321
342
  async function saveOptionalArtifact(operation, logger) {
322
343
  try {
323
344
  return await operation();
@@ -357,10 +378,12 @@ async function pollGeneratedImageOrTextAssistantResponse(Runtime, timeoutMs, min
357
378
  const deadline = Date.now() + timeoutMs;
358
379
  while (Date.now() < deadline) {
359
380
  let snapshot = await readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId).catch(() => null);
381
+ throwIfAssistantUiError(snapshot);
360
382
  if (!snapshot && typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex)) {
361
383
  const relaxedSnapshot = await readAssistantSnapshot(Runtime, undefined, expectedConversationId).catch(() => null);
362
384
  const relaxedHtml = typeof relaxedSnapshot?.html === "string" ? relaxedSnapshot.html : "";
363
- if (relaxedHtml.includes("/backend-api/estuary/content?id=file_")) {
385
+ if (!relaxedSnapshot?.uiError &&
386
+ relaxedHtml.includes("/backend-api/estuary/content?id=file_")) {
364
387
  snapshot = relaxedSnapshot;
365
388
  }
366
389
  }
@@ -491,6 +514,13 @@ async function runSubmissionWithRecovery({ prompt, attachments, fallbackSubmissi
491
514
  if (fallbackSubmission && isPromptTooLarge && !usedFallbackSubmission) {
492
515
  usedFallbackSubmission = true;
493
516
  logger("[browser] Inline prompt too large; retrying with file uploads.");
517
+ if (fallbackSubmission.prepare) {
518
+ await fallbackSubmission.prepare();
519
+ }
520
+ assertUniqueAttachmentBasenames(fallbackSubmission.attachments, {
521
+ stage: "upload-fallback",
522
+ subject: "The inline prompt was too large, but its upload fallback",
523
+ });
494
524
  await prepareFallbackSubmission();
495
525
  currentPrompt = fallbackSubmission.prompt;
496
526
  currentAttachments = fallbackSubmission.attachments;
@@ -526,24 +556,20 @@ export function isLocalChromeHostForTest(host) {
526
556
  return isLocalChromeHost(host);
527
557
  }
528
558
  async function closeRemoteConnectionAfterRun(options) {
529
- if (options.connectionClosedUnexpectedly) {
530
- return;
531
- }
532
559
  if (!options.connection) {
533
560
  await options.client?.close();
534
561
  return;
535
562
  }
536
- if (options.runStatus === "complete") {
537
- await options.connection.close();
538
- }
539
- else {
540
- await options.client?.close();
541
- }
563
+ await options.connection.close({
564
+ preserveTarget: options.connectionClosedUnexpectedly || options.preserveTarget,
565
+ });
542
566
  }
543
567
  function shouldCloseOwnedRunTargetAfterRun(options) {
544
- return (options.runStatus === "complete" &&
545
- options.ownsTarget &&
546
- (Boolean(options.closeOwnedTabOnComplete) || !options.keepBrowser));
568
+ return (options.ownsTarget &&
569
+ ((options.runStatus === "cancelled" &&
570
+ (options.closeOwnedTabOnCancel ?? !options.keepBrowser)) ||
571
+ (options.runStatus === "complete" &&
572
+ (Boolean(options.closeOwnedTabOnComplete) || !options.keepBrowser))));
547
573
  }
548
574
  function shouldCleanupBlankTabsAfterLastLease(options) {
549
575
  return (options.runStatus === "complete" &&
@@ -553,6 +579,79 @@ function shouldCleanupBlankTabsAfterLastLease(options) {
553
579
  options.keepBrowser &&
554
580
  Boolean(options.chromePort));
555
581
  }
582
+ async function releaseLocalBrowserTabLease(options) {
583
+ let decisionObserved = false;
584
+ let keepBrowserOpen = false;
585
+ let terminationHandled = false;
586
+ let otherLeasesRemain = false;
587
+ let releaseError;
588
+ try {
589
+ await options.lease.release({
590
+ onRelease: async ({ isLastLease }) => {
591
+ decisionObserved = true;
592
+ if (!isLastLease) {
593
+ // Record this before any best-effort tab cleanup so a cleanup failure can
594
+ // never fall through into terminating Chrome used by another lease.
595
+ keepBrowserOpen = true;
596
+ otherLeasesRemain = true;
597
+ }
598
+ await options.closeOwnedRunTarget().catch(() => undefined);
599
+ if (!isLastLease) {
600
+ return;
601
+ }
602
+ await options.cleanupBlankTabs().catch(() => undefined);
603
+ if (options.terminateSharedChrome) {
604
+ options.logger(`[browser] ChatGPT browser slot ${options.lease.id.slice(0, 8)} is final; ` +
605
+ `terminating shared Chrome (${formatBrowserLeaseDiagnostics(options)}).`);
606
+ const terminated = await options.terminateSharedChrome().catch(() => false);
607
+ if (terminated) {
608
+ terminationHandled = true;
609
+ }
610
+ else {
611
+ // A reused Chrome handle may have a no-op kill implementation. Never
612
+ // claim cleanup or fall through into an unverified lock-free kill.
613
+ keepBrowserOpen = true;
614
+ options.logger("[browser] Could not verify shared Chrome termination; leaving it available for reuse.");
615
+ }
616
+ }
617
+ },
618
+ });
619
+ }
620
+ catch (error) {
621
+ releaseError = error instanceof Error ? error : new Error(String(error));
622
+ if (!terminationHandled)
623
+ keepBrowserOpen = true;
624
+ options.logger(`[browser] Failed to release the ChatGPT browser slot registry lock; restart Oracle/Codex MCP before another browser run: ${releaseError.message}`);
625
+ }
626
+ if (!decisionObserved) {
627
+ options.logger("[browser] Could not verify final ChatGPT tab lease; leaving shared Chrome running.");
628
+ return {
629
+ keepBrowserOpen: true,
630
+ terminationHandled: false,
631
+ ...(releaseError ? { releaseError } : {}),
632
+ };
633
+ }
634
+ if (otherLeasesRemain) {
635
+ options.logger(`[browser] Other ChatGPT tab leases still active; leaving shared Chrome running; ` +
636
+ `browser slot ${options.lease.id.slice(0, 8)} is non-final ` +
637
+ `(${formatBrowserLeaseDiagnostics(options)}).`);
638
+ }
639
+ return {
640
+ keepBrowserOpen,
641
+ terminationHandled,
642
+ ...(releaseError ? { releaseError } : {}),
643
+ };
644
+ }
645
+ function formatBrowserLeaseDiagnostics(options) {
646
+ return [
647
+ `session=${options.sessionId ?? "unknown"}`,
648
+ `controllerPid=${process.pid}`,
649
+ `chromePid=${options.chromePid ?? "unknown"}`,
650
+ `chromePort=${options.chromePort ?? "unknown"}`,
651
+ `target=${options.chromeTargetId ?? "unknown"}`,
652
+ `launch=${options.launchDisposition ?? "unknown"}`,
653
+ ].join("; ");
654
+ }
556
655
  function buildSkippedModelSelectionEvidence(desiredModel, strategy) {
557
656
  return {
558
657
  requestedModel: desiredModel ?? null,
@@ -565,11 +664,25 @@ function buildSkippedModelSelectionEvidence(desiredModel, strategy) {
565
664
  };
566
665
  }
567
666
  export async function runBrowserMode(options) {
667
+ const cancellation = new BrowserCancellation(options.signal, options.log);
668
+ try {
669
+ cancellation.check();
670
+ return await cancellation.run(() => runBrowserModeInternal(options, cancellation));
671
+ }
672
+ finally {
673
+ cancellation.dispose();
674
+ }
675
+ }
676
+ async function runBrowserModeInternal(options, cancellation) {
677
+ const attachments = options.attachments ?? [];
678
+ assertUniqueAttachmentBasenames(attachments, {
679
+ stage: "upload",
680
+ subject: "Browser upload",
681
+ });
568
682
  const promptText = options.prompt?.trim();
569
683
  if (!promptText) {
570
684
  throw new Error("Prompt text is required when using browser mode.");
571
685
  }
572
- const attachments = options.attachments ?? [];
573
686
  const fallbackSubmission = options.fallbackSubmission;
574
687
  let config = resolveBrowserConfig(options.config);
575
688
  const usingCopiedProfile = Boolean(config.copyProfileSource);
@@ -595,7 +708,11 @@ export async function runBrowserMode(options) {
595
708
  let lastTargetId;
596
709
  let lastUrl;
597
710
  let promptSubmitted = false;
711
+ let ownedRecoveryTarget;
712
+ const targetClaimId = randomUUID();
598
713
  let modelSelectionEvidence;
714
+ let thinkingSelectionEvidence;
715
+ let researchPlan;
599
716
  let tabLease = null;
600
717
  let conversationUrlMonitor = null;
601
718
  const emitRuntimeHint = async () => {
@@ -611,8 +728,10 @@ export async function runBrowserMode(options) {
611
728
  tabUrl: lastUrl,
612
729
  conversationId,
613
730
  promptSubmitted,
731
+ ownedRecoveryTarget,
614
732
  userDataDir,
615
733
  controllerPid: process.pid,
734
+ researchPlan,
616
735
  };
617
736
  try {
618
737
  await runtimeHintCb?.(hint, modelSelectionEvidence);
@@ -646,7 +765,7 @@ export async function runBrowserMode(options) {
646
765
  logger(line);
647
766
  }
648
767
  if (config.attachRunning) {
649
- const attached = await resolveAttachRunningConnection(config, logger);
768
+ const attached = await cancellation.call(() => resolveAttachRunningConnection(config, logger));
650
769
  config = {
651
770
  ...config,
652
771
  remoteChrome: { host: attached.host, port: attached.port },
@@ -656,7 +775,7 @@ export async function runBrowserMode(options) {
656
775
  }
657
776
  if (!config.remoteChrome && !config.manualLogin) {
658
777
  const preferredPort = config.debugPort ?? DEFAULT_DEBUG_PORT;
659
- const availablePort = await pickAvailableDebugPort(preferredPort, logger);
778
+ const availablePort = await cancellation.call(() => pickAvailableDebugPort(preferredPort, logger));
660
779
  if (availablePort !== preferredPort) {
661
780
  logger(`DevTools port ${preferredPort} busy; using ${availablePort} to avoid attaching to stray Chrome.`);
662
781
  }
@@ -669,7 +788,7 @@ export async function runBrowserMode(options) {
669
788
  if (ignoredFlags.length > 0) {
670
789
  logger(`Note: --remote-chrome ignores local Chrome flags (${ignoredFlags.join(", ")}).`);
671
790
  }
672
- return runRemoteBrowserMode(promptText, attachments, config, logger, options);
791
+ return runRemoteBrowserMode(promptText, attachments, config, logger, options, cancellation);
673
792
  }
674
793
  const manualLogin = Boolean(config.manualLogin);
675
794
  if (manualLogin && usingCopiedProfile) {
@@ -683,70 +802,82 @@ export async function runBrowserMode(options) {
683
802
  : defaultManualLoginProfileDir();
684
803
  const userDataDir = manualLogin
685
804
  ? manualProfileDir
686
- : await mkdtemp(path.join(await resolveUserDataBaseDir(), "oracle-browser-"));
805
+ : await cancellation.acquire(async () => mkdtemp(path.join(await resolveUserDataBaseDir(), "oracle-browser-")), (dir) => rm(dir, { recursive: true, force: true }));
687
806
  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)`);
807
+ try {
808
+ if (manualLogin) {
809
+ // Learned: manual login reuses a persistent profile so cookies/SSO survive.
810
+ await cancellation.call(() => mkdir(userDataDir, { recursive: true }));
811
+ logger(`Manual login mode enabled; reusing persistent profile at ${userDataDir}`);
812
+ await cancellation.call(() => assertManualLoginProfileReadyForRun({ userDataDir, keepBrowser: effectiveKeepBrowser }));
813
+ }
814
+ else if (config.copyProfileSource) {
815
+ const copying = copyChromeProfile(config.copyProfileSource, userDataDir, config.chromeProfile);
816
+ const copiedProfileDirectory = await cancellation.race(copying.finally(async () => {
817
+ if (options.signal?.aborted)
818
+ await withoutBrowserCancellation(() => rm(userDataDir, { recursive: true, force: true }));
819
+ }));
820
+ config = { ...config, chromeProfile: copiedProfileDirectory };
821
+ logger(`Seeded temporary Chrome profile ${copiedProfileDirectory} from ${config.copyProfileSource} (copy-profile mode; signed-in session reused without manual login)`);
822
+ }
823
+ else {
824
+ logger(`Created temporary Chrome profile at ${userDataDir}`);
825
+ }
701
826
  }
702
- else {
703
- logger(`Created temporary Chrome profile at ${userDataDir}`);
827
+ catch (error) {
828
+ if (!manualLogin)
829
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
830
+ throw error;
704
831
  }
705
832
  if (manualLogin) {
706
- tabLease = await acquireBrowserTabLease(userDataDir, {
833
+ tabLease = await cancellation.acquire(() => acquireBrowserTabLease(userDataDir, {
707
834
  maxConcurrentTabs: config.maxConcurrentTabs,
708
835
  timeoutMs: config.timeoutMs,
709
836
  logger,
710
837
  sessionId: options.sessionId,
711
- });
838
+ signal: options.signal,
839
+ }), (lease) => lease.release());
712
840
  }
713
841
  let acquiredChrome;
714
842
  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);
843
+ if (manualLogin) {
844
+ acquiredChrome = await cancellation.acquire(() => acquireManualLoginChromeForRun(userDataDir, config, logger, options.sessionId), async ({ chrome }) => {
845
+ detachKeptChromeProcess(chrome);
846
+ });
730
847
  }
731
- if (usingCopiedProfile) {
732
- await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
848
+ else {
849
+ const launched = await cancellation.acquire(() => launchChrome({ ...config, remoteChrome: config.remoteChrome }, userDataDir, logger), async (chrome) => {
850
+ try {
851
+ await chrome.kill();
852
+ }
853
+ finally {
854
+ await rm(userDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
855
+ }
856
+ });
857
+ acquiredChrome = { chrome: launched, reusedChrome: null };
733
858
  }
859
+ }
860
+ catch (error) {
861
+ await withoutBrowserCancellation(async () => {
862
+ if (tabLease) {
863
+ const handle = tabLease;
864
+ tabLease = null;
865
+ await handle.release().catch(() => undefined);
866
+ }
867
+ if (!manualLogin)
868
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
869
+ });
734
870
  throw error;
735
871
  }
736
872
  const { chrome, reusedChrome } = acquiredChrome;
737
873
  const chromeHost = chrome.host ?? "127.0.0.1";
738
- if (tabLease) {
739
- await tabLease.update({
740
- chromeHost,
741
- chromePort: chrome.port,
742
- });
743
- }
744
874
  let removeTerminationHooks = null;
745
875
  try {
746
876
  removeTerminationHooks = registerTerminationHooks(chrome, userDataDir, effectiveKeepBrowser, logger, {
747
877
  isInFlight: () => runStatus !== "complete",
748
878
  emitRuntimeHint,
749
879
  preserveUserDataDir: manualLogin,
880
+ preserveSharedChromeOnSignal: manualLogin,
750
881
  // copy-profile is a throwaway copy of a signed-in profile; never leave it on disk.
751
882
  forceProfileCleanup: usingCopiedProfile,
752
883
  });
@@ -768,14 +899,17 @@ export async function runBrowserMode(options) {
768
899
  let appliedCookies = 0;
769
900
  let preserveBrowserOnError = false;
770
901
  try {
902
+ if (tabLease)
903
+ await cancellation.call(() => tabLease.update({ chromeHost, chromePort: chrome.port }));
771
904
  try {
772
905
  if (config.browserTabRef) {
773
- const attached = await connectToExistingChatGptTab({
906
+ const tabRef = config.browserTabRef;
907
+ const attached = await cancellation.acquire(() => connectToExistingChatGptTab({
774
908
  host: chromeHost,
775
909
  port: chrome.port,
776
- ref: config.browserTabRef,
777
- });
778
- client = attached.client;
910
+ ref: tabRef,
911
+ }), (attached) => attached.client.close());
912
+ client = cancellation.client(attached.client);
779
913
  isolatedTargetId = attached.targetId ?? null;
780
914
  lastTargetId = attached.targetId ?? undefined;
781
915
  lastUrl = attached.tab.url || lastUrl;
@@ -785,14 +919,26 @@ export async function runBrowserMode(options) {
785
919
  else {
786
920
  const strictTabIsolation = Boolean(manualLogin && reusedChrome);
787
921
  const devtoolsRetries = manualLogin ? 6 : 0;
788
- const connection = await connectWithNewTab(chrome.port, logger, "about:blank", chromeHost, {
922
+ const connection = await cancellation.acquire(() => connectWithNewTab(chrome.port, logger, "about:blank", chromeHost, {
789
923
  fallbackToDefault: !strictTabIsolation,
790
924
  retries: devtoolsRetries,
791
925
  retryDelayMs: 500,
926
+ }), async (connection) => {
927
+ await connection.client.close().catch(() => undefined);
928
+ if (connection.targetId)
929
+ await closeTab(chrome.port, connection.targetId, logger, chromeHost);
792
930
  });
793
- client = connection.client;
931
+ client = cancellation.client(connection.client);
794
932
  isolatedTargetId = connection.targetId ?? null;
795
- ownsTarget = true;
933
+ ownsTarget = Boolean(connection.targetId);
934
+ if (connection.targetId && (!config.keepBrowser || options.closeOwnedTabOnComplete)) {
935
+ ownedRecoveryTarget = {
936
+ host: chromeHost,
937
+ port: chrome.port,
938
+ targetId: connection.targetId,
939
+ claimId: targetClaimId,
940
+ };
941
+ }
796
942
  }
797
943
  if (tabLease && isolatedTargetId) {
798
944
  await tabLease.update({
@@ -840,21 +986,30 @@ export async function runBrowserMode(options) {
840
986
  ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
841
987
  : undefined,
842
988
  promptSubmitted,
989
+ ownedRecoveryTarget,
843
990
  controllerPid: process.pid,
991
+ researchPlan,
844
992
  },
845
993
  }));
846
994
  })();
847
995
  });
848
996
  });
849
- const raceWithDisconnect = (promise) => Promise.race([promise, disconnectPromise]);
997
+ const raceWithDisconnect = (promise) => cancellation.race(Promise.race([promise, disconnectPromise]));
850
998
  const { Network, Page, Runtime, Input, DOM, Target } = client;
851
999
  const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
852
1000
  if (DOM && typeof DOM.enable === "function") {
853
1001
  domainEnablers.push(DOM.enable());
854
1002
  }
855
1003
  await Promise.all(domainEnablers);
1004
+ if (config.browserTabRef)
1005
+ await claimBrowserTarget(Runtime, targetClaimId);
856
1006
  if (!config.headless && config.hideWindow) {
857
- await positionChromeWindowOffscreen(client, logger);
1007
+ await positionChromeWindowOffscreen(client, userDataDir, logger);
1008
+ }
1009
+ else if (!config.headless) {
1010
+ // Persistent profiles can retain bounds from a prior hidden run. Visible
1011
+ // local runs must actively restore the Oracle-owned Chrome window.
1012
+ await positionChromeWindowOnscreen(client, userDataDir, logger);
858
1013
  }
859
1014
  // The send button is clicked with trusted CDP input events at viewport
860
1015
  // coordinates, which ChatGPT silently drops when the window is hidden or
@@ -1076,16 +1231,14 @@ export async function runBrowserMode(options) {
1076
1231
  : "Model picker: skipped (strategy=ignore)");
1077
1232
  }
1078
1233
  const deepResearch = config.researchMode === "deep";
1079
- // Handle thinking time selection if specified. Deep Research owns its own effort flow.
1080
- const thinkingTime = config.thinkingTime;
1081
- if (thinkingTime && !deepResearch) {
1234
+ if (shouldApplyThinkingTimeSelection(config)) {
1082
1235
  const thinkingTargetModel = modelStrategy === "select" ? config.desiredModel : null;
1083
- await raceWithDisconnect(withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger, thinkingTargetModel), {
1236
+ thinkingSelectionEvidence = await raceWithDisconnect(withRetries(() => ensureThinkingTime(Runtime, config.thinkingTime, logger, thinkingTargetModel), {
1084
1237
  retries: 2,
1085
1238
  delayMs: 300,
1086
1239
  onRetry: (attempt, error) => {
1087
1240
  if (options.verbose) {
1088
- logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
1241
+ logger(`[retry] Thinking time (${config.thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
1089
1242
  }
1090
1243
  },
1091
1244
  }));
@@ -1095,9 +1248,12 @@ export async function runBrowserMode(options) {
1095
1248
  const acquireProfileLockIfNeeded = async () => {
1096
1249
  if (profileLockTimeoutMs <= 0)
1097
1250
  return;
1098
- profileLock = await acquireProfileRunLock(userDataDir, {
1251
+ profileLock = await cancellation.acquire(() => acquireProfileRunLock(userDataDir, {
1099
1252
  timeoutMs: profileLockTimeoutMs,
1100
1253
  logger,
1254
+ signal: options.signal,
1255
+ }), async (lock) => {
1256
+ await lock?.release();
1101
1257
  });
1102
1258
  };
1103
1259
  const releaseProfileLockIfHeld = async () => {
@@ -1105,9 +1261,10 @@ export async function runBrowserMode(options) {
1105
1261
  return;
1106
1262
  const handle = profileLock;
1107
1263
  profileLock = null;
1108
- await handle.release().catch(() => undefined);
1264
+ await withoutBrowserCancellation(() => handle.release()).catch(() => undefined);
1109
1265
  };
1110
1266
  const submitOnce = async (prompt, submissionAttachments) => {
1267
+ await claimBrowserTarget(Runtime, targetClaimId);
1111
1268
  const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
1112
1269
  const baselineAssistantText = typeof baselineSnapshot?.text === "string" ? baselineSnapshot.text.trim() : "";
1113
1270
  const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
@@ -1116,17 +1273,20 @@ export async function runBrowserMode(options) {
1116
1273
  generatedBundle: a.generatedBundle === true,
1117
1274
  }));
1118
1275
  let inputOnlyAttachments = false;
1276
+ let attachmentNavigationUrl;
1119
1277
  await raceWithDisconnect(clearPromptComposer(Runtime, logger));
1120
1278
  await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
1121
1279
  if (submissionAttachments.length > 0) {
1122
1280
  if (!DOM) {
1123
1281
  throw new Error("Chrome DOM domain unavailable while uploading attachments.");
1124
1282
  }
1283
+ attachmentNavigationUrl = await raceWithDisconnect(captureComposerNavigationUrl(Runtime));
1125
1284
  await clearComposerAttachments(Runtime, 5_000, logger);
1126
1285
  for (let attachmentIndex = 0; attachmentIndex < submissionAttachments.length; attachmentIndex += 1) {
1127
1286
  const attachment = submissionAttachments[attachmentIndex];
1287
+ await raceWithDisconnect(assertComposerPlusStayedInPlace(Runtime, attachmentNavigationUrl));
1128
1288
  logger(`Uploading attachment: ${attachment.displayPath}`);
1129
- const uiConfirmed = await uploadAttachmentFile({ runtime: Runtime, dom: DOM, input: Input }, attachment, logger, { expectedCount: attachmentIndex + 1 });
1289
+ const uiConfirmed = await uploadAttachmentFile({ runtime: Runtime, dom: DOM, input: Input }, attachment, logger, { expectedCount: attachmentIndex + 1, navigationUrl: attachmentNavigationUrl });
1130
1290
  if (!uiConfirmed) {
1131
1291
  inputOnlyAttachments = true;
1132
1292
  }
@@ -1158,13 +1318,16 @@ export async function runBrowserMode(options) {
1158
1318
  const providerState = {
1159
1319
  runtime: Runtime,
1160
1320
  input: Input,
1321
+ page: Page,
1161
1322
  logger,
1162
1323
  timeoutMs: config.timeoutMs,
1163
1324
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
1164
1325
  attachmentTimeoutMs: config.attachmentTimeoutMs ?? undefined,
1165
1326
  baselineTurns: baselineTurns ?? undefined,
1166
1327
  attachmentNames: attachmentExpectations,
1328
+ attachmentNavigationUrl,
1167
1329
  onPromptSubmitted: markPromptSubmitted,
1330
+ webSearch: config.researchMode === "search",
1168
1331
  };
1169
1332
  const deepResearchTargetBaseline = deepResearch && client
1170
1333
  ? await captureDeepResearchTargetBaseline(client, logger)
@@ -1239,7 +1402,17 @@ export async function runBrowserMode(options) {
1239
1402
  }
1240
1403
  const imageArtifactMinTurnIndex = baselineTurns;
1241
1404
  if (deepResearch) {
1242
- await raceWithDisconnect(waitForResearchPlanAutoConfirm(Runtime, logger));
1405
+ await raceWithDisconnect(waitForResearchPlanAutoConfirm(Runtime, logger, undefined, {
1406
+ Page,
1407
+ client,
1408
+ ignoredTargetKeys: deepResearchTargetKeys,
1409
+ targetBaselineCaptured: deepResearchTargetBaselineCaptured,
1410
+ minTurnIndex: baselineTurns,
1411
+ onPlan: async (plan) => {
1412
+ researchPlan = plan;
1413
+ await emitRuntimeHint();
1414
+ },
1415
+ }));
1243
1416
  const researchResult = await raceWithDisconnect(waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client, {
1244
1417
  ignoredTargetKeys: deepResearchTargetKeys,
1245
1418
  targetBaselineCaptured: deepResearchTargetBaselineCaptured,
@@ -1278,6 +1451,7 @@ export async function runBrowserMode(options) {
1278
1451
  artifacts: savedArtifacts,
1279
1452
  archive,
1280
1453
  modelSelection: modelSelectionEvidence,
1454
+ thinkingSelection: thinkingSelectionEvidence,
1281
1455
  tookMs: durationMs,
1282
1456
  answerTokens: tokens,
1283
1457
  answerChars: researchResult.text.length,
@@ -1289,7 +1463,9 @@ export async function runBrowserMode(options) {
1289
1463
  tabUrl: lastUrl,
1290
1464
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1291
1465
  promptSubmitted,
1466
+ ownedRecoveryTarget,
1292
1467
  controllerPid: process.pid,
1468
+ researchPlan,
1293
1469
  };
1294
1470
  }
1295
1471
  // Helper to normalize text for echo detection (collapse whitespace, lowercase)
@@ -1302,6 +1478,7 @@ export async function runBrowserMode(options) {
1302
1478
  const deadline = Date.now() + timeoutMs;
1303
1479
  while (Date.now() < deadline) {
1304
1480
  const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
1481
+ throwIfAssistantUiError(snapshot);
1305
1482
  const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
1306
1483
  if (text) {
1307
1484
  const normalized = normalizeForComparison(text);
@@ -1378,6 +1555,7 @@ export async function runBrowserMode(options) {
1378
1555
  tabUrl: lastUrl,
1379
1556
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1380
1557
  promptSubmitted,
1558
+ ownedRecoveryTarget,
1381
1559
  controllerPid: process.pid,
1382
1560
  },
1383
1561
  });
@@ -1434,6 +1612,7 @@ export async function runBrowserMode(options) {
1434
1612
  tabUrl: lastUrl,
1435
1613
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1436
1614
  promptSubmitted,
1615
+ ownedRecoveryTarget,
1437
1616
  controllerPid: process.pid,
1438
1617
  };
1439
1618
  throw await createAssistantTimeoutError({
@@ -1529,6 +1708,7 @@ export async function runBrowserMode(options) {
1529
1708
  let stableCount = 0;
1530
1709
  while (Date.now() < deadline) {
1531
1710
  const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
1711
+ throwIfAssistantUiError(snapshot);
1532
1712
  const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
1533
1713
  const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
1534
1714
  if (!isStillEcho) {
@@ -1558,6 +1738,7 @@ export async function runBrowserMode(options) {
1558
1738
  let stableCycles = 0;
1559
1739
  while (Date.now() < deadline) {
1560
1740
  const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
1741
+ throwIfAssistantUiError(snapshot);
1561
1742
  const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
1562
1743
  if (text && text.length > bestText.length) {
1563
1744
  bestText = text;
@@ -1657,6 +1838,7 @@ export async function runBrowserMode(options) {
1657
1838
  tabUrl: lastUrl,
1658
1839
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1659
1840
  promptSubmitted,
1841
+ ownedRecoveryTarget,
1660
1842
  controllerPid: process.pid,
1661
1843
  },
1662
1844
  }),
@@ -1712,6 +1894,7 @@ export async function runBrowserMode(options) {
1712
1894
  savedFiles: fileArtifacts.savedFiles,
1713
1895
  archive,
1714
1896
  modelSelection: modelSelectionEvidence,
1897
+ thinkingSelection: thinkingSelectionEvidence,
1715
1898
  tookMs: durationMs,
1716
1899
  answerTokens,
1717
1900
  answerChars,
@@ -1723,10 +1906,15 @@ export async function runBrowserMode(options) {
1723
1906
  tabUrl: lastUrl,
1724
1907
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1725
1908
  promptSubmitted,
1909
+ ownedRecoveryTarget,
1726
1910
  controllerPid: process.pid,
1727
1911
  };
1728
1912
  }
1729
1913
  catch (error) {
1914
+ if (options.signal?.aborted || error instanceof BrowserRunCancelledError) {
1915
+ runStatus = "cancelled";
1916
+ throw new BrowserRunCancelledError();
1917
+ }
1730
1918
  const normalizedError = error instanceof Error ? error : new Error(String(error));
1731
1919
  const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError);
1732
1920
  connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed;
@@ -1745,6 +1933,7 @@ export async function runBrowserMode(options) {
1745
1933
  chromeTargetId: lastTargetId,
1746
1934
  tabUrl: lastUrl,
1747
1935
  promptSubmitted,
1936
+ ownedRecoveryTarget,
1748
1937
  controllerPid: process.pid,
1749
1938
  };
1750
1939
  const reuseProfileHint = `oracle --engine browser --browser-manual-login ` +
@@ -1808,155 +1997,161 @@ export async function runBrowserMode(options) {
1808
1997
  ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
1809
1998
  : undefined,
1810
1999
  promptSubmitted,
2000
+ ownedRecoveryTarget,
1811
2001
  controllerPid: process.pid,
2002
+ researchPlan,
1812
2003
  },
1813
2004
  }, normalizedError);
1814
2005
  }
1815
2006
  finally {
1816
- await conversationUrlMonitor?.stop();
1817
- try {
1818
- if (!connectionClosedUnexpectedly) {
1819
- await client?.close();
1820
- }
1821
- }
1822
- catch {
1823
- // ignore
1824
- }
1825
- // Close the isolated tab once the response has been fully captured to prevent
1826
- // tab accumulation across repeated runs. Keep the tab open on incomplete runs
1827
- // so reattach can recover the response.
1828
- const shouldCloseOwnedRunTarget = shouldCloseOwnedRunTargetAfterRun({
1829
- runStatus,
1830
- ownsTarget,
1831
- keepBrowser: effectiveKeepBrowser,
1832
- closeOwnedTabOnComplete: options.closeOwnedTabOnComplete,
1833
- });
1834
- let keepBrowserOpen = shouldKeepLocalBrowserOpen({
1835
- effectiveKeepBrowser,
1836
- preserveBrowserOnError,
1837
- usingCopiedProfile,
1838
- });
1839
- let cleanupProfileLock = null;
1840
- let terminatedRecordedChrome = false;
1841
- let otherActiveBrowserTabLeases = null;
1842
- const hasOtherActiveLeases = async () => {
1843
- if (!manualLogin || !tabLease) {
1844
- return false;
1845
- }
1846
- if (otherActiveBrowserTabLeases === null) {
1847
- otherActiveBrowserTabLeases = await hasOtherActiveBrowserTabLeases(userDataDir, tabLease.id);
1848
- }
1849
- return otherActiveBrowserTabLeases;
1850
- };
1851
- if (!keepBrowserOpen && manualLogin && tabLease) {
1852
- const cleanupLockTimeoutMs = Math.max(0, config.profileLockTimeoutMs ?? 0);
1853
- if (cleanupLockTimeoutMs > 0) {
1854
- cleanupProfileLock = await acquireProfileRunLock(userDataDir, {
1855
- timeoutMs: cleanupLockTimeoutMs,
1856
- logger,
1857
- sessionId: options.sessionId,
1858
- }).catch(() => null);
1859
- }
1860
- keepBrowserOpen = await hasOtherActiveLeases().catch(() => false);
1861
- if (keepBrowserOpen) {
1862
- logger("[browser] Other ChatGPT tab leases still active; leaving shared Chrome running.");
1863
- }
1864
- else if (reusedChrome && !connectionClosedUnexpectedly) {
1865
- terminatedRecordedChrome = await terminateRecordedChromeForProfile(userDataDir, logger).catch(() => false);
1866
- }
1867
- }
1868
- const closeOwnedRunTarget = async () => {
1869
- if (!shouldCloseOwnedRunTarget || !isolatedTargetId || !chrome?.port) {
1870
- return;
1871
- }
1872
- const safeToClose = !effectiveKeepBrowser ||
1873
- Boolean(await ensureChromePageTargetAfterClose(chrome.port, isolatedTargetId, logger, chromeHost));
1874
- if (!safeToClose) {
1875
- logger(`[browser] Leaving completed browser tab open because Chrome has no replacement page target.`);
1876
- return;
1877
- }
1878
- const closeConfirmed = await closeTab(chrome.port, isolatedTargetId, logger, chromeHost);
1879
- if (!closeConfirmed && effectiveKeepBrowser) {
1880
- const replacementTargetId = await createChromePageTarget(chrome.port, logger, chromeHost);
1881
- if (!replacementTargetId) {
1882
- logger(`[browser] Chrome page retention could not be verified after closing ${isolatedTargetId}.`);
2007
+ await withoutBrowserCancellation(async () => {
2008
+ stopThinkingMonitor?.();
2009
+ await conversationUrlMonitor?.stop();
2010
+ try {
2011
+ if (!connectionClosedUnexpectedly) {
2012
+ await client?.close();
1883
2013
  }
1884
2014
  }
1885
- };
1886
- const cleanupBlankTabs = async () => {
1887
- if (!shouldCleanupBlankTabsAfterLastLease({
2015
+ catch {
2016
+ // ignore
2017
+ }
2018
+ // Close the isolated tab once the response has been fully captured to prevent
2019
+ // tab accumulation across repeated runs. Keep the tab open on incomplete runs
2020
+ // so reattach can recover the response.
2021
+ const shouldCloseOwnedRunTarget = shouldCloseOwnedRunTargetAfterRun({
1888
2022
  runStatus,
1889
2023
  ownsTarget,
1890
- connectionClosedUnexpectedly,
1891
- manualLogin,
1892
2024
  keepBrowser: effectiveKeepBrowser,
1893
- chromePort: chrome?.port,
1894
- }) ||
1895
- !chrome?.port) {
1896
- return;
1897
- }
1898
- await closeBlankChromeTabs(chrome.port, logger, chromeHost, {
1899
- excludeTargetIds: [isolatedTargetId, lastTargetId],
1900
- preserveOneBlank: true,
2025
+ closeOwnedTabOnComplete: options.closeOwnedTabOnComplete,
2026
+ closeOwnedTabOnCancel: options.closeOwnedTabOnCancel,
1901
2027
  });
1902
- };
1903
- if (tabLease) {
1904
- const handle = tabLease;
1905
- tabLease = null;
1906
- const onRelease = async ({ isLastLease }) => {
1907
- await closeOwnedRunTarget();
1908
- if (isLastLease) {
1909
- await cleanupBlankTabs();
2028
+ let keepBrowserOpen = (manualLogin && runStatus === "cancelled") ||
2029
+ shouldKeepLocalBrowserOpen({
2030
+ effectiveKeepBrowser,
2031
+ preserveBrowserOnError,
2032
+ usingCopiedProfile,
2033
+ });
2034
+ let cleanupProfileLock = null;
2035
+ let browserTerminationHandledByLease = false;
2036
+ let tabLeaseReleaseError;
2037
+ if (!keepBrowserOpen && manualLogin && tabLease) {
2038
+ const cleanupLockTimeoutMs = Math.max(0, config.profileLockTimeoutMs ?? 0);
2039
+ if (cleanupLockTimeoutMs > 0) {
2040
+ cleanupProfileLock = await acquireProfileRunLock(userDataDir, {
2041
+ timeoutMs: cleanupLockTimeoutMs,
2042
+ logger,
2043
+ sessionId: options.sessionId,
2044
+ }).catch(() => null);
1910
2045
  }
1911
- };
1912
- await handle.release({ onRelease }).catch(() => undefined);
1913
- }
1914
- else {
1915
- await closeOwnedRunTarget();
1916
- await cleanupBlankTabs();
1917
- }
1918
- removeDialogHandler?.();
1919
- removeTerminationHooks?.();
1920
- if (!keepBrowserOpen) {
1921
- if (!connectionClosedUnexpectedly) {
1922
- try {
1923
- if (!terminatedRecordedChrome) {
1924
- await chrome.kill();
1925
- }
2046
+ }
2047
+ const closeOwnedRunTarget = async () => {
2048
+ if (!shouldCloseOwnedRunTarget || !isolatedTargetId || !chrome?.port) {
2049
+ return;
1926
2050
  }
1927
- catch {
1928
- // ignore kill failures
2051
+ const safeToClose = !keepBrowserOpen ||
2052
+ Boolean(await ensureChromePageTargetAfterClose(chrome.port, isolatedTargetId, logger, chromeHost));
2053
+ if (!safeToClose) {
2054
+ logger(`[browser] Leaving completed browser tab open because Chrome has no replacement page target.`);
2055
+ return;
1929
2056
  }
1930
- }
1931
- if (manualLogin) {
1932
- const shouldCleanup = await shouldCleanupManualLoginProfileState(userDataDir, logger.verbose ? logger : undefined, {
2057
+ const closeConfirmed = await closeTab(chrome.port, isolatedTargetId, logger, chromeHost);
2058
+ if (!closeConfirmed && keepBrowserOpen) {
2059
+ const replacementTargetId = await createChromePageTarget(chrome.port, logger, chromeHost);
2060
+ if (!replacementTargetId) {
2061
+ logger(`[browser] Chrome page retention could not be verified after closing ${isolatedTargetId}.`);
2062
+ }
2063
+ }
2064
+ };
2065
+ const cleanupBlankTabs = async () => {
2066
+ if (!shouldCleanupBlankTabsAfterLastLease({
2067
+ runStatus,
2068
+ ownsTarget,
1933
2069
  connectionClosedUnexpectedly,
1934
- host: chromeHost,
2070
+ manualLogin,
2071
+ keepBrowser: effectiveKeepBrowser,
2072
+ chromePort: chrome?.port,
2073
+ }) ||
2074
+ !chrome?.port) {
2075
+ return;
2076
+ }
2077
+ await closeBlankChromeTabs(chrome.port, logger, chromeHost, {
2078
+ excludeTargetIds: [isolatedTargetId, lastTargetId],
2079
+ preserveOneBlank: true,
2080
+ });
2081
+ };
2082
+ if (tabLease) {
2083
+ const handle = tabLease;
2084
+ tabLease = null;
2085
+ const terminateSharedChrome = !keepBrowserOpen && manualLogin && !connectionClosedUnexpectedly
2086
+ ? async () => terminateRecordedChromeForProfile(userDataDir, logger).catch(() => false)
2087
+ : undefined;
2088
+ const releaseResult = await releaseLocalBrowserTabLease({
2089
+ lease: handle,
2090
+ closeOwnedRunTarget,
2091
+ cleanupBlankTabs,
2092
+ terminateSharedChrome,
2093
+ sessionId: options.sessionId,
2094
+ chromePid: chrome.pid,
2095
+ chromePort: chrome.port,
2096
+ chromeTargetId: isolatedTargetId,
2097
+ launchDisposition: reusedChrome ? "reused" : "launched",
2098
+ logger,
1935
2099
  });
1936
- if (shouldCleanup) {
1937
- // Preserve the persistent manual-login profile, but clear stale reattach hints.
1938
- await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "never" }).catch(() => undefined);
2100
+ keepBrowserOpen ||= releaseResult.keepBrowserOpen;
2101
+ browserTerminationHandledByLease = releaseResult.terminationHandled;
2102
+ tabLeaseReleaseError = releaseResult.releaseError;
2103
+ }
2104
+ else {
2105
+ await closeOwnedRunTarget();
2106
+ await cleanupBlankTabs();
2107
+ }
2108
+ removeDialogHandler?.();
2109
+ removeTerminationHooks?.();
2110
+ if (!keepBrowserOpen) {
2111
+ if (!connectionClosedUnexpectedly) {
2112
+ try {
2113
+ if (!browserTerminationHandledByLease) {
2114
+ await chrome.kill();
2115
+ }
2116
+ }
2117
+ catch {
2118
+ // ignore kill failures
2119
+ }
2120
+ }
2121
+ if (manualLogin) {
2122
+ const shouldCleanup = await shouldCleanupManualLoginProfileState(userDataDir, logger.verbose ? logger : undefined, {
2123
+ connectionClosedUnexpectedly,
2124
+ host: chromeHost,
2125
+ });
2126
+ if (shouldCleanup) {
2127
+ // Preserve the persistent manual-login profile, but clear stale reattach hints.
2128
+ await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "never" }).catch(() => undefined);
2129
+ }
2130
+ }
2131
+ else {
2132
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
2133
+ }
2134
+ if (!connectionClosedUnexpectedly) {
2135
+ const totalSeconds = (Date.now() - startedAt) / 1000;
2136
+ logger(`Cleanup ${runStatus} • ${totalSeconds.toFixed(1)}s total`);
1939
2137
  }
1940
2138
  }
1941
2139
  else {
1942
- await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
2140
+ detachKeptChromeProcess(chrome);
2141
+ if (!connectionClosedUnexpectedly) {
2142
+ logger(`Chrome left running on port ${chrome.port} with profile ${userDataDir}`);
2143
+ }
1943
2144
  }
1944
- if (!connectionClosedUnexpectedly) {
1945
- const totalSeconds = (Date.now() - startedAt) / 1000;
1946
- logger(`Cleanup ${runStatus} • ${totalSeconds.toFixed(1)}s total`);
2145
+ if (cleanupProfileLock) {
2146
+ const handle = cleanupProfileLock;
2147
+ cleanupProfileLock = null;
2148
+ await handle.release().catch(() => undefined);
1947
2149
  }
1948
- }
1949
- else {
1950
- detachKeptChromeProcess(chrome);
1951
- if (!connectionClosedUnexpectedly) {
1952
- logger(`Chrome left running on port ${chrome.port} with profile ${userDataDir}`);
2150
+ if (tabLeaseReleaseError) {
2151
+ // 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.
2152
+ throw new Error("Failed to release the ChatGPT browser slot registry lock; restart Oracle/Codex MCP before another browser run.", { cause: tabLeaseReleaseError });
1953
2153
  }
1954
- }
1955
- if (cleanupProfileLock) {
1956
- const handle = cleanupProfileLock;
1957
- cleanupProfileLock = null;
1958
- await handle.release().catch(() => undefined);
1959
- }
2154
+ });
1960
2155
  }
1961
2156
  }
1962
2157
  const DEFAULT_DEBUG_PORT = 9222;
@@ -2190,7 +2385,7 @@ async function maybeReuseRunningChrome(userDataDir, logger, options = {}) {
2190
2385
  process: undefined,
2191
2386
  };
2192
2387
  }
2193
- async function runRemoteBrowserMode(promptText, attachments, config, logger, options) {
2388
+ async function runRemoteBrowserMode(promptText, attachments, config, logger, options, cancellation) {
2194
2389
  const remoteChromeConfig = config.remoteChrome;
2195
2390
  if (!remoteChromeConfig) {
2196
2391
  throw new Error("Remote Chrome configuration missing. Pass --remote-chrome <host:port> to use this mode.");
@@ -2202,7 +2397,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2202
2397
  let tabLease = null;
2203
2398
  let lastUrl;
2204
2399
  let promptSubmitted = false;
2400
+ let ownedRecoveryTarget;
2401
+ const targetClaimId = randomUUID();
2205
2402
  let modelSelectionEvidence;
2403
+ let thinkingSelectionEvidence;
2404
+ let researchPlan;
2206
2405
  let attachedExistingTab = false;
2207
2406
  let ownsTarget = true;
2208
2407
  let conversationUrlMonitor = null;
@@ -2220,7 +2419,9 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2220
2419
  tabUrl: lastUrl,
2221
2420
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2222
2421
  promptSubmitted,
2422
+ ownedRecoveryTarget,
2223
2423
  controllerPid: process.pid,
2424
+ researchPlan,
2224
2425
  }, modelSelectionEvidence);
2225
2426
  await tabLease?.update({
2226
2427
  chromeHost: host,
@@ -2259,22 +2460,24 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2259
2460
  : resolveRemoteTabLeaseProfileDir(config);
2260
2461
  if (remoteLeaseProfileDir) {
2261
2462
  await mkdir(remoteLeaseProfileDir, { recursive: true });
2262
- tabLease = await acquireBrowserTabLease(remoteLeaseProfileDir, {
2463
+ tabLease = await cancellation.acquire(() => acquireBrowserTabLease(remoteLeaseProfileDir, {
2263
2464
  maxConcurrentTabs: config.maxConcurrentTabs,
2264
2465
  timeoutMs: config.timeoutMs,
2265
2466
  logger,
2266
2467
  sessionId: options.sessionId,
2267
2468
  chromeHost: host,
2268
2469
  chromePort: port,
2269
- });
2470
+ signal: options.signal,
2471
+ }), (lease) => lease.release());
2270
2472
  }
2271
2473
  if (config.browserTabRef) {
2272
- const attached = await connectToExistingChatGptTab({
2474
+ const tabRef = config.browserTabRef;
2475
+ const attached = await cancellation.acquire(() => connectToExistingChatGptTab({
2273
2476
  host,
2274
2477
  port,
2275
- ref: config.browserTabRef,
2276
- });
2277
- client = attached.client;
2478
+ ref: tabRef,
2479
+ }), (attached) => attached.client.close());
2480
+ client = cancellation.client(attached.client);
2278
2481
  remoteTargetId = attached.targetId ?? null;
2279
2482
  lastUrl = attached.tab.url || lastUrl;
2280
2483
  attachedExistingTab = true;
@@ -2282,12 +2485,22 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2282
2485
  logger(`Attached to existing remote ChatGPT tab ${attached.targetId}${attached.tab.url ? ` (${attached.tab.url})` : ""}`);
2283
2486
  }
2284
2487
  else {
2285
- connection = await connectToRemoteChrome(host, port, logger, "about:blank", browserWSEndpoint, {
2286
- approvalWaitMs: config.attachRunning && browserWSEndpoint ? 20_000 : undefined,
2287
- });
2288
- client = connection.client;
2488
+ connection = await cancellation.acquire(() => connectToRemoteChrome(host, port, logger, "about:blank", browserWSEndpoint, {
2489
+ approvalWaitMs: browserWSEndpoint ? config.approvalWaitMs : undefined,
2490
+ fallbackToDefault: false,
2491
+ }), (connection) => connection.close());
2492
+ client = cancellation.client(connection.client);
2289
2493
  remoteTargetId = connection.targetId ?? null;
2290
- ownsTarget = true;
2494
+ ownsTarget = Boolean(connection.targetId);
2495
+ if (connection.targetId && (!config.keepBrowser || options.closeOwnedTabOnComplete)) {
2496
+ ownedRecoveryTarget = {
2497
+ host,
2498
+ port,
2499
+ targetId: connection.targetId,
2500
+ browserWSEndpoint,
2501
+ claimId: targetClaimId,
2502
+ };
2503
+ }
2291
2504
  }
2292
2505
  if (tabLease && remoteTargetId) {
2293
2506
  await tabLease.update({
@@ -2307,6 +2520,8 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2307
2520
  domainEnablers.push(DOM.enable());
2308
2521
  }
2309
2522
  await Promise.all(domainEnablers);
2523
+ if (config.browserTabRef)
2524
+ await claimBrowserTarget(Runtime, targetClaimId);
2310
2525
  removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
2311
2526
  await enableFocusEmulation(client, logger, "remote target");
2312
2527
  const activeConversationUrlMonitor = createConversationUrlMonitor({
@@ -2394,21 +2609,20 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2394
2609
  : "Model picker: skipped (strategy=ignore)");
2395
2610
  }
2396
2611
  const deepResearch = config.researchMode === "deep";
2397
- // Handle thinking time selection if specified. Deep Research owns its own effort flow.
2398
- const thinkingTime = config.thinkingTime;
2399
- if (thinkingTime && !deepResearch) {
2612
+ if (shouldApplyThinkingTimeSelection(config)) {
2400
2613
  const thinkingTargetModel = modelStrategy === "select" ? config.desiredModel : null;
2401
- await withRetries(() => ensureThinkingTime(Runtime, thinkingTime, logger, thinkingTargetModel), {
2614
+ thinkingSelectionEvidence = await withRetries(() => ensureThinkingTime(Runtime, config.thinkingTime, logger, thinkingTargetModel), {
2402
2615
  retries: 2,
2403
2616
  delayMs: 300,
2404
2617
  onRetry: (attempt, error) => {
2405
2618
  if (options.verbose) {
2406
- logger(`[retry] Thinking time (${thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
2619
+ logger(`[retry] Thinking time (${config.thinkingTime}) attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
2407
2620
  }
2408
2621
  },
2409
2622
  });
2410
2623
  }
2411
2624
  const submitOnce = async (prompt, submissionAttachments) => {
2625
+ await claimBrowserTarget(Runtime, targetClaimId);
2412
2626
  const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
2413
2627
  const baselineAssistantText = typeof baselineSnapshot?.text === "string" ? baselineSnapshot.text.trim() : "";
2414
2628
  const attachmentNames = submissionAttachments.map((a) => path.basename(a.path));
@@ -2416,17 +2630,20 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2416
2630
  name: path.basename(a.path),
2417
2631
  generatedBundle: a.generatedBundle === true,
2418
2632
  }));
2633
+ let attachmentNavigationUrl;
2419
2634
  await clearPromptComposer(Runtime, logger);
2420
2635
  await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
2421
2636
  if (submissionAttachments.length > 0) {
2422
2637
  if (!DOM) {
2423
2638
  throw new Error("Chrome DOM domain unavailable while uploading attachments.");
2424
2639
  }
2640
+ attachmentNavigationUrl = await captureComposerNavigationUrl(Runtime);
2425
2641
  await clearComposerAttachments(Runtime, 5_000, logger);
2426
2642
  // Use remote file transfer for remote Chrome (reads local files and injects via CDP)
2427
2643
  for (const attachment of submissionAttachments) {
2644
+ await assertComposerPlusStayedInPlace(Runtime, attachmentNavigationUrl);
2428
2645
  logger(`Uploading attachment: ${attachment.displayPath}`);
2429
- await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM }, attachment, logger);
2646
+ await uploadAttachmentViaDataTransfer({ runtime: Runtime, dom: DOM, navigationUrl: attachmentNavigationUrl }, attachment, logger);
2430
2647
  await delay(500);
2431
2648
  }
2432
2649
  // Scale timeout based on number of files: base 30s + 15s per additional file
@@ -2454,13 +2671,16 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2454
2671
  const providerState = {
2455
2672
  runtime: Runtime,
2456
2673
  input: Input,
2674
+ page: Page,
2457
2675
  logger,
2458
2676
  timeoutMs: config.timeoutMs,
2459
2677
  inputTimeoutMs: config.inputTimeoutMs ?? undefined,
2460
2678
  attachmentTimeoutMs: config.attachmentTimeoutMs ?? undefined,
2461
2679
  baselineTurns: baselineTurns ?? undefined,
2462
2680
  attachmentNames: attachmentExpectations,
2681
+ attachmentNavigationUrl,
2463
2682
  onPromptSubmitted: markPromptSubmitted,
2683
+ webSearch: config.researchMode === "search",
2464
2684
  };
2465
2685
  const deepResearchTargetBaseline = deepResearch && client
2466
2686
  ? await captureDeepResearchTargetBaseline(client, logger)
@@ -2511,7 +2731,17 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2511
2731
  deepResearchTargetBaselineCaptured = submission.deepResearchTargetBaselineCaptured ?? false;
2512
2732
  const imageArtifactMinTurnIndex = baselineTurns;
2513
2733
  if (deepResearch) {
2514
- await waitForResearchPlanAutoConfirm(Runtime, logger);
2734
+ await waitForResearchPlanAutoConfirm(Runtime, logger, undefined, {
2735
+ Page,
2736
+ client,
2737
+ ignoredTargetKeys: deepResearchTargetKeys,
2738
+ targetBaselineCaptured: deepResearchTargetBaselineCaptured,
2739
+ minTurnIndex: baselineTurns,
2740
+ onPlan: async (plan) => {
2741
+ researchPlan = plan;
2742
+ await emitRuntimeHint();
2743
+ },
2744
+ });
2515
2745
  const researchResult = await waitForDeepResearchCompletion(Runtime, logger, config.timeoutMs, baselineTurns, Page, client, {
2516
2746
  ignoredTargetKeys: deepResearchTargetKeys,
2517
2747
  targetBaselineCaptured: deepResearchTargetBaselineCaptured,
@@ -2550,6 +2780,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2550
2780
  artifacts: savedArtifacts,
2551
2781
  archive,
2552
2782
  modelSelection: modelSelectionEvidence,
2783
+ thinkingSelection: thinkingSelectionEvidence,
2553
2784
  tookMs: durationMs,
2554
2785
  answerTokens: tokens,
2555
2786
  answerChars: researchResult.text.length,
@@ -2559,7 +2790,9 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2559
2790
  tabUrl: lastUrl,
2560
2791
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2561
2792
  promptSubmitted,
2793
+ ownedRecoveryTarget,
2562
2794
  controllerPid: process.pid,
2795
+ researchPlan,
2563
2796
  };
2564
2797
  }
2565
2798
  // Helper to normalize text for echo detection (collapse whitespace, lowercase)
@@ -2572,6 +2805,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2572
2805
  const deadline = Date.now() + timeoutMs;
2573
2806
  while (Date.now() < deadline) {
2574
2807
  const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
2808
+ throwIfAssistantUiError(snapshot);
2575
2809
  const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
2576
2810
  if (text) {
2577
2811
  const normalized = normalizeForComparison(text);
@@ -2647,6 +2881,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2647
2881
  tabUrl: lastUrl,
2648
2882
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2649
2883
  promptSubmitted,
2884
+ ownedRecoveryTarget,
2650
2885
  controllerPid: process.pid,
2651
2886
  },
2652
2887
  });
@@ -2705,6 +2940,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2705
2940
  tabUrl: lastUrl,
2706
2941
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2707
2942
  promptSubmitted,
2943
+ ownedRecoveryTarget,
2708
2944
  controllerPid: process.pid,
2709
2945
  };
2710
2946
  throw await createAssistantTimeoutError({
@@ -2793,6 +3029,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2793
3029
  let stableCount = 0;
2794
3030
  while (Date.now() < deadline) {
2795
3031
  const snapshot = await readAssistantSnapshot(Runtime, baselineTurns ?? undefined, expectedConversationId()).catch(() => null);
3032
+ throwIfAssistantUiError(snapshot);
2796
3033
  const text = typeof snapshot?.text === "string" ? snapshot.text.trim() : "";
2797
3034
  const isStillEcho = !text || Boolean(promptEchoMatcher?.isEcho(text));
2798
3035
  if (!isStillEcho) {
@@ -2887,6 +3124,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2887
3124
  tabUrl: lastUrl,
2888
3125
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2889
3126
  promptSubmitted,
3127
+ ownedRecoveryTarget,
2890
3128
  controllerPid: process.pid,
2891
3129
  },
2892
3130
  }),
@@ -2949,6 +3187,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2949
3187
  tabUrl: lastUrl,
2950
3188
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2951
3189
  promptSubmitted,
3190
+ ownedRecoveryTarget,
2952
3191
  artifacts: savedArtifacts,
2953
3192
  generatedImages: imageArtifacts.generatedImages,
2954
3193
  savedImages: imageArtifacts.savedImages,
@@ -2956,10 +3195,15 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2956
3195
  savedFiles: fileArtifacts.savedFiles,
2957
3196
  archive,
2958
3197
  modelSelection: modelSelectionEvidence,
3198
+ thinkingSelection: thinkingSelectionEvidence,
2959
3199
  controllerPid: process.pid,
2960
3200
  };
2961
3201
  }
2962
3202
  catch (error) {
3203
+ if (options.signal?.aborted || error instanceof BrowserRunCancelledError) {
3204
+ runStatus = "cancelled";
3205
+ throw new BrowserRunCancelledError();
3206
+ }
2963
3207
  const normalizedError = error instanceof Error ? error : new Error(String(error));
2964
3208
  const socketClosed = connectionClosedUnexpectedly || isWebSocketClosureError(normalizedError);
2965
3209
  connectionClosedUnexpectedly = connectionClosedUnexpectedly || socketClosed;
@@ -2992,62 +3236,66 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2992
3236
  ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
2993
3237
  : undefined,
2994
3238
  promptSubmitted,
3239
+ ownedRecoveryTarget,
2995
3240
  controllerPid: process.pid,
3241
+ researchPlan,
2996
3242
  },
2997
3243
  });
2998
3244
  }
2999
3245
  finally {
3000
- await conversationUrlMonitor?.stop();
3001
- try {
3002
- await closeRemoteConnectionAfterRun({
3003
- connectionClosedUnexpectedly,
3004
- connection,
3005
- client,
3246
+ await withoutBrowserCancellation(async () => {
3247
+ stopThinkingMonitor?.();
3248
+ await conversationUrlMonitor?.stop();
3249
+ removeDialogHandler?.();
3250
+ const keepRemoteBrowser = Boolean(config.keepBrowser);
3251
+ const shouldCloseOwnedRemoteTarget = shouldCloseOwnedRunTargetAfterRun({
3006
3252
  runStatus,
3253
+ ownsTarget,
3254
+ keepBrowser: keepRemoteBrowser,
3255
+ closeOwnedTabOnComplete: options.closeOwnedTabOnComplete,
3256
+ closeOwnedTabOnCancel: options.closeOwnedTabOnCancel,
3007
3257
  });
3008
- }
3009
- catch {
3010
- // ignore
3011
- }
3012
- removeDialogHandler?.();
3013
- const keepRemoteBrowser = Boolean(config.keepBrowser);
3014
- const shouldCloseOwnedRemoteTarget = shouldCloseOwnedRunTargetAfterRun({
3015
- runStatus,
3016
- ownsTarget,
3017
- keepBrowser: keepRemoteBrowser,
3018
- closeOwnedTabOnComplete: options.closeOwnedTabOnComplete,
3019
- });
3020
- const closeOwnedRemoteTarget = async () => {
3021
- if (!shouldCloseOwnedRemoteTarget || !remoteTargetId) {
3022
- return;
3023
- }
3024
- const safeToClose = !keepRemoteBrowser ||
3025
- Boolean(await ensureChromePageTargetAfterClose(port, remoteTargetId, logger, host));
3026
- if (!safeToClose) {
3027
- logger(`[browser] Leaving completed remote browser tab open because Chrome has no replacement page target.`);
3028
- return;
3029
- }
3030
- const closeConfirmed = await closeTab(port, remoteTargetId, logger, host);
3031
- if (!closeConfirmed && keepRemoteBrowser) {
3032
- const replacementTargetId = await createChromePageTarget(port, logger, host);
3033
- if (!replacementTargetId) {
3034
- logger(`[browser] Remote Chrome page retention could not be verified after closing ${remoteTargetId}.`);
3258
+ const closeConnection = async () => {
3259
+ let preserveTarget = !shouldCloseOwnedRemoteTarget;
3260
+ if (!preserveTarget && keepRemoteBrowser && client && remoteTargetId) {
3261
+ try {
3262
+ const { targetInfos } = await client.Target.getTargets();
3263
+ if (!targetInfos.some((target) => target.type === "page" && target.targetId !== remoteTargetId)) {
3264
+ const replacement = await client.Target.createTarget({ url: "about:blank" });
3265
+ if (!replacement.targetId)
3266
+ preserveTarget = true;
3267
+ }
3268
+ }
3269
+ catch {
3270
+ preserveTarget = true;
3271
+ }
3035
3272
  }
3273
+ await closeRemoteConnectionAfterRun({
3274
+ connectionClosedUnexpectedly,
3275
+ connection,
3276
+ client,
3277
+ preserveTarget,
3278
+ });
3279
+ };
3280
+ if (tabLease) {
3281
+ const handle = tabLease;
3282
+ tabLease = null;
3283
+ await handle.release({ onRelease: closeConnection }).catch(async () => {
3284
+ await closeRemoteConnectionAfterRun({
3285
+ connectionClosedUnexpectedly,
3286
+ connection,
3287
+ client,
3288
+ preserveTarget: true,
3289
+ }).catch(() => undefined);
3290
+ });
3036
3291
  }
3037
- };
3038
- if (tabLease) {
3039
- const handle = tabLease;
3040
- tabLease = null;
3041
- await handle
3042
- .release({ onRelease: async () => closeOwnedRemoteTarget() })
3043
- .catch(() => undefined);
3044
- }
3045
- else {
3046
- await closeOwnedRemoteTarget();
3047
- }
3048
- // Don't kill remote Chrome - it's not ours to manage
3049
- const totalSeconds = (Date.now() - startedAt) / 1000;
3050
- logger(`Remote session complete • ${totalSeconds.toFixed(1)}s total`);
3292
+ else {
3293
+ await closeConnection().catch(() => undefined);
3294
+ }
3295
+ // Don't kill remote Chrome - it's not ours to manage
3296
+ const totalSeconds = (Date.now() - startedAt) / 1000;
3297
+ logger(`Remote session complete ${totalSeconds.toFixed(1)}s total`);
3298
+ });
3051
3299
  }
3052
3300
  }
3053
3301
  export { estimateTokenCount } from "./utils.js";
@@ -3066,10 +3314,13 @@ export const __test__ = {
3066
3314
  isImageOnlyUiChromeText,
3067
3315
  listIgnoredRemoteChromeFlags,
3068
3316
  normalizeAuthenticatedModelSelectionError,
3317
+ pollGeneratedImageOrTextAssistantResponse,
3069
3318
  resolveManualLoginWaitMs,
3319
+ shouldApplyThinkingTimeSelection,
3070
3320
  shouldCleanupBlankTabsAfterLastLease,
3071
3321
  shouldCloseOwnedRunTargetAfterRun,
3072
3322
  shouldKeepLocalBrowserOpen,
3323
+ releaseLocalBrowserTabLease,
3073
3324
  waitForAssistantResponseWithReload,
3074
3325
  };
3075
3326
  export { syncCookies } from "./cookies.js";