@steipete/oracle 0.15.2 → 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 (67) hide show
  1. package/dist/bin/oracle-cli.js +1 -1
  2. package/dist/src/browser/actions/assistantResponse.js +218 -126
  3. package/dist/src/browser/actions/modelSelection.js +133 -22
  4. package/dist/src/browser/actions/navigation.js +235 -14
  5. package/dist/src/browser/actions/thinkingStatus.js +228 -0
  6. package/dist/src/browser/actions/thinkingTime.js +68 -11
  7. package/dist/src/browser/chromeLifecycle.js +29 -28
  8. package/dist/src/browser/config.js +14 -1
  9. package/dist/src/browser/constants.js +5 -1
  10. package/dist/src/browser/controlPlan.js +2 -2
  11. package/dist/src/browser/index.js +56 -16
  12. package/dist/src/browser/liveTabs.js +113 -31
  13. package/dist/src/browser/pageActions.js +1 -1
  14. package/dist/src/browser/projectSourcesRunner.js +4 -4
  15. package/dist/src/browser/reattach.js +2 -2
  16. package/dist/src/browser/recoverConversation.js +90 -29
  17. package/dist/src/cli/browserConfig.js +5 -1
  18. package/dist/src/cli/browserTabs.js +54 -33
  19. package/dist/src/cli/options.js +24 -0
  20. package/dist/src/cli/runOptions.js +6 -1
  21. package/dist/src/cli/sessionDisplay.js +38 -14
  22. package/dist/src/oracle/config.js +25 -0
  23. package/dist/src/oracle/geminiModels.js +2 -0
  24. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  25. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  26. package/package.json +9 -10
  27. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  28. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  29. package/dist/bin/oracle.js +0 -569
  30. package/dist/docs-site/.nojekyll +0 -0
  31. package/dist/docs-site/CNAME +0 -1
  32. package/dist/docs-site/RELEASING.html +0 -410
  33. package/dist/docs-site/agents.html +0 -374
  34. package/dist/docs-site/anthropic.html +0 -368
  35. package/dist/docs-site/bridge.html +0 -416
  36. package/dist/docs-site/browser-mode.html +0 -594
  37. package/dist/docs-site/chromium-forks.html +0 -347
  38. package/dist/docs-site/cli-reference.html +0 -346
  39. package/dist/docs-site/configuration.html +0 -462
  40. package/dist/docs-site/favicon.svg +0 -14
  41. package/dist/docs-site/followup.html +0 -375
  42. package/dist/docs-site/gemini.html +0 -383
  43. package/dist/docs-site/grok.html +0 -325
  44. package/dist/docs-site/index.html +0 -360
  45. package/dist/docs-site/install.html +0 -335
  46. package/dist/docs-site/linux.html +0 -321
  47. package/dist/docs-site/llms.txt +0 -43
  48. package/dist/docs-site/manual-tests.html +0 -596
  49. package/dist/docs-site/mcp.html +0 -391
  50. package/dist/docs-site/multimodel.html +0 -364
  51. package/dist/docs-site/mythical-pro-agents.html +0 -360
  52. package/dist/docs-site/notifier.html +0 -338
  53. package/dist/docs-site/openai-endpoints.html +0 -399
  54. package/dist/docs-site/openrouter.html +0 -344
  55. package/dist/docs-site/quickstart.html +0 -369
  56. package/dist/docs-site/refactor/ux.html +0 -532
  57. package/dist/docs-site/sessions.html +0 -388
  58. package/dist/docs-site/social-card.png +0 -0
  59. package/dist/docs-site/social-card.svg +0 -79
  60. package/dist/docs-site/spec.html +0 -363
  61. package/dist/docs-site/testing.html +0 -320
  62. package/dist/docs-site/tui-debug.html +0 -326
  63. package/dist/docs-site/windows-work.html +0 -323
  64. package/dist/docs-site/windows.html +0 -320
  65. package/dist/src/browser/chromeCookies.js +0 -312
  66. package/dist/src/browser/keytarShim.js +0 -56
  67. package/dist/src/browser/windowsCookies.js +0 -219
@@ -4,9 +4,9 @@ import os from "node:os";
4
4
  import net from "node:net";
5
5
  import { resolveBrowserConfig } from "./config.js";
6
6
  import { copyChromeProfile } from "./profileCopy.js";
7
- import { launchChrome, registerTerminationHooks, hideChromeWindow, connectToRemoteChrome, connectWithNewTab, closeTab, closeRemoteChromeTarget, closeBlankChromeTabs, } from "./chromeLifecycle.js";
7
+ import { launchChrome, registerTerminationHooks, positionChromeWindowOffscreen, connectToRemoteChrome, connectWithNewTab, closeTab, closeRemoteChromeTarget, closeBlankChromeTabs, } from "./chromeLifecycle.js";
8
8
  import { clearStaleChatGptConversationCookies, syncCookies } from "./cookies.js";
9
- import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js";
9
+ import { navigateToChatGPT, navigateToPromptReadyWithFallback, ensureNotBlocked, ensureLoggedIn, ensurePromptReady, ensureChatMode, waitForResumedConversationHydration, installJavaScriptDialogAutoDismissal, ensureModelSelection, clearPromptComposer, waitForAssistantResponse, captureAssistantMarkdown, clearComposerAttachments, uploadAttachmentFile, waitForAttachmentCompletion, waitForUserTurnAttachments, readAssistantSnapshot, } from "./pageActions.js";
10
10
  import { INPUT_SELECTORS } from "./constants.js";
11
11
  import { uploadAttachmentViaDataTransfer } from "./actions/remoteFileTransfer.js";
12
12
  import { ensureThinkingTime } from "./actions/thinkingTime.js";
@@ -281,6 +281,25 @@ async function createAssistantTimeoutError(params) {
281
281
  }
282
282
  return warningError;
283
283
  }
284
+ /**
285
+ * Make the page behave like a focused foreground tab.
286
+ *
287
+ * The send button is activated with trusted CDP input events dispatched at
288
+ * viewport coordinates. Chrome delivers those only to a window that is being
289
+ * composited, so a hidden (`--browser-hide-window`), minimized, or occluded
290
+ * window swallows the click while the automation still believes it clicked.
291
+ * Soft-fails: focus emulation is an optimization, never a hard requirement.
292
+ */
293
+ async function enableFocusEmulation(client, logger, label) {
294
+ try {
295
+ await client.Emulation.setFocusEmulationEnabled({ enabled: true });
296
+ logger(`[browser] Focus emulation enabled for ${label}`);
297
+ }
298
+ catch (error) {
299
+ const message = error instanceof Error ? error.message : String(error);
300
+ logger(`[browser] Focus emulation unavailable: ${message}`);
301
+ }
302
+ }
284
303
  function listIgnoredRemoteChromeFlags(config) {
285
304
  return [
286
305
  config.headless ? "--browser-headless" : null,
@@ -356,13 +375,13 @@ async function pollGeneratedImageOrTextAssistantResponse(Runtime, timeoutMs, min
356
375
  }
357
376
  return null;
358
377
  }
359
- function isImageOnlyUiChromeText(text) {
378
+ export function isImageOnlyUiChromeText(text) {
360
379
  const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
361
380
  return (normalized.length === 0 ||
362
381
  normalized === "edit" ||
363
382
  normalized === "stopped thinking" ||
364
383
  normalized === "stopped thinking edit" ||
365
- /^thought for \d+(?:\.\d+)?\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\s+edit$/.test(normalized));
384
+ /^(?:reasoning\s+|pro thinking\s+)?thought for \d+(?:\.\d+)?\s*(?:s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours)\s+edit$/.test(normalized));
366
385
  }
367
386
  function normalizeBrowserFollowUpPrompts(values) {
368
387
  return (values ?? []).map((entry) => entry.trim()).filter(Boolean);
@@ -783,14 +802,18 @@ export async function runBrowserMode(options) {
783
802
  });
784
803
  const raceWithDisconnect = (promise) => Promise.race([promise, disconnectPromise]);
785
804
  const { Network, Page, Runtime, Input, DOM, Target } = client;
786
- if (!config.headless && config.hideWindow) {
787
- await hideChromeWindow(chrome, logger);
788
- }
789
805
  const domainEnablers = [Network.enable({}), Page.enable(), Runtime.enable()];
790
806
  if (DOM && typeof DOM.enable === "function") {
791
807
  domainEnablers.push(DOM.enable());
792
808
  }
793
809
  await Promise.all(domainEnablers);
810
+ if (!config.headless && config.hideWindow) {
811
+ await positionChromeWindowOffscreen(client, logger);
812
+ }
813
+ // The send button is clicked with trusted CDP input events at viewport
814
+ // coordinates, which ChatGPT silently drops when the window is hidden or
815
+ // occluded. Emulate focus so the page behaves like a foreground tab.
816
+ await enableFocusEmulation(client, logger, "local target");
794
817
  removeDialogHandler = installJavaScriptDialogAutoDismissal(Page, logger);
795
818
  if (!profileIsPreSigned) {
796
819
  await Network.clearBrowserCookies();
@@ -911,6 +934,18 @@ export async function runBrowserMode(options) {
911
934
  }));
912
935
  }
913
936
  }
937
+ const chatMode = await raceWithDisconnect(ensureChatMode(Runtime, Input, config.inputTimeoutMs, logger, {
938
+ resetWorkConversation: config.browserTabRef && !isResumingConversation
939
+ ? async () => {
940
+ await navigateToChatGPT(Page, Runtime, config.url, logger);
941
+ await ensureNotBlocked(Runtime, config.headless, logger);
942
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
943
+ }
944
+ : undefined,
945
+ }));
946
+ if (chatMode === "switched") {
947
+ await raceWithDisconnect(ensurePromptReady(Runtime, config.inputTimeoutMs, logger));
948
+ }
914
949
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
915
950
  const captureRuntimeSnapshot = async () => {
916
951
  try {
@@ -1961,7 +1996,7 @@ function detachKeptChromeProcess(chrome) {
1961
1996
  // Best-effort only; cleanup should not mask the original browser result.
1962
1997
  }
1963
1998
  }
1964
- async function acquireManualLoginChromeForRun(userDataDir, config, logger, sessionId, deps = {}) {
1999
+ export async function acquireManualLoginChromeForRun(userDataDir, config, logger, sessionId, deps = {}) {
1965
2000
  const maybeReuse = deps.maybeReuse ?? maybeReuseRunningChrome;
1966
2001
  const launch = deps.launch ?? launchChrome;
1967
2002
  const lockTimeoutMs = Math.max(0, config.profileLockTimeoutMs ?? 0);
@@ -2176,14 +2211,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2176
2211
  }
2177
2212
  await Promise.all(domainEnablers);
2178
2213
  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
- }
2214
+ await enableFocusEmulation(client, logger, "remote target");
2187
2215
  const activeConversationUrlMonitor = createConversationUrlMonitor({
2188
2216
  readUrl: async () => {
2189
2217
  const { result } = await Runtime.evaluate({
@@ -2222,6 +2250,18 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2222
2250
  expectedConversationUrl: config.resumeConversationUrl,
2223
2251
  });
2224
2252
  }
2253
+ const chatMode = await ensureChatMode(Runtime, Input, config.inputTimeoutMs, logger, {
2254
+ resetWorkConversation: attachedExistingTab && !config.resumeConversationUrl
2255
+ ? async () => {
2256
+ await navigateToChatGPT(Page, Runtime, config.url, logger);
2257
+ await ensureNotBlocked(Runtime, config.headless, logger);
2258
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
2259
+ }
2260
+ : undefined,
2261
+ });
2262
+ if (chatMode === "switched") {
2263
+ await ensurePromptReady(Runtime, config.inputTimeoutMs, logger);
2264
+ }
2225
2265
  logger(`Prompt textarea ready (initial focus, ${promptText.length.toLocaleString()} chars queued)`);
2226
2266
  try {
2227
2267
  const { result } = await Runtime.evaluate({
@@ -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,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,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") {