@steipete/oracle 0.15.1 → 0.16.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 (78) hide show
  1. package/dist/bin/oracle-cli.js +1 -1
  2. package/dist/scripts/test-browser.js +13 -2
  3. package/dist/src/browser/actions/assistantResponse.js +235 -121
  4. package/dist/src/browser/actions/attachments.js +3 -4
  5. package/dist/src/browser/actions/deepResearch.js +6 -6
  6. package/dist/src/browser/actions/modelSelection.js +133 -22
  7. package/dist/src/browser/actions/navigation.js +235 -14
  8. package/dist/src/browser/actions/promptComposer.js +4 -5
  9. package/dist/src/browser/actions/thinkingStatus.js +228 -0
  10. package/dist/src/browser/actions/thinkingTime.js +68 -11
  11. package/dist/src/browser/chatgptFiles.js +4 -7
  12. package/dist/src/browser/chatgptImages.js +3 -4
  13. package/dist/src/browser/chromeLifecycle.js +30 -28
  14. package/dist/src/browser/config.js +14 -1
  15. package/dist/src/browser/constants.js +6 -1
  16. package/dist/src/browser/controlPlan.js +2 -2
  17. package/dist/src/browser/conversationTurns.js +16 -0
  18. package/dist/src/browser/conversationUrlMonitor.js +64 -0
  19. package/dist/src/browser/cookies.js +72 -0
  20. package/dist/src/browser/index.js +121 -72
  21. package/dist/src/browser/liveTabs.js +113 -31
  22. package/dist/src/browser/pageActions.js +1 -1
  23. package/dist/src/browser/projectSourcesRunner.js +7 -6
  24. package/dist/src/browser/reattach.js +29 -13
  25. package/dist/src/browser/reattachHelpers.js +14 -5
  26. package/dist/src/browser/recoverConversation.js +90 -29
  27. package/dist/src/cli/browserConfig.js +5 -1
  28. package/dist/src/cli/browserTabs.js +54 -33
  29. package/dist/src/cli/options.js +24 -0
  30. package/dist/src/cli/runOptions.js +6 -1
  31. package/dist/src/cli/sessionDisplay.js +38 -14
  32. package/dist/src/oracle/config.js +25 -0
  33. package/dist/src/oracle/geminiModels.js +2 -0
  34. package/dist/src/oracle/oscProgress.js +3 -2
  35. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  36. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  37. package/package.json +21 -22
  38. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  39. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  40. package/dist/bin/oracle.js +0 -569
  41. package/dist/docs-site/.nojekyll +0 -0
  42. package/dist/docs-site/CNAME +0 -1
  43. package/dist/docs-site/RELEASING.html +0 -410
  44. package/dist/docs-site/agents.html +0 -374
  45. package/dist/docs-site/anthropic.html +0 -368
  46. package/dist/docs-site/bridge.html +0 -416
  47. package/dist/docs-site/browser-mode.html +0 -594
  48. package/dist/docs-site/chromium-forks.html +0 -347
  49. package/dist/docs-site/cli-reference.html +0 -346
  50. package/dist/docs-site/configuration.html +0 -462
  51. package/dist/docs-site/favicon.svg +0 -14
  52. package/dist/docs-site/followup.html +0 -375
  53. package/dist/docs-site/gemini.html +0 -383
  54. package/dist/docs-site/grok.html +0 -325
  55. package/dist/docs-site/index.html +0 -360
  56. package/dist/docs-site/install.html +0 -335
  57. package/dist/docs-site/linux.html +0 -321
  58. package/dist/docs-site/llms.txt +0 -43
  59. package/dist/docs-site/manual-tests.html +0 -596
  60. package/dist/docs-site/mcp.html +0 -391
  61. package/dist/docs-site/multimodel.html +0 -364
  62. package/dist/docs-site/mythical-pro-agents.html +0 -360
  63. package/dist/docs-site/notifier.html +0 -338
  64. package/dist/docs-site/openai-endpoints.html +0 -399
  65. package/dist/docs-site/openrouter.html +0 -344
  66. package/dist/docs-site/quickstart.html +0 -369
  67. package/dist/docs-site/refactor/ux.html +0 -532
  68. package/dist/docs-site/sessions.html +0 -388
  69. package/dist/docs-site/social-card.png +0 -0
  70. package/dist/docs-site/social-card.svg +0 -79
  71. package/dist/docs-site/spec.html +0 -363
  72. package/dist/docs-site/testing.html +0 -320
  73. package/dist/docs-site/tui-debug.html +0 -326
  74. package/dist/docs-site/windows-work.html +0 -323
  75. package/dist/docs-site/windows.html +0 -320
  76. package/dist/src/browser/chromeCookies.js +0 -312
  77. package/dist/src/browser/keytarShim.js +0 -56
  78. package/dist/src/browser/windowsCookies.js +0 -219
@@ -61,6 +61,7 @@ export function resolveBrowserConfig(config) {
61
61
  const debugPortEnv = parseDebugPort(process.env.ORACLE_BROWSER_PORT ?? process.env.ORACLE_BROWSER_DEBUG_PORT);
62
62
  const envAllowCookieErrors = (process.env.ORACLE_BROWSER_ALLOW_COOKIE_ERRORS ?? "").trim().toLowerCase() === "true" ||
63
63
  (process.env.ORACLE_BROWSER_ALLOW_COOKIE_ERRORS ?? "").trim() === "1";
64
+ const envMaxConcurrentTabs = parseMaxConcurrentTabs(process.env.ORACLE_BROWSER_MAX_CONCURRENT_TABS);
64
65
  const rawUrl = config?.chatgptUrl ?? config?.url ?? DEFAULT_BROWSER_CONFIG.url;
65
66
  const normalizedUrl = normalizeChatgptUrl(rawUrl ?? DEFAULT_BROWSER_CONFIG.url, DEFAULT_BROWSER_CONFIG.url);
66
67
  const desiredModel = config?.desiredModel ?? DEFAULT_BROWSER_CONFIG.desiredModel ?? DEFAULT_MODEL_TARGET;
@@ -87,7 +88,7 @@ export function resolveBrowserConfig(config) {
87
88
  assistantRecheckTimeoutMs: config?.assistantRecheckTimeoutMs ?? DEFAULT_BROWSER_CONFIG.assistantRecheckTimeoutMs,
88
89
  reuseChromeWaitMs: config?.reuseChromeWaitMs ?? DEFAULT_BROWSER_CONFIG.reuseChromeWaitMs,
89
90
  profileLockTimeoutMs: config?.profileLockTimeoutMs ?? DEFAULT_BROWSER_CONFIG.profileLockTimeoutMs,
90
- maxConcurrentTabs: normalizeMaxConcurrentTabs(config?.maxConcurrentTabs ?? DEFAULT_BROWSER_CONFIG.maxConcurrentTabs),
91
+ maxConcurrentTabs: normalizeMaxConcurrentTabs(config?.maxConcurrentTabs ?? envMaxConcurrentTabs ?? DEFAULT_BROWSER_CONFIG.maxConcurrentTabs),
91
92
  autoReattachDelayMs: config?.autoReattachDelayMs ?? DEFAULT_BROWSER_CONFIG.autoReattachDelayMs,
92
93
  autoReattachIntervalMs: config?.autoReattachIntervalMs ?? DEFAULT_BROWSER_CONFIG.autoReattachIntervalMs,
93
94
  autoReattachTimeoutMs: config?.autoReattachTimeoutMs ?? DEFAULT_BROWSER_CONFIG.autoReattachTimeoutMs,
@@ -134,6 +135,18 @@ function parseDebugPort(raw) {
134
135
  }
135
136
  return value;
136
137
  }
138
+ function parseMaxConcurrentTabs(raw) {
139
+ if (!raw)
140
+ return null;
141
+ const trimmed = raw.trim();
142
+ if (!/^\d+$/.test(trimmed))
143
+ return null;
144
+ const value = Number(trimmed);
145
+ if (!Number.isInteger(value) || value <= 0) {
146
+ return null;
147
+ }
148
+ return value;
149
+ }
137
150
  function resolveManualLoginProfileDir(...candidates) {
138
151
  for (const candidate of candidates) {
139
152
  const profileDir = candidate?.trim();
@@ -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,10 +64,14 @@ export const UPLOAD_STATUS_SELECTORS = [
63
64
  '[aria-live="assertive"]',
64
65
  ];
65
66
  export const STOP_BUTTON_SELECTOR = '[data-testid="stop-button"]';
67
+ // The aria-label fallback exists for data-testid drift, but a document-wide match makes ANY
68
+ // visible "stop" control (read-aloud, voice/dictation) read as "still generating", which blocks
69
+ // completion until the response timeout. Scope it to the composer form and exclude the known
70
+ // non-generation stop controls that legitimately live there.
66
71
  export const STOP_BUTTON_SELECTORS = [
67
72
  STOP_BUTTON_SELECTOR,
68
73
  '[data-testid="composer-stop-button"]',
69
- 'button[aria-label*="stop" i]',
74
+ 'form button[aria-label*="stop" i]:not([aria-label*="dictat" i]):not([aria-label*="voice" i]):not([aria-label*="read" i])',
70
75
  ];
71
76
  export const SEND_BUTTON_SELECTORS = [
72
77
  'button[data-testid="send-button"]',
@@ -45,13 +45,13 @@ export function describeBrowserControlPlan(config = {}) {
45
45
  };
46
46
  }
47
47
  if (config.hideWindow) {
48
- guidance.push("Chrome may briefly focus while launching before Oracle hides it.");
48
+ guidance.push("On macOS, Oracle launches Chrome off-screen while keeping the page rendered.");
49
49
  guidance.push("For the calmest shared-desktop flow, prefer --browser-attach-running or --remote-chrome.");
50
50
  return {
51
51
  mode: "hidden-window",
52
52
  launchesChrome: true,
53
53
  mayFocusWindow: true,
54
- summary: "launch Chrome and hide the window after startup",
54
+ summary: "launch Chrome in hidden-window mode",
55
55
  guidance,
56
56
  };
57
57
  }
@@ -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) {
@@ -4,9 +4,9 @@ import os from "node:os";
4
4
  import net from "node:net";
5
5
  import { resolveBrowserConfig } from "./config.js";
6
6
  import { copyChromeProfile } from "./profileCopy.js";
7
- import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToRemoteChrome, connectWithNewTab, closeTab, closeRemoteChromeTarget, closeBlankChromeTabs, } from "./chromeLifecycle.js";
8
- import { syncCookies } from "./cookies.js";
9
- import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js";
7
+ import { launchChrome, registerTerminationHooks, positionChromeWindowOffscreen, connectToRemoteChrome, connectWithNewTab, closeTab, closeRemoteChromeTarget, closeBlankChromeTabs, } from "./chromeLifecycle.js";
8
+ import { clearStaleChatGptConversationCookies, syncCookies } from "./cookies.js";
9
+ import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureChatMode, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js";
10
10
  import { INPUT_SELECTORS } from "./constants.js";
11
11
  import { uploadAttachmentViaDataTransfer } from "./actions/remoteFileTransfer.js";
12
12
  import { ensureThinkingTime } from "./actions/thinkingTime.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";
@@ -279,6 +281,25 @@ async function createAssistantTimeoutError(params) {
279
281
  }
280
282
  return warningError;
281
283
  }
284
+ /**
285
+ * Make the page behave like a focused foreground tab.
286
+ *
287
+ * The send button is activated with trusted CDP input events dispatched at
288
+ * viewport coordinates. Chrome delivers those only to a window that is being
289
+ * composited, so a hidden (`--browser-hide-window`), minimized, or occluded
290
+ * window swallows the click while the automation still believes it clicked.
291
+ * Soft-fails: focus emulation is an optimization, never a hard requirement.
292
+ */
293
+ async function enableFocusEmulation(client, logger, label) {
294
+ try {
295
+ await client.Emulation.setFocusEmulationEnabled({ enabled: true });
296
+ logger(`[browser] Focus emulation enabled for ${label}`);
297
+ }
298
+ catch (error) {
299
+ const message = error instanceof Error ? error.message : String(error);
300
+ logger(`[browser] Focus emulation unavailable: ${message}`);
301
+ }
302
+ }
282
303
  function listIgnoredRemoteChromeFlags(config) {
283
304
  return [
284
305
  config.headless ? "--browser-headless" : null,
@@ -354,13 +375,13 @@ async function pollGeneratedImageOrTextAssistantResponse(Runtime, timeoutMs, min
354
375
  }
355
376
  return null;
356
377
  }
357
- function isImageOnlyUiChromeText(text) {
378
+ export function isImageOnlyUiChromeText(text) {
358
379
  const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
359
380
  return (normalized.length === 0 ||
360
381
  normalized === "edit" ||
361
382
  normalized === "stopped thinking" ||
362
383
  normalized === "stopped thinking edit" ||
363
- /^thought for \d+(?:\.\d+)?\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\s+edit$/.test(normalized));
384
+ /^(?:reasoning\s+|pro thinking\s+)?thought for \d+(?:\.\d+)?\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\s+edit$/.test(normalized));
364
385
  }
365
386
  function normalizeBrowserFollowUpPrompts(values) {
366
387
  return (values ?? []).map((entry) => entry.trim()).filter(Boolean);
@@ -560,6 +581,7 @@ export async function runBrowserMode(options) {
560
581
  let promptSubmitted = false;
561
582
  let modelSelectionEvidence;
562
583
  let tabLease = null;
584
+ let conversationUrlMonitor = null;
563
585
  const emitRuntimeHint = async () => {
564
586
  if (!chrome?.port) {
565
587
  return;
@@ -596,6 +618,7 @@ export async function runBrowserMode(options) {
596
618
  }
597
619
  promptSubmitted = true;
598
620
  await emitRuntimeHint();
621
+ void conversationUrlMonitor?.schedule("post-submit", config.timeoutMs ?? 120_000);
599
622
  };
600
623
  if (config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") {
601
624
  logger(`[browser-mode] config: ${JSON.stringify({
@@ -746,7 +769,7 @@ export async function runBrowserMode(options) {
746
769
  else {
747
770
  const strictTabIsolation = Boolean(manualLogin && reusedChrome);
748
771
  const devtoolsRetries = manualLogin ? 6 : 0;
749
- const connection = await connectWithNewTab(chrome.port, logger, config.url, chromeHost, {
772
+ const connection = await connectWithNewTab(chrome.port, logger, "about:blank", chromeHost, {
750
773
  fallbackToDefault: !strictTabIsolation,
751
774
  retries: devtoolsRetries,
752
775
  retryDelayMs: 500,
@@ -778,15 +801,19 @@ export async function runBrowserMode(options) {
778
801
  });
779
802
  });
780
803
  const raceWithDisconnect = (promise) => Promise.race([promise, disconnectPromise]);
781
- const { Network, Page, Runtime, Input, DOM } = client;
782
- if (!config.headless && config.hideWindow) {
783
- await hideChromeWindow(chrome, logger);
784
- }
804
+ const { Network, Page, Runtime, Input, DOM, Target } = client;
785
805
  const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
786
806
  if (DOM && typeof DOM.enable === "function") {
787
807
  domainEnablers.push(DOM.enable());
788
808
  }
789
809
  await Promise.all(domainEnablers);
810
+ if (!config.headless && config.hideWindow) {
811
+ await positionChromeWindowOffscreen(client, logger);
812
+ }
813
+ // The send button is clicked with trusted CDP input events at viewport
814
+ // coordinates, which ChatGPT silently drops when the window is hidden or
815
+ // occluded. Emulate focus so the page behaves like a foreground tab.
816
+ await enableFocusEmulation(client, logger, "local target");
790
817
  removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
791
818
  if (!profileIsPreSigned) {
792
819
  await Network.clearBrowserCookies();
@@ -828,6 +855,12 @@ export async function runBrowserMode(options) {
828
855
  ? "Skipping Chrome cookie sync (--browser-manual-login enabled); reuse the opened profile after signing in."
829
856
  : "Skipping Chrome cookie sync (--browser-no-cookie-sync)");
830
857
  }
858
+ await clearStaleChatGptConversationCookies(Network, Target, logger, {
859
+ preserveConversationIds: [
860
+ extractConversationIdFromUrl(config.resumeConversationUrl ?? ""),
861
+ extractConversationIdFromUrl(lastUrl ?? ""),
862
+ ],
863
+ });
831
864
  if (cookieSyncEnabled && !manualLogin && (appliedCookies ?? 0) === 0 && !config.inlineCookies) {
832
865
  // Learned: if the profile has no ChatGPT cookies, browser mode will just bounce to login.
833
866
  // Fail early so the user knows to sign in.
@@ -901,6 +934,18 @@ export async function runBrowserMode(options) {
901
934
  }));
902
935
  }
903
936
  }
937
+ const chatMode = await raceWithDisconnect(ensureChatMode(Runtime, Input, config.inputTimeoutMs, logger, {
938
+ resetWorkConversation: config.browserTabRef && !isResumingConversation
939
+ ? async () => {
940
+ await navigateToChatGPT(Page, Runtime, config.url, logger);
941
+ await ensureNotBlocked(Runtime, config.headless, logger);
942
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
943
+ }
944
+ : undefined,
945
+ }));
946
+ if (chatMode === "switched") {
947
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
948
+ }
904
949
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
905
950
  const captureRuntimeSnapshot = async () => {
906
951
  try {
@@ -939,44 +984,22 @@ export async function runBrowserMode(options) {
939
984
  await emitRuntimeHint();
940
985
  }
941
986
  };
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
- };
987
+ const activeConversationUrlMonitor = createConversationUrlMonitor({
988
+ readUrl: async () => {
989
+ const { result } = await Runtime.evaluate({
990
+ expression: "location.href",
991
+ returnByValue: true,
992
+ });
993
+ return typeof result?.value === "string" ? result.value : null;
994
+ },
995
+ persistUrl: async (url) => {
996
+ lastUrl = url;
997
+ await emitRuntimeHint();
998
+ },
999
+ logger,
1000
+ });
1001
+ conversationUrlMonitor = activeConversationUrlMonitor;
1002
+ const updateConversationHint = conversationUrlMonitor.update;
980
1003
  await captureRuntimeSnapshot();
981
1004
  const modelStrategy = config.modelStrategy ?? DEFAULT_MODEL_STRATEGY;
982
1005
  if (config.desiredModel && modelStrategy !== "ignore" && !isResumingConversation) {
@@ -1128,8 +1151,6 @@ export async function runBrowserMode(options) {
1128
1151
  }
1129
1152
  }
1130
1153
  }
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
1154
  return {
1134
1155
  baselineTurns,
1135
1156
  baselineAssistantText,
@@ -1725,6 +1746,7 @@ export async function runBrowserMode(options) {
1725
1746
  }, normalizedError);
1726
1747
  }
1727
1748
  finally {
1749
+ await conversationUrlMonitor?.stop();
1728
1750
  try {
1729
1751
  if (!connectionClosedUnexpectedly) {
1730
1752
  await client?.close();
@@ -1974,7 +1996,7 @@ function detachKeptChromeProcess(chrome) {
1974
1996
  // Best-effort only; cleanup should not mask the original browser result.
1975
1997
  }
1976
1998
  }
1977
- async function acquireManualLoginChromeForRun(userDataDir, config, logger, sessionId, deps = {}) {
1999
+ export async function acquireManualLoginChromeForRun(userDataDir, config, logger, sessionId, deps = {}) {
1978
2000
  const maybeReuse = deps.maybeReuse ?? maybeReuseRunningChrome;
1979
2001
  const launch = deps.launch ?? launchChrome;
1980
2002
  const lockTimeoutMs = Math.max(0, config.profileLockTimeoutMs ?? 0);
@@ -2086,6 +2108,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2086
2108
  let modelSelectionEvidence;
2087
2109
  let attachedExistingTab = false;
2088
2110
  let ownsTarget = true;
2111
+ let conversationUrlMonitor = null;
2089
2112
  const runtimeHintCb = options.runtimeHintCb;
2090
2113
  const emitRuntimeHint = async () => {
2091
2114
  if (!runtimeHintCb)
@@ -2120,6 +2143,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2120
2143
  }
2121
2144
  promptSubmitted = true;
2122
2145
  await emitRuntimeHint();
2146
+ void conversationUrlMonitor?.schedule("post-submit", config.timeoutMs ?? 120_000);
2123
2147
  };
2124
2148
  const startedAt = Date.now();
2125
2149
  let answerText = "";
@@ -2161,7 +2185,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2161
2185
  logger(`Attached to existing remote ChatGPT tab ${attached.targetId}${attached.tab.url ? ` (${attached.tab.url})` : ""}`);
2162
2186
  }
2163
2187
  else {
2164
- connection = await connectToRemoteChrome(host, port, logger, config.url, browserWSEndpoint, {
2188
+ connection = await connectToRemoteChrome(host, port, logger, "about:blank", browserWSEndpoint, {
2165
2189
  approvalWaitMs: config.attachRunning && browserWSEndpoint ? 20_000 : undefined,
2166
2190
  });
2167
2191
  client = connection.client;
@@ -2180,15 +2204,37 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2180
2204
  connectionClosedUnexpectedly = true;
2181
2205
  };
2182
2206
  client.on("disconnect", markConnectionLost);
2183
- const { Network, Page, Runtime, Input, DOM } = client;
2207
+ const { Network, Page, Runtime, Input, DOM, Target } = client;
2184
2208
  const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
2185
2209
  if (DOM && typeof DOM.enable === "function") {
2186
2210
  domainEnablers.push(DOM.enable());
2187
2211
  }
2188
2212
  await Promise.all(domainEnablers);
2189
2213
  removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
2214
+ await enableFocusEmulation(client, logger, "remote target");
2215
+ const activeConversationUrlMonitor = createConversationUrlMonitor({
2216
+ readUrl: async () => {
2217
+ const { result } = await Runtime.evaluate({
2218
+ expression: "location.href",
2219
+ returnByValue: true,
2220
+ });
2221
+ return typeof result?.value === "string" ? result.value : null;
2222
+ },
2223
+ persistUrl: async (url) => {
2224
+ lastUrl = url;
2225
+ await emitRuntimeHint();
2226
+ },
2227
+ logger,
2228
+ });
2229
+ conversationUrlMonitor = activeConversationUrlMonitor;
2190
2230
  // Skip cookie sync for remote Chrome - it already has cookies
2191
2231
  logger("Skipping cookie sync for remote Chrome (using existing session)");
2232
+ await clearStaleChatGptConversationCookies(Network, Target, logger, {
2233
+ preserveConversationIds: [
2234
+ extractConversationIdFromUrl(config.resumeConversationUrl ?? ""),
2235
+ extractConversationIdFromUrl(lastUrl ?? ""),
2236
+ ],
2237
+ });
2192
2238
  if (config.resumeConversationUrl) {
2193
2239
  await navigateToChatGPT(Page, Runtime, config.resumeConversationUrl, logger);
2194
2240
  }
@@ -2204,6 +2250,18 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2204
2250
  expectedConversationUrl: config.resumeConversationUrl,
2205
2251
  });
2206
2252
  }
2253
+ const chatMode = await ensureChatMode(Runtime, Input, config.inputTimeoutMs, logger, {
2254
+ resetWorkConversation: attachedExistingTab && !config.resumeConversationUrl
2255
+ ? async () => {
2256
+ await navigateToChatGPT(Page, Runtime, config.url, logger);
2257
+ await ensureNotBlocked(Runtime, config.headless, logger);
2258
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
2259
+ }
2260
+ : undefined,
2261
+ });
2262
+ if (chatMode === "switched") {
2263
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
2264
+ }
2207
2265
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
2208
2266
  try {
2209
2267
  const { result } = await Runtime.evaluate({
@@ -2361,7 +2419,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2361
2419
  ignoredTargetKeys: deepResearchTargetKeys,
2362
2420
  targetBaselineCaptured: deepResearchTargetBaselineCaptured,
2363
2421
  });
2364
- await emitRuntimeHint();
2422
+ await activeConversationUrlMonitor.update("post-deep-research", 15_000).catch(() => false);
2365
2423
  const durationMs = Date.now() - startedAt;
2366
2424
  const tokens = estimateTokenCount(researchResult.text);
2367
2425
  const reportArtifact = await saveOptionalArtifact(() => saveDeepResearchReportArtifact({
@@ -2512,11 +2570,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2512
2570
  const captureAssistantTurn = async (turnPrompt, label) => {
2513
2571
  let turnAnswer;
2514
2572
  try {
2515
- const conversationUrl = await readConversationUrl(Runtime).catch(() => null);
2516
- if (conversationUrl && isConversationUrl(conversationUrl)) {
2517
- lastUrl = conversationUrl;
2518
- await emitRuntimeHint();
2519
- }
2573
+ await activeConversationUrlMonitor.update("assistant-wait", 15_000).catch(() => false);
2520
2574
  turnAnswer = await waitWithThinkingMonitor(() => waitForAssistantOrGeneratedImageResponse({
2521
2575
  Runtime,
2522
2576
  waitForText: () => waitForAssistantResponseWithReload(Runtime, Page, config.timeoutMs, logger, baselineTurns ?? undefined, expectedConversationId()),
@@ -2534,16 +2588,9 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2534
2588
  turnAnswer = rechecked;
2535
2589
  }
2536
2590
  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();
2591
+ await activeConversationUrlMonitor
2592
+ .update("assistant-timeout", 15_000)
2593
+ .catch(() => false);
2547
2594
  const diagnostics = await captureBrowserDiagnostics(Runtime, logger, "assistant-timeout", {
2548
2595
  Page,
2549
2596
  sessionId: options.sessionId,
@@ -2572,6 +2619,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2572
2619
  throw error;
2573
2620
  }
2574
2621
  }
2622
+ await activeConversationUrlMonitor.update("post-response", 15_000).catch(() => false);
2575
2623
  const baselineNormalized = baselineAssistantText
2576
2624
  ? normalizeForComparison(baselineAssistantText)
2577
2625
  : "";
@@ -2836,6 +2884,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2836
2884
  });
2837
2885
  }
2838
2886
  finally {
2887
+ await conversationUrlMonitor?.stop();
2839
2888
  try {
2840
2889
  await closeRemoteConnectionAfterRun({
2841
2890
  connectionClosedUnexpectedly,
@@ -3053,12 +3102,12 @@ function buildSessionValidationExpression() {
3053
3102
  })()`;
3054
3103
  }
3055
3104
  async function readConversationTurnCount(Runtime, logger) {
3056
- const selectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
3105
+ const expression = buildConversationTurnCountExpression();
3057
3106
  const attempts = 4;
3058
3107
  for (let attempt = 0; attempt < attempts; attempt += 1) {
3059
3108
  try {
3060
3109
  const { result } = await Runtime.evaluate({
3061
- expression: `document.querySelectorAll(${selectorLiteral}).length`,
3110
+ expression,
3062
3111
  returnByValue: true,
3063
3112
  });
3064
3113
  const raw = typeof result?.value === "number" ? result.value : Number(result?.value);