@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
@@ -1,7 +1,8 @@
1
1
  import CDP from "chrome-remote-interface";
2
2
  import { createHash } from "node:crypto";
3
- import { ANSWER_SELECTORS, ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, INPUT_SELECTORS, MODEL_BUTTON_SELECTOR, SEND_BUTTON_SELECTORS, STOP_BUTTON_SELECTOR, } from "./constants.js";
3
+ import { ANSWER_SELECTORS, ASSISTANT_ROLE_SELECTOR, INPUT_SELECTORS, MODEL_BUTTON_SELECTOR, SEND_BUTTON_SELECTORS, STOP_BUTTON_SELECTOR, } from "./constants.js";
4
4
  import { captureAssistantMarkdown, readAssistantSnapshot } from "./actions/assistantResponse.js";
5
+ import { buildConversationTurnListExpression } from "./conversationTurns.js";
5
6
  import { delay } from "./utils.js";
6
7
  export const DEFAULT_REMOTE_CHROME_HOST = "127.0.0.1";
7
8
  export const DEFAULT_REMOTE_CHROME_PORT = 9222;
@@ -58,7 +59,6 @@ function buildTabInspectionExpression() {
58
59
  const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
59
60
  const sendSelectorsLiteral = JSON.stringify(SEND_BUTTON_SELECTORS);
60
61
  const answerSelectorsLiteral = JSON.stringify(ANSWER_SELECTORS);
61
- const turnSelectorLiteral = escapeLiteral(CONVERSATION_TURN_SELECTOR);
62
62
  const assistantRoleLiteral = escapeLiteral(ASSISTANT_ROLE_SELECTOR);
63
63
  const modelButtonSelectorLiteral = escapeLiteral(MODEL_BUTTON_SELECTOR);
64
64
  const stopSelectorLiteral = escapeLiteral(STOP_BUTTON_SELECTOR);
@@ -66,7 +66,6 @@ function buildTabInspectionExpression() {
66
66
  const INPUT_SELECTORS = ${inputSelectorsLiteral};
67
67
  const SEND_SELECTORS = ${sendSelectorsLiteral};
68
68
  const ANSWER_SELECTORS = ${answerSelectorsLiteral};
69
- const TURN_SELECTOR = ${turnSelectorLiteral};
70
69
  const ASSISTANT_ROLE_SELECTOR = ${assistantRoleLiteral};
71
70
  const MODEL_BUTTON_SELECTOR = ${modelButtonSelectorLiteral};
72
71
  const STOP_BUTTON_SELECTOR = ${stopSelectorLiteral};
@@ -97,15 +96,21 @@ function buildTabInspectionExpression() {
97
96
  const sendExists = Boolean(sendButton);
98
97
  const promptNode = firstVisible(INPUT_SELECTORS);
99
98
  const promptReady = Boolean(promptNode);
100
- const turns = Array.from(document.querySelectorAll(TURN_SELECTOR));
99
+ const turns = ${buildConversationTurnListExpression()};
101
100
  const assistantTurns = turns.filter((turn) => {
102
101
  const role = normalize(turn.getAttribute('data-message-author-role') || turn.getAttribute('data-turn')).toLowerCase();
103
102
  if (role === 'assistant') return true;
104
103
  return Boolean(turn.querySelector(ASSISTANT_ROLE_SELECTOR));
105
104
  });
105
+ const fallbackUserTurns = Array.from(
106
+ document.querySelectorAll('[data-message-author-role="user"], [data-turn="user"]'),
107
+ );
106
108
  const userTurns = turns.filter((turn) => {
107
109
  const role = normalize(turn.getAttribute('data-message-author-role') || turn.getAttribute('data-turn')).toLowerCase();
108
- return role === 'user';
110
+ if (role === 'user') return true;
111
+ return Boolean(
112
+ turn.querySelector('[data-message-author-role="user"], [data-turn="user"]'),
113
+ );
109
114
  });
110
115
  const answerNode = ANSWER_SELECTORS
111
116
  .map((selector) => document.querySelectorAll(selector))
@@ -116,16 +121,47 @@ function buildTabInspectionExpression() {
116
121
  if (currentModelLabel === 'ChatGPT' && hasProPill) {
117
122
  currentModelLabel = 'ChatGPT + Pro';
118
123
  }
119
- const assistantTexts = assistantTurns
120
- .map((node) => normalize(node.textContent))
121
- .filter(Boolean);
122
- const userTexts = userTurns
123
- .map((node) => normalize(node.textContent))
124
- .filter(Boolean);
125
- const answerTexts = Array.from(answerNode || []).map((node) => normalize(node.textContent)).filter(Boolean);
126
- const assistantCount = assistantTurns.length > 0 ? assistantTurns.length : answerTexts.length;
127
- const lastAssistantText = assistantTexts[assistantTexts.length - 1] || answerTexts[answerTexts.length - 1] || '';
128
- const lastUserText = userTexts[userTexts.length - 1] || '';
124
+ const rawAnswerNodes = Array.from(answerNode || []);
125
+ const userCandidates = Array.from(new Set([...userTurns, ...fallbackUserTurns]));
126
+ const lastUserTurn = userCandidates.reduce((latest, candidate) => {
127
+ if (!latest) return candidate;
128
+ return latest.compareDocumentPosition(candidate) & 4 ? candidate : latest;
129
+ }, null);
130
+ const lastUserContainer = lastUserTurn
131
+ ? turns.find((turn) => turn === lastUserTurn || turn.contains?.(lastUserTurn))
132
+ : null;
133
+ const answerNodes = rawAnswerNodes.filter(
134
+ (node) =>
135
+ !lastUserTurn ||
136
+ (node !== lastUserTurn &&
137
+ !lastUserTurn.contains?.(node) &&
138
+ !node.contains?.(lastUserTurn)),
139
+ );
140
+ const answerTexts = answerNodes.map((node) => normalize(node.textContent)).filter(Boolean);
141
+ const assistantCandidates = Array.from(new Set([...assistantTurns, ...answerNodes]));
142
+ const lastAssistantNode = assistantCandidates.reduce((latest, candidate) => {
143
+ if (!latest) return candidate;
144
+ return latest.compareDocumentPosition(candidate) & 4 ? candidate : latest;
145
+ }, null);
146
+ const lastAssistantContainer = lastAssistantNode
147
+ ? turns.find((turn) => turn === lastAssistantNode || turn.contains?.(lastAssistantNode))
148
+ : null;
149
+ const assistantFollowsLatestUser = Boolean(
150
+ lastAssistantNode &&
151
+ lastUserTurn &&
152
+ lastAssistantNode !== lastUserTurn &&
153
+ (lastUserTurn.compareDocumentPosition(lastAssistantNode) & 4),
154
+ );
155
+ const lastAssistantTurnIndex = lastAssistantContainer
156
+ ? turns.indexOf(lastAssistantContainer)
157
+ : -1;
158
+ const lastUserTurnIndex = lastUserContainer ? turns.indexOf(lastUserContainer) : -1;
159
+ const assistantOwners = assistantCandidates.map(
160
+ (node) => turns.find((turn) => turn === node || turn.contains?.(node)) || node,
161
+ );
162
+ const assistantCount = new Set(assistantOwners).size;
163
+ const lastAssistantText = normalize(lastAssistantNode?.textContent);
164
+ const lastUserText = normalize(lastUserTurn?.textContent);
129
165
  const authenticated = !loginButtonExists && (promptReady || sendExists || stopExists || assistantCount > 0);
130
166
  return {
131
167
  title: normalize(document.title),
@@ -138,12 +174,18 @@ function buildTabInspectionExpression() {
138
174
  authenticated,
139
175
  assistantCount,
140
176
  lastAssistantText,
177
+ assistantFollowsLatestUser,
178
+ lastAssistantTurnIndex,
179
+ lastUserTurnIndex,
141
180
  lastUserText,
142
181
  visibilityState: document.visibilityState,
143
182
  focused: Boolean(document.hasFocus?.()),
144
183
  };
145
184
  })()`;
146
185
  }
186
+ export function buildTabInspectionExpressionForTest() {
187
+ return buildTabInspectionExpression();
188
+ }
147
189
  export async function listChatGptTargets(options = {}) {
148
190
  const { host, port } = normalizeHostPort(options);
149
191
  const targets = (await CDP.List({ host, port }));
@@ -183,7 +225,20 @@ export async function inspectChatGptTab(options) {
183
225
  });
184
226
  const info = (evaluation.result?.value ?? {});
185
227
  const snapshot = await readAssistantSnapshot(Runtime).catch(() => null);
186
- const lastAssistantText = typeof snapshot?.text === "string" && snapshot.text.trim().length > 0
228
+ const inspectedAssistantTurnIndex = typeof info.lastAssistantTurnIndex === "number" && info.lastAssistantTurnIndex >= 0
229
+ ? info.lastAssistantTurnIndex
230
+ : undefined;
231
+ const normalizedSnapshotText = normalizeTitle(snapshot?.text ?? "").toLowerCase();
232
+ const normalizedInspectedText = normalizeTitle(info.lastAssistantText ?? "").toLowerCase();
233
+ const snapshotMatchesInspectedTurn = (typeof snapshot?.turnIndex === "number" &&
234
+ snapshot.turnIndex === inspectedAssistantTurnIndex) ||
235
+ (snapshot?.turnIndex == null &&
236
+ inspectedAssistantTurnIndex === undefined &&
237
+ normalizedSnapshotText.length > 0 &&
238
+ normalizedSnapshotText === normalizedInspectedText);
239
+ const lastAssistantText = snapshotMatchesInspectedTurn &&
240
+ typeof snapshot?.text === "string" &&
241
+ snapshot.text.trim().length > 0
187
242
  ? snapshot.text.trim()
188
243
  : String(info.lastAssistantText ?? "").trim();
189
244
  const lastUserText = String(info.lastUserText ?? "").trim();
@@ -201,6 +256,11 @@ export async function inspectChatGptTab(options) {
201
256
  authenticated: Boolean(info.authenticated),
202
257
  assistantCount: Number.isFinite(info.assistantCount) ? Number(info.assistantCount) : 0,
203
258
  lastAssistantText,
259
+ assistantFollowsLatestUser: Boolean(info.assistantFollowsLatestUser),
260
+ lastAssistantTurnIndex: inspectedAssistantTurnIndex,
261
+ lastUserTurnIndex: typeof info.lastUserTurnIndex === "number" && info.lastUserTurnIndex >= 0
262
+ ? info.lastUserTurnIndex
263
+ : undefined,
204
264
  lastAssistantSnippet: trimToSnippet(lastAssistantText),
205
265
  lastUserText,
206
266
  lastUserSnippet: trimToSnippet(lastUserText),
@@ -210,8 +270,12 @@ export async function inspectChatGptTab(options) {
210
270
  fingerprint: "",
211
271
  state: "detached",
212
272
  lastAssistantMarkdown: null,
213
- lastAssistantMessageId: typeof snapshot?.messageId === "string" ? snapshot.messageId : undefined,
214
- lastAssistantTurnId: typeof snapshot?.turnId === "string" ? snapshot.turnId : undefined,
273
+ lastAssistantMessageId: snapshotMatchesInspectedTurn && typeof snapshot?.messageId === "string"
274
+ ? snapshot.messageId
275
+ : undefined,
276
+ lastAssistantTurnId: snapshotMatchesInspectedTurn && typeof snapshot?.turnId === "string"
277
+ ? snapshot.turnId
278
+ : undefined,
215
279
  };
216
280
  summary.state = classifyTabState(summary);
217
281
  summary.fingerprint = buildTargetFingerprint(summary);
@@ -292,6 +356,10 @@ function resolveChatGptTabFromSummaries(summaries, ref) {
292
356
  if (exactUrl) {
293
357
  return exactUrl;
294
358
  }
359
+ const exactConversationId = summaries.find((tab) => tab.conversationId === trimmedRef);
360
+ if (exactConversationId) {
361
+ return exactConversationId;
362
+ }
295
363
  const lower = trimmedRef.toLowerCase();
296
364
  const titleMatches = summaries.filter((tab) => tab.title.toLowerCase().includes(lower));
297
365
  if (titleMatches.length === 1) {
@@ -328,17 +396,6 @@ export async function harvestChatGptTab(options = {}) {
328
396
  try {
329
397
  const { Runtime } = client;
330
398
  const snapshot = await readAssistantSnapshot(Runtime).catch(() => null);
331
- let assistantMarkdown = null;
332
- if (snapshot?.messageId || snapshot?.turnId) {
333
- assistantMarkdown = await captureAssistantMarkdown(Runtime, {
334
- messageId: snapshot.messageId,
335
- turnId: snapshot.turnId,
336
- }, noopLogger).catch(() => null);
337
- }
338
- const latestText = typeof snapshot?.text === "string" && snapshot.text.trim().length > 0
339
- ? snapshot.text.trim()
340
- : resolved.lastAssistantText;
341
- const lastAssistantText = latestText ?? "";
342
399
  const nowSummary = await inspectChatGptTab({
343
400
  host,
344
401
  port,
@@ -349,15 +406,37 @@ export async function harvestChatGptTab(options = {}) {
349
406
  type: "page",
350
407
  },
351
408
  });
409
+ const normalizedSnapshotText = normalizeTitle(snapshot?.text ?? "").toLowerCase();
410
+ const normalizedInspectedText = normalizeTitle(nowSummary.lastAssistantText).toLowerCase();
411
+ const snapshotMatchesLatestTurn = (typeof snapshot?.turnIndex === "number" &&
412
+ snapshot.turnIndex === nowSummary.lastAssistantTurnIndex) ||
413
+ (snapshot?.turnIndex == null &&
414
+ nowSummary.lastAssistantTurnIndex === undefined &&
415
+ normalizedSnapshotText.length > 0 &&
416
+ normalizedSnapshotText === normalizedInspectedText);
417
+ let assistantMarkdown = null;
418
+ if (snapshotMatchesLatestTurn && (snapshot?.messageId || snapshot?.turnId)) {
419
+ assistantMarkdown = await captureAssistantMarkdown(Runtime, {
420
+ messageId: snapshot.messageId,
421
+ turnId: snapshot.turnId,
422
+ }, noopLogger).catch(() => null);
423
+ }
424
+ const lastAssistantText = snapshotMatchesLatestTurn &&
425
+ typeof snapshot?.text === "string" &&
426
+ snapshot.text.trim().length > 0
427
+ ? snapshot.text.trim()
428
+ : nowSummary.lastAssistantText;
352
429
  const harvested = {
353
430
  ...nowSummary,
354
431
  lastAssistantText,
355
432
  lastAssistantSnippet: trimToSnippet(lastAssistantText),
356
433
  lastAssistantMarkdown: assistantMarkdown ?? (lastAssistantText || null),
357
- lastAssistantMessageId: typeof snapshot?.messageId === "string"
434
+ lastAssistantMessageId: snapshotMatchesLatestTurn && typeof snapshot?.messageId === "string"
358
435
  ? snapshot.messageId
359
436
  : nowSummary.lastAssistantMessageId,
360
- lastAssistantTurnId: typeof snapshot?.turnId === "string" ? snapshot.turnId : nowSummary.lastAssistantTurnId,
437
+ lastAssistantTurnId: snapshotMatchesLatestTurn && typeof snapshot?.turnId === "string"
438
+ ? snapshot.turnId
439
+ : nowSummary.lastAssistantTurnId,
361
440
  };
362
441
  if (harvested.stopExists && options.stallWindowMs && options.stallWindowMs > 0) {
363
442
  const firstFingerprint = harvested.fingerprint;
@@ -383,6 +462,9 @@ export async function harvestChatGptTab(options = {}) {
383
462
  harvested.loginButtonExists = followup.loginButtonExists;
384
463
  harvested.lastUserText = followup.lastUserText;
385
464
  harvested.lastUserSnippet = followup.lastUserSnippet;
465
+ harvested.assistantFollowsLatestUser = followup.assistantFollowsLatestUser;
466
+ harvested.lastAssistantTurnIndex = followup.lastAssistantTurnIndex;
467
+ harvested.lastUserTurnIndex = followup.lastUserTurnIndex;
386
468
  harvested.fingerprint = followup.fingerprint;
387
469
  harvested.state =
388
470
  harvested.stopExists && firstFingerprint === followup.fingerprint
@@ -1,4 +1,4 @@
1
- export { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, } from "./actions/navigation.js";
1
+ export { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureChatMode, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, } from "./actions/navigation.js";
2
2
  export { ensureModelSelection } from "./actions/modelSelection.js";
3
3
  export { submitPrompt, clearPromptComposer } from "./actions/promptComposer.js";
4
4
  export { clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, buildUserTurnAttachmentExpressionForTest, } from "./actions/attachments.js";
@@ -1,9 +1,9 @@
1
1
  import { mkdtemp, mkdir, rm } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- import { closeTab, connectWithNewTab, hideChromeWindow, launchChrome, registerTerminationHooks, } from "./chromeLifecycle.js";
4
+ import { closeTab, connectWithNewTab, launchChrome, positionChromeWindowOffscreen, 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,15 +120,15 @@ 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;
124
- if (!config.headless && config.hideWindow) {
125
- await hideChromeWindow(chrome, logger);
126
- }
123
+ const { Network, Page, Runtime, Input, DOM, Target } = client;
127
124
  const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
128
125
  if (DOM && typeof DOM.enable === "function") {
129
126
  domainEnablers.push(DOM.enable());
130
127
  }
131
128
  await Promise.all(domainEnablers);
129
+ if (!config.headless && config.hideWindow) {
130
+ await positionChromeWindowOffscreen(client, logger);
131
+ }
132
132
  removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
133
133
  if (!manualLogin) {
134
134
  await Network.clearBrowserCookies();
@@ -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,
@@ -3,10 +3,11 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { mkdtemp, mkdir, rm } from "node:fs/promises";
5
5
  import { waitForAssistantResponse, captureAssistantMarkdown, navigateToChatGPT, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, } from "./pageActions.js";
6
- import { launchChrome, connectToChrome, hideChromeWindow, connectToRemoteChromeTarget, listRemoteChromeTargets, } from "./chromeLifecycle.js";
6
+ import { launchChrome, connectToChrome, positionChromeWindowOffscreen, 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
  }
@@ -171,7 +180,7 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
171
180
  await DOM.enable();
172
181
  }
173
182
  if (!resolved.headless && resolved.hideWindow) {
174
- await hideChromeWindow(chrome, logger);
183
+ await positionChromeWindowOffscreen(client, logger);
175
184
  }
176
185
  let appliedCookies = 0;
177
186
  if (!manualLogin && resolved.cookieSync) {
@@ -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,7 +1,10 @@
1
- import { launch } from "chrome-launcher";
2
- import { defaultManualLoginProfileDir } from "./manualLoginProfile.js";
1
+ import { isAnswerNowPlaceholderText } from "./actions/assistantResponse.js";
2
+ import { resolveBrowserConfig } from "./config.js";
3
+ import { acquireManualLoginChromeForRun, isImageOnlyUiChromeText } from "./index.js";
3
4
  import { isRecoverableChatGptConversationUrl } from "./reattachability.js";
4
- const DEFAULT_HYDRATION_DELAY_MS = 3_000;
5
+ import { harvestChatGptTab, openChatGptTarget } from "./liveTabs.js";
6
+ const DEFAULT_READY_TIMEOUT_MS = 30_000;
7
+ const READY_POLL_MS = 1_000;
5
8
  /**
6
9
  * Picks the URL to navigate the recovered Chrome tab to.
7
10
  *
@@ -23,12 +26,54 @@ export function resolveRecoveryUrl(meta) {
23
26
  }
24
27
  return null;
25
28
  }
26
- function resolveProfileDir(meta) {
27
- const fromMeta = meta?.browser?.config?.manualLoginProfileDir;
28
- if (typeof fromMeta === "string" && fromMeta.length > 0) {
29
- return fromMeta;
29
+ export function resolveRecoveryProfileDir(meta) {
30
+ const config = meta?.browser?.config;
31
+ const resolved = resolveBrowserConfig(config);
32
+ if (!resolved.manualLogin) {
33
+ throw new Error("Cannot recover conversation: session was not run with a manual-login browser profile.");
30
34
  }
31
- return defaultManualLoginProfileDir();
35
+ const runtime = meta?.browser?.runtime;
36
+ const profileDir = runtime?.userDataDir ?? resolved.manualLoginProfileDir;
37
+ if (typeof profileDir !== "string" || profileDir.trim().length === 0) {
38
+ throw new Error("Cannot recover conversation: session metadata has no recorded manual-login profile directory.");
39
+ }
40
+ return profileDir;
41
+ }
42
+ async function waitForRecoveredConversationReady(endpoint, ref, timeoutMs) {
43
+ const deadline = Date.now() + timeoutMs;
44
+ let lastError = null;
45
+ while (Date.now() < deadline) {
46
+ try {
47
+ const harvested = await harvestChatGptTab({ ...endpoint, ref });
48
+ if (isRecoveredConversationHarvestReady(harvested)) {
49
+ return;
50
+ }
51
+ lastError = new Error(`recovered tab is still ${harvested.state}`);
52
+ }
53
+ catch (error) {
54
+ lastError = error;
55
+ }
56
+ await new Promise((resolve) => setTimeout(resolve, READY_POLL_MS));
57
+ }
58
+ const suffix = lastError instanceof Error ? ` Last error: ${lastError.message}` : "";
59
+ throw new Error(`Recovered ChatGPT conversation did not become ready in time.${suffix}`);
60
+ }
61
+ export function isRecoveredConversationHarvestReady(harvested) {
62
+ const latestAssistant = harvested.lastAssistantText ??
63
+ harvested.lastAssistantMarkdown ??
64
+ harvested.lastAssistantSnippet ??
65
+ "";
66
+ const assistantFollowsLatestUser = harvested.assistantFollowsLatestUser === true ||
67
+ (typeof harvested.lastAssistantTurnIndex === "number" &&
68
+ typeof harvested.lastUserTurnIndex === "number" &&
69
+ harvested.lastAssistantTurnIndex > harvested.lastUserTurnIndex);
70
+ return (harvested.stopExists === true ||
71
+ ((harvested.assistantCount ?? 0) > 0 &&
72
+ assistantFollowsLatestUser &&
73
+ latestAssistant.trim().length > 0 &&
74
+ !isImageOnlyUiChromeText(latestAssistant) &&
75
+ !isAnswerNowPlaceholderText(latestAssistant) &&
76
+ !/^answer now$/i.test(latestAssistant.trim())));
32
77
  }
33
78
  /**
34
79
  * Re-open a previously-harvested ChatGPT conversation by relaunching Chrome
@@ -46,28 +91,44 @@ export async function recoverConversationTab(meta, logger, options = {}) {
46
91
  throw new Error("Cannot recover conversation: session metadata has no recoverable ChatGPT conversation URL " +
47
92
  "(expected browser.harvest.url or browser.runtime.tabUrl to be a chatgpt.com/c/<id> URL).");
48
93
  }
49
- const userDataDir = resolveProfileDir(meta);
94
+ const readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
95
+ const waitForReady = options.waitForReady !== false;
96
+ if (options.existingEndpoint) {
97
+ try {
98
+ logger(`[browser] Recovery: opening saved conversation in existing Chrome at ` +
99
+ `${options.existingEndpoint.host}:${options.existingEndpoint.port}`);
100
+ const targetId = await openChatGptTarget({ ...options.existingEndpoint, url });
101
+ if (waitForReady) {
102
+ await waitForRecoveredConversationReady(options.existingEndpoint, targetId, readyTimeoutMs);
103
+ }
104
+ return { ...options.existingEndpoint, url, ref: targetId, chrome: null };
105
+ }
106
+ catch (error) {
107
+ const message = error instanceof Error ? error.message : String(error);
108
+ logger(`[browser] Recovery: existing Chrome could not reopen the conversation (${message}).`);
109
+ }
110
+ }
111
+ const userDataDir = resolveRecoveryProfileDir(meta);
112
+ const config = resolveBrowserConfig(meta.browser?.config);
50
113
  logger(`[browser] Recovery: relaunching Chrome with profile ${userDataDir} and navigating to ${url}`);
51
- const chrome = await launch({
52
- chromeFlags: [
53
- "--no-first-run",
54
- "--no-default-browser-check",
55
- "--disable-features=AutomationControlled,TranslateUI",
56
- "--disable-sync",
57
- "--password-store=basic",
58
- "--use-mock-keychain",
59
- "--lang=en-US",
60
- url,
61
- ],
62
- userDataDir,
63
- handleSIGINT: false,
64
- });
65
- const host = "127.0.0.1";
114
+ const { chrome } = await acquireManualLoginChromeForRun(userDataDir, config, logger, meta.id, {});
115
+ const host = chrome.host ?? "127.0.0.1";
66
116
  const port = chrome.port;
67
- const hydrationDelayMs = options.hydrationDelayMs ?? DEFAULT_HYDRATION_DELAY_MS;
68
- if (hydrationDelayMs > 0) {
69
- await new Promise((resolve) => setTimeout(resolve, hydrationDelayMs));
117
+ try {
118
+ const targetId = await openChatGptTarget({ host, port, url });
119
+ if (waitForReady) {
120
+ await waitForRecoveredConversationReady({ host, port }, targetId, readyTimeoutMs);
121
+ }
122
+ logger(`[browser] Recovery: Chrome listening on ${host}:${port}; tab loaded.`);
123
+ return { host, port, url, ref: targetId, chrome };
124
+ }
125
+ catch (error) {
126
+ try {
127
+ chrome.kill();
128
+ }
129
+ catch {
130
+ // best-effort cleanup
131
+ }
132
+ throw error;
70
133
  }
71
- logger(`[browser] Recovery: Chrome listening on ${host}:${port}; tab loaded.`);
72
- return { host, port, url, chrome };
73
134
  }
@@ -16,6 +16,8 @@ const DEFAULT_CHROME_PROFILE = "Default";
16
16
  // The browser label is passed to the model picker which fuzzy-matches against ChatGPT's UI.
17
17
  const BROWSER_MODEL_LABELS = [
18
18
  // Most specific first (e.g., "gpt-5.2-thinking" before "gpt-5.2")
19
+ ["gpt-5.6-sol", "GPT-5.6 Sol"],
20
+ ["gpt-5.6", "GPT-5.6 Sol"],
19
21
  ["gpt-5.5-pro", "Pro"],
20
22
  ["gpt-5.5-instant", "GPT-5.5 Instant"],
21
23
  ["gpt-5.5", "Thinking 5.5"],
@@ -40,7 +42,9 @@ export function normalizeChatGptModelForBrowser(model) {
40
42
  if (!normalized.startsWith("gpt-") || normalized.includes("codex")) {
41
43
  return model;
42
44
  }
43
- if (normalized === "gpt-5.5-pro" ||
45
+ if (normalized === "gpt-5.6-sol" ||
46
+ normalized === "gpt-5.6" ||
47
+ normalized === "gpt-5.5-pro" ||
44
48
  normalized === "gpt-5.5-instant" ||
45
49
  normalized === "gpt-5.5" ||
46
50
  normalized === "gpt-5.4") {