@steipete/oracle 0.15.2 → 0.16.1

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 (79) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +1 -1
  3. package/dist/bin/oracle-mcp.js +0 -0
  4. package/dist/src/browser/actions/assistantResponse.js +218 -126
  5. package/dist/src/browser/actions/modelSelection.js +133 -22
  6. package/dist/src/browser/actions/navigation.js +235 -14
  7. package/dist/src/browser/actions/thinkingStatus.js +228 -0
  8. package/dist/src/browser/actions/thinkingTime.js +68 -11
  9. package/dist/src/browser/cdpLiveness.js +78 -0
  10. package/dist/src/browser/chromeLifecycle.js +112 -30
  11. package/dist/src/browser/config.js +14 -1
  12. package/dist/src/browser/constants.js +5 -1
  13. package/dist/src/browser/controlPlan.js +2 -2
  14. package/dist/src/browser/conversationUrl.js +16 -0
  15. package/dist/src/browser/conversationUrlMonitor.js +2 -4
  16. package/dist/src/browser/cookies.js +6 -4
  17. package/dist/src/browser/index.js +213 -57
  18. package/dist/src/browser/liveTabs.js +115 -33
  19. package/dist/src/browser/pageActions.js +1 -1
  20. package/dist/src/browser/projectSourcesRunner.js +4 -4
  21. package/dist/src/browser/reattach.js +2 -2
  22. package/dist/src/browser/reattachHelpers.js +3 -5
  23. package/dist/src/browser/reattachability.js +2 -1
  24. package/dist/src/browser/recoverConversation.js +90 -29
  25. package/dist/src/browser/tabLeaseRegistry.js +9 -3
  26. package/dist/src/cli/browserConfig.js +5 -1
  27. package/dist/src/cli/browserDefaults.js +4 -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/cli/sessionRunner.js +49 -2
  33. package/dist/src/oracle/config.js +25 -0
  34. package/dist/src/oracle/geminiModels.js +2 -0
  35. package/dist/src/remote/server.js +8 -0
  36. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  37. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  38. package/package.json +13 -14
  39. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  40. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  41. package/dist/bin/oracle.js +0 -569
  42. package/dist/docs-site/.nojekyll +0 -0
  43. package/dist/docs-site/CNAME +0 -1
  44. package/dist/docs-site/RELEASING.html +0 -410
  45. package/dist/docs-site/agents.html +0 -374
  46. package/dist/docs-site/anthropic.html +0 -368
  47. package/dist/docs-site/bridge.html +0 -416
  48. package/dist/docs-site/browser-mode.html +0 -594
  49. package/dist/docs-site/chromium-forks.html +0 -347
  50. package/dist/docs-site/cli-reference.html +0 -346
  51. package/dist/docs-site/configuration.html +0 -462
  52. package/dist/docs-site/favicon.svg +0 -14
  53. package/dist/docs-site/followup.html +0 -375
  54. package/dist/docs-site/gemini.html +0 -383
  55. package/dist/docs-site/grok.html +0 -325
  56. package/dist/docs-site/index.html +0 -360
  57. package/dist/docs-site/install.html +0 -335
  58. package/dist/docs-site/linux.html +0 -321
  59. package/dist/docs-site/llms.txt +0 -43
  60. package/dist/docs-site/manual-tests.html +0 -596
  61. package/dist/docs-site/mcp.html +0 -391
  62. package/dist/docs-site/multimodel.html +0 -364
  63. package/dist/docs-site/mythical-pro-agents.html +0 -360
  64. package/dist/docs-site/notifier.html +0 -338
  65. package/dist/docs-site/openai-endpoints.html +0 -399
  66. package/dist/docs-site/openrouter.html +0 -344
  67. package/dist/docs-site/quickstart.html +0 -369
  68. package/dist/docs-site/refactor/ux.html +0 -532
  69. package/dist/docs-site/sessions.html +0 -388
  70. package/dist/docs-site/social-card.png +0 -0
  71. package/dist/docs-site/social-card.svg +0 -79
  72. package/dist/docs-site/spec.html +0 -363
  73. package/dist/docs-site/testing.html +0 -320
  74. package/dist/docs-site/tui-debug.html +0 -326
  75. package/dist/docs-site/windows-work.html +0 -323
  76. package/dist/docs-site/windows.html +0 -320
  77. package/dist/src/browser/chromeCookies.js +0 -312
  78. package/dist/src/browser/keytarShim.js +0 -56
  79. package/dist/src/browser/windowsCookies.js +0 -219
@@ -1,7 +1,9 @@
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";
6
+ import { extractStableConversationIdFromUrl } from "./conversationUrl.js";
5
7
  import { delay } from "./utils.js";
6
8
  export const DEFAULT_REMOTE_CHROME_HOST = "127.0.0.1";
7
9
  export const DEFAULT_REMOTE_CHROME_PORT = 9222;
@@ -58,7 +60,6 @@ function buildTabInspectionExpression() {
58
60
  const inputSelectorsLiteral = JSON.stringify(INPUT_SELECTORS);
59
61
  const sendSelectorsLiteral = JSON.stringify(SEND_BUTTON_SELECTORS);
60
62
  const answerSelectorsLiteral = JSON.stringify(ANSWER_SELECTORS);
61
- const turnSelectorLiteral = escapeLiteral(CONVERSATION_TURN_SELECTOR);
62
63
  const assistantRoleLiteral = escapeLiteral(ASSISTANT_ROLE_SELECTOR);
63
64
  const modelButtonSelectorLiteral = escapeLiteral(MODEL_BUTTON_SELECTOR);
64
65
  const stopSelectorLiteral = escapeLiteral(STOP_BUTTON_SELECTOR);
@@ -66,7 +67,6 @@ function buildTabInspectionExpression() {
66
67
  const INPUT_SELECTORS = ${inputSelectorsLiteral};
67
68
  const SEND_SELECTORS = ${sendSelectorsLiteral};
68
69
  const ANSWER_SELECTORS = ${answerSelectorsLiteral};
69
- const TURN_SELECTOR = ${turnSelectorLiteral};
70
70
  const ASSISTANT_ROLE_SELECTOR = ${assistantRoleLiteral};
71
71
  const MODEL_BUTTON_SELECTOR = ${modelButtonSelectorLiteral};
72
72
  const STOP_BUTTON_SELECTOR = ${stopSelectorLiteral};
@@ -97,15 +97,21 @@ function buildTabInspectionExpression() {
97
97
  const sendExists = Boolean(sendButton);
98
98
  const promptNode = firstVisible(INPUT_SELECTORS);
99
99
  const promptReady = Boolean(promptNode);
100
- const turns = Array.from(document.querySelectorAll(TURN_SELECTOR));
100
+ const turns = ${buildConversationTurnListExpression()};
101
101
  const assistantTurns = turns.filter((turn) => {
102
102
  const role = normalize(turn.getAttribute('data-message-author-role') || turn.getAttribute('data-turn')).toLowerCase();
103
103
  if (role === 'assistant') return true;
104
104
  return Boolean(turn.querySelector(ASSISTANT_ROLE_SELECTOR));
105
105
  });
106
+ const fallbackUserTurns = Array.from(
107
+ document.querySelectorAll('[data-message-author-role="user"], [data-turn="user"]'),
108
+ );
106
109
  const userTurns = turns.filter((turn) => {
107
110
  const role = normalize(turn.getAttribute('data-message-author-role') || turn.getAttribute('data-turn')).toLowerCase();
108
- return role === 'user';
111
+ if (role === 'user') return true;
112
+ return Boolean(
113
+ turn.querySelector('[data-message-author-role="user"], [data-turn="user"]'),
114
+ );
109
115
  });
110
116
  const answerNode = ANSWER_SELECTORS
111
117
  .map((selector) => document.querySelectorAll(selector))
@@ -116,16 +122,47 @@ function buildTabInspectionExpression() {
116
122
  if (currentModelLabel === 'ChatGPT' && hasProPill) {
117
123
  currentModelLabel = 'ChatGPT + Pro';
118
124
  }
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] || '';
125
+ const rawAnswerNodes = Array.from(answerNode || []);
126
+ const userCandidates = Array.from(new Set([...userTurns, ...fallbackUserTurns]));
127
+ const lastUserTurn = userCandidates.reduce((latest, candidate) => {
128
+ if (!latest) return candidate;
129
+ return latest.compareDocumentPosition(candidate) & 4 ? candidate : latest;
130
+ }, null);
131
+ const lastUserContainer = lastUserTurn
132
+ ? turns.find((turn) => turn === lastUserTurn || turn.contains?.(lastUserTurn))
133
+ : null;
134
+ const answerNodes = rawAnswerNodes.filter(
135
+ (node) =>
136
+ !lastUserTurn ||
137
+ (node !== lastUserTurn &&
138
+ !lastUserTurn.contains?.(node) &&
139
+ !node.contains?.(lastUserTurn)),
140
+ );
141
+ const answerTexts = answerNodes.map((node) => normalize(node.textContent)).filter(Boolean);
142
+ const assistantCandidates = Array.from(new Set([...assistantTurns, ...answerNodes]));
143
+ const lastAssistantNode = assistantCandidates.reduce((latest, candidate) => {
144
+ if (!latest) return candidate;
145
+ return latest.compareDocumentPosition(candidate) & 4 ? candidate : latest;
146
+ }, null);
147
+ const lastAssistantContainer = lastAssistantNode
148
+ ? turns.find((turn) => turn === lastAssistantNode || turn.contains?.(lastAssistantNode))
149
+ : null;
150
+ const assistantFollowsLatestUser = Boolean(
151
+ lastAssistantNode &&
152
+ lastUserTurn &&
153
+ lastAssistantNode !== lastUserTurn &&
154
+ (lastUserTurn.compareDocumentPosition(lastAssistantNode) & 4),
155
+ );
156
+ const lastAssistantTurnIndex = lastAssistantContainer
157
+ ? turns.indexOf(lastAssistantContainer)
158
+ : -1;
159
+ const lastUserTurnIndex = lastUserContainer ? turns.indexOf(lastUserContainer) : -1;
160
+ const assistantOwners = assistantCandidates.map(
161
+ (node) => turns.find((turn) => turn === node || turn.contains?.(node)) || node,
162
+ );
163
+ const assistantCount = new Set(assistantOwners).size;
164
+ const lastAssistantText = normalize(lastAssistantNode?.textContent);
165
+ const lastUserText = normalize(lastUserTurn?.textContent);
129
166
  const authenticated = !loginButtonExists && (promptReady || sendExists || stopExists || assistantCount > 0);
130
167
  return {
131
168
  title: normalize(document.title),
@@ -138,12 +175,18 @@ function buildTabInspectionExpression() {
138
175
  authenticated,
139
176
  assistantCount,
140
177
  lastAssistantText,
178
+ assistantFollowsLatestUser,
179
+ lastAssistantTurnIndex,
180
+ lastUserTurnIndex,
141
181
  lastUserText,
142
182
  visibilityState: document.visibilityState,
143
183
  focused: Boolean(document.hasFocus?.()),
144
184
  };
145
185
  })()`;
146
186
  }
187
+ export function buildTabInspectionExpressionForTest() {
188
+ return buildTabInspectionExpression();
189
+ }
147
190
  export async function listChatGptTargets(options = {}) {
148
191
  const { host, port } = normalizeHostPort(options);
149
192
  const targets = (await CDP.List({ host, port }));
@@ -183,7 +226,20 @@ export async function inspectChatGptTab(options) {
183
226
  });
184
227
  const info = (evaluation.result?.value ?? {});
185
228
  const snapshot = await readAssistantSnapshot(Runtime).catch(() => null);
186
- const lastAssistantText = typeof snapshot?.text === "string" && snapshot.text.trim().length > 0
229
+ const inspectedAssistantTurnIndex = typeof info.lastAssistantTurnIndex === "number" && info.lastAssistantTurnIndex >= 0
230
+ ? info.lastAssistantTurnIndex
231
+ : undefined;
232
+ const normalizedSnapshotText = normalizeTitle(snapshot?.text ?? "").toLowerCase();
233
+ const normalizedInspectedText = normalizeTitle(info.lastAssistantText ?? "").toLowerCase();
234
+ const snapshotMatchesInspectedTurn = (typeof snapshot?.turnIndex === "number" &&
235
+ snapshot.turnIndex === inspectedAssistantTurnIndex) ||
236
+ (snapshot?.turnIndex == null &&
237
+ inspectedAssistantTurnIndex === undefined &&
238
+ normalizedSnapshotText.length > 0 &&
239
+ normalizedSnapshotText === normalizedInspectedText);
240
+ const lastAssistantText = snapshotMatchesInspectedTurn &&
241
+ typeof snapshot?.text === "string" &&
242
+ snapshot.text.trim().length > 0
187
243
  ? snapshot.text.trim()
188
244
  : String(info.lastAssistantText ?? "").trim();
189
245
  const lastUserText = String(info.lastUserText ?? "").trim();
@@ -201,6 +257,11 @@ export async function inspectChatGptTab(options) {
201
257
  authenticated: Boolean(info.authenticated),
202
258
  assistantCount: Number.isFinite(info.assistantCount) ? Number(info.assistantCount) : 0,
203
259
  lastAssistantText,
260
+ assistantFollowsLatestUser: Boolean(info.assistantFollowsLatestUser),
261
+ lastAssistantTurnIndex: inspectedAssistantTurnIndex,
262
+ lastUserTurnIndex: typeof info.lastUserTurnIndex === "number" && info.lastUserTurnIndex >= 0
263
+ ? info.lastUserTurnIndex
264
+ : undefined,
204
265
  lastAssistantSnippet: trimToSnippet(lastAssistantText),
205
266
  lastUserText,
206
267
  lastUserSnippet: trimToSnippet(lastUserText),
@@ -210,8 +271,12 @@ export async function inspectChatGptTab(options) {
210
271
  fingerprint: "",
211
272
  state: "detached",
212
273
  lastAssistantMarkdown: null,
213
- lastAssistantMessageId: typeof snapshot?.messageId === "string" ? snapshot.messageId : undefined,
214
- lastAssistantTurnId: typeof snapshot?.turnId === "string" ? snapshot.turnId : undefined,
274
+ lastAssistantMessageId: snapshotMatchesInspectedTurn && typeof snapshot?.messageId === "string"
275
+ ? snapshot.messageId
276
+ : undefined,
277
+ lastAssistantTurnId: snapshotMatchesInspectedTurn && typeof snapshot?.turnId === "string"
278
+ ? snapshot.turnId
279
+ : undefined,
215
280
  };
216
281
  summary.state = classifyTabState(summary);
217
282
  summary.fingerprint = buildTargetFingerprint(summary);
@@ -292,6 +357,10 @@ function resolveChatGptTabFromSummaries(summaries, ref) {
292
357
  if (exactUrl) {
293
358
  return exactUrl;
294
359
  }
360
+ const exactConversationId = summaries.find((tab) => tab.conversationId === trimmedRef);
361
+ if (exactConversationId) {
362
+ return exactConversationId;
363
+ }
295
364
  const lower = trimmedRef.toLowerCase();
296
365
  const titleMatches = summaries.filter((tab) => tab.title.toLowerCase().includes(lower));
297
366
  if (titleMatches.length === 1) {
@@ -328,17 +397,6 @@ export async function harvestChatGptTab(options = {}) {
328
397
  try {
329
398
  const { Runtime } = client;
330
399
  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
400
  const nowSummary = await inspectChatGptTab({
343
401
  host,
344
402
  port,
@@ -349,15 +407,37 @@ export async function harvestChatGptTab(options = {}) {
349
407
  type: "page",
350
408
  },
351
409
  });
410
+ const normalizedSnapshotText = normalizeTitle(snapshot?.text ?? "").toLowerCase();
411
+ const normalizedInspectedText = normalizeTitle(nowSummary.lastAssistantText).toLowerCase();
412
+ const snapshotMatchesLatestTurn = (typeof snapshot?.turnIndex === "number" &&
413
+ snapshot.turnIndex === nowSummary.lastAssistantTurnIndex) ||
414
+ (snapshot?.turnIndex == null &&
415
+ nowSummary.lastAssistantTurnIndex === undefined &&
416
+ normalizedSnapshotText.length > 0 &&
417
+ normalizedSnapshotText === normalizedInspectedText);
418
+ let assistantMarkdown = null;
419
+ if (snapshotMatchesLatestTurn && (snapshot?.messageId || snapshot?.turnId)) {
420
+ assistantMarkdown = await captureAssistantMarkdown(Runtime, {
421
+ messageId: snapshot.messageId,
422
+ turnId: snapshot.turnId,
423
+ }, noopLogger).catch(() => null);
424
+ }
425
+ const lastAssistantText = snapshotMatchesLatestTurn &&
426
+ typeof snapshot?.text === "string" &&
427
+ snapshot.text.trim().length > 0
428
+ ? snapshot.text.trim()
429
+ : nowSummary.lastAssistantText;
352
430
  const harvested = {
353
431
  ...nowSummary,
354
432
  lastAssistantText,
355
433
  lastAssistantSnippet: trimToSnippet(lastAssistantText),
356
434
  lastAssistantMarkdown: assistantMarkdown ?? (lastAssistantText || null),
357
- lastAssistantMessageId: typeof snapshot?.messageId === "string"
435
+ lastAssistantMessageId: snapshotMatchesLatestTurn && typeof snapshot?.messageId === "string"
358
436
  ? snapshot.messageId
359
437
  : nowSummary.lastAssistantMessageId,
360
- lastAssistantTurnId: typeof snapshot?.turnId === "string" ? snapshot.turnId : nowSummary.lastAssistantTurnId,
438
+ lastAssistantTurnId: snapshotMatchesLatestTurn && typeof snapshot?.turnId === "string"
439
+ ? snapshot.turnId
440
+ : nowSummary.lastAssistantTurnId,
361
441
  };
362
442
  if (harvested.stopExists && options.stallWindowMs && options.stallWindowMs > 0) {
363
443
  const firstFingerprint = harvested.fingerprint;
@@ -383,6 +463,9 @@ export async function harvestChatGptTab(options = {}) {
383
463
  harvested.loginButtonExists = followup.loginButtonExists;
384
464
  harvested.lastUserText = followup.lastUserText;
385
465
  harvested.lastUserSnippet = followup.lastUserSnippet;
466
+ harvested.assistantFollowsLatestUser = followup.assistantFollowsLatestUser;
467
+ harvested.lastAssistantTurnIndex = followup.lastAssistantTurnIndex;
468
+ harvested.lastUserTurnIndex = followup.lastUserTurnIndex;
386
469
  harvested.fingerprint = followup.fingerprint;
387
470
  harvested.state =
388
471
  harvested.stopExists && firstFingerprint === followup.fingerprint
@@ -399,8 +482,7 @@ export async function harvestChatGptTab(options = {}) {
399
482
  }
400
483
  }
401
484
  export function extractConversationIdFromUrl(url) {
402
- const match = normalizeUrl(url).match(/\/c\/([^/?#]+)/);
403
- return match?.[1] ?? undefined;
485
+ return extractStableConversationIdFromUrl(normalizeUrl(url));
404
486
  }
405
487
  export function formatBrowserTabState(tab) {
406
488
  return tab.state ?? classifyTabState(tab);
@@ -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,7 +1,7 @@
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
6
  import { clearStaleChatGptConversationCookies, syncCookies } from "./cookies.js";
7
7
  import { installJavaScriptDialogAutoDismissal, navigateToChatGPT, ensureLoggedIn, } from "./pageActions.js";
@@ -121,14 +121,14 @@ export async function runBrowserProjectSources(request) {
121
121
  });
122
122
  const raceWithDisconnect = (promise) => Promise.race([promise, disconnectPromise]);
123
123
  const { Network, Page, Runtime, Input, DOM, Target } = client;
124
- if (!config.headless && config.hideWindow) {
125
- await hideChromeWindow(chrome, logger);
126
- }
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();
@@ -3,7 +3,7 @@ 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
8
  import { clearStaleChatGptConversationCookies, syncCookies } from "./cookies.js";
9
9
  import { CHATGPT_URL } from "./constants.js";
@@ -180,7 +180,7 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
180
180
  await DOM.enable();
181
181
  }
182
182
  if (!resolved.headless && resolved.hideWindow) {
183
- await hideChromeWindow(chrome, logger);
183
+ await positionChromeWindowOffscreen(client, logger);
184
184
  }
185
185
  let appliedCookies = 0;
186
186
  if (!manualLogin && resolved.cookieSync) {
@@ -1,5 +1,6 @@
1
1
  import { CONVERSATION_TURN_SELECTOR } from "./constants.js";
2
2
  import { buildConversationTurnCountExpression } from "./conversationTurns.js";
3
+ import { extractStableConversationIdFromUrl } from "./conversationUrl.js";
3
4
  import { delay } from "./utils.js";
4
5
  import { readAssistantSnapshot } from "./pageActions.js";
5
6
  export function pickTarget(targets, runtime) {
@@ -29,14 +30,11 @@ export function pickTarget(targets, runtime) {
29
30
  return targets.find((t) => t.type === "page") ?? targets[0];
30
31
  }
31
32
  export function extractConversationIdFromUrl(url) {
32
- if (!url)
33
- return undefined;
34
- const match = url.match(/\/c\/([a-zA-Z0-9-]+)/);
35
- return match?.[1];
33
+ return extractStableConversationIdFromUrl(url);
36
34
  }
37
35
  export function buildConversationUrl(runtime, baseUrl) {
38
36
  if (runtime.tabUrl) {
39
- if (runtime.tabUrl.includes("/c/")) {
37
+ if (extractConversationIdFromUrl(runtime.tabUrl)) {
40
38
  return runtime.tabUrl;
41
39
  }
42
40
  return null;
@@ -1,3 +1,4 @@
1
+ import { isStableConversationUrl } from "./conversationUrl.js";
1
2
  /**
2
3
  * True when the URL points at a specific ChatGPT conversation (`/c/<id>`) on
3
4
  * chatgpt.com or chat.openai.com. Rejects home, project shell, and external
@@ -17,7 +18,7 @@ export function isRecoverableChatGptConversationUrl(candidate) {
17
18
  if (url.hostname !== "chatgpt.com" && url.hostname !== "chat.openai.com") {
18
19
  return false;
19
20
  }
20
- return /(?:^|\/)c\/[^/]+/.test(url.pathname);
21
+ return isStableConversationUrl(url.pathname);
21
22
  }
22
23
  catch {
23
24
  return false;
@@ -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
  }
@@ -61,7 +61,7 @@ export async function acquireBrowserTabLease(profileDir, options, deps = {}) {
61
61
  options.logger?.(`[browser] Acquired ChatGPT browser slot ${leaseId.slice(0, 8)} (${maxConcurrentTabs} max).`);
62
62
  return {
63
63
  id: leaseId,
64
- release: async () => releaseBrowserTabLease(profileDir, leaseId, options.logger),
64
+ release: async (releaseOptions) => releaseBrowserTabLease(profileDir, leaseId, options.logger, releaseOptions),
65
65
  update: async (patch) => updateBrowserTabLease(profileDir, leaseId, patch),
66
66
  };
67
67
  }
@@ -86,11 +86,17 @@ export async function updateBrowserTabLease(profileDir, leaseId, patch) {
86
86
  await writeRegistry(profileDir, { version: 1, leases });
87
87
  });
88
88
  }
89
- export async function releaseBrowserTabLease(profileDir, leaseId, logger) {
89
+ export async function releaseBrowserTabLease(profileDir, leaseId, logger, options = {}) {
90
90
  await withRegistryLock(profileDir, async () => {
91
91
  const registry = await readRegistry(profileDir);
92
- const leases = registry.leases.filter((lease) => lease.id !== leaseId);
92
+ const active = pruneStaleLeases(registry.leases, {
93
+ nowMs: Date.now(),
94
+ staleMs: DEFAULT_STALE_MS,
95
+ isProcessAlive,
96
+ });
97
+ const leases = active.filter((lease) => lease.id !== leaseId);
93
98
  await writeRegistry(profileDir, { version: 1, leases });
99
+ await options.onRelease?.({ isLastLease: leases.length === 0 });
94
100
  }).catch(() => undefined);
95
101
  logger?.(`[browser] Released ChatGPT browser slot ${leaseId.slice(0, 8)}.`);
96
102
  }
@@ -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") {
@@ -11,6 +11,7 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
11
11
  };
12
12
  const attachRunningRequested = options.browserAttachRunning === true ||
13
13
  (isUnset("browserAttachRunning") && browser.attachRunning === true);
14
+ const currentModelRequestedByCli = options.browserModelStrategy === "current" && getSource("browserModelStrategy") === "cli";
14
15
  const configuredChatgptUrl = browser.chatgptUrl ?? browser.url;
15
16
  const cliChatgptSet = options.chatgptUrl !== undefined || options.browserUrl !== undefined;
16
17
  if (isUnset("chatgptUrl") && !cliChatgptSet && configuredChatgptUrl !== undefined) {
@@ -89,7 +90,9 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
89
90
  if (isUnset("browserModelStrategy") && browser.modelStrategy !== undefined) {
90
91
  options.browserModelStrategy = browser.modelStrategy;
91
92
  }
92
- if (isUnset("browserThinkingTime") && browser.thinkingTime !== undefined) {
93
+ if (!currentModelRequestedByCli &&
94
+ isUnset("browserThinkingTime") &&
95
+ browser.thinkingTime !== undefined) {
93
96
  options.browserThinkingTime = normalizeThinkingTimeLevel(browser.thinkingTime) ?? undefined;
94
97
  }
95
98
  if (isUnset("browserResearch") && browser.researchMode !== undefined) {