@steipete/oracle 0.14.1 → 0.15.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 (85) hide show
  1. package/dist/bin/oracle-cli.js +16 -6
  2. package/dist/bin/oracle.js +569 -0
  3. package/dist/docs-site/.nojekyll +0 -0
  4. package/dist/docs-site/CNAME +1 -0
  5. package/dist/docs-site/RELEASING.html +410 -0
  6. package/dist/docs-site/agents.html +374 -0
  7. package/dist/docs-site/anthropic.html +368 -0
  8. package/dist/docs-site/bridge.html +416 -0
  9. package/dist/docs-site/browser-mode.html +594 -0
  10. package/dist/docs-site/chromium-forks.html +347 -0
  11. package/dist/docs-site/cli-reference.html +346 -0
  12. package/dist/docs-site/configuration.html +462 -0
  13. package/dist/docs-site/favicon.svg +14 -0
  14. package/dist/docs-site/followup.html +375 -0
  15. package/dist/docs-site/gemini.html +383 -0
  16. package/dist/docs-site/grok.html +325 -0
  17. package/dist/docs-site/index.html +360 -0
  18. package/dist/docs-site/install.html +335 -0
  19. package/dist/docs-site/linux.html +321 -0
  20. package/dist/docs-site/llms.txt +43 -0
  21. package/dist/docs-site/manual-tests.html +596 -0
  22. package/dist/docs-site/mcp.html +391 -0
  23. package/dist/docs-site/multimodel.html +364 -0
  24. package/dist/docs-site/mythical-pro-agents.html +360 -0
  25. package/dist/docs-site/notifier.html +338 -0
  26. package/dist/docs-site/openai-endpoints.html +399 -0
  27. package/dist/docs-site/openrouter.html +344 -0
  28. package/dist/docs-site/quickstart.html +369 -0
  29. package/dist/docs-site/refactor/ux.html +532 -0
  30. package/dist/docs-site/sessions.html +388 -0
  31. package/dist/docs-site/social-card.png +0 -0
  32. package/dist/docs-site/social-card.svg +79 -0
  33. package/dist/docs-site/spec.html +363 -0
  34. package/dist/docs-site/testing.html +320 -0
  35. package/dist/docs-site/tui-debug.html +326 -0
  36. package/dist/docs-site/windows-work.html +323 -0
  37. package/dist/docs-site/windows.html +320 -0
  38. package/dist/src/browser/actions/assistantResponse.js +40 -31
  39. package/dist/src/browser/actions/attachments.js +28 -1
  40. package/dist/src/browser/actions/deepResearch.js +344 -128
  41. package/dist/src/browser/actions/modelSelection.js +75 -9
  42. package/dist/src/browser/actions/promptComposer.js +71 -14
  43. package/dist/src/browser/actions/thinkingStatus.js +19 -1
  44. package/dist/src/browser/actions/thinkingTime.js +65 -20
  45. package/dist/src/browser/artifacts.js +193 -14
  46. package/dist/src/browser/chatgptFiles.js +674 -91
  47. package/dist/src/browser/chromeCookies.js +312 -0
  48. package/dist/src/browser/chromeLifecycle.js +35 -4
  49. package/dist/src/browser/constants.js +5 -0
  50. package/dist/src/browser/deepResearchResult.js +23 -0
  51. package/dist/src/browser/index.js +112 -41
  52. package/dist/src/browser/keytarShim.js +56 -0
  53. package/dist/src/browser/profileCopy.js +93 -0
  54. package/dist/src/browser/sessionRunner.js +9 -3
  55. package/dist/src/browser/windowsCookies.js +219 -0
  56. package/dist/src/cli/bridge/client.js +4 -1
  57. package/dist/src/cli/bridge/doctor.js +19 -0
  58. package/dist/src/cli/browserConfig.js +17 -1
  59. package/dist/src/cli/runOptions.js +11 -2
  60. package/dist/src/cli/sessionDisplay.js +6 -1
  61. package/dist/src/cli/sessionRunner.js +41 -17
  62. package/dist/src/config.js +3 -0
  63. package/dist/src/oracle/client.js +2 -0
  64. package/dist/src/oracle/modelResolver.js +85 -0
  65. package/dist/src/oracle/multiModelRunner.js +4 -1
  66. package/dist/src/oracle/run.js +4 -1
  67. package/dist/src/remote/client.js +253 -22
  68. package/dist/src/remote/health.js +27 -0
  69. package/dist/src/remote/server.js +239 -4
  70. package/dist/src/remote/types.js +1 -1
  71. package/dist/src/sessionManager.js +1 -0
  72. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  73. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  74. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  75. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  76. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  77. package/dist/vendor/oracle-notifier/build-notifier.sh +0 -0
  78. package/package.json +45 -43
  79. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  80. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Info.plist +20 -0
  81. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  82. package/vendor/oracle-notifier/OracleNotifier.app/Contents/Resources/OracleIcon.icns +0 -0
  83. package/vendor/oracle-notifier/OracleNotifier.app/Contents/_CodeSignature/CodeResources +128 -0
  84. package/vendor/oracle-notifier/README.md +26 -0
  85. package/vendor/oracle-notifier/build-notifier.sh +0 -0
@@ -1,13 +1,14 @@
1
1
  import { DEEP_RESEARCH_PLUS_BUTTON, DEEP_RESEARCH_DROPDOWN_ITEM_TEXT, DEEP_RESEARCH_PILL_LABEL, DEEP_RESEARCH_POLL_INTERVAL_MS, DEEP_RESEARCH_AUTO_CONFIRM_WAIT_MS, DEEP_RESEARCH_DEFAULT_TIMEOUT_MS, FINISHED_ACTIONS_SELECTOR, STOP_BUTTON_SELECTOR, CONVERSATION_TURN_SELECTOR, } from "../constants.js";
2
2
  import { delay } from "../utils.js";
3
+ import { isDeepResearchIncompleteText } from "../deepResearchResult.js";
3
4
  import { buildClickDispatcher } from "./domEvents.js";
4
5
  import { captureAssistantMarkdown, readAssistantSnapshot } from "./assistantResponse.js";
5
6
  import { BrowserAutomationError } from "../../oracle/errors.js";
6
7
  /**
7
- * Activates Deep Research mode through ChatGPT's slash command, with the
8
- * composer tools menu as a fallback for older UI variants.
8
+ * Activates Deep Research mode through ChatGPT's composer tools menu and
9
+ * verifies the selected tool pill before prompt submission.
9
10
  */
10
- export async function activateDeepResearch(Runtime, _Input, logger) {
11
+ export async function activateDeepResearch(Runtime, Input, logger) {
11
12
  const expression = buildActivateDeepResearchExpression();
12
13
  const outcome = await Runtime.evaluate({
13
14
  expression,
@@ -31,14 +32,48 @@ export async function activateDeepResearch(Runtime, _Input, logger) {
31
32
  throw new BrowserAutomationError(`"Deep research" option not found in composer dropdown.${hint} ` +
32
33
  "This feature may require a ChatGPT Plus or Pro subscription.", { stage: "deep-research-activate", code: "dropdown-item-missing" });
33
34
  }
34
- case "pill-not-confirmed":
35
+ case "pill-not-confirmed": {
36
+ const point = result.clickPoint;
37
+ if (typeof point?.x === "number" && typeof point.y === "number") {
38
+ await clickTrustedPoint(Runtime, Input, point.x, point.y);
39
+ if (await waitForDeepResearchPill(Runtime)) {
40
+ logger("Deep Research mode activated");
41
+ return;
42
+ }
43
+ }
35
44
  throw new BrowserAutomationError("Deep Research pill did not appear after selection. The UI may have changed.", { stage: "deep-research-activate", code: "pill-not-confirmed" });
45
+ }
36
46
  default:
37
47
  throw new BrowserAutomationError("Unexpected result from Deep Research activation.", {
38
48
  stage: "deep-research-activate",
39
49
  });
40
50
  }
41
51
  }
52
+ async function clickTrustedPoint(Runtime, Input, x, y) {
53
+ if (Input && typeof Input.dispatchMouseEvent === "function") {
54
+ await Input.dispatchMouseEvent({ type: "mouseMoved", x, y });
55
+ await Input.dispatchMouseEvent({ type: "mousePressed", x, y, button: "left", clickCount: 1 });
56
+ await Input.dispatchMouseEvent({ type: "mouseReleased", x, y, button: "left", clickCount: 1 });
57
+ return;
58
+ }
59
+ await Runtime.evaluate({
60
+ expression: `(() => {
61
+ const el = document.elementFromPoint(${JSON.stringify(x)}, ${JSON.stringify(y)});
62
+ if (!(el instanceof HTMLElement)) return false;
63
+ el.click();
64
+ return true;
65
+ })()`,
66
+ returnByValue: true,
67
+ });
68
+ }
69
+ async function waitForDeepResearchPill(Runtime, timeoutMs = 5000) {
70
+ const { result } = await Runtime.evaluate({
71
+ expression: buildWaitForDeepResearchPillExpression(timeoutMs),
72
+ awaitPromise: true,
73
+ returnByValue: true,
74
+ });
75
+ return Boolean(result?.value);
76
+ }
42
77
  /**
43
78
  * After prompt submission, waits for the research plan to appear and
44
79
  * auto-confirm (~60s countdown + 10s safety margin).
@@ -116,7 +151,10 @@ export async function waitForDeepResearchCompletion(Runtime, logger, timeoutMs =
116
151
  : -1;
117
152
  const scopedToNewTurns = minTurnLiteral >= 0;
118
153
  const ignoredTargetKeys = new Set(options?.ignoredTargetKeys ?? []);
119
- const requireScopedTargetOwner = options?.requireScopedTargetOwner === true;
154
+ const requireScopedTargetOwner = options?.requireScopedTargetOwner === true ||
155
+ (scopedToNewTurns && options?.targetBaselineCaptured !== true);
156
+ let observedResearchEvidence = false;
157
+ let loggedIncompleteResult = false;
120
158
  logger(`Monitoring Deep Research (timeout: ${Math.round(timeoutMs / 60_000)}min)...`);
121
159
  while (Date.now() - start < timeoutMs) {
122
160
  const { result } = await Runtime.evaluate({
@@ -127,7 +165,6 @@ export async function waitForDeepResearchCompletion(Runtime, logger, timeoutMs =
127
165
  if (val?.accountBlocked) {
128
166
  throw new BrowserAutomationError("ChatGPT account security block detected during Deep Research. Open chatgpt.com in Chrome, secure the account, then rerun Oracle.", { stage: "chatgpt-account-blocked", code: "chatgpt-account-blocked" });
129
167
  }
130
- const activeScopedResearch = Boolean(val?.hasActiveScopedResearch);
131
168
  // ChatGPT renders the Deep Research report inside an out-of-process,
132
169
  // sandboxed iframe (connector_openai_deep_research.*.oaiusercontent.com),
133
170
  // doubly nested and same-origin. That OOPIF does NOT appear in the main
@@ -136,24 +173,27 @@ export async function waitForDeepResearchCompletion(Runtime, logger, timeoutMs =
136
173
  // (readDeepResearchTargetResult) attaches to the iframe's own CDP target and
137
174
  // walks its nested frames, so it CAN read the report. Prefer the target path
138
175
  // and fall back to the in-page frame path for legacy/inline rendering.
139
- const targetResult = client
176
+ const rawTargetResult = client
140
177
  ? ((await readDeepResearchTargetResult(client, ignoredTargetKeys, requireScopedTargetOwner ? minTurnLiteral : -1).catch(() => null))?.read ?? null)
141
178
  : null;
179
+ const targetResult = filterIncompleteDeepResearchRead(rawTargetResult);
142
180
  // A completed target read is authoritative. If the target read is missing or
143
181
  // only in-progress, still try the in-page frame path so an incomplete target
144
182
  // read does not suppress a completed report there (legacy/inline rendering).
145
- const inPageResult = !targetResult?.completed && Page
146
- ? await readDeepResearchFrameResult(Runtime, Page).catch(() => null)
183
+ const inPageScan = !targetResult?.completed && Page
184
+ ? await readDeepResearchFrameResult(Runtime, Page, client, scopedToNewTurns ? minTurnLiteral : -1).catch(() => null)
147
185
  : null;
186
+ const rawInPageResult = inPageScan?.read ?? null;
187
+ const inPageResult = filterIncompleteDeepResearchRead(rawInPageResult);
148
188
  const read = pickPreferredDeepResearchRead(targetResult, inPageResult);
149
- // A target-confirmed completion read the live connector iframe directly, so
150
- // it is authoritative even when the main DOM exposes no assistant turn (the
151
- // report lives entirely in the OOPIF). The main-DOM hasActiveScopedResearch
152
- // heuristic no longer holds in that case, so don't gate on it.
153
- const completedFromTarget = Boolean(targetResult?.completed);
154
- if (read?.completed &&
155
- read.text &&
156
- (completedFromTarget || !scopedToNewTurns || activeScopedResearch)) {
189
+ // Target keys captured before submission are ignored, so a target result is
190
+ // tied to this run. Main-page iframes are not: old reports can remain in the
191
+ // conversation and must never authorize a new normal-response fallback.
192
+ observedResearchEvidence ||= Boolean(rawTargetResult ||
193
+ (scopedToNewTurns && rawInPageResult) ||
194
+ val?.researchActivity ||
195
+ val?.hasActiveScopedResearch);
196
+ if (read?.completed && read.text) {
157
197
  logger(`Deep Research completed (${Math.round((Date.now() - start) / 1000)}s elapsed)`);
158
198
  return {
159
199
  text: read.text,
@@ -163,9 +203,18 @@ export async function waitForDeepResearchCompletion(Runtime, logger, timeoutMs =
163
203
  }
164
204
  // Completion detected
165
205
  if (val?.finished) {
206
+ if (!observedResearchEvidence) {
207
+ throw new BrowserAutomationError("ChatGPT returned a completed response without starting Deep Research. The Deep Research selection may have silently fallen back to a normal response.", { stage: "deep-research-not-started", code: "deep-research-not-started" });
208
+ }
166
209
  logger(`Deep Research completed (${Math.round((Date.now() - start) / 1000)}s elapsed)`);
167
210
  return await extractDeepResearchResult(Runtime, logger, minTurnIndex ?? undefined);
168
211
  }
212
+ const incompleteFrameResult = Boolean((rawTargetResult?.completed && !targetResult?.completed) ||
213
+ (rawInPageResult?.completed && !inPageResult?.completed));
214
+ if ((val?.incompleteResult || incompleteFrameResult) && !loggedIncompleteResult) {
215
+ logger("Deep Research interim status detected; waiting for the final report");
216
+ loggedIncompleteResult = true;
217
+ }
169
218
  // Progress logging every 60 seconds
170
219
  const now = Date.now();
171
220
  if (now - lastLogTime >= 60_000) {
@@ -204,24 +253,26 @@ export async function extractDeepResearchResult(Runtime, logger, minTurnIndex) {
204
253
  };
205
254
  // Try the copy-button approach first for clean markdown
206
255
  const markdown = await captureAssistantMarkdown(Runtime, meta, logger);
207
- if (markdown && !isDeepResearchPlaceholderText(markdown)) {
256
+ if (markdown && !isDeepResearchIncompleteText(markdown)) {
208
257
  return { text: markdown, html: snapshot?.html ?? undefined, meta };
209
258
  }
210
259
  // Fall back to snapshot text
211
- if (snapshot?.text && !isDeepResearchPlaceholderText(snapshot.text)) {
260
+ if (snapshot?.text && !isDeepResearchIncompleteText(snapshot.text)) {
212
261
  return { text: snapshot.text, html: snapshot.html ?? undefined, meta };
213
262
  }
214
263
  throw new BrowserAutomationError("Deep Research completed but failed to extract the response text.", { stage: "deep-research-extract", code: "extraction-failed" });
215
264
  }
216
- function isDeepResearchPlaceholderText(text) {
217
- const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
218
- return (normalized === "called tool" ||
219
- normalized === "used tool" ||
220
- normalized === "użyto narzędzia" ||
221
- normalized === "narzędzie wywołane");
222
- }
223
265
  export function isDeepResearchPlaceholderTextForTest(text) {
224
- return isDeepResearchPlaceholderText(text);
266
+ return isDeepResearchIncompleteText(text);
267
+ }
268
+ function filterIncompleteDeepResearchRead(result) {
269
+ if (!result?.completed || !result.text || !isDeepResearchIncompleteText(result.text)) {
270
+ return result;
271
+ }
272
+ return { ...result, completed: false, inProgress: true };
273
+ }
274
+ export function filterIncompleteDeepResearchReadForTest(result) {
275
+ return filterIncompleteDeepResearchRead(result);
225
276
  }
226
277
  /**
227
278
  * Choose the authoritative Deep Research read between the target-attach result
@@ -243,37 +294,64 @@ function pickPreferredDeepResearchRead(targetResult, inPageResult) {
243
294
  export function pickPreferredDeepResearchReadForTest(targetResult, inPageResult) {
244
295
  return pickPreferredDeepResearchRead(targetResult, inPageResult);
245
296
  }
246
- async function readDeepResearchFrameResult(Runtime, Page) {
297
+ async function readDeepResearchFrameResult(Runtime, Page, client, minTurnIndex = -1) {
247
298
  const pageWithFrames = Page;
248
299
  if (typeof pageWithFrames.getFrameTree !== "function" ||
249
300
  typeof pageWithFrames.createIsolatedWorld !== "function") {
250
301
  return null;
251
302
  }
252
303
  const frameTree = (await pageWithFrames.getFrameTree())?.frameTree;
253
- const frameId = findDeepResearchFrameId(frameTree);
254
- if (!frameId) {
304
+ const frameIds = collectPageDeepResearchFrameIds(frameTree);
305
+ if (frameIds.length === 0) {
255
306
  return null;
256
307
  }
257
- const world = await pageWithFrames.createIsolatedWorld({
258
- frameId,
259
- worldName: "oracle-deep-research",
260
- grantUniveralAccess: true,
261
- });
262
- if (typeof world.executionContextId !== "number") {
263
- return null;
308
+ const rawClient = client;
309
+ if (minTurnIndex >= 0) {
310
+ if (typeof rawClient?.send !== "function") {
311
+ return null;
312
+ }
264
313
  }
265
- const { result } = await Runtime.evaluate({
266
- expression: buildDeepResearchFrameStatusExpression(),
267
- contextId: world.executionContextId,
268
- returnByValue: true,
269
- });
270
- return result?.value ?? null;
314
+ let best = null;
315
+ for (const frameId of frameIds) {
316
+ let ownerTurnIndex = null;
317
+ if (minTurnIndex >= 0 && rawClient?.send) {
318
+ ownerTurnIndex = await readDeepResearchTargetOwnerTurnIndex(rawClient, frameId, rawClient.oraclePageSessionId);
319
+ if (ownerTurnIndex === null || ownerTurnIndex < minTurnIndex) {
320
+ continue;
321
+ }
322
+ }
323
+ const world = await pageWithFrames.createIsolatedWorld({
324
+ frameId,
325
+ worldName: "oracle-deep-research",
326
+ grantUniveralAccess: true,
327
+ });
328
+ if (typeof world.executionContextId !== "number") {
329
+ continue;
330
+ }
331
+ const { result } = await Runtime.evaluate({
332
+ expression: buildDeepResearchFrameStatusExpression(),
333
+ contextId: world.executionContextId,
334
+ returnByValue: true,
335
+ });
336
+ const read = result?.value ?? null;
337
+ if (!read) {
338
+ continue;
339
+ }
340
+ best = { read, ownerTurnIndex };
341
+ if (read.completed) {
342
+ return best;
343
+ }
344
+ }
345
+ return best;
271
346
  }
272
347
  async function readDeepResearchTargetResult(client, ignoredTargetKeys = new Set(), minTurnIndex = -1) {
273
348
  const rawClient = client;
274
349
  if (typeof rawClient.send !== "function") {
275
350
  return null;
276
351
  }
352
+ if (typeof client.on !== "function") {
353
+ return null;
354
+ }
277
355
  // On the browser-WSEndpoint path, `client` is a session-bound wrapper whose
278
356
  // domain methods target the page session but whose raw `send` is the
279
357
  // browser-level send. We must therefore pass the page session id explicitly so
@@ -299,7 +377,7 @@ async function readDeepResearchTargetResult(client, ignoredTargetKeys = new Set(
299
377
  ownedSessionIds.add(eventSessionId);
300
378
  }
301
379
  };
302
- client.on?.("Target.attachedToTarget", onAttached);
380
+ client.on("Target.attachedToTarget", onAttached);
303
381
  try {
304
382
  // Scope discovery to the current Oracle-controlled page. `client` is
305
383
  // connected to the conversation page target, so enabling auto-attach on this
@@ -311,13 +389,16 @@ async function readDeepResearchTargetResult(client, ignoredTargetKeys = new Set(
311
389
  // would surface another tab's completed Deep Research report and let it be
312
390
  // saved into the current session (cross-tab leak). Only auto-attached,
313
391
  // page-scoped sessions are treated as belonging to this run.
314
- await rawClient
392
+ const autoAttachEnabled = await rawClient
315
393
  .send("Target.setAutoAttach", {
316
394
  autoAttach: true,
317
395
  waitForDebuggerOnStart: false,
318
396
  flatten: true,
319
397
  }, pageSessionId)
320
- .catch(() => undefined);
398
+ .then(() => true, () => false);
399
+ if (!autoAttachEnabled) {
400
+ return null;
401
+ }
321
402
  await delay(100);
322
403
  if (minTurnIndex >= 0) {
323
404
  await rawClient.send("DOM.enable", {}, pageSessionId).catch(() => undefined);
@@ -374,7 +455,11 @@ async function readDeepResearchTargetResult(client, ignoredTargetKeys = new Set(
374
455
  }
375
456
  }
376
457
  export async function captureDeepResearchTargetKeys(client) {
377
- return (await readDeepResearchTargetResult(client))?.targetKeys ?? [];
458
+ const scan = await readDeepResearchTargetResult(client);
459
+ if (!scan) {
460
+ throw new Error("Deep Research target baseline capture unavailable");
461
+ }
462
+ return scan.targetKeys;
378
463
  }
379
464
  async function readDeepResearchTargetOwnerTurnIndex(rawClient, frameId, pageSessionId) {
380
465
  const owner = (await rawClient
@@ -419,7 +504,7 @@ async function readDeepResearchTargetSession(rawClient, sessionId, targetUrl) {
419
504
  const frameTree = (await rawClient
420
505
  .send("Page.getFrameTree", {}, sessionId)
421
506
  .catch(() => null));
422
- const frameId = frameTree?.frameTree?.frame?.id;
507
+ const ownerFrameId = frameTree?.frameTree?.frame?.id;
423
508
  if (!isConfirmedDeepResearchTarget(targetUrl, frameTree?.frameTree)) {
424
509
  return { confirmed: false, read: null };
425
510
  }
@@ -438,7 +523,7 @@ async function readDeepResearchTargetSession(rawClient, sessionId, targetUrl) {
438
523
  }
439
524
  const value = await evaluateDeepResearchFrameStatus(rawClient, sessionId, world.executionContextId);
440
525
  if (value?.completed) {
441
- return { confirmed: true, read: value, frameId };
526
+ return { confirmed: true, read: value, frameId: ownerFrameId };
442
527
  }
443
528
  if ((value?.textLength ?? 0) > (best?.textLength ?? 0) || value?.inProgress) {
444
529
  best = value;
@@ -446,12 +531,12 @@ async function readDeepResearchTargetSession(rawClient, sessionId, targetUrl) {
446
531
  }
447
532
  const topFrameValue = await evaluateDeepResearchFrameStatus(rawClient, sessionId);
448
533
  if (topFrameValue?.completed) {
449
- return { confirmed: true, read: topFrameValue, frameId };
534
+ return { confirmed: true, read: topFrameValue, frameId: ownerFrameId };
450
535
  }
451
536
  if ((topFrameValue?.textLength ?? 0) > (best?.textLength ?? 0) || topFrameValue?.inProgress) {
452
537
  best = topFrameValue;
453
538
  }
454
- return { confirmed: true, read: best, frameId };
539
+ return { confirmed: true, read: best, frameId: ownerFrameId };
455
540
  }
456
541
  async function evaluateDeepResearchFrameStatus(rawClient, sessionId, contextId) {
457
542
  const response = (await rawClient
@@ -474,21 +559,20 @@ function isDeepResearchFrameDescriptor(url, name = "") {
474
559
  return (descriptor.includes("connector_openai_deep_research") || descriptor.includes("deep-research"));
475
560
  }
476
561
  function findDeepResearchFrameId(tree) {
562
+ return collectPageDeepResearchFrameIds(tree)[0] ?? null;
563
+ }
564
+ function collectPageDeepResearchFrameIds(tree) {
477
565
  if (!tree?.frame) {
478
- return null;
566
+ return [];
479
567
  }
480
- const url = tree.frame.url ?? "";
481
- const name = tree.frame.name ?? "";
482
- if (isDeepResearchFrameDescriptor(url, name)) {
483
- return tree.frame.id ?? null;
568
+ const ids = [];
569
+ if (tree.frame.id && isDeepResearchFrameDescriptor(tree.frame.url ?? "", tree.frame.name ?? "")) {
570
+ ids.push(tree.frame.id);
484
571
  }
485
572
  for (const child of tree.childFrames ?? []) {
486
- const match = findDeepResearchFrameId(child);
487
- if (match) {
488
- return match;
489
- }
573
+ ids.push(...collectPageDeepResearchFrameIds(child));
490
574
  }
491
- return null;
575
+ return ids;
492
576
  }
493
577
  function collectDeepResearchFrameIds(tree) {
494
578
  if (!tree?.frame) {
@@ -644,20 +728,41 @@ function buildDeepResearchCompletionPollExpression(minTurnIndex) {
644
728
  const text = (lastTurn?.textContent || '').trim();
645
729
  const normalized = text.toLowerCase().replace(/\\s+/g, ' ').trim();
646
730
  const textLength = text.length;
731
+ const lines = text.split(/\\n+/).map(line => line.trim()).filter(Boolean);
732
+ const tailIsPlanningPanel = text.length <= 1500 &&
733
+ lines.length >= 4 &&
734
+ lines.length <= 20 &&
735
+ /^update$/i.test(lines[1] || '') &&
736
+ /^stop research$/i.test(lines[lines.length - 1] || '') &&
737
+ /^determining steps for creating a report(?:\\.\\.\\.)?$/i.test(lines[lines.length - 2] || '');
647
738
  const isToolStub = normalized === 'called tool' ||
648
739
  normalized === 'used tool' ||
649
740
  normalized === 'użyto narzędzia' ||
650
741
  normalized === 'narzędzie wywołane';
742
+ const incompleteResult = isToolStub ||
743
+ normalized === 'planning' ||
744
+ normalized === 'researching' ||
745
+ normalized === 'searching the web' ||
746
+ (text.trimStart().startsWith('<system-reminder>') &&
747
+ /<system-reminder>[\\s\\S]*#\\s*plan mode\\b/i.test(text)) ||
748
+ tailIsPlanningPanel;
651
749
  const finished = Boolean(lastTurn?.querySelector(${finishedSelector})) &&
652
750
  textLength >= 40 &&
653
- !isToolStub;
751
+ !incompleteResult;
654
752
  const hasIframe = Array.from(document.querySelectorAll('iframe')).some(f => {
655
753
  const rect = f.getBoundingClientRect();
656
754
  return rect.width > 200 && rect.height > 200;
657
755
  });
658
- const hasActiveScopedResearch = scopedToNewTurns && Boolean(lastTurn) && hasIframe &&
659
- (textLength < 40 || isToolStub || /chatgpt\\s+said:?$/i.test(text));
660
- return { finished, stopVisible, textLength, hasIframe, isToolStub, hasActiveScopedResearch, accountBlocked };
756
+ const hasScopedDeepResearchIframe = Array.from(lastTurn?.querySelectorAll?.('iframe') || []).some(f => {
757
+ const rect = f.getBoundingClientRect();
758
+ const descriptor = String(f.getAttribute('src') || '') + ' ' + String(f.getAttribute('name') || '');
759
+ return rect.width > 200 && rect.height > 200 &&
760
+ /connector_openai_deep_research|deep-research/i.test(descriptor);
761
+ });
762
+ const hasActiveScopedResearch = scopedToNewTurns && Boolean(lastTurn) &&
763
+ hasScopedDeepResearchIframe &&
764
+ (textLength < 40 || isToolStub || tailIsPlanningPanel || /chatgpt\\s+said:?$/i.test(text));
765
+ return { finished, stopVisible, textLength, hasIframe, isToolStub, incompleteResult, researchActivity: tailIsPlanningPanel || (isToolStub && hasScopedDeepResearchIframe), hasActiveScopedResearch, accountBlocked };
661
766
  })()`;
662
767
  }
663
768
  export function buildDeepResearchStatusExpressionForTest() {
@@ -666,29 +771,56 @@ export function buildDeepResearchStatusExpressionForTest() {
666
771
  export function buildDeepResearchCompletionPollExpressionForTest(minTurnIndex = -1) {
667
772
  return buildDeepResearchCompletionPollExpression(minTurnIndex);
668
773
  }
669
- function buildActivateDeepResearchExpression() {
670
- const plusBtnSelector = JSON.stringify(DEEP_RESEARCH_PLUS_BUTTON);
671
- const targetText = JSON.stringify(DEEP_RESEARCH_DROPDOWN_ITEM_TEXT);
774
+ function buildFindDeepResearchPillExpression(functionName = "findDeepResearchPill") {
672
775
  const pillLabel = JSON.stringify(DEEP_RESEARCH_PILL_LABEL);
673
- // pillLabel is used inside the expression for verification
674
- void pillLabel;
675
- return `(async () => {
676
- ${buildClickDispatcher()}
677
-
678
- const findDeepResearchPill = () => {
679
- const pills = document.querySelectorAll('.__composer-pill-composite, .__composer-pill, [class*="composer-pill"]');
680
- for (const pill of pills) {
681
- const text = pill.textContent?.trim() || '';
682
- const aria = pill.getAttribute('aria-label') ||
776
+ return `const ${functionName} = () => {
777
+ const label = ${pillLabel}.toLowerCase();
778
+ const selectors = [
779
+ '.__composer-pill-composite',
780
+ '.__composer-pill',
781
+ '[class*="composer-pill"]',
782
+ ].join(',');
783
+ const candidates = Array.from(document.querySelectorAll(selectors));
784
+ const composerRoots = Array.from(document.querySelectorAll('[data-testid="composer"], form, [class*="composer"]'));
785
+ for (const root of composerRoots) {
786
+ candidates.push(...Array.from(root.querySelectorAll('button, [role="button"], [class*="pill"], [class*="composer-pill"]')));
787
+ }
788
+ const seen = new Set();
789
+ for (const pill of candidates) {
790
+ if (!(pill instanceof Element) || seen.has(pill)) continue;
791
+ seen.add(pill);
792
+ const rect = pill.getBoundingClientRect?.();
793
+ if (!rect || rect.width <= 0 || rect.height <= 0) continue;
794
+ const text = (pill.textContent || '').replace(/\\s+/g, ' ').trim().toLowerCase();
795
+ const aria = (
796
+ pill.getAttribute('aria-label') ||
683
797
  pill.querySelector('button')?.getAttribute('aria-label') ||
684
- '';
685
- if (text.toLowerCase().includes('deep research') ||
686
- aria.toLowerCase().includes('deep research')) {
798
+ ''
799
+ ).toLowerCase();
800
+ if (text.includes(label) || aria.includes(label)) {
687
801
  return pill;
688
802
  }
689
803
  }
690
804
  return null;
691
- };
805
+ };`;
806
+ }
807
+ function buildWaitForDeepResearchPillExpression(timeoutMs) {
808
+ return `(async () => {
809
+ ${buildFindDeepResearchPillExpression()}
810
+ const deadline = Date.now() + ${JSON.stringify(Math.max(timeoutMs, 0))};
811
+ while (Date.now() < deadline) {
812
+ if (findDeepResearchPill()) return true;
813
+ await new Promise(resolve => setTimeout(resolve, 200));
814
+ }
815
+ return Boolean(findDeepResearchPill());
816
+ })()`;
817
+ }
818
+ function buildActivateDeepResearchExpression() {
819
+ const plusBtnSelector = JSON.stringify(DEEP_RESEARCH_PLUS_BUTTON);
820
+ const targetText = JSON.stringify(DEEP_RESEARCH_DROPDOWN_ITEM_TEXT);
821
+ return `(async () => {
822
+ ${buildClickDispatcher()}
823
+ ${buildFindDeepResearchPillExpression()}
692
824
 
693
825
  const waitForPill = () => new Promise((resolve) => {
694
826
  let elapsed = 0;
@@ -703,24 +835,105 @@ function buildActivateDeepResearchExpression() {
703
835
  setTimeout(tick, 200);
704
836
  });
705
837
 
706
- const clearComposer = (composer) => {
707
- if (!composer) return;
708
- if ('value' in composer) composer.value = '';
709
- else composer.textContent = '';
710
- composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
838
+ const menuItemSelector = [
839
+ '[data-radix-collection-item]',
840
+ '[role="option"]',
841
+ '[cmdk-item]',
842
+ 'button',
843
+ '[role="menuitem"]',
844
+ '[role="menuitemradio"]',
845
+ '.__menu-item',
846
+ '[class*="__menu-item"]',
847
+ '[class*="menu-item"]',
848
+ ].join(',');
849
+ const dropdownItemSelector = [
850
+ '[data-radix-collection-item]',
851
+ '[role="menuitem"]',
852
+ '[role="menuitemradio"]',
853
+ '[role="option"]',
854
+ '[cmdk-item]',
855
+ 'button',
856
+ '.__menu-item',
857
+ '[class*="__menu-item"]',
858
+ '[class*="menu-item"]',
859
+ ].join(',');
860
+ const popoverSelector = [
861
+ '.popover',
862
+ '[class*="popover"]',
863
+ '[data-radix-popper-content-wrapper]',
864
+ '[data-floating-ui-portal]',
865
+ ].join(',');
866
+ const target = ${targetText}.toLowerCase();
867
+ const normalizeText = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
868
+ const getText = (item) => normalizeText(item.textContent || item.getAttribute?.('aria-label') || '');
869
+ const isInPopover = (item) => Boolean(item.closest?.(popoverSelector));
870
+ const isVisible = (item) => {
871
+ const rect = item.getBoundingClientRect?.();
872
+ if (!rect || rect.width <= 0 || rect.height <= 0) return false;
873
+ const style = window.getComputedStyle?.(item);
874
+ return !style || (style.visibility !== 'hidden' && style.display !== 'none');
711
875
  };
712
-
713
- const setComposerText = (composer, text) => {
714
- composer.focus?.();
715
- if ('value' in composer) composer.value = text;
716
- else composer.textContent = text;
717
- composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
876
+ const findPopoverSearchInput = () => Array.from(
877
+ document.querySelectorAll('input, textarea, [contenteditable="true"]')
878
+ ).find(item => {
879
+ const type = (item.getAttribute?.('type') || '').toLowerCase();
880
+ const testId = (item.getAttribute?.('data-testid') || '').toLowerCase();
881
+ return isInPopover(item) &&
882
+ isVisible(item) &&
883
+ type !== 'file' &&
884
+ testId !== 'upload-photos-input';
885
+ }) || null;
886
+ const setSearchText = (input, text) => {
887
+ input.focus?.();
888
+ if ('value' in input) input.value = text;
889
+ else input.textContent = text;
890
+ input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
891
+ input.dispatchEvent(new Event('change', { bubbles: true }));
718
892
  };
719
-
720
- const findDeepResearchItem = () => {
721
- const target = ${targetText}.toLowerCase();
722
- const candidates = Array.from(document.querySelectorAll('[data-radix-collection-item], [role="option"], [cmdk-item], button, [role="menuitem"], [role="menuitemradio"]'));
723
- return candidates.find(item => (item.textContent || '').trim().toLowerCase() === target) || null;
893
+ const isDeepResearchText = (text) => (
894
+ text === target ||
895
+ text.startsWith(target + ' ') ||
896
+ text === 'get a detailed report' ||
897
+ text.startsWith('get a detailed report ') ||
898
+ (text.includes(target) && text.includes('detailed report')) ||
899
+ text.replace(/\\s+/g, '').startsWith('deepresearch')
900
+ );
901
+ const getClickableItem = (item) => item.closest?.(
902
+ '[data-radix-collection-item], [role="option"], [cmdk-item], button, [role="menuitem"], [role="menuitemradio"], .__menu-item, [class*="__menu-item"], [class*="menu-item"]'
903
+ ) || item;
904
+ const findDeepResearchItem = (options = {}) => {
905
+ const matches = Array.from(document.querySelectorAll(menuItemSelector))
906
+ .filter(item => {
907
+ const text = getText(item);
908
+ return text &&
909
+ text.length <= 180 &&
910
+ isVisible(item) &&
911
+ (!options.requirePopover || isInPopover(item)) &&
912
+ isDeepResearchText(text);
913
+ })
914
+ .map(item => {
915
+ const text = getText(item);
916
+ const clickable = getClickableItem(item);
917
+ const exact = text === target ? 0 : 1;
918
+ const menuRow = /(^|\\s)__menu-item(\\s|$)/.test(clickable.className || '') ? 0 : 1;
919
+ return { item: clickable, score: exact + menuRow, textLength: text.length };
920
+ })
921
+ .sort((a, b) => a.score - b.score || a.textLength - b.textLength);
922
+ return matches[0]?.item || null;
923
+ };
924
+ const collectAvailableItems = (options = {}) => {
925
+ const seen = new Set();
926
+ return Array.from(document.querySelectorAll(dropdownItemSelector))
927
+ .filter(item => !options.requirePopover || isInPopover(item))
928
+ .filter(item => isVisible(item))
929
+ .map(item => (item.textContent || '').replace(/\\s+/g, ' ').trim())
930
+ .filter(text => text && text.length <= 180)
931
+ .filter(text => {
932
+ const key = text.toLowerCase();
933
+ if (seen.has(key)) return false;
934
+ seen.add(key);
935
+ return true;
936
+ });
724
937
  };
725
938
 
726
939
  // Step 0: Check if already active
@@ -728,20 +941,8 @@ function buildActivateDeepResearchExpression() {
728
941
  return { status: 'already-active' };
729
942
  }
730
943
 
731
- // Step 1: Prefer the official slash command flow.
732
- const composer = document.querySelector('[contenteditable="true"], textarea');
733
- if (composer) {
734
- setComposerText(composer, '/Deepresearch');
735
- await new Promise(resolve => setTimeout(resolve, 600));
736
- const slashItem = findDeepResearchItem();
737
- if (slashItem) {
738
- dispatchClickSequence(slashItem);
739
- if (await waitForPill()) return { status: 'activated' };
740
- }
741
- clearComposer(composer);
742
- }
743
-
744
- // Step 2: Fall back to the composer tools menu.
944
+ // Step 1: Open the composer tools menu. Avoid slash commands because they
945
+ // mutate the main composer and can be submitted as normal prompt text.
745
946
  const plusBtn = document.querySelector(${plusBtnSelector}) ||
746
947
  Array.from(document.querySelectorAll('button')).find(
747
948
  b => (b.getAttribute('aria-label') || '').toLowerCase().includes('add files')
@@ -749,14 +950,21 @@ function buildActivateDeepResearchExpression() {
749
950
  if (!plusBtn) return { status: 'plus-button-missing' };
750
951
  dispatchClickSequence(plusBtn);
751
952
 
752
- // Step 3: Wait for dropdown
953
+ // Step 2: Wait for dropdown
753
954
  const waitForDropdown = () => new Promise((resolve) => {
754
955
  let elapsed = 0;
755
956
  const tick = () => {
756
- const items = document.querySelectorAll('[data-radix-collection-item], [role="menuitem"], [role="menuitemradio"], [role="option"], [cmdk-item]');
757
- if (items.length > 0) { resolve(items); return; }
957
+ const items = collectAvailableItems({ requirePopover: true });
958
+ if (findDeepResearchItem({ requirePopover: true }) || items.some(text => {
959
+ const normalized = normalizeText(text);
960
+ return normalized.includes('add photos') ||
961
+ normalized.includes('create image') ||
962
+ normalized.includes('web search') ||
963
+ normalized.includes('deep research') ||
964
+ normalized.includes('get a detailed report');
965
+ })) { resolve(items); return; }
758
966
  elapsed += 150;
759
- if (elapsed > 3000) { resolve(null); return; }
967
+ if (elapsed > 3000) { resolve(items.length ? items : null); return; }
760
968
  setTimeout(tick, 150);
761
969
  };
762
970
  setTimeout(tick, 150);
@@ -764,25 +972,33 @@ function buildActivateDeepResearchExpression() {
764
972
  const items = await waitForDropdown();
765
973
  if (!items) return { status: 'dropdown-item-missing', available: [] };
766
974
 
767
- // Step 4: Find "Deep research" item
768
- const target = ${targetText}.toLowerCase();
769
- let match = null;
770
- const available = [];
771
- for (const item of items) {
772
- const text = (item.textContent || '').trim();
773
- available.push(text);
774
- if (text.toLowerCase() === target) {
775
- match = item;
975
+ // Step 3: Find "Deep research" item. Some ChatGPT variants only reveal it
976
+ // after typing in the tools menu search field.
977
+ let match = findDeepResearchItem({ requirePopover: true });
978
+ let available = Array.isArray(items) ? items : collectAvailableItems({ requirePopover: true });
979
+ if (!match) {
980
+ const searchInput = findPopoverSearchInput();
981
+ if (searchInput) {
982
+ setSearchText(searchInput, ${targetText});
983
+ await new Promise(resolve => setTimeout(resolve, 600));
984
+ match = findDeepResearchItem({ requirePopover: true });
985
+ available = collectAvailableItems({ requirePopover: true });
776
986
  }
777
987
  }
778
988
  if (!match) return { status: 'dropdown-item-missing', available };
779
989
 
780
- // Step 5: Click it
990
+ // Step 4: Click it
991
+ match.scrollIntoView?.({ block: 'center', inline: 'center' });
992
+ await new Promise(resolve => setTimeout(resolve, 100));
993
+ const rect = match.getBoundingClientRect();
994
+ const clickPoint = rect && rect.width > 0 && rect.height > 0
995
+ ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
996
+ : undefined;
781
997
  dispatchClickSequence(match);
782
998
 
783
- // Step 6: Verify pill appeared
999
+ // Step 5: Verify pill appeared
784
1000
  const pillConfirmed = await waitForPill();
785
- return pillConfirmed ? { status: 'activated' } : { status: 'pill-not-confirmed' };
1001
+ return pillConfirmed ? { status: 'activated' } : { status: 'pill-not-confirmed', clickPoint };
786
1002
  })()`;
787
1003
  }
788
1004
  export function buildActivateDeepResearchExpressionForTest() {