@steipete/oracle 0.18.0 → 0.19.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 (159) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +65 -63
  3. package/dist/bin/oracle-mcp.js +0 -0
  4. package/dist/src/browser/actions/archiveConversation.js +31 -7
  5. package/dist/src/browser/actions/assistantResponse.js +43 -1
  6. package/dist/src/browser/actions/attachmentContext.js +239 -0
  7. package/dist/src/browser/actions/attachmentDataTransfer.js +19 -1
  8. package/dist/src/browser/actions/attachmentEvidence.js +78 -0
  9. package/dist/src/browser/actions/attachmentProgress.js +41 -0
  10. package/dist/src/browser/actions/attachmentPrompt.js +130 -0
  11. package/dist/src/browser/actions/attachments.js +306 -355
  12. package/dist/src/browser/actions/deepResearch.js +2 -1
  13. package/dist/src/browser/actions/modelSelection.js +13 -3
  14. package/dist/src/browser/actions/navigation.js +1 -1
  15. package/dist/src/browser/actions/promptComposer.js +361 -186
  16. package/dist/src/browser/actions/remoteFileTransfer.js +4 -2
  17. package/dist/src/browser/actions/thinkingStatus.js +2 -5
  18. package/dist/src/browser/actions/thinkingTime.js +119 -16
  19. package/dist/src/browser/attachRunning.js +46 -2
  20. package/dist/src/browser/attachmentValidation.js +53 -0
  21. package/dist/src/browser/cancellation.js +105 -0
  22. package/dist/src/browser/chatgptImages.js +2 -0
  23. package/dist/src/browser/chatgptThrottle.js +104 -0
  24. package/dist/src/browser/chromeLifecycle.js +267 -43
  25. package/dist/src/browser/config.js +13 -0
  26. package/dist/src/browser/detect.js +1 -1
  27. package/dist/src/browser/index.js +444 -261
  28. package/dist/src/browser/modelDisplay.js +9 -0
  29. package/dist/src/browser/pageActions.js +2 -2
  30. package/dist/src/browser/profileState.js +114 -9
  31. package/dist/src/browser/projectSourcesRunner.js +83 -15
  32. package/dist/src/browser/prompt.js +272 -113
  33. package/dist/src/browser/providers/chatgptDomProvider.js +2 -0
  34. package/dist/src/browser/reattach.js +10 -3
  35. package/dist/src/browser/sessionRunner.js +71 -11
  36. package/dist/src/browser/tabLeaseRegistry.js +414 -38
  37. package/dist/src/browser/utils.js +20 -2
  38. package/dist/src/cli/browserConfig.js +3 -1
  39. package/dist/src/cli/browserDefaults.js +10 -0
  40. package/dist/src/cli/browserTabs.js +12 -29
  41. package/dist/src/cli/detachedSession.js +69 -0
  42. package/dist/src/cli/dryRun.js +58 -48
  43. package/dist/src/cli/harvestIntegrity.js +140 -0
  44. package/dist/src/cli/serveBrowserConfig.js +24 -0
  45. package/dist/src/cli/sessionDisplay.js +9 -2
  46. package/dist/src/cli/sessionRunner.js +91 -7
  47. package/dist/src/config.js +1 -0
  48. package/dist/src/gemini-web/client.js +67 -21
  49. package/dist/src/gemini-web/executor.js +4 -0
  50. package/dist/src/gemini-web/http.js +15 -0
  51. package/dist/src/gemini-web/models.js +15 -11
  52. package/dist/src/mcp/server.js +11 -14
  53. package/dist/src/mcp/tools/chatgptImage.js +4 -4
  54. package/dist/src/mcp/tools/consult.js +84 -22
  55. package/dist/src/mcp/tools/projectSources.js +4 -6
  56. package/dist/src/mcp/tools/sessionResources.js +1 -1
  57. package/dist/src/mcp/tools/sessions.js +2 -2
  58. package/dist/src/mcp/tools/wait.js +182 -0
  59. package/dist/src/mcp/types.js +7 -0
  60. package/dist/src/oracle/errors.js +11 -0
  61. package/dist/src/oracle/providerRoutePlan.js +3 -2
  62. package/dist/src/remote/client.js +70 -6
  63. package/dist/src/remote/health.js +6 -2
  64. package/dist/src/remote/runSlots.js +83 -0
  65. package/dist/src/remote/server.js +345 -71
  66. package/dist/src/sessionManager.js +36 -1
  67. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  68. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  69. package/package.json +25 -23
  70. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  71. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  72. package/dist/bin/oracle.js +0 -683
  73. package/dist/docs-site/.nojekyll +0 -0
  74. package/dist/docs-site/CNAME +0 -1
  75. package/dist/docs-site/RELEASING.html +0 -410
  76. package/dist/docs-site/agents.html +0 -374
  77. package/dist/docs-site/anthropic.html +0 -368
  78. package/dist/docs-site/bridge.html +0 -400
  79. package/dist/docs-site/browser-mode.html +0 -588
  80. package/dist/docs-site/chromium-forks.html +0 -347
  81. package/dist/docs-site/cli-reference.html +0 -346
  82. package/dist/docs-site/configuration.html +0 -436
  83. package/dist/docs-site/favicon.svg +0 -14
  84. package/dist/docs-site/followup.html +0 -368
  85. package/dist/docs-site/gemini.html +0 -379
  86. package/dist/docs-site/grok.html +0 -325
  87. package/dist/docs-site/index.html +0 -360
  88. package/dist/docs-site/install.html +0 -335
  89. package/dist/docs-site/linux.html +0 -321
  90. package/dist/docs-site/llms.txt +0 -43
  91. package/dist/docs-site/manual-tests.html +0 -595
  92. package/dist/docs-site/mcp.html +0 -368
  93. package/dist/docs-site/multimodel.html +0 -364
  94. package/dist/docs-site/mythical-pro-agents.html +0 -360
  95. package/dist/docs-site/notifier.html +0 -338
  96. package/dist/docs-site/openai-endpoints.html +0 -387
  97. package/dist/docs-site/openrouter.html +0 -344
  98. package/dist/docs-site/quickstart.html +0 -369
  99. package/dist/docs-site/refactor/ux.html +0 -532
  100. package/dist/docs-site/sessions.html +0 -388
  101. package/dist/docs-site/social-card.png +0 -0
  102. package/dist/docs-site/social-card.svg +0 -79
  103. package/dist/docs-site/spec.html +0 -363
  104. package/dist/docs-site/testing.html +0 -320
  105. package/dist/docs-site/tui-debug.html +0 -326
  106. package/dist/docs-site/windows-work.html +0 -323
  107. package/dist/docs-site/windows.html +0 -320
  108. package/dist/markdansi/types/index.js +0 -4
  109. package/dist/oracle/bin/oracle-cli.js +0 -472
  110. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  111. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  112. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  113. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  114. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  115. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  116. package/dist/oracle/src/browser/config.js +0 -33
  117. package/dist/oracle/src/browser/constants.js +0 -40
  118. package/dist/oracle/src/browser/cookies.js +0 -210
  119. package/dist/oracle/src/browser/domDebug.js +0 -36
  120. package/dist/oracle/src/browser/index.js +0 -331
  121. package/dist/oracle/src/browser/pageActions.js +0 -5
  122. package/dist/oracle/src/browser/prompt.js +0 -88
  123. package/dist/oracle/src/browser/promptSummary.js +0 -20
  124. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  125. package/dist/oracle/src/browser/types.js +0 -1
  126. package/dist/oracle/src/browser/utils.js +0 -62
  127. package/dist/oracle/src/browserMode.js +0 -1
  128. package/dist/oracle/src/cli/browserConfig.js +0 -44
  129. package/dist/oracle/src/cli/dryRun.js +0 -59
  130. package/dist/oracle/src/cli/engine.js +0 -17
  131. package/dist/oracle/src/cli/errorUtils.js +0 -9
  132. package/dist/oracle/src/cli/help.js +0 -70
  133. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  134. package/dist/oracle/src/cli/options.js +0 -103
  135. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  136. package/dist/oracle/src/cli/rootAlias.js +0 -30
  137. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  138. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  139. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  140. package/dist/oracle/src/heartbeat.js +0 -43
  141. package/dist/oracle/src/oracle/client.js +0 -48
  142. package/dist/oracle/src/oracle/config.js +0 -29
  143. package/dist/oracle/src/oracle/errors.js +0 -101
  144. package/dist/oracle/src/oracle/files.js +0 -220
  145. package/dist/oracle/src/oracle/format.js +0 -33
  146. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  147. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  148. package/dist/oracle/src/oracle/request.js +0 -48
  149. package/dist/oracle/src/oracle/run.js +0 -444
  150. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  151. package/dist/oracle/src/oracle/types.js +0 -1
  152. package/dist/oracle/src/oracle.js +0 -9
  153. package/dist/oracle/src/sessionManager.js +0 -205
  154. package/dist/oracle/src/version.js +0 -39
  155. package/dist/scripts/chrome/browser-tools.js +0 -295
  156. package/dist/src/browser/chromeCookies.js +0 -312
  157. package/dist/src/browser/keytarShim.js +0 -56
  158. package/dist/src/browser/profileSync.js +0 -141
  159. package/dist/src/browser/windowsCookies.js +0 -219
@@ -4,6 +4,7 @@ import { waitForAttachmentVisible } from "./attachments.js";
4
4
  import { delay } from "../utils.js";
5
5
  import { logDomFailure } from "../domDebug.js";
6
6
  import { transferAttachmentViaDataTransfer } from "./attachmentDataTransfer.js";
7
+ import { beginAttachmentEvidence } from "./attachmentEvidence.js";
7
8
  /**
8
9
  * Upload file to remote Chrome by transferring content via CDP
9
10
  * Used when browser is on a different machine than CLI
@@ -28,10 +29,11 @@ export async function uploadAttachmentViaDataTransfer(deps, attachment, logger)
28
29
  await logDomFailure(runtime, logger, "file-input");
29
30
  throw new Error("Unable to locate ChatGPT file attachment input.");
30
31
  }
31
- const transferResult = await transferAttachmentViaDataTransfer(runtime, attachment, fileInputSelector);
32
+ const evidenceId = await beginAttachmentEvidence(runtime, path.basename(attachment.path));
33
+ const transferResult = await transferAttachmentViaDataTransfer(runtime, attachment, fileInputSelector, deps.navigationUrl);
32
34
  logger(`File transferred: ${transferResult.fileName} (${transferResult.size} bytes)`);
33
35
  // Give ChatGPT a moment to process the file
34
36
  await delay(500);
35
- await waitForAttachmentVisible(runtime, transferResult.fileName, 10_000, logger);
37
+ await waitForAttachmentVisible(runtime, transferResult.fileName, 10_000, logger, evidenceId);
36
38
  logger("Attachment queued");
37
39
  }
@@ -1,5 +1,6 @@
1
1
  import { formatElapsed } from "../../oracle/format.js";
2
2
  import { ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR, STOP_BUTTON_SELECTORS, } from "../constants.js";
3
+ import { buildConversationTurnListExpression } from "../conversationTurns.js";
3
4
  const THINKING_STALE_HINT_MS = 10 * 60_000;
4
5
  export function startThinkingStatusMonitor(Runtime, logger, options = {}) {
5
6
  const intervalMs = resolveThinkingStatusInterval(options.intervalMs);
@@ -438,7 +439,6 @@ const ACTIVE_THINKING_LABELS = [
438
439
  function buildThinkingActivityPredicateJs(fnName, detailed) {
439
440
  const stopLiteral = JSON.stringify(STOP_BUTTON_SELECTORS.join(", "));
440
441
  const activeLabelsLiteral = JSON.stringify(ACTIVE_THINKING_LABELS);
441
- const conversationLiteral = JSON.stringify(CONVERSATION_TURN_SELECTOR);
442
442
  const assistantLiteral = JSON.stringify(ASSISTANT_ROLE_SELECTOR);
443
443
  const strong = detailed ? "{ active: true, strong: true }" : "true";
444
444
  const weak = detailed ? "{ active: true, strong: false }" : "true";
@@ -446,7 +446,6 @@ function buildThinkingActivityPredicateJs(fnName, detailed) {
446
446
  return `const ${fnName} = () => {
447
447
  const STOP_SELECTOR = ${stopLiteral};
448
448
  const ACTIVE_LABELS = ${activeLabelsLiteral};
449
- const CONVERSATION_SELECTOR = ${conversationLiteral};
450
449
  const ASSISTANT_SELECTOR = ${assistantLiteral};
451
450
  const isVisible = (node) => {
452
451
  if (!(node instanceof HTMLElement)) return false;
@@ -549,9 +548,7 @@ function buildThinkingActivityPredicateJs(fnName, detailed) {
549
548
  // a visible progress bar mounted indefinitely (review P1), and a document-wide veto would
550
549
  // then hold thinkingActive true until the watchdog timeout on a completed response. The
551
550
  // sidecar check below covers verified reasoning panels; here only the latest turn counts.
552
- const turns = (() => {
553
- try { return document.querySelectorAll(CONVERSATION_SELECTOR); } catch { return []; }
554
- })();
551
+ const turns = ${buildConversationTurnListExpression()};
555
552
  const lastTurn = turns.length ? turns[turns.length - 1] : null;
556
553
  if (
557
554
  lastTurn instanceof HTMLElement &&
@@ -52,8 +52,8 @@ function logPickerDiagnostic(result, logger) {
52
52
  /**
53
53
  * Selects a thinking-time level in ChatGPT's composer.
54
54
  *
55
- * Missing controls remain best-effort except Pro Extended, which fails closed
56
- * unless the selected option is confirmed.
55
+ * Returns the UI selection observed at capturedAt, not backend attestation.
56
+ * Explicit Pro and Pro Extended requests still fail closed when unconfirmed.
57
57
  */
58
58
  export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
59
59
  const result = await evaluateThinkingTimeSelection(Runtime, level, desiredModel);
@@ -65,13 +65,24 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
65
65
  // own, independently of the legacy Pro-model + "extended" combination.
66
66
  const strictProEffort = level === "pro" ||
67
67
  ((targetModelKind === "pro" || observedModelKind === "pro") && level === "extended");
68
+ const evidence = (status, resolvedLabel) => ({
69
+ requestedLevel: level,
70
+ status,
71
+ resolvedLabel,
72
+ verified: status === "already-selected" || status === "switched",
73
+ strictFailClosed: strictProEffort,
74
+ targetModelKind: targetModelKind ?? null,
75
+ observedModelKind: observedModelKind ?? null,
76
+ source: "chatgpt-thinking-picker",
77
+ capturedAt: new Date().toISOString(),
78
+ });
68
79
  switch (result?.status) {
69
80
  case "already-selected":
70
81
  logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} (already selected)`));
71
- return;
82
+ return evidence("already-selected", result.label ?? null);
72
83
  case "switched":
73
84
  logger(formatBrowserThinkingLog(result.label ?? capitalizedLevel));
74
- return;
85
+ return evidence("switched", result.label ?? null);
75
86
  case "option-disabled": {
76
87
  await logDomFailure(Runtime, logger, "thinking-option-disabled");
77
88
  logPickerDiagnostic(result, logger);
@@ -82,7 +93,7 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
82
93
  // strict error's "refusing to submit" wording: the request really is sent,
83
94
  // at whatever effort ChatGPT already had selected.
84
95
  logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} is unavailable on this account (${result.notice ?? "no reason given"}); keeping the effort already selected in ChatGPT.`));
85
- return;
96
+ return evidence("unverified", null);
86
97
  }
87
98
  case "chip-not-found":
88
99
  case "menu-not-found":
@@ -108,7 +119,7 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
108
119
  ? "the effort in ChatGPT is unconfirmed"
109
120
  : "keeping the effort already selected in ChatGPT";
110
121
  logger(formatBrowserThinkingLog(`${message}; ${outcome}.`));
111
- return;
122
+ return evidence("unverified", null);
112
123
  }
113
124
  default: {
114
125
  await logDomFailure(Runtime, logger, "thinking-time-unknown");
@@ -118,7 +129,7 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
118
129
  throw new Error(`Thinking time: unknown outcome selecting ${capitalizedLevel}; refusing to submit without confirmed ${target}.`);
119
130
  }
120
131
  logger(formatBrowserThinkingLog(`unknown outcome selecting ${capitalizedLevel}; continuing with ChatGPT default.`));
121
- return;
132
+ return evidence("unverified", null);
122
133
  }
123
134
  }
124
135
  }
@@ -191,12 +202,12 @@ function buildThinkingTimeExpression(level, desiredModel) {
191
202
  const TARGET_MODEL_KIND = ${targetModelKindLiteral};
192
203
  const TARGET_IS_GPT56_MODEL = ${targetIsGpt56ModelLiteral};
193
204
 
194
- // Multilingual matchers: English level token + observed German/Japanese/Chinese variants.
205
+ // Multilingual matchers: English level token + observed localized variants.
195
206
  const LEVEL_TOKENS = {
196
- light: ['light', 'instant', 'sofort', 'leicht', '最速', '轻', '极速'],
197
- standard: ['standard', 'medium', 'mittel', '中程度', '标准', '中'],
198
- extended: ['extended', 'high', 'hoch', 'erweitert', '高い', '扩展', '深度', '加强', '高'],
199
- 'extra-high': ['extra high', 'sehr hoch', '非常に高い', '极高'],
207
+ light: ['light', 'instant', 'sofort', 'leicht', '最速', '轻', '极速', '즉시'],
208
+ standard: ['standard', 'medium', 'mittel', '中程度', '标准', '中', '중간'],
209
+ extended: ['extended', 'high', 'hoch', 'erweitert', '高い', '扩展', '深度', '加强', '高', '높음'],
210
+ 'extra-high': ['extra high', 'sehr hoch', '非常に高い', '极高', '매우 높음'],
200
211
  heavy: ['heavy', 'schwer', '重度', '加重'],
201
212
  };
202
213
  // Pro is a tier you can request, but it is also a MODEL name, so it must never
@@ -215,7 +226,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
215
226
  const INTELLIGENCE_WAIT_MS = 2500;
216
227
 
217
228
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
218
- // Keep CJK characters, including Japanese kana, so localized labels survive
229
+ // Keep CJK characters, including Japanese kana and Hangul, so labels survive
219
230
  // normalization before being matched against LEVEL_TOKENS and picker controls.
220
231
  const normalize = (value) => (value || '')
221
232
  // Compose first so NFD umlauts fold too, then map them onto ASCII before
@@ -226,7 +237,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
226
237
  .replace(/ö/g, 'o')
227
238
  .replace(/ü/g, 'u')
228
239
  .replace(/ß/g, 'ss')
229
- .replace(/[^a-z0-9\\u3040-\\u30ff\\u4e00-\\u9fff]+/g, ' ')
240
+ .replace(/[^a-z0-9\\u3040-\\u30ff\\u4e00-\\u9fff\\uac00-\\ud7af]+/g, ' ')
230
241
  .replace(/\\s+/g, ' ')
231
242
  .trim();
232
243
  const hasToken = (text, token) => normalize(text).split(' ').includes(token);
@@ -255,6 +266,8 @@ function buildThinkingTimeExpression(level, desiredModel) {
255
266
  if (token === 'extra high') return hasPhrase(t, 'extra high');
256
267
  if (token === 'hoch') return hasPhrase(t, 'hoch') && !hasPhrase(t, 'sehr hoch');
257
268
  if (token === 'sehr hoch') return hasPhrase(t, 'sehr hoch');
269
+ if (token === '높음') return hasPhrase(t, '높음') && !hasPhrase(t, '매우 높음');
270
+ if (token === '매우 높음') return hasPhrase(t, '매우 높음');
258
271
  if (token === '高い' || token === '非常に高い') {
259
272
  return t === token || hasToken(t, token);
260
273
  }
@@ -842,11 +855,11 @@ function buildThinkingTimeExpression(level, desiredModel) {
842
855
  // token matching cannot be used here — substring is deliberate, as in
843
856
  // countEffortLevels above.
844
857
  const ADVANCED_WORDS = [
845
- 'advanced', 'erweitert', '高级', '詳細設定', '詳細表示',
858
+ 'advanced', 'erweitert', '高级', '詳細設定', '詳細表示', '고급',
846
859
  'avanzado', 'avancado', 'avance',
847
860
  ];
848
861
  const EFFORT_WORDS = [
849
- 'effort', 'aufwand', '强度', '努力', '推論レベル',
862
+ 'effort', 'aufwand', '强度', '努力', '推論レベル', '思考量', '추론 수준',
850
863
  'esfuerzo', 'esforco', 'sforzo', 'inspanning', 'wysilek',
851
864
  ];
852
865
  const containsAny = (label, words) => words.some((word) => label.includes(word));
@@ -945,6 +958,94 @@ function buildThinkingTimeExpression(level, desiredModel) {
945
958
  return null;
946
959
  };
947
960
 
961
+ // The direct-slider rollout removes the Effort submenu entirely. Its keyboard
962
+ // owner announces the actual tier via aria-describedby; neither the pill nor
963
+ // the slider's maximum position alone proves that Pro was selected.
964
+ const selectDirectEffortSlider = async (menu) => {
965
+ const view = menu.querySelector?.('[data-model-selection-view="true"]');
966
+ const simple = view?.querySelector?.('[data-testid="composer-model-picker-slider-simple-view"]');
967
+ if (!simple || simple.getAttribute('data-active') !== 'true' || !isVisible(simple)) return null;
968
+ const resolve = () => {
969
+ const currentView = menu.querySelector?.('[data-model-selection-view="true"]');
970
+ const currentSimple = currentView?.querySelector?.('[data-testid="composer-model-picker-slider-simple-view"]');
971
+ if (currentSimple?.getAttribute('data-active') !== 'true') return null;
972
+ const slider = currentSimple.querySelector('[data-model-reasoning-effort-slider]');
973
+ const control = slider?.closest?.('[role="menuitem"]');
974
+ const thumb = slider?.querySelector?.('[role="slider"]');
975
+ if (!control || !thumb || !isVisible(control)) return null;
976
+ const levels = ['light', 'standard', 'extended', 'extra-high', 'pro'];
977
+ // The selected label leads localized aria-describedby prose, while punctuation
978
+ // and ordinal grammar vary by locale. Match only that leading label and let
979
+ // the thumb's numeric ARIA state independently prove its position.
980
+ const readLeadingLevel = (description) => {
981
+ const value = (description ?? '').normalize('NFC').trim();
982
+ if (!value) return null;
983
+ const matches = levels.flatMap((level, index) => {
984
+ const token = [...TARGET_LEVEL_TOKENS[level]]
985
+ .sort((left, right) => right.length - left.length)
986
+ .find((candidate) => {
987
+ const normalizedCandidate = candidate.normalize('NFC');
988
+ const prefix = value.slice(0, normalizedCandidate.length);
989
+ if (normalize(prefix) !== normalize(normalizedCandidate)) return false;
990
+ // normalize() erases Unicode letters and marks; inspect the boundary intact.
991
+ const suffix = value.slice(prefix.length);
992
+ return !suffix || /^[\\s\\p{P}]/u.test(suffix);
993
+ });
994
+ return token
995
+ ? [{ level, index, label: value.slice(0, token.normalize('NFC').length) }]
996
+ : [];
997
+ });
998
+ return matches.length === 1 ? matches[0] : null;
999
+ };
1000
+ const selections = describedIds(control)
1001
+ .map((id) => readLeadingLevel(document.getElementById?.(id)?.textContent ?? ''))
1002
+ .filter(Boolean);
1003
+ if (selections.length !== 1) return null;
1004
+ const { label, index, level } = selections[0];
1005
+ // This adapter owns the observed five-tier layout only. A different range
1006
+ // or contradictory announcement must not turn a numeric guess into proof.
1007
+ if (thumb.getAttribute('aria-valuemin') !== '0' || thumb.getAttribute('aria-valuemax') !== '4' ||
1008
+ thumb.getAttribute('aria-valuenow') !== String(index)) return null;
1009
+ return { control, label, index, level };
1010
+ };
1011
+ let current = resolve();
1012
+ const finish = (result) => { closeOpenMenus(); return result; };
1013
+ // The picker can expose its simple view before the keyboard owner is mounted or visible.
1014
+ const readyDeadline = performance.now() + MAX_WAIT_MS;
1015
+ while (!current && performance.now() < readyDeadline) {
1016
+ await sleep(100);
1017
+ current = resolve();
1018
+ }
1019
+ if (!current) return finish(failure('selection-unverified'));
1020
+ // Preserve the legacy Pro-model + extended contract on unified pickers.
1021
+ const target = TARGET_MODEL_KIND === 'pro' && TARGET_LEVEL === 'extended' ? 'pro' : TARGET_LEVEL;
1022
+ const targetIndex = ['light', 'standard', 'extended', 'extra-high', 'pro'].indexOf(target);
1023
+ if (targetIndex < 0) {
1024
+ if (TARGET_IS_GPT56_MODEL && TARGET_LEVEL === 'heavy' && current.level === 'pro') {
1025
+ return finish({ status: 'already-selected', label: current.label });
1026
+ }
1027
+ return finish(failure('option-not-found'));
1028
+ }
1029
+ if (current.level === target) return finish({ status: 'already-selected', label: current.label });
1030
+ const deadline = performance.now() + MAX_WAIT_MS;
1031
+ for (let attempt = 0; attempt < 4 && performance.now() < deadline; attempt += 1) {
1032
+ if (isOptionDisabled(current.control)) return finish(failure('option-disabled', { label: current.label }));
1033
+ const previousIndex = current.index;
1034
+ const key = targetIndex > previousIndex ? 'ArrowRight' : 'ArrowLeft';
1035
+ current.control.focus?.();
1036
+ current.control.dispatchEvent(new KeyboardEvent('keydown', { key, code: key, bubbles: true, cancelable: true }));
1037
+ current = null;
1038
+ while (performance.now() < deadline) {
1039
+ await sleep(100);
1040
+ const next = resolve();
1041
+ if (next && next.index !== previousIndex) { current = next; break; }
1042
+ }
1043
+ if (!current) return finish(failure('selection-unverified'));
1044
+ if (current.level === target) return finish({ status: 'switched', label: current.label });
1045
+ }
1046
+ return finish(failure('selection-unverified'));
1047
+ };
1048
+
948
1049
  // Current ChatGPT exposes a standalone Pro or Thinking composer pill whose
949
1050
  // controlled menu contains the effort levels. Prefer this ownership boundary
950
1051
  // before probing older model-picker layouts.
@@ -1069,6 +1170,8 @@ function buildThinkingTimeExpression(level, desiredModel) {
1069
1170
  while (performance.now() < deadline) {
1070
1171
  const menu = findVisibleEffortMenu(composerEffortPill);
1071
1172
  if (menu) {
1173
+ const sliderResult = await selectDirectEffortSlider(menu);
1174
+ if (sliderResult) return sliderResult;
1072
1175
  const proEffortResult = await selectProEffortFromSubmenu();
1073
1176
  if (proEffortResult) {
1074
1177
  return proEffortResult;
@@ -1,4 +1,4 @@
1
- import { discoverDevToolsActivePortCandidates, } from "./detect.js";
1
+ import { discoverDevToolsActivePortCandidates, formatWebSocketHost, resolveDevToolsActivePortDiscoveryRoots, } from "./detect.js";
2
2
  export async function resolveAttachRunningConnection(config, logger) {
3
3
  const host = config.remoteChrome?.host ?? "127.0.0.1";
4
4
  const port = config.remoteChrome?.port ?? 9222;
@@ -12,7 +12,17 @@ export async function resolveAttachRunningConnection(config, logger) {
12
12
  .filter((candidate) => candidate.port === port)
13
13
  .sort(compareDevToolsCandidates);
14
14
  if (candidates.length === 0) {
15
- throw new Error(`No running browser with attach metadata matched ${host}:${port}. Enable remote debugging in chrome://inspect/#remote-debugging first.`);
15
+ const probe = await probeDevToolsBrowserWSEndpoint({ host, port });
16
+ if (probe.ok) {
17
+ return {
18
+ host,
19
+ port,
20
+ browserWSEndpoint: probe.browserWSEndpoint,
21
+ profileRoot: null,
22
+ };
23
+ }
24
+ const discoveryRoots = resolveDevToolsActivePortDiscoveryRoots();
25
+ throw new Error(`No running browser matched ${host}:${port}. DevToolsActivePort discovery searched ${discoveryRoots.join(", ") || "no roots"}, and endpoint probe http://${formatWebSocketHost(host)}:${port}/json/version failed: ${probe.error}.`);
16
26
  }
17
27
  const candidate = candidates[0];
18
28
  logger(`Selected attach-running browser metadata from ${candidate.path}`);
@@ -23,6 +33,40 @@ export async function resolveAttachRunningConnection(config, logger) {
23
33
  profileRoot: candidate.profileRoot,
24
34
  };
25
35
  }
36
+ async function probeDevToolsBrowserWSEndpoint({ host, port, attempts = 2, timeoutMs = 1000, }) {
37
+ const versionUrl = `http://${formatWebSocketHost(host)}:${port}/json/version`;
38
+ for (let attempt = 0; attempt < attempts; attempt++) {
39
+ try {
40
+ const controller = new AbortController();
41
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
42
+ try {
43
+ const response = await fetch(versionUrl, { signal: controller.signal });
44
+ if (!response.ok) {
45
+ throw new Error(`HTTP ${response.status}`);
46
+ }
47
+ const version = (await response.json());
48
+ if (typeof version.webSocketDebuggerUrl !== "string" || !version.webSocketDebuggerUrl) {
49
+ throw new Error("response did not include webSocketDebuggerUrl");
50
+ }
51
+ return { ok: true, browserWSEndpoint: version.webSocketDebuggerUrl };
52
+ }
53
+ finally {
54
+ // Headers can arrive before a stalled body; bound and clean up the whole request.
55
+ clearTimeout(timeout);
56
+ controller.abort();
57
+ }
58
+ }
59
+ catch (error) {
60
+ if (attempt < attempts - 1) {
61
+ await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)));
62
+ continue;
63
+ }
64
+ const message = error instanceof Error ? error.message : String(error);
65
+ return { ok: false, error: message };
66
+ }
67
+ }
68
+ return { ok: false, error: "unreachable" };
69
+ }
26
70
  function compareDevToolsCandidates(left, right) {
27
71
  if (right.mtimeMs !== left.mtimeMs) {
28
72
  return right.mtimeMs - left.mtimeMs;
@@ -0,0 +1,53 @@
1
+ import path from "node:path";
2
+ function compareStrings(left, right) {
3
+ if (left < right)
4
+ return -1;
5
+ if (left > right)
6
+ return 1;
7
+ return 0;
8
+ }
9
+ export function findAttachmentBasenameCollisions(attachments) {
10
+ const attachmentsByBasename = new Map();
11
+ for (const attachment of attachments) {
12
+ const basename = path.basename(attachment.path);
13
+ const matchingAttachments = attachmentsByBasename.get(basename) ?? [];
14
+ matchingAttachments.push(attachment);
15
+ attachmentsByBasename.set(basename, matchingAttachments);
16
+ }
17
+ return [...attachmentsByBasename.entries()]
18
+ .filter(([, matchingAttachments]) => matchingAttachments.length > 1)
19
+ .sort(([leftBasename], [rightBasename]) => compareStrings(leftBasename, rightBasename))
20
+ .map(([basename, matchingAttachments]) => ({
21
+ basename,
22
+ attachments: [...matchingAttachments].sort((left, right) => compareStrings(left.displayPath, right.displayPath) ||
23
+ compareStrings(left.path, right.path)),
24
+ }));
25
+ }
26
+ export function buildAttachmentBasenameCollisionDetails(collisions, displayFile) {
27
+ const collisionDetails = collisions.map((collision) => ({
28
+ basename: collision.basename,
29
+ files: collision.attachments.map(displayFile),
30
+ }));
31
+ return {
32
+ collisions: collisionDetails,
33
+ files: collisionDetails.flatMap((collision) => collision.files),
34
+ };
35
+ }
36
+ export function formatAttachmentBasenameCollisionMessage(subject, collisions) {
37
+ const collisionLines = collisions.length === 1
38
+ ? [
39
+ `${subject} cannot safely include multiple files named "${collisions[0].basename}":`,
40
+ ...collisions[0].files.map((file) => `- ${file}`),
41
+ ]
42
+ : [
43
+ `${subject} cannot safely include files with colliding basenames:`,
44
+ ...collisions.flatMap((collision) => [
45
+ `Files named "${collision.basename}":`,
46
+ ...collision.files.map((file) => `- ${file}`),
47
+ ]),
48
+ ];
49
+ return [
50
+ ...collisionLines,
51
+ "Use --browser-bundle-files (and --browser-bundle-format zip for raw or binary files), or choose unique names before retrying.",
52
+ ].join("\n");
53
+ }
@@ -0,0 +1,105 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { BrowserRunCancelledError } from "../oracle/errors.js";
3
+ const context = new AsyncLocalStorage();
4
+ const cleanupContext = new AsyncLocalStorage();
5
+ export const currentBrowserAbortSignal = () => context.getStore();
6
+ export const withoutBrowserCancellation = (task) => cleanupContext.run(true, () => context.run(undefined, task));
7
+ /** Per-run cancellation, including polling and resources that arrive after their caller left. */
8
+ export class BrowserCancellation {
9
+ signal;
10
+ log;
11
+ aborted;
12
+ onAbort;
13
+ proxies = new WeakMap();
14
+ constructor(signal, log = () => { }) {
15
+ this.signal = signal;
16
+ this.log = log;
17
+ if (signal) {
18
+ let rejectAbort;
19
+ this.aborted = new Promise((_, reject) => {
20
+ rejectAbort = reject;
21
+ });
22
+ this.aborted.catch(() => undefined);
23
+ this.onAbort = () => rejectAbort(new BrowserRunCancelledError());
24
+ if (signal.aborted)
25
+ this.onAbort();
26
+ else
27
+ signal.addEventListener("abort", this.onAbort, { once: true });
28
+ }
29
+ }
30
+ check() {
31
+ if (this.signal?.aborted)
32
+ throw new BrowserRunCancelledError();
33
+ }
34
+ run(task) {
35
+ return cleanupContext.run(false, () => context.run(this.signal, task));
36
+ }
37
+ dispose() {
38
+ if (this.onAbort)
39
+ this.signal?.removeEventListener("abort", this.onAbort);
40
+ }
41
+ race(pending) {
42
+ return this.aborted ? Promise.race([pending, this.aborted]) : pending;
43
+ }
44
+ call(create) {
45
+ this.check();
46
+ return this.race(create());
47
+ }
48
+ acquire(create, releaseLate) {
49
+ this.check();
50
+ const pending = create().then(async (resource) => {
51
+ if (this.signal?.aborted) {
52
+ await withoutBrowserCancellation(() => releaseLate(resource)).catch((error) => {
53
+ this.log(`[browser] Late cancelled resource cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
54
+ });
55
+ throw new BrowserRunCancelledError();
56
+ }
57
+ return resource;
58
+ });
59
+ return this.race(pending);
60
+ }
61
+ client(client) {
62
+ if (!this.signal)
63
+ return client;
64
+ const wrap = (target) => {
65
+ const cached = this.proxies.get(target);
66
+ if (cached)
67
+ return cached;
68
+ const proxy = new Proxy(target, {
69
+ get: (object, key) => {
70
+ const value = Reflect.get(object, key);
71
+ if (typeof value === "function") {
72
+ if ([
73
+ "on",
74
+ "once",
75
+ "off",
76
+ "emit",
77
+ "removeListener",
78
+ "removeAllListeners",
79
+ "addListener",
80
+ "close",
81
+ ].includes(String(key)))
82
+ return value.bind(object);
83
+ return (...args) => {
84
+ // CDP event subscriptions are synchronous and must remain removable during cleanup.
85
+ if (typeof args[0] === "function")
86
+ return Reflect.apply(value, object, args);
87
+ if (cleanupContext.getStore())
88
+ return Reflect.apply(value, object, args);
89
+ if (this.signal?.aborted)
90
+ return Promise.reject(new BrowserRunCancelledError());
91
+ const result = Reflect.apply(value, object, args);
92
+ return result && typeof result.then === "function"
93
+ ? this.race(Promise.resolve(result))
94
+ : result;
95
+ };
96
+ }
97
+ return value && typeof value === "object" ? wrap(value) : value;
98
+ },
99
+ });
100
+ this.proxies.set(target, proxy);
101
+ return proxy;
102
+ };
103
+ return wrap(client);
104
+ }
105
+ }
@@ -5,6 +5,7 @@ import { ASSISTANT_ROLE_SELECTOR } from "./constants.js";
5
5
  import { buildConversationTurnListExpression } from "./conversationTurns.js";
6
6
  import { delay } from "./utils.js";
7
7
  import { readAssistantSnapshot } from "./pageActions.js";
8
+ import { throwIfAssistantUiError } from "./actions/assistantResponse.js";
8
9
  import { getOracleHomeDir } from "../oracleHome.js";
9
10
  import { resolveSessionArtifactsDir } from "./artifacts.js";
10
11
  import { saveAssistantDownloadButtonArtifacts } from "./chatgptFiles.js";
@@ -456,6 +457,7 @@ export async function collectGeneratedImageArtifacts(params) {
456
457
  break;
457
458
  }
458
459
  const latestSnapshot = await readAssistantSnapshot(params.Runtime, params.minTurnIndex ?? undefined).catch(() => null);
460
+ throwIfAssistantUiError(latestSnapshot);
459
461
  const snapshotText = typeof latestSnapshot?.text === "string" ? latestSnapshot.text.trim() : "";
460
462
  if (snapshotText) {
461
463
  latestAnswerText = snapshotText;
@@ -0,0 +1,104 @@
1
+ import { BrowserAutomationError } from "../oracle/errors.js";
2
+ /**
3
+ * Detection for ChatGPT's own rate-limit notice.
4
+ *
5
+ * When ChatGPT decides a session is asking for too much at once it puts a modal
6
+ * over the page — "Too many requests … We've temporarily limited access to your
7
+ * conversations … Please wait a few minutes" — with a single "Got it" button.
8
+ *
9
+ * The modal is not marked as an error anywhere a DOM scrape would notice. It is
10
+ * a dialog full of ordinary text, so a menu reader walking the page finds its
11
+ * button and reports it as an available option. That is how a throttle came to be
12
+ * reported as `Unable to find model option matching "Thinking 5.5". Available:
13
+ * Got it` — a message that sends the reader looking for a model-naming bug that
14
+ * does not exist, when the correct response is to wait a few minutes.
15
+ *
16
+ * Observed live: six ChatGPT conversations opened at once triggered it
17
+ * repeatedly, while five did not, and the limit cleared on its own after a
18
+ * pause. Runs must therefore treat this as a transient, recoverable state rather
19
+ * than a failure of the request they were making.
20
+ */
21
+ const THROTTLE_PHRASES = [
22
+ "too many requests",
23
+ "making requests too quickly",
24
+ "temporarily limited access to your conversations",
25
+ ];
26
+ /** True when the text carries ChatGPT's rate-limit wording. */
27
+ export function matchesThrottleNotice(text) {
28
+ if (!text) {
29
+ return false;
30
+ }
31
+ const normalized = text.toLowerCase();
32
+ return THROTTLE_PHRASES.some((phrase) => normalized.includes(phrase));
33
+ }
34
+ export function buildThrottleProbeExpression() {
35
+ return `(() => {
36
+ const PHRASES = ${JSON.stringify(THROTTLE_PHRASES)};
37
+ const matches = (value) => {
38
+ const text = (value || '').toLowerCase();
39
+ return PHRASES.some((phrase) => text.includes(phrase));
40
+ };
41
+ // Dialogs first, because that is what this notice is; the body sweep is a
42
+ // fallback for a variant that does not carry the role.
43
+ const dialogs = Array.from(document.querySelectorAll('[role="dialog"], [role="alertdialog"]'));
44
+ for (const dialog of dialogs) {
45
+ const text = (dialog.innerText || '').trim();
46
+ if (matches(text)) return { message: text.slice(0, 400) };
47
+ }
48
+ const body = (document.body && document.body.innerText) || '';
49
+ if (matches(body)) {
50
+ const line = body
51
+ .split('\\n')
52
+ .map((entry) => entry.trim())
53
+ .find((entry) => matches(entry));
54
+ return { message: (line || 'ChatGPT is rate limiting this session.').slice(0, 400) };
55
+ }
56
+ return null;
57
+ })()`;
58
+ }
59
+ /**
60
+ * Looks for the rate-limit notice. Never throws: this runs on paths that are
61
+ * already reporting a different failure, and a probe that failed must not
62
+ * replace the original diagnosis with its own.
63
+ */
64
+ export async function detectChatGptThrottleNotice(Runtime) {
65
+ try {
66
+ const evaluated = await Runtime.evaluate({
67
+ expression: buildThrottleProbeExpression(),
68
+ returnByValue: true,
69
+ });
70
+ const value = evaluated.result?.value;
71
+ if (value && typeof value.message === "string" && value.message.trim()) {
72
+ return { message: value.message.trim() };
73
+ }
74
+ }
75
+ catch {
76
+ // fall through: no notice detected
77
+ }
78
+ return null;
79
+ }
80
+ export function buildThrottleError(notice, context) {
81
+ return new BrowserAutomationError(`ChatGPT is rate limiting this session: ${notice.message} ` +
82
+ "This is a temporary limit on the account, not a problem with the request — " +
83
+ "wait a few minutes and retry, and reduce how many conversations are started at once.", {
84
+ stage: "chatgpt-throttled",
85
+ details: {
86
+ // Named so a caller can branch on it rather than parsing the message.
87
+ retryable: true,
88
+ origin: context.stage,
89
+ notice: notice.message,
90
+ },
91
+ });
92
+ }
93
+ /**
94
+ * Replaces a misleading failure with the real one when the page is throttled.
95
+ * Returns nothing when it is not, leaving the caller's own error intact.
96
+ */
97
+ export async function throwIfThrottled(Runtime, context, logger) {
98
+ const notice = await detectChatGptThrottleNotice(Runtime);
99
+ if (!notice) {
100
+ return;
101
+ }
102
+ logger?.(`[browser] ChatGPT rate limit detected during ${context.stage}: ${notice.message}`);
103
+ throw buildThrottleError(notice, context);
104
+ }