@steipete/oracle 0.15.1 → 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.
@@ -58,9 +58,9 @@ function firewallHint(host, devtoolsPort) {
58
58
  "Re-run ./runner pnpm test:browser after adding the rule.",
59
59
  ].join("\n");
60
60
  }
61
- async function fetchVersion(host, devtoolsPort) {
61
+ async function fetchVersion(host, devtoolsPort, timeoutMs = 5000) {
62
62
  const controller = new AbortController();
63
- const timer = setTimeout(() => controller.abort(), 5000);
63
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
64
64
  try {
65
65
  const res = await fetch(`http://${host}:${devtoolsPort}/json/version`, {
66
66
  signal: controller.signal,
@@ -77,6 +77,16 @@ async function fetchVersion(host, devtoolsPort) {
77
77
  clearTimeout(timer);
78
78
  }
79
79
  }
80
+ async function waitForDevToolsShutdown(host, devtoolsPort) {
81
+ const deadline = Date.now() + 5000;
82
+ while (Date.now() < deadline) {
83
+ if (!(await fetchVersion(host, devtoolsPort, 250))) {
84
+ return;
85
+ }
86
+ await sleep(100);
87
+ }
88
+ throw new Error(`DevTools did not stop listening at ${host}:${devtoolsPort}`);
89
+ }
80
90
  async function main() {
81
91
  console.log(`[browser-test] launching Chrome on ${targetHost}:${port} (headful)…`);
82
92
  const chrome = await launch({
@@ -89,6 +99,7 @@ async function main() {
89
99
  ok = await fetchVersion(targetHost, chrome.port);
90
100
  }
91
101
  await chrome.kill();
102
+ await waitForDevToolsShutdown(targetHost, chrome.port);
92
103
  if (ok) {
93
104
  console.log(`[browser-test] PASS: DevTools responding on ${targetHost}:${chrome.port}`);
94
105
  process.exit(0);
@@ -1,9 +1,20 @@
1
1
  import { ANSWER_SELECTORS, ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, COPY_BUTTON_SELECTOR, FINISHED_ACTIONS_SELECTOR, STOP_BUTTON_SELECTORS, } from "../constants.js";
2
+ import { buildConversationTurnListExpression } from "../conversationTurns.js";
2
3
  import { delay } from "../utils.js";
3
4
  import { logDomFailure, logConversationSnapshot, buildConversationDebugExpression, } from "../domDebug.js";
4
5
  import { buildClickDispatcher } from "./domEvents.js";
5
6
  const ASSISTANT_POLL_TIMEOUT_ERROR = "assistant-response-watchdog-timeout";
6
7
  const STOP_CONTROL_SELECTOR = STOP_BUTTON_SELECTORS.join(", ");
8
+ const MIN_CONFIDENT_ANSWER_LENGTH = 16;
9
+ function isImplausiblyShortAnswer(candidateLength) {
10
+ return candidateLength > 0 && candidateLength < MIN_CONFIDENT_ANSWER_LENGTH;
11
+ }
12
+ export function shouldConfirmAssistantCompletion(args) {
13
+ if (args.stopVisible || args.completionVisible) {
14
+ return true;
15
+ }
16
+ return isImplausiblyShortAnswer(args.candidateLength);
17
+ }
7
18
  const THINKING_STATUS_LABELS = [
8
19
  "thinking",
9
20
  "pro thinking",
@@ -109,7 +120,9 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
109
120
  if (source === "poll" &&
110
121
  error instanceof Error &&
111
122
  error.message === ASSISTANT_POLL_TIMEOUT_ERROR) {
112
- evaluation = await evaluationPromise;
123
+ evaluationPromise.catch(() => undefined);
124
+ await terminateRuntimeExecution(Runtime);
125
+ throw error;
113
126
  }
114
127
  else if (source === "poll") {
115
128
  throw error;
@@ -162,6 +175,8 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
162
175
  // The evaluation path can race ahead of completion. If ChatGPT is still streaming, wait for the watchdog poller.
163
176
  const elapsedMs = Date.now() - start;
164
177
  const remainingMs = Math.max(0, timeoutMs - elapsedMs);
178
+ const candidateText = String(candidate?.text ?? "").trim();
179
+ const suspiciouslyShort = isImplausiblyShortAnswer(candidateText.length);
165
180
  if (remainingMs > 0) {
166
181
  const [stopVisible, completionVisible] = await Promise.all([
167
182
  isStopButtonVisible(Runtime),
@@ -170,17 +185,25 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
170
185
  // Completion controls can appear briefly while Pro is still replacing its thinking UI.
171
186
  // Confirm every capture from that transition with the stability-based watchdog; a
172
187
  // partial first paragraph can be arbitrarily long.
173
- const candidateText = String(candidate?.text ?? "").trim();
174
- if (stopVisible || completionVisible) {
188
+ if (shouldConfirmAssistantCompletion({
189
+ candidateLength: candidateText.length,
190
+ stopVisible,
191
+ completionVisible,
192
+ })) {
175
193
  logger(stopVisible
176
194
  ? "Assistant still generating; waiting for completion"
177
- : "Completion controls surfaced; confirming stable assistant response");
195
+ : completionVisible
196
+ ? "Completion controls surfaced; confirming stable assistant response"
197
+ : "Captured an implausibly short response; confirming it is not a mid-stream capture");
178
198
  const completed = await pollAssistantCompletion(Runtime, remainingMs, minTurnIndex, expectedConversationId);
179
199
  if (completed && String(completed.text ?? "").trim().length >= candidateText.length) {
180
200
  return completed;
181
201
  }
182
202
  }
183
203
  }
204
+ if (suspiciouslyShort) {
205
+ throw new Error("assistant-response short capture could not be confirmed before timeout; refusing to finalize it");
206
+ }
184
207
  return candidate;
185
208
  }
186
209
  export async function readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId) {
@@ -243,11 +266,17 @@ async function recoverAssistantResponse(Runtime, timeoutMs, logger, minTurnIndex
243
266
  if (recoveryTimeoutMs === 0) {
244
267
  return null;
245
268
  }
269
+ const recoveryStartedAt = Date.now();
246
270
  const recovered = await waitForCondition(async () => {
247
271
  const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex, expectedConversationId);
248
272
  return normalizeAssistantSnapshot(snapshot);
249
273
  }, recoveryTimeoutMs, 400);
250
274
  if (recovered) {
275
+ if (isImplausiblyShortAnswer(recovered.text.length)) {
276
+ logger("Recovered an implausibly short response; waiting for completion proof");
277
+ const remainingMs = Math.max(0, recoveryTimeoutMs - (Date.now() - recoveryStartedAt));
278
+ return pollAssistantCompletion(Runtime, remainingMs, minTurnIndex, expectedConversationId);
279
+ }
251
280
  logger("Recovered assistant response via polling fallback");
252
281
  return recovered;
253
282
  }
@@ -364,8 +393,8 @@ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, expecte
364
393
  if (isGeneratedImageAssistantAnswer(normalized)) {
365
394
  return normalized;
366
395
  }
367
- const shortAnswer = currentLength > 0 && currentLength < 16;
368
- const mediumAnswer = currentLength >= 16 && currentLength < 40;
396
+ const shortAnswer = isImplausiblyShortAnswer(currentLength);
397
+ const mediumAnswer = currentLength >= MIN_CONFIDENT_ANSWER_LENGTH && currentLength < 40;
369
398
  const longAnswer = currentLength >= 40 && currentLength < 500;
370
399
  // Learned: short answers need a longer stability window or they truncate.
371
400
  // Learned: long streaming responses (esp. thinking models) can pause mid-stream;
@@ -378,7 +407,7 @@ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, expecte
378
407
  if (!stopVisible) {
379
408
  const stableEnough = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
380
409
  const completionEnough = completionVisible && stableCycles >= completionStableTarget && stableMs >= minStableMs;
381
- if (completionEnough || stableEnough) {
410
+ if (completionEnough || (!shortAnswer && stableEnough)) {
382
411
  return normalized;
383
412
  }
384
413
  }
@@ -445,7 +474,7 @@ async function isCompletionVisible(Runtime) {
445
474
  return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
446
475
  };
447
476
 
448
- const turns = Array.from(document.querySelectorAll('${CONVERSATION_TURN_SELECTOR}'));
477
+ const turns = ${buildConversationTurnListExpression()};
449
478
  let lastAssistantTurn = null;
450
479
  for (let i = turns.length - 1; i >= 0; i--) {
451
480
  if (isAssistantTurn(turns[i])) {
@@ -557,7 +586,6 @@ function buildAssistantSnapshotExpression(minTurnIndex, expectedConversationId)
557
586
  }
558
587
  function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConversationId) {
559
588
  const selectorsLiteral = JSON.stringify(ANSWER_SELECTORS);
560
- const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
561
589
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
562
590
  const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
563
591
  ? Math.floor(minTurnIndex)
@@ -570,7 +598,6 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
570
598
  const SELECTORS = ${selectorsLiteral};
571
599
  const STOP_SELECTOR = ${JSON.stringify(STOP_CONTROL_SELECTOR)};
572
600
  const FINISHED_SELECTOR = '${FINISHED_ACTIONS_SELECTOR}';
573
- const CONVERSATION_SELECTOR = ${conversationLiteral};
574
601
  const ASSISTANT_SELECTOR = ${assistantLiteral};
575
602
  const EXPECTED_CONVERSATION_ID = ${expectedConversationLiteral};
576
603
  // Learned: settling avoids capturing mid-stream HTML; keep short.
@@ -693,7 +720,7 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
693
720
 
694
721
  // Check if the last assistant turn has finished (scoped to avoid detecting old turns).
695
722
  const isLastAssistantTurnFinished = () => {
696
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
723
+ const turns = ${buildConversationTurnListExpression()};
697
724
  let lastAssistantTurn = null;
698
725
  for (let i = turns.length - 1; i >= 0; i--) {
699
726
  if (isAssistantTurn(turns[i])) {
@@ -717,8 +744,8 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
717
744
  // Learned: long streaming responses (esp. thinking models) can pause mid-stream;
718
745
  // use progressively longer windows to avoid truncation (#71).
719
746
  const initialLength = snapshot?.text?.length ?? 0;
720
- const shortAnswer = initialLength > 0 && initialLength < 16;
721
- const mediumAnswer = initialLength >= 16 && initialLength < 40;
747
+ const shortAnswer = initialLength > 0 && initialLength < ${MIN_CONFIDENT_ANSWER_LENGTH};
748
+ const mediumAnswer = initialLength >= ${MIN_CONFIDENT_ANSWER_LENGTH} && initialLength < 40;
722
749
  const longAnswer = initialLength >= 40 && initialLength < 500;
723
750
  const settleWindowMs = shortAnswer ? 12_000 : mediumAnswer ? 5_000 : longAnswer ? 8_000 : 10_000;
724
751
  const settleIntervalMs = 400;
@@ -792,11 +819,9 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex, expectedConver
792
819
  })()`;
793
820
  }
794
821
  function buildAssistantExtractor(functionName) {
795
- const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
796
822
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
797
823
  return `const ${functionName} = () => {
798
824
  ${buildClickDispatcher()}
799
- const CONVERSATION_SELECTOR = ${conversationLiteral};
800
825
  const ASSISTANT_SELECTOR = ${assistantLiteral};
801
826
  const isAssistantTurn = (node) => {
802
827
  if (!(node instanceof HTMLElement)) return false;
@@ -832,7 +857,7 @@ function buildAssistantExtractor(functionName) {
832
857
  }
833
858
  };
834
859
 
835
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
860
+ const turns = ${buildConversationTurnListExpression()};
836
861
  for (let index = turns.length - 1; index >= 0; index -= 1) {
837
862
  const turn = turns[index];
838
863
  if (!isAssistantTurn(turn)) {
@@ -916,13 +941,10 @@ function buildMarkdownFallbackExtractor(minTurnLiteral) {
916
941
  }
917
942
  }
918
943
  if (!root) return null;
919
- const CONVERSATION_SELECTOR = '${CONVERSATION_TURN_SELECTOR}';
920
- const turnNodes = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
944
+ const turnNodes = ${buildConversationTurnListExpression()};
921
945
  const hasTurns = turnNodes.length > 0;
922
946
  const resolveTurnIndex = (node) => {
923
- const turn = node?.closest?.(CONVERSATION_SELECTOR);
924
- if (!turn) return null;
925
- const idx = turnNodes.indexOf(turn);
947
+ const idx = turnNodes.findIndex((turn) => turn === node || turn.contains?.(node));
926
948
  return idx >= 0 ? idx : null;
927
949
  };
928
950
  const isAfterMinTurn = (node) => {
@@ -1050,7 +1072,7 @@ function buildCopyExpression(meta) {
1050
1072
  if (testId.includes('assistant')) return true;
1051
1073
  return Boolean(node.querySelector(ASSISTANT_SELECTOR) || node.querySelector('[data-testid*="assistant"]'));
1052
1074
  };
1053
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
1075
+ const turns = ${buildConversationTurnListExpression()};
1054
1076
  for (let i = turns.length - 1; i >= 0; i -= 1) {
1055
1077
  const turn = turns[i];
1056
1078
  if (!isAssistantTurn(turn)) continue;
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
- import { CONVERSATION_TURN_SELECTOR, INPUT_SELECTORS, SEND_BUTTON_SELECTORS, UPLOAD_STATUS_SELECTORS, } from "../constants.js";
2
+ import { INPUT_SELECTORS, SEND_BUTTON_SELECTORS, UPLOAD_STATUS_SELECTORS } from "../constants.js";
3
+ import { buildConversationTurnListExpression } from "../conversationTurns.js";
3
4
  import { delay } from "../utils.js";
4
5
  import { logDomFailure } from "../domDebug.js";
5
6
  import { transferAttachmentViaDataTransfer } from "./attachmentDataTransfer.js";
@@ -1594,14 +1595,12 @@ export async function waitForUserTurnAttachments(Runtime, expectedNames, timeout
1594
1595
  throw new Error("Attachment was not present on the sent user message.");
1595
1596
  }
1596
1597
  function buildUserTurnAttachmentExpression(options) {
1597
- const conversationSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
1598
1598
  const minTurnLiteral = options.minTurnIndex === null ? "null" : String(options.minTurnIndex);
1599
1599
  const expectedPromptLiteral = JSON.stringify(options.expectedPromptPrefix);
1600
1600
  const expectedConversationLiteral = options.expectedConversationId
1601
1601
  ? JSON.stringify(options.expectedConversationId)
1602
1602
  : "null";
1603
1603
  return `(() => {
1604
- const CONVERSATION_SELECTOR = ${conversationSelectorLiteral};
1605
1604
  const MIN_TURN_INDEX = ${minTurnLiteral};
1606
1605
  const EXPECTED_PROMPT_PREFIX = ${expectedPromptLiteral};
1607
1606
  const EXPECTED_CONVERSATION_ID = ${expectedConversationLiteral};
@@ -1614,7 +1613,7 @@ function buildUserTurnAttachmentExpression(options) {
1614
1613
  ) {
1615
1614
  return { ok: false, conversationMismatch: true };
1616
1615
  }
1617
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
1616
+ const turns = ${buildConversationTurnListExpression()};
1618
1617
  const userTurns = turns.map((node, index) => ({ node, index })).filter(({ node }) => {
1619
1618
  const attr = (node.getAttribute('data-message-author-role') || node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
1620
1619
  if (attr === 'user') return true;
@@ -1,4 +1,5 @@
1
- import { DEEP_RESEARCH_PLUS_BUTTON, DEEP_RESEARCH_DROPDOWN_ITEM_TEXT, DEEP_RESEARCH_PILL_LABEL, DEEP_RESEARCH_POLL_INTERVAL_MS, DEEP_RESEARCH_AUTO_CONFIRM_WAIT_MS, DEEP_RESEARCH_DEFAULT_TIMEOUT_MS, FINISHED_ACTIONS_SELECTOR, STOP_BUTTON_SELECTOR, CONVERSATION_TURN_SELECTOR, } from "../constants.js";
1
+ import { DEEP_RESEARCH_PLUS_BUTTON, DEEP_RESEARCH_DROPDOWN_ITEM_TEXT, DEEP_RESEARCH_PILL_LABEL, DEEP_RESEARCH_POLL_INTERVAL_MS, DEEP_RESEARCH_AUTO_CONFIRM_WAIT_MS, DEEP_RESEARCH_DEFAULT_TIMEOUT_MS, FINISHED_ACTIONS_SELECTOR, STOP_BUTTON_SELECTOR, } from "../constants.js";
2
+ import { buildConversationTurnListExpression } from "../conversationTurns.js";
2
3
  import { delay } from "../utils.js";
3
4
  import { isDeepResearchIncompleteText } from "../deepResearchResult.js";
4
5
  import { buildClickDispatcher } from "./domEvents.js";
@@ -480,9 +481,9 @@ async function readDeepResearchTargetOwnerTurnIndex(rawClient, frameId, pageSess
480
481
  .send("Runtime.callFunctionOn", {
481
482
  objectId,
482
483
  functionDeclaration: `function() {
483
- const selector = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
484
- const turn = this.closest(selector);
485
- return turn ? Array.from(document.querySelectorAll(selector)).indexOf(turn) : null;
484
+ const turns = ${buildConversationTurnListExpression()};
485
+ const index = turns.findIndex((turn) => turn === this || turn.contains?.(this));
486
+ return index >= 0 ? index : null;
486
487
  }`,
487
488
  returnByValue: true,
488
489
  }, pageSessionId)
@@ -703,7 +704,6 @@ function buildDeepResearchStatusExpression() {
703
704
  function buildDeepResearchCompletionPollExpression(minTurnIndex) {
704
705
  const finishedSelector = JSON.stringify(FINISHED_ACTIONS_SELECTOR);
705
706
  const stopSelector = JSON.stringify(STOP_BUTTON_SELECTOR);
706
- const turnSelector = JSON.stringify(CONVERSATION_TURN_SELECTOR);
707
707
  return `(() => {
708
708
  const MIN_TURN_INDEX = ${minTurnIndex};
709
709
  const stopVisible = Boolean(document.querySelector(${stopSelector}));
@@ -719,7 +719,7 @@ function buildDeepResearchCompletionPollExpression(minTurnIndex) {
719
719
  String(node.getAttribute('data-testid') || '').toLowerCase().includes('conversation-turn') &&
720
720
  /chatgpt\\s+said/i.test(node.innerText || node.textContent || '');
721
721
  };
722
- const conversationTurns = Array.from(document.querySelectorAll(${turnSelector}));
722
+ const conversationTurns = ${buildConversationTurnListExpression()};
723
723
  const allAssistantTurns = Array.from(document.querySelectorAll('[data-message-author-role="assistant"], [data-turn="assistant"]'));
724
724
  const scopedTurns = scopedToNewTurns
725
725
  ? conversationTurns.slice(MIN_TURN_INDEX).filter(isAssistantTurn)
@@ -1,4 +1,5 @@
1
- import { INPUT_SELECTORS, PROMPT_PRIMARY_SELECTOR, PROMPT_FALLBACK_SELECTOR, SEND_BUTTON_SELECTORS, CONVERSATION_TURN_SELECTOR, STOP_BUTTON_SELECTOR, ASSISTANT_ROLE_SELECTOR, } from "../constants.js";
1
+ import { INPUT_SELECTORS, PROMPT_PRIMARY_SELECTOR, PROMPT_FALLBACK_SELECTOR, SEND_BUTTON_SELECTORS, STOP_BUTTON_SELECTOR, ASSISTANT_ROLE_SELECTOR, } from "../constants.js";
2
+ import { buildConversationTurnCountExpression, buildConversationTurnListExpression, } from "../conversationTurns.js";
2
3
  import { delay } from "../utils.js";
3
4
  import { logDomFailure } from "../domDebug.js";
4
5
  import { buildClickDispatcher } from "./domEvents.js";
@@ -691,14 +692,13 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
691
692
  const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
692
693
  const stopSelectorLiteral = JSON.stringify(STOP_BUTTON_SELECTOR);
693
694
  const assistantSelectorLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
694
- const turnSelectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
695
695
  let baseline = typeof baselineTurns === "number" && Number.isFinite(baselineTurns) && baselineTurns >= 0
696
696
  ? Math.floor(baselineTurns)
697
697
  : null;
698
698
  if (baseline === null) {
699
699
  try {
700
700
  const { result } = await Runtime.evaluate({
701
- expression: `document.querySelectorAll(${turnSelectorLiteral}).length`,
701
+ expression: buildConversationTurnCountExpression(),
702
702
  returnByValue: true,
703
703
  });
704
704
  const raw = typeof result?.value === "number" ? result.value : Number(result?.value);
@@ -726,8 +726,7 @@ async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger, baselin
726
726
  };
727
727
  const normalizedPrompt = normalize(${encodedPrompt});
728
728
  const normalizedPromptPrefix = normalizedPrompt.slice(0, 120);
729
- const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
730
- const articles = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
729
+ const articles = ${buildConversationTurnListExpression()};
731
730
  const normalizedTurns = articles.map((node) => normalize(node?.innerText));
732
731
  const readValue = (node) => {
733
732
  if (!node) return '';
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR } from "./constants.js";
3
+ import { ASSISTANT_ROLE_SELECTOR } from "./constants.js";
4
+ import { buildConversationTurnListExpression } from "./conversationTurns.js";
4
5
  import { computeFileSha256, resolveSessionArtifactsDir, sanitizeArtifactFilename, validateArtifactFile, writeBinaryBrowserArtifact, } from "./artifacts.js";
5
6
  const CHATGPT_DOWNLOAD_BASE_URL = "https://chatgpt.com/";
6
7
  const DOWNLOAD_BUTTON_WAIT_MS = 15_000;
@@ -272,11 +273,9 @@ function buildAssistantDownloadableFilesExpression(minTurnIndex) {
272
273
  const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
273
274
  ? Math.floor(minTurnIndex)
274
275
  : -1;
275
- const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
276
276
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
277
277
  return `(() => {
278
278
  const MIN_TURN_INDEX = ${minTurnLiteral};
279
- const CONVERSATION_SELECTOR = ${conversationLiteral};
280
279
  const ASSISTANT_SELECTOR = ${assistantLiteral};
281
280
  const isAssistantTurn = (node) => {
282
281
  if (!(node instanceof HTMLElement)) return false;
@@ -385,7 +384,7 @@ function buildAssistantDownloadableFilesExpression(minTurnIndex) {
385
384
  ].join(',')))
386
385
  .map(serializeCandidate)
387
386
  .filter(Boolean);
388
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
387
+ const turns = ${buildConversationTurnListExpression()};
389
388
  const files = [];
390
389
  for (let index = turns.length - 1; index >= 0; index -= 1) {
391
390
  const turn = turns[index];
@@ -626,7 +625,6 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
626
625
  const minTurnLiteral = typeof minTurnIndex === "number" && Number.isFinite(minTurnIndex) && minTurnIndex >= 0
627
626
  ? Math.floor(minTurnIndex)
628
627
  : -1;
629
- const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
630
628
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
631
629
  const expectedLabelsLiteral = JSON.stringify(expectedLabels);
632
630
  const allowGenericDownloadLabelsLiteral = JSON.stringify(allowGenericDownloadLabels);
@@ -639,7 +637,6 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
639
637
  : 0;
640
638
  return `(() => {
641
639
  const MIN_TURN_INDEX = ${minTurnLiteral};
642
- const CONVERSATION_SELECTOR = ${conversationLiteral};
643
640
  const ASSISTANT_SELECTOR = ${assistantLiteral};
644
641
  const EXPECTED_LABELS = ${expectedLabelsLiteral};
645
642
  const ALLOW_GENERIC_DOWNLOAD_LABELS = ${allowGenericDownloadLabelsLiteral};
@@ -762,7 +759,7 @@ function buildClickAssistantDownloadButtonsExpression(minTurnIndex, expectedLabe
762
759
  const genericBehaviorButton = (info) =>
763
760
  ALLOW_GENERIC_DOWNLOAD_LABELS && info.className.includes('behavior-btn') && hasDownloadIntent(info);
764
761
  const genericFallbackButton = (info) => ALLOW_GENERIC_DOWNLOAD_LABELS && hasDownloadIntent(info);
765
- const turns = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
762
+ const turns = ${buildConversationTurnListExpression()};
766
763
  const expectedMatches = new Set();
767
764
  const genericBehaviorMatches = new Set();
768
765
  const genericFallbackMatches = new Set();
@@ -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";
@@ -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";
@@ -560,6 +562,7 @@ export async function runBrowserMode(options) {
560
562
  let promptSubmitted = false;
561
563
  let modelSelectionEvidence;
562
564
  let tabLease = null;
565
+ let conversationUrlMonitor = null;
563
566
  const emitRuntimeHint = async () => {
564
567
  if (!chrome?.port) {
565
568
  return;
@@ -596,6 +599,7 @@ export async function runBrowserMode(options) {
596
599
  }
597
600
  promptSubmitted = true;
598
601
  await emitRuntimeHint();
602
+ void conversationUrlMonitor?.schedule("post-submit", config.timeoutMs ?? 120_000);
599
603
  };
600
604
  if (config.debug || process.env.CHATGPT_DEVTOOLS_TRACE === "1") {
601
605
  logger(`[browser-mode] config: ${JSON.stringify({
@@ -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) {
@@ -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();
@@ -2086,6 +2073,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2086
2073
  let modelSelectionEvidence;
2087
2074
  let attachedExistingTab = false;
2088
2075
  let ownsTarget = true;
2076
+ let conversationUrlMonitor = null;
2089
2077
  const runtimeHintCb = options.runtimeHintCb;
2090
2078
  const emitRuntimeHint = async () => {
2091
2079
  if (!runtimeHintCb)
@@ -2120,6 +2108,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2120
2108
  }
2121
2109
  promptSubmitted = true;
2122
2110
  await emitRuntimeHint();
2111
+ void conversationUrlMonitor?.schedule("post-submit", config.timeoutMs ?? 120_000);
2123
2112
  };
2124
2113
  const startedAt = Date.now();
2125
2114
  let answerText = "";
@@ -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
  }
@@ -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,
@@ -5,8 +5,9 @@ import { mkdtemp, mkdir, rm } from "node:fs/promises";
5
5
  import { waitForAssistantResponse, captureAssistantMarkdown, navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, } from "./pageActions.js";
6
6
  import { launchChrome, connectToChrome, hideChromeWindow, connectToRemoteChromeTarget, listRemoteChromeTargets, } from "./chromeLifecycle.js";
7
7
  import { resolveBrowserConfig } from "./config.js";
8
- import { syncCookies } from "./cookies.js";
9
- import { CHATGPT_URL, CONVERSATION_TURN_SELECTOR } from "./constants.js";
8
+ import { clearStaleChatGptConversationCookies, syncCookies } from "./cookies.js";
9
+ import { CHATGPT_URL } from "./constants.js";
10
+ import { buildConversationTurnListExpression } from "./conversationTurns.js";
10
11
  import { cleanupStaleProfileState } from "./profileState.js";
11
12
  import { readDevToolsActivePortInfo } from "./detect.js";
12
13
  import { pickTarget, extractConversationIdFromUrl, buildConversationUrl, withTimeout, openConversationFromSidebar, openConversationFromSidebarWithRetry, waitForLocationChange, readConversationTurnIndex, buildPromptEchoMatcher, recoverPromptEcho, alignPromptEchoMarkdown, } from "./reattachHelpers.js";
@@ -14,6 +15,12 @@ import { waitForDeepResearchCompletion } from "./actions/deepResearch.js";
14
15
  export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
15
16
  const recoverSession = deps.recoverSession ??
16
17
  (async (runtimeMeta, configMeta) => resumeBrowserSessionViaNewChrome(runtimeMeta, configMeta, logger, deps));
18
+ let closeAttachedConnection = null;
19
+ const closeAttached = async () => {
20
+ const close = closeAttachedConnection;
21
+ closeAttachedConnection = null;
22
+ await close?.().catch(() => undefined);
23
+ };
17
24
  if (!runtime.chromePort && !runtime.chromeBrowserWSEndpoint) {
18
25
  logger("No running Chrome detected; reopening browser to locate the session.");
19
26
  return recoverSession(runtime, config);
@@ -37,8 +44,8 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
37
44
  targetId: target?.targetId ?? target?.id,
38
45
  closeTargetOnDispose: false,
39
46
  })
40
- : {
41
- client: (await (deps.connect ?? ((options) => CDP(options)))(browserWSEndpoint
47
+ : await (async () => {
48
+ const client = (await (deps.connect ?? ((options) => CDP(options)))(browserWSEndpoint
42
49
  ? {
43
50
  target: browserWSEndpoint,
44
51
  local: true,
@@ -48,9 +55,10 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
48
55
  host,
49
56
  port,
50
57
  target: target?.targetId ?? target?.id,
51
- })),
52
- close: async () => undefined,
53
- };
58
+ }));
59
+ return { client, close: () => client.close() };
60
+ })();
61
+ closeAttachedConnection = () => connection.close();
54
62
  const client = connection.client;
55
63
  const { Runtime, DOM, Page } = client;
56
64
  if (Runtime?.enable) {
@@ -97,7 +105,7 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
97
105
  const researchResult = await withTimeout(waitForDeepResearch(Runtime, logger, timeoutMs, minTurnIndex ?? undefined, Page, client, {
98
106
  requireScopedTargetOwner: true,
99
107
  }), timeoutMs + 5_000, "Reattach Deep Research response timed out");
100
- await connection.close().catch(() => undefined);
108
+ await closeAttached();
101
109
  return {
102
110
  answerText: researchResult.text,
103
111
  answerMarkdown: researchResult.text,
@@ -108,10 +116,11 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
108
116
  const recovered = await recoverPromptEcho(Runtime, answer, promptEcho, logger, minTurnIndex, timeoutMs);
109
117
  const markdown = (await withTimeout(captureMarkdown(Runtime, recovered.meta, logger), 15_000, "Reattach markdown capture timed out")) ?? recovered.text;
110
118
  const aligned = alignPromptEchoMarkdown(recovered.text, markdown, promptEcho, logger);
111
- await connection.close().catch(() => undefined);
119
+ await closeAttached();
112
120
  return { answerText: aligned.answerText, answerMarkdown: aligned.answerMarkdown };
113
121
  }
114
122
  catch (error) {
123
+ await closeAttached();
115
124
  const message = error instanceof Error ? error.message : String(error);
116
125
  logger(`Existing Chrome reattach failed (${message}); reopening browser to locate the session.`);
117
126
  return recoverSession(runtime, config);
@@ -163,7 +172,7 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
163
172
  const chrome = await launchChrome(resolved, userDataDir, logger);
164
173
  const chromeHost = chrome.host ?? "127.0.0.1";
165
174
  const client = await connectToChrome(chrome.port, logger, chromeHost);
166
- const { Network, Page, Runtime, DOM } = client;
175
+ const { Network, Page, Runtime, DOM, Target } = client;
167
176
  if (Runtime?.enable) {
168
177
  await Runtime.enable();
169
178
  }
@@ -183,6 +192,13 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
183
192
  waitMs: resolved.cookieSyncWaitMs ?? 0,
184
193
  });
185
194
  }
195
+ await clearStaleChatGptConversationCookies(Network, Target, logger, {
196
+ preserveConversationIds: [
197
+ runtime.conversationId,
198
+ extractConversationIdFromUrl(runtime.tabUrl ?? ""),
199
+ extractConversationIdFromUrl(resolved.url),
200
+ ],
201
+ });
186
202
  await navigateToChatGPT(Page, Runtime, CHATGPT_URL, logger);
187
203
  await ensureNotBlocked(Runtime, resolved.headless, logger);
188
204
  await ensureLoggedIn(Runtime, logger, { appliedCookies });
@@ -268,7 +284,7 @@ async function readPromptPreviewTurnIndex(Runtime, promptPreview) {
268
284
  const needle = ${JSON.stringify(preview.toLowerCase().replace(/\s+/g, " ").slice(0, 120))};
269
285
  if (!needle) return null;
270
286
  const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
271
- const turns = Array.from(document.querySelectorAll(${JSON.stringify(CONVERSATION_TURN_SELECTOR)}));
287
+ const turns = ${buildConversationTurnListExpression()};
272
288
  let matched = null;
273
289
  for (const [index, node] of turns.entries()) {
274
290
  const attr = (node.getAttribute('data-message-author-role') || node.getAttribute('data-turn') || node.dataset?.turn || '').toLowerCase();
@@ -1,15 +1,25 @@
1
1
  import { CONVERSATION_TURN_SELECTOR } from "./constants.js";
2
+ import { buildConversationTurnCountExpression } from "./conversationTurns.js";
2
3
  import { delay } from "./utils.js";
3
4
  import { readAssistantSnapshot } from "./pageActions.js";
4
5
  export function pickTarget(targets, runtime) {
5
6
  if (!Array.isArray(targets) || targets.length === 0) {
6
7
  return undefined;
7
8
  }
8
- if (runtime.chromeTargetId) {
9
- const byId = targets.find((t) => (t.targetId ?? t.id) === runtime.chromeTargetId);
10
- if (byId)
9
+ const conversationId = runtime.conversationId ?? extractConversationIdFromUrl(runtime.tabUrl ?? "");
10
+ const byId = runtime.chromeTargetId
11
+ ? targets.find((target) => (target.targetId ?? target.id) === runtime.chromeTargetId)
12
+ : undefined;
13
+ if (conversationId) {
14
+ if (byId && extractConversationIdFromUrl(byId.url ?? "") === conversationId) {
11
15
  return byId;
16
+ }
17
+ const byConversation = targets.find((target) => extractConversationIdFromUrl(target.url ?? "") === conversationId);
18
+ if (byConversation)
19
+ return byConversation;
12
20
  }
21
+ if (byId)
22
+ return byId;
13
23
  if (runtime.tabUrl) {
14
24
  const byUrl = targets.find((t) => t.url?.startsWith(runtime.tabUrl)) ||
15
25
  targets.find((t) => runtime.tabUrl.startsWith(t.url || ""));
@@ -253,10 +263,9 @@ export async function waitForLocationChange(Runtime, timeoutMs) {
253
263
  }
254
264
  }
255
265
  export async function readConversationTurnIndex(Runtime, logger) {
256
- const selectorLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
257
266
  try {
258
267
  const { result } = await Runtime.evaluate({
259
- expression: `document.querySelectorAll(${selectorLiteral}).length`,
268
+ expression: buildConversationTurnCountExpression(),
260
269
  returnByValue: true,
261
270
  });
262
271
  const raw = typeof result?.value === "number" ? result.value : Number(result?.value);
@@ -1,6 +1,6 @@
1
1
  import process from "node:process";
2
2
  import { startOscProgress as startOscProgressShared, supportsOscProgress as supportsOscProgressShared, } from "osc-progress";
3
- export function supportsOscProgress(env = process.env, isTty = process.stdout.isTTY) {
3
+ export function supportsOscProgress(env = process.env, isTty = process.stdout.isTTY === true) {
4
4
  if (env.CODEX_MANAGED_BY_NPM === "1" && env.ORACLE_FORCE_OSC_PROGRESS !== "1") {
5
5
  return false;
6
6
  }
@@ -16,8 +16,9 @@ export function startOscProgress(options = {}) {
16
16
  }
17
17
  return startOscProgressShared({
18
18
  ...options,
19
- // Preserve Oracle's previous default: progress emits to stdout.
19
+ // Preserve Oracle's previous defaults: progress emits to and checks stdout.
20
20
  write: options.write ?? ((text) => process.stdout.write(text)),
21
+ isTty: options.isTty ?? process.stdout.isTTY === true,
21
22
  disableEnvVar: "ORACLE_NO_OSC_PROGRESS",
22
23
  forceEnvVar: "ORACLE_FORCE_OSC_PROGRESS",
23
24
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.5 Pro, GPT-5.5, GPT-5.4, GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "keywords": [],
6
6
  "homepage": "https://askoracle.sh",
@@ -58,7 +58,7 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@anthropic-ai/tokenizer": "^0.0.4",
61
- "@google/genai": "^2.9.0",
61
+ "@google/genai": "^2.10.0",
62
62
  "@google/generative-ai": "^0.24.1",
63
63
  "@modelcontextprotocol/sdk": "^1.29.0",
64
64
  "@steipete/sweet-cookie": "^0.4.0",
@@ -73,31 +73,31 @@
73
73
  "inquirer": "14.0.2",
74
74
  "json5": "^2.2.3",
75
75
  "kleur": "^4.1.5",
76
- "markdansi": "0.3.1",
77
- "openai": "^6.44.0",
78
- "osc-progress": "^0.3.1",
79
- "qs": "^6.15.2",
80
- "shiki": "^4.2.0",
76
+ "markdansi": "0.3.2",
77
+ "openai": "^6.45.0",
78
+ "osc-progress": "^0.3.2",
79
+ "qs": "^6.15.3",
80
+ "shiki": "^4.3.1",
81
81
  "toasted-notifier": "^10.1.0",
82
- "tokentally": "^0.1.1",
82
+ "tokentally": "^0.1.2",
83
83
  "zod": "^4.4.3"
84
84
  },
85
85
  "devDependencies": {
86
86
  "@anthropic-ai/tokenizer": "^0.0.4",
87
87
  "@types/chrome-remote-interface": "^0.34.0",
88
88
  "@types/inquirer": "^9.0.10",
89
- "@types/node": "^26.0.0",
90
- "@typescript/native-preview": "7.0.0-dev.20260623.1",
91
- "@vitest/coverage-v8": "4.1.9",
92
- "devtools-protocol": "0.0.1648812",
93
- "es-toolkit": "^1.48.1",
89
+ "@types/node": "^26.1.0",
90
+ "@typescript/native-preview": "7.0.0-dev.20260706.1",
91
+ "@vitest/coverage-v8": "4.1.10",
92
+ "devtools-protocol": "0.0.1656784",
93
+ "es-toolkit": "^1.49.0",
94
94
  "esbuild": "^0.28.1",
95
- "oxfmt": "0.56.0",
96
- "oxlint": "^1.71.0",
97
- "puppeteer-core": "^25.2.0",
98
- "tsx": "^4.22.4",
95
+ "oxfmt": "0.57.0",
96
+ "oxlint": "^1.72.0",
97
+ "puppeteer-core": "^25.3.0",
98
+ "tsx": "^4.23.0",
99
99
  "typescript": "^6.0.3",
100
- "vitest": "^4.1.9"
100
+ "vitest": "^4.1.10"
101
101
  },
102
102
  "devEngines": {
103
103
  "runtime": [