@steipete/oracle 0.15.0 → 0.15.2

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 (46) hide show
  1. package/dist/bin/oracle-cli.js +14 -6
  2. package/dist/docs-site/bridge.html +17 -1
  3. package/dist/docs-site/browser-mode.html +2 -2
  4. package/dist/docs-site/configuration.html +12 -2
  5. package/dist/docs-site/openai-endpoints.html +12 -0
  6. package/dist/scripts/test-browser.js +13 -2
  7. package/dist/src/browser/actions/assistantResponse.js +81 -50
  8. package/dist/src/browser/actions/attachments.js +31 -5
  9. package/dist/src/browser/actions/deepResearch.js +218 -73
  10. package/dist/src/browser/actions/modelSelection.js +30 -7
  11. package/dist/src/browser/actions/promptComposer.js +75 -19
  12. package/dist/src/browser/actions/thinkingStatus.js +19 -1
  13. package/dist/src/browser/artifacts.js +191 -6
  14. package/dist/src/browser/chatgptFiles.js +529 -98
  15. package/dist/src/browser/chatgptImages.js +3 -4
  16. package/dist/src/browser/chromeLifecycle.js +1 -0
  17. package/dist/src/browser/constants.js +6 -0
  18. package/dist/src/browser/conversationTurns.js +16 -0
  19. package/dist/src/browser/conversationUrlMonitor.js +64 -0
  20. package/dist/src/browser/cookies.js +72 -0
  21. package/dist/src/browser/index.js +103 -94
  22. package/dist/src/browser/projectSourcesRunner.js +3 -2
  23. package/dist/src/browser/reattach.js +27 -11
  24. package/dist/src/browser/reattachHelpers.js +14 -5
  25. package/dist/src/browser/sessionRunner.js +9 -3
  26. package/dist/src/cli/bridge/client.js +4 -1
  27. package/dist/src/cli/bridge/doctor.js +19 -0
  28. package/dist/src/cli/runOptions.js +11 -2
  29. package/dist/src/cli/sessionDisplay.js +6 -1
  30. package/dist/src/cli/sessionRunner.js +28 -10
  31. package/dist/src/config.js +3 -0
  32. package/dist/src/oracle/client.js +2 -0
  33. package/dist/src/oracle/modelResolver.js +85 -0
  34. package/dist/src/oracle/multiModelRunner.js +4 -1
  35. package/dist/src/oracle/oscProgress.js +3 -2
  36. package/dist/src/oracle/run.js +4 -1
  37. package/dist/src/remote/client.js +253 -22
  38. package/dist/src/remote/health.js +27 -0
  39. package/dist/src/remote/server.js +239 -4
  40. package/dist/src/remote/types.js +1 -1
  41. package/dist/src/sessionManager.js +1 -0
  42. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  43. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  44. package/package.json +20 -20
  45. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  46. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
- import { ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR } from "./constants.js";
4
+ import { ASSISTANT_ROLE_SELECTOR } from "./constants.js";
5
+ import { buildConversationTurnListExpression } from "./conversationTurns.js";
5
6
  import { delay } from "./utils.js";
6
7
  import { readAssistantSnapshot } from "./pageActions.js";
7
8
  import { getOracleHomeDir } from "../oracleHome.js";
@@ -61,11 +62,9 @@ function buildAssistantImageExpression(minTurnIndex) {
61
62
  const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
62
63
  ? Math.floor(minTurnIndex)
63
64
  : -1;
64
- const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
65
65
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
66
66
  return `(() => {
67
67
  const MIN_TURN_INDEX = ${minTurnLiteral};
68
- const CONVERSATION_SELECTOR = ${conversationLiteral};
69
68
  const ASSISTANT_SELECTOR = ${assistantLiteral};
70
69
  const isGeneratedImage = (img) => {
71
70
  const url = new URL(img?.src || '', location.origin || 'https://chatgpt.com');
@@ -101,7 +100,7 @@ function buildAssistantImageExpression(minTurnIndex) {
101
100
  if (testId.includes('assistant')) return true;
102
101
  return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
103
102
  };
104
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
103
+ const turns = ${buildConversationTurnListExpression()};
105
104
  for (let index = turns.length - 1; index >= 0; index -= 1) {
106
105
  const turn = turns[index];
107
106
  if (!isAssistantTurn(turn)) continue;
@@ -367,6 +367,7 @@ function createSessionBoundChromeClient(browser, sessionId) {
367
367
  Runtime: bindDomain("Runtime"),
368
368
  Input: bindDomain("Input"),
369
369
  DOM: bindDomain("DOM"),
370
+ Emulation: bindDomain("Emulation"),
370
371
  on: browserWithEvents.on.bind(browserWithEvents),
371
372
  once: browserWithEvents.once.bind(browserWithEvents),
372
373
  off: browserWithEvents.off?.bind(browserWithEvents) ??
@@ -32,6 +32,7 @@ export const ANSWER_SELECTORS = [
32
32
  export const CONVERSATION_TURN_SELECTOR = 'article[data-testid^="conversation-turn"], div[data-testid^="conversation-turn"], section[data-testid^="conversation-turn"], ' +
33
33
  "article[data-message-author-role], div[data-message-author-role], section[data-message-author-role], " +
34
34
  "article[data-turn], div[data-turn], section[data-turn]";
35
+ export const CONVERSATION_TURN_CONTAINER_SELECTOR = '[data-testid^="conversation-turn"]';
35
36
  export const ASSISTANT_ROLE_SELECTOR = '[data-message-author-role="assistant"], [data-turn="assistant"]';
36
37
  export const CLOUDFLARE_SCRIPT_SELECTOR = 'script[src*="/challenge-platform/"]';
37
38
  export const CLOUDFLARE_TITLE = "just a moment";
@@ -63,6 +64,11 @@ export const UPLOAD_STATUS_SELECTORS = [
63
64
  '[aria-live="assertive"]',
64
65
  ];
65
66
  export const STOP_BUTTON_SELECTOR = '[data-testid="stop-button"]';
67
+ export const STOP_BUTTON_SELECTORS = [
68
+ STOP_BUTTON_SELECTOR,
69
+ '[data-testid="composer-stop-button"]',
70
+ 'button[aria-label*="stop" i]',
71
+ ];
66
72
  export const SEND_BUTTON_SELECTORS = [
67
73
  'button[data-testid="send-button"]',
68
74
  'button[data-testid*="composer-send"]',
@@ -0,0 +1,16 @@
1
+ import { CONVERSATION_TURN_CONTAINER_SELECTOR, CONVERSATION_TURN_SELECTOR } from "./constants.js";
2
+ /** Build a browser-context expression that returns one DOM node per conversation turn. */
3
+ export function buildConversationTurnListExpression(rootExpression = "document") {
4
+ const containerSelector = JSON.stringify(CONVERSATION_TURN_CONTAINER_SELECTOR);
5
+ const fallbackSelector = JSON.stringify(CONVERSATION_TURN_SELECTOR);
6
+ return `(() => {
7
+ const root = ${rootExpression};
8
+ const containers = Array.from(root.querySelectorAll(${containerSelector}));
9
+ return containers.length > 0
10
+ ? containers
11
+ : Array.from(root.querySelectorAll(${fallbackSelector}));
12
+ })()`;
13
+ }
14
+ export function buildConversationTurnCountExpression(rootExpression = "document") {
15
+ return `(${buildConversationTurnListExpression(rootExpression)}).length`;
16
+ }
@@ -0,0 +1,64 @@
1
+ import { delay } from "./utils.js";
2
+ export function createConversationUrlMonitor(options) {
3
+ const pollIntervalMs = options.pollIntervalMs ?? 250;
4
+ const wait = options.wait ?? delay;
5
+ const now = options.now ?? Date.now;
6
+ let inFlight = null;
7
+ let stopped = false;
8
+ const activePersists = new Set();
9
+ const update = async (label, timeoutMs = 10_000) => {
10
+ const startedAt = now();
11
+ while (!stopped && now() - startedAt < timeoutMs) {
12
+ try {
13
+ const url = await options.readUrl();
14
+ if (stopped) {
15
+ return false;
16
+ }
17
+ if (url && isConversationUrl(url)) {
18
+ options.logger(`[browser] conversation url (${label}) = ${url}`);
19
+ const persist = options.persistUrl(url);
20
+ activePersists.add(persist);
21
+ try {
22
+ await persist;
23
+ }
24
+ finally {
25
+ activePersists.delete(persist);
26
+ }
27
+ return true;
28
+ }
29
+ }
30
+ catch {
31
+ // The page can navigate or disconnect between polls; keep trying until timeout.
32
+ }
33
+ await wait(pollIntervalMs);
34
+ }
35
+ return false;
36
+ };
37
+ const schedule = (label, timeoutMs) => {
38
+ if (stopped) {
39
+ return Promise.resolve(false);
40
+ }
41
+ if (inFlight) {
42
+ return inFlight;
43
+ }
44
+ // The /c/ URL can appear after submit. Persist it without blocking response capture.
45
+ inFlight = update(label, timeoutMs)
46
+ .catch(() => false)
47
+ .finally(() => {
48
+ inFlight = null;
49
+ });
50
+ return inFlight;
51
+ };
52
+ return {
53
+ update,
54
+ schedule,
55
+ isInFlight: () => inFlight !== null,
56
+ stop: async () => {
57
+ stopped = true;
58
+ await Promise.allSettled(activePersists);
59
+ },
60
+ };
61
+ }
62
+ function isConversationUrl(url) {
63
+ return /\/c\/[a-z0-9-]+/i.test(url);
64
+ }
@@ -3,6 +3,56 @@ import { delay } from "./utils.js";
3
3
  import { getCookies } from "@steipete/sweet-cookie";
4
4
  export class ChromeCookieSyncError extends Error {
5
5
  }
6
+ export async function clearStaleChatGptConversationCookies(Network, Target, logger, options = {}) {
7
+ try {
8
+ const preservedNames = new Set((options.preserveConversationIds ?? [])
9
+ .filter((id) => Boolean(id))
10
+ .map((id) => `conv_key_${id}`));
11
+ try {
12
+ const { targetInfos = [] } = await Target.getTargets();
13
+ for (const target of targetInfos) {
14
+ const conversationId = extractChatGptConversationId(target.url ?? "");
15
+ if (conversationId) {
16
+ preservedNames.add(`conv_key_${conversationId}`);
17
+ }
18
+ }
19
+ }
20
+ catch (error) {
21
+ const message = error instanceof Error ? error.message : String(error);
22
+ logger(`[cookies] Failed to inspect active ChatGPT conversations; skipping stale cookie cleanup: ${message}`);
23
+ return 0;
24
+ }
25
+ const { cookies = [] } = await Network.getAllCookies();
26
+ const targets = cookies.filter((cookie) => isChatGptConversationCookie(cookie) && !preservedNames.has(String(cookie.name ?? "")));
27
+ if (targets.length === 0) {
28
+ return 0;
29
+ }
30
+ let deleted = 0;
31
+ for (const cookie of targets) {
32
+ try {
33
+ await Network.deleteCookies({
34
+ name: cookie.name,
35
+ domain: cookie.domain,
36
+ path: cookie.path ?? "/",
37
+ });
38
+ deleted += 1;
39
+ }
40
+ catch (error) {
41
+ const message = error instanceof Error ? error.message : String(error);
42
+ logger(`[cookies] Failed to clear a stale ChatGPT conversation cookie: ${message}`);
43
+ }
44
+ }
45
+ if (deleted > 0) {
46
+ logger(`[cookies] Cleared ${deleted} stale ChatGPT conversation cookie${deleted === 1 ? "" : "s"}.`);
47
+ }
48
+ return deleted;
49
+ }
50
+ catch (error) {
51
+ const message = error instanceof Error ? error.message : String(error);
52
+ logger(`[cookies] Failed to inspect ChatGPT conversation cookies: ${message}`);
53
+ return 0;
54
+ }
55
+ }
6
56
  export async function syncCookies(Network, url, profile, logger, options = {}) {
7
57
  const { allowErrors = false, filterNames, inlineCookies, cookiePath, waitMs = 0 } = options;
8
58
  try {
@@ -94,6 +144,28 @@ async function readChromeCookies(url, profile, filterNames, cookiePath) {
94
144
  }
95
145
  return Array.from(merged.values());
96
146
  }
147
+ function isChatGptConversationCookie(cookie) {
148
+ if (!cookie.name?.startsWith("conv_key_")) {
149
+ return false;
150
+ }
151
+ const domain = String(cookie.domain ?? "")
152
+ .replace(/^\./, "")
153
+ .toLowerCase();
154
+ return domain === "chatgpt.com" || domain === "chat.openai.com";
155
+ }
156
+ function extractChatGptConversationId(url) {
157
+ try {
158
+ const parsed = new URL(url);
159
+ const domain = parsed.hostname.toLowerCase();
160
+ if (domain !== "chatgpt.com" && domain !== "chat.openai.com") {
161
+ return undefined;
162
+ }
163
+ return parsed.pathname.match(/\/c\/([a-zA-Z0-9-]+)/)?.[1];
164
+ }
165
+ catch {
166
+ return undefined;
167
+ }
168
+ }
97
169
  function normalizeInlineCookies(rawCookies, fallbackHost) {
98
170
  const merged = new Map();
99
171
  for (const cookie of rawCookies) {
@@ -5,7 +5,7 @@ import net from "node:net";
5
5
  import { resolveBrowserConfig } from "./config.js";
6
6
  import { copyChromeProfile } from "./profileCopy.js";
7
7
  import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToRemoteChrome, connectWithNewTab, closeTab, closeRemoteChromeTarget, closeBlankChromeTabs, } from "./chromeLifecycle.js";
8
- import { syncCookies } from "./cookies.js";
8
+ import { clearStaleChatGptConversationCookies, syncCookies } from "./cookies.js";
9
9
  import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js";
10
10
  import { INPUT_SELECTORS } from "./constants.js";
11
11
  import { uploadAttachmentViaDataTransfer } from "./actions/remoteFileTransfer.js";
@@ -14,9 +14,10 @@ import { startThinkingStatusMonitor } from "./actions/thinkingStatus.js";
14
14
  import { activateDeepResearch, captureDeepResearchTargetKeys, waitForDeepResearchCompletion, waitForResearchPlanAutoConfirm, } from "./actions/deepResearch.js";
15
15
  import { estimateTokenCount, withRetries, delay } from "./utils.js";
16
16
  import { formatElapsed } from "../oracle/format.js";
17
- import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR, DEFAULT_MODEL_STRATEGY } from "./constants.js";
17
+ import { CHATGPT_URL, DEFAULT_MODEL_STRATEGY } from "./constants.js";
18
18
  import { BrowserAutomationError } from "../oracle/errors.js";
19
19
  import { alignPromptEchoPair, buildPromptEchoMatcher } from "./reattachHelpers.js";
20
+ import { buildConversationTurnCountExpression } from "./conversationTurns.js";
20
21
  import { cleanupStaleProfileState, acquireProfileRunLock, findRunningChromeDebugTargetForProfile, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, terminateRecordedChromeForProfile, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from "./profileState.js";
21
22
  import { acquireBrowserTabLease, hasOtherActiveBrowserTabLeases, } from "./tabLeaseRegistry.js";
22
23
  import { appendArtifacts, saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "./artifacts.js";
@@ -30,6 +31,7 @@ import { captureBrowserDiagnostics } from "./domDebug.js";
30
31
  import { archiveChatGptConversation, resolveBrowserArchiveDecision, } from "./actions/archiveConversation.js";
31
32
  import { assertManualLoginProfileReadyForRun, defaultManualLoginProfileDir, formatManualLoginSetupCommand, isManualLoginProfileInitialized, resolveManualLoginWaitMs, } from "./manualLoginProfile.js";
32
33
  import { describeBrowserControlPlan, formatBrowserControlPlan } from "./controlPlan.js";
34
+ import { createConversationUrlMonitor, } from "./conversationUrlMonitor.js";
33
35
  export { CHATGPT_URL, DEFAULT_MODEL_STRATEGY, DEFAULT_MODEL_TARGET } from "./constants.js";
34
36
  export { parseDuration, delay, normalizeChatgptUrl, isTemporaryChatUrl } from "./utils.js";
35
37
  export { formatThinkingLog, formatThinkingWaitingLog, buildThinkingStatusExpressionForTest, readThinkingStatusForTest, sanitizeThinkingText, startThinkingStatusMonitorForTest, } from "./actions/thinkingStatus.js";
@@ -558,7 +560,9 @@ export async function runBrowserMode(options) {
558
560
  let lastTargetId;
559
561
  let lastUrl;
560
562
  let promptSubmitted = false;
563
+ let modelSelectionEvidence;
561
564
  let tabLease = null;
565
+ let conversationUrlMonitor = null;
562
566
  const emitRuntimeHint = async () => {
563
567
  if (!chrome?.port) {
564
568
  return;
@@ -576,7 +580,7 @@ export async function runBrowserMode(options) {
576
580
  controllerPid: process.pid,
577
581
  };
578
582
  try {
579
- await runtimeHintCb?.(hint);
583
+ await runtimeHintCb?.(hint, modelSelectionEvidence);
580
584
  await tabLease?.update({
581
585
  chromeHost,
582
586
  chromePort: chrome.port,
@@ -595,6 +599,7 @@ export async function runBrowserMode(options) {
595
599
  }
596
600
  promptSubmitted = true;
597
601
  await emitRuntimeHint();
602
+ void conversationUrlMonitor?.schedule("post-submit", config.timeoutMs ?? 120_000);
598
603
  };
599
604
  if (config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") {
600
605
  logger(`[browser-mode] config: ${JSON.stringify({
@@ -722,7 +727,6 @@ export async function runBrowserMode(options) {
722
727
  let answerMarkdown = "";
723
728
  let answerHtml = "";
724
729
  let runStatus = "attempted";
725
- let modelSelectionEvidence;
726
730
  let connectionClosedUnexpectedly = false;
727
731
  let stopThinkingMonitor = null;
728
732
  let removeDialogHandler = null;
@@ -746,7 +750,7 @@ export async function runBrowserMode(options) {
746
750
  else {
747
751
  const strictTabIsolation = Boolean(manualLogin && reusedChrome);
748
752
  const devtoolsRetries = manualLogin ? 6 : 0;
749
- const connection = await connectWithNewTab(chrome.port, logger, config.url, chromeHost, {
753
+ const connection = await connectWithNewTab(chrome.port, logger, "about:blank", chromeHost, {
750
754
  fallbackToDefault: !strictTabIsolation,
751
755
  retries: devtoolsRetries,
752
756
  retryDelayMs: 500,
@@ -778,7 +782,7 @@ export async function runBrowserMode(options) {
778
782
  });
779
783
  });
780
784
  const raceWithDisconnect = (promise) => Promise.race([promise, disconnectPromise]);
781
- const { Network, Page, Runtime, Input, DOM } = client;
785
+ const { Network, Page, Runtime, Input, DOM, Target } = client;
782
786
  if (!config.headless && config.hideWindow) {
783
787
  await hideChromeWindow(chrome, logger);
784
788
  }
@@ -828,6 +832,12 @@ export async function runBrowserMode(options) {
828
832
  ? "Skipping Chrome cookie sync (--browser-manual-login enabled); reuse the opened profile after signing in."
829
833
  : "Skipping Chrome cookie sync (--browser-no-cookie-sync)");
830
834
  }
835
+ await clearStaleChatGptConversationCookies(Network, Target, logger, {
836
+ preserveConversationIds: [
837
+ extractConversationIdFromUrl(config.resumeConversationUrl ?? ""),
838
+ extractConversationIdFromUrl(lastUrl ?? ""),
839
+ ],
840
+ });
831
841
  if (cookieSyncEnabled && !manualLogin && (appliedCookies ?? 0) === 0 && !config.inlineCookies) {
832
842
  // Learned: if the profile has no ChatGPT cookies, browser mode will just bounce to login.
833
843
  // Fail early so the user knows to sign in.
@@ -939,44 +949,22 @@ export async function runBrowserMode(options) {
939
949
  await emitRuntimeHint();
940
950
  }
941
951
  };
942
- let conversationHintInFlight = null;
943
- const updateConversationHint = async (label, timeoutMs = 10_000) => {
944
- if (!chrome?.port) {
945
- return false;
946
- }
947
- const start = Date.now();
948
- while (Date.now() - start < timeoutMs) {
949
- try {
950
- const { result } = await Runtime.evaluate({
951
- expression: "location.href",
952
- returnByValue: true,
953
- });
954
- if (typeof result?.value === "string" && result.value.includes("/c/")) {
955
- lastUrl = result.value;
956
- logger(`[browser] conversation url (${label}) = ${lastUrl}`);
957
- await emitRuntimeHint();
958
- return true;
959
- }
960
- }
961
- catch {
962
- // ignore; keep polling until timeout
963
- }
964
- await delay(250);
965
- }
966
- return false;
967
- };
968
- const scheduleConversationHint = (label, timeoutMs) => {
969
- if (conversationHintInFlight) {
970
- return;
971
- }
972
- // Learned: the /c/ URL can update after the answer; emit hints in the background.
973
- // Run in the background so prompt submission/streaming isn't blocked by slow URL updates.
974
- conversationHintInFlight = updateConversationHint(label, timeoutMs)
975
- .catch(() => false)
976
- .finally(() => {
977
- conversationHintInFlight = null;
978
- });
979
- };
952
+ const activeConversationUrlMonitor = createConversationUrlMonitor({
953
+ readUrl: async () => {
954
+ const { result } = await Runtime.evaluate({
955
+ expression: "location.href",
956
+ returnByValue: true,
957
+ });
958
+ return typeof result?.value === "string" ? result.value : null;
959
+ },
960
+ persistUrl: async (url) => {
961
+ lastUrl = url;
962
+ await emitRuntimeHint();
963
+ },
964
+ logger,
965
+ });
966
+ conversationUrlMonitor = activeConversationUrlMonitor;
967
+ const updateConversationHint = conversationUrlMonitor.update;
980
968
  await captureRuntimeSnapshot();
981
969
  const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
982
970
  if (config.desiredModel && modelStrategy !== "ignore" && !isResumingConversation) {
@@ -1019,19 +1007,6 @@ export async function runBrowserMode(options) {
1019
1007
  },
1020
1008
  }));
1021
1009
  }
1022
- if (deepResearch) {
1023
- await raceWithDisconnect(withRetries(() => activateDeepResearch(Runtime, Input, logger), {
1024
- retries: 2,
1025
- delayMs: 500,
1026
- onRetry: (attempt, error) => {
1027
- if (options.verbose) {
1028
- logger(`[retry] Deep Research activation attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
1029
- }
1030
- },
1031
- }));
1032
- await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
1033
- logger(`Prompt textarea ready (after Deep Research activation, ${promptText.length.toLocaleString()} chars queued)`);
1034
- }
1035
1010
  const profileLockTimeoutMs = manualLogin ? (config.profileLockTimeoutMs ?? 0) : 0;
1036
1011
  let profileLock = null;
1037
1012
  const acquireProfileLockIfNeeded = async () => {
@@ -1082,6 +1057,19 @@ export async function runBrowserMode(options) {
1082
1057
  await waitForAttachmentCompletion(Runtime, attachmentWaitBudget, attachmentNames, logger);
1083
1058
  logger("All attachments uploaded");
1084
1059
  }
1060
+ if (deepResearch) {
1061
+ await raceWithDisconnect(withRetries(() => activateDeepResearch(Runtime, Input, logger), {
1062
+ retries: 2,
1063
+ delayMs: 500,
1064
+ onRetry: (attempt, error) => {
1065
+ if (options.verbose) {
1066
+ logger(`[retry] Deep Research activation attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
1067
+ }
1068
+ },
1069
+ }));
1070
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
1071
+ logger(`Prompt textarea ready (after Deep Research activation, ${prompt.length.toLocaleString()} chars queued)`);
1072
+ }
1085
1073
  let baselineTurns = await readConversationTurnCount(Runtime, logger);
1086
1074
  // Learned: return baselineTurns so assistant polling can ignore earlier content.
1087
1075
  const providerState = {
@@ -1128,8 +1116,6 @@ export async function runBrowserMode(options) {
1128
1116
  }
1129
1117
  }
1130
1118
  }
1131
- // Reattach needs a /c/ URL; ChatGPT can update it late, so poll in the background.
1132
- scheduleConversationHint("post-submit", config.timeoutMs ?? 120_000);
1133
1119
  return {
1134
1120
  baselineTurns,
1135
1121
  baselineAssistantText,
@@ -1725,6 +1711,7 @@ export async function runBrowserMode(options) {
1725
1711
  }, normalizedError);
1726
1712
  }
1727
1713
  finally {
1714
+ await conversationUrlMonitor?.stop();
1728
1715
  try {
1729
1716
  if (!connectionClosedUnexpectedly) {
1730
1717
  await client?.close();
@@ -2083,8 +2070,10 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2083
2070
  let tabLease = null;
2084
2071
  let lastUrl;
2085
2072
  let promptSubmitted = false;
2073
+ let modelSelectionEvidence;
2086
2074
  let attachedExistingTab = false;
2087
2075
  let ownsTarget = true;
2076
+ let conversationUrlMonitor = null;
2088
2077
  const runtimeHintCb = options.runtimeHintCb;
2089
2078
  const emitRuntimeHint = async () => {
2090
2079
  if (!runtimeHintCb)
@@ -2100,7 +2089,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2100
2089
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2101
2090
  promptSubmitted,
2102
2091
  controllerPid: process.pid,
2103
- });
2092
+ }, modelSelectionEvidence);
2104
2093
  await tabLease?.update({
2105
2094
  chromeHost: host,
2106
2095
  chromePort: port,
@@ -2119,6 +2108,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2119
2108
  }
2120
2109
  promptSubmitted = true;
2121
2110
  await emitRuntimeHint();
2111
+ void conversationUrlMonitor?.schedule("post-submit", config.timeoutMs ?? 120_000);
2122
2112
  };
2123
2113
  const startedAt = Date.now();
2124
2114
  let answerText = "";
@@ -2126,7 +2116,6 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2126
2116
  let answerHtml = "";
2127
2117
  let connectionClosedUnexpectedly = false;
2128
2118
  let runStatus = "attempted";
2129
- let modelSelectionEvidence;
2130
2119
  let stopThinkingMonitor = null;
2131
2120
  let removeDialogHandler = null;
2132
2121
  let connection = null;
@@ -2161,7 +2150,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2161
2150
  logger(`Attached to existing remote ChatGPT tab ${attached.targetId}${attached.tab.url ? ` (${attached.tab.url})` : ""}`);
2162
2151
  }
2163
2152
  else {
2164
- connection = await connectToRemoteChrome(host, port, logger, config.url, browserWSEndpoint, {
2153
+ connection = await connectToRemoteChrome(host, port, logger, "about:blank", browserWSEndpoint, {
2165
2154
  approvalWaitMs: config.attachRunning && browserWSEndpoint ? 20_000 : undefined,
2166
2155
  });
2167
2156
  client = connection.client;
@@ -2180,15 +2169,44 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2180
2169
  connectionClosedUnexpectedly = true;
2181
2170
  };
2182
2171
  client.on("disconnect", markConnectionLost);
2183
- const { Network, Page, Runtime, Input, DOM } = client;
2172
+ const { Network, Page, Runtime, Input, DOM, Target } = client;
2184
2173
  const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
2185
2174
  if (DOM && typeof DOM.enable === "function") {
2186
2175
  domainEnablers.push(DOM.enable());
2187
2176
  }
2188
2177
  await Promise.all(domainEnablers);
2189
2178
  removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
2179
+ try {
2180
+ await client.Emulation.setFocusEmulationEnabled({ enabled: true });
2181
+ logger("[browser] Focus emulation enabled for remote target");
2182
+ }
2183
+ catch (error) {
2184
+ const message = error instanceof Error ? error.message : String(error);
2185
+ logger(`[browser] Focus emulation unavailable: ${message}`);
2186
+ }
2187
+ const activeConversationUrlMonitor = createConversationUrlMonitor({
2188
+ readUrl: async () => {
2189
+ const { result } = await Runtime.evaluate({
2190
+ expression: "location.href",
2191
+ returnByValue: true,
2192
+ });
2193
+ return typeof result?.value === "string" ? result.value : null;
2194
+ },
2195
+ persistUrl: async (url) => {
2196
+ lastUrl = url;
2197
+ await emitRuntimeHint();
2198
+ },
2199
+ logger,
2200
+ });
2201
+ conversationUrlMonitor = activeConversationUrlMonitor;
2190
2202
  // Skip cookie sync for remote Chrome - it already has cookies
2191
2203
  logger("Skipping cookie sync for remote Chrome (using existing session)");
2204
+ await clearStaleChatGptConversationCookies(Network, Target, logger, {
2205
+ preserveConversationIds: [
2206
+ extractConversationIdFromUrl(config.resumeConversationUrl ?? ""),
2207
+ extractConversationIdFromUrl(lastUrl ?? ""),
2208
+ ],
2209
+ });
2192
2210
  if (config.resumeConversationUrl) {
2193
2211
  await navigateToChatGPT(Page, Runtime, config.resumeConversationUrl, logger);
2194
2212
  }
@@ -2253,19 +2271,6 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2253
2271
  },
2254
2272
  });
2255
2273
  }
2256
- if (deepResearch) {
2257
- await withRetries(() => activateDeepResearch(Runtime, Input, logger), {
2258
- retries: 2,
2259
- delayMs: 500,
2260
- onRetry: (attempt, error) => {
2261
- if (options.verbose) {
2262
- logger(`[retry] Deep Research activation attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
2263
- }
2264
- },
2265
- });
2266
- await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
2267
- logger(`Prompt textarea ready (after Deep Research activation, ${promptText.length.toLocaleString()} chars queued)`);
2268
- }
2269
2274
  const submitOnce = async (prompt, submissionAttachments) => {
2270
2275
  const baselineSnapshot = await readAssistantSnapshot(Runtime).catch(() => null);
2271
2276
  const baselineAssistantText = typeof baselineSnapshot?.text === "string" ? baselineSnapshot.text.trim() : "";
@@ -2295,6 +2300,19 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2295
2300
  await waitForAttachmentCompletion(Runtime, attachmentWaitBudget, attachmentNames, logger);
2296
2301
  logger("All attachments uploaded");
2297
2302
  }
2303
+ if (deepResearch) {
2304
+ await withRetries(() => activateDeepResearch(Runtime, Input, logger), {
2305
+ retries: 2,
2306
+ delayMs: 500,
2307
+ onRetry: (attempt, error) => {
2308
+ if (options.verbose) {
2309
+ logger(`[retry] Deep Research activation attempt ${attempt + 1}: ${error instanceof Error ? error.message : error}`);
2310
+ }
2311
+ },
2312
+ });
2313
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
2314
+ logger(`Prompt textarea ready (after Deep Research activation, ${prompt.length.toLocaleString()} chars queued)`);
2315
+ }
2298
2316
  let baselineTurns = await readConversationTurnCount(Runtime, logger);
2299
2317
  const providerState = {
2300
2318
  runtime: Runtime,
@@ -2361,7 +2379,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2361
2379
  ignoredTargetKeys: deepResearchTargetKeys,
2362
2380
  targetBaselineCaptured: deepResearchTargetBaselineCaptured,
2363
2381
  });
2364
- await emitRuntimeHint();
2382
+ await activeConversationUrlMonitor.update("post-deep-research", 15_000).catch(() => false);
2365
2383
  const durationMs = Date.now() - startedAt;
2366
2384
  const tokens = estimateTokenCount(researchResult.text);
2367
2385
  const reportArtifact = await saveOptionalArtifact(() => saveDeepResearchReportArtifact({
@@ -2512,11 +2530,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2512
2530
  const captureAssistantTurn = async (turnPrompt, label) => {
2513
2531
  let turnAnswer;
2514
2532
  try {
2515
- const conversationUrl = await readConversationUrl(Runtime).catch(() => null);
2516
- if (conversationUrl && isConversationUrl(conversationUrl)) {
2517
- lastUrl = conversationUrl;
2518
- await emitRuntimeHint();
2519
- }
2533
+ await activeConversationUrlMonitor.update("assistant-wait", 15_000).catch(() => false);
2520
2534
  turnAnswer = await waitWithThinkingMonitor(() => waitForAssistantOrGeneratedImageResponse({
2521
2535
  Runtime,
2522
2536
  waitForText: () => waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined, expectedConversationId()),
@@ -2534,16 +2548,9 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2534
2548
  turnAnswer = rechecked;
2535
2549
  }
2536
2550
  else {
2537
- try {
2538
- const conversationUrl = await readConversationUrl(Runtime);
2539
- if (conversationUrl) {
2540
- lastUrl = conversationUrl;
2541
- }
2542
- }
2543
- catch {
2544
- // ignore
2545
- }
2546
- await emitRuntimeHint();
2551
+ await activeConversationUrlMonitor
2552
+ .update("assistant-timeout", 15_000)
2553
+ .catch(() => false);
2547
2554
  const diagnostics = await captureBrowserDiagnostics(Runtime, logger, "assistant-timeout", {
2548
2555
  Page,
2549
2556
  sessionId: options.sessionId,
@@ -2572,6 +2579,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2572
2579
  throw error;
2573
2580
  }
2574
2581
  }
2582
+ await activeConversationUrlMonitor.update("post-response", 15_000).catch(() => false);
2575
2583
  const baselineNormalized = baselineAssistantText
2576
2584
  ? normalizeForComparison(baselineAssistantText)
2577
2585
  : "";
@@ -2836,6 +2844,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2836
2844
  });
2837
2845
  }
2838
2846
  finally {
2847
+ await conversationUrlMonitor?.stop();
2839
2848
  try {
2840
2849
  await closeRemoteConnectionAfterRun({
2841
2850
  connectionClosedUnexpectedly,
@@ -3053,12 +3062,12 @@ function buildSessionValidationExpression() {
3053
3062
  })()`;
3054
3063
  }
3055
3064
  async function readConversationTurnCount(Runtime, logger) {
3056
- const selectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
3065
+ const expression = buildConversationTurnCountExpression();
3057
3066
  const attempts = 4;
3058
3067
  for (let attempt = 0; attempt < attempts; attempt += 1) {
3059
3068
  try {
3060
3069
  const { result } = await Runtime.evaluate({
3061
- expression: `document.querySelectorAll(${selectorLiteral}).length`,
3070
+ expression,
3062
3071
  returnByValue: true,
3063
3072
  });
3064
3073
  const raw = typeof result?.value === "number" ? result.value : Number(result?.value);
@@ -3,7 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { closeTab, connectWithNewTab, hideChromeWindow, launchChrome, registerTerminationHooks, } from "./chromeLifecycle.js";
5
5
  import { resolveBrowserConfig } from "./config.js";
6
- import { syncCookies } from "./cookies.js";
6
+ import { clearStaleChatGptConversationCookies, syncCookies } from "./cookies.js";
7
7
  import { installJavaScriptDialogAutoDismissal, navigateToChatGPT, ensureLoggedIn, } from "./pageActions.js";
8
8
  import { acquireBrowserTabLease, hasOtherActiveBrowserTabLeases, } from "./tabLeaseRegistry.js";
9
9
  import { acquireProfileRunLock, cleanupStaleProfileState, findRunningChromeDebugTargetForProfile, readChromePid, readDevToolsPort, shouldCleanupManualLoginProfileState, verifyDevToolsReachable, writeChromePid, writeDevToolsActivePort, } from "./profileState.js";
@@ -120,7 +120,7 @@ export async function runBrowserProjectSources(request) {
120
120
  });
121
121
  });
122
122
  const raceWithDisconnect = (promise) => Promise.race([promise, disconnectPromise]);
123
- const { Network, Page, Runtime, Input, DOM } = client;
123
+ const { Network, Page, Runtime, Input, DOM, Target } = client;
124
124
  if (!config.headless && config.hideWindow) {
125
125
  await hideChromeWindow(chrome, logger);
126
126
  }
@@ -139,6 +139,7 @@ export async function runBrowserProjectSources(request) {
139
139
  manualLogin,
140
140
  logger,
141
141
  });
142
+ await clearStaleChatGptConversationCookies(Network, Target, logger);
142
143
  await raceWithDisconnect(navigateToChatGPT(Page, Runtime, CHATGPT_URL, logger));
143
144
  await raceWithDisconnect(waitForProjectSourcesLogin({
144
145
  runtime: Runtime,