@steipete/oracle 0.17.3 → 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 (161) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +75 -64
  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 +267 -15
  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 +15 -3
  26. package/dist/src/browser/detect.js +1 -1
  27. package/dist/src/browser/index.js +447 -263
  28. package/dist/src/browser/modelDisplay.js +9 -0
  29. package/dist/src/browser/pageActions.js +2 -2
  30. package/dist/src/browser/policies.js +6 -2
  31. package/dist/src/browser/profileState.js +114 -9
  32. package/dist/src/browser/projectSourcesRunner.js +88 -17
  33. package/dist/src/browser/prompt.js +272 -113
  34. package/dist/src/browser/providers/chatgptDomProvider.js +2 -0
  35. package/dist/src/browser/reattach.js +14 -4
  36. package/dist/src/browser/sessionRunner.js +71 -11
  37. package/dist/src/browser/tabLeaseRegistry.js +414 -38
  38. package/dist/src/browser/utils.js +20 -2
  39. package/dist/src/cli/browserConfig.js +40 -15
  40. package/dist/src/cli/browserDefaults.js +13 -0
  41. package/dist/src/cli/browserTabs.js +12 -29
  42. package/dist/src/cli/detachedSession.js +69 -0
  43. package/dist/src/cli/dryRun.js +58 -47
  44. package/dist/src/cli/harvestIntegrity.js +140 -0
  45. package/dist/src/cli/projectSources.js +5 -3
  46. package/dist/src/cli/serveBrowserConfig.js +24 -0
  47. package/dist/src/cli/sessionDisplay.js +9 -2
  48. package/dist/src/cli/sessionRunner.js +91 -7
  49. package/dist/src/config.js +1 -0
  50. package/dist/src/gemini-web/client.js +67 -21
  51. package/dist/src/gemini-web/executor.js +4 -0
  52. package/dist/src/gemini-web/http.js +15 -0
  53. package/dist/src/gemini-web/models.js +15 -11
  54. package/dist/src/mcp/server.js +11 -14
  55. package/dist/src/mcp/tools/chatgptImage.js +4 -4
  56. package/dist/src/mcp/tools/consult.js +97 -26
  57. package/dist/src/mcp/tools/projectSources.js +4 -6
  58. package/dist/src/mcp/tools/sessionResources.js +1 -1
  59. package/dist/src/mcp/tools/sessions.js +2 -2
  60. package/dist/src/mcp/tools/wait.js +182 -0
  61. package/dist/src/mcp/types.js +7 -0
  62. package/dist/src/oracle/errors.js +11 -0
  63. package/dist/src/oracle/providerRoutePlan.js +3 -2
  64. package/dist/src/remote/client.js +70 -6
  65. package/dist/src/remote/health.js +6 -2
  66. package/dist/src/remote/runSlots.js +83 -0
  67. package/dist/src/remote/server.js +351 -72
  68. package/dist/src/sessionManager.js +36 -1
  69. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  70. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  71. package/package.json +25 -23
  72. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  73. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  74. package/dist/bin/oracle.js +0 -683
  75. package/dist/docs-site/.nojekyll +0 -0
  76. package/dist/docs-site/CNAME +0 -1
  77. package/dist/docs-site/RELEASING.html +0 -410
  78. package/dist/docs-site/agents.html +0 -374
  79. package/dist/docs-site/anthropic.html +0 -368
  80. package/dist/docs-site/bridge.html +0 -400
  81. package/dist/docs-site/browser-mode.html +0 -588
  82. package/dist/docs-site/chromium-forks.html +0 -347
  83. package/dist/docs-site/cli-reference.html +0 -346
  84. package/dist/docs-site/configuration.html +0 -436
  85. package/dist/docs-site/favicon.svg +0 -14
  86. package/dist/docs-site/followup.html +0 -368
  87. package/dist/docs-site/gemini.html +0 -379
  88. package/dist/docs-site/grok.html +0 -325
  89. package/dist/docs-site/index.html +0 -360
  90. package/dist/docs-site/install.html +0 -335
  91. package/dist/docs-site/linux.html +0 -321
  92. package/dist/docs-site/llms.txt +0 -43
  93. package/dist/docs-site/manual-tests.html +0 -595
  94. package/dist/docs-site/mcp.html +0 -368
  95. package/dist/docs-site/multimodel.html +0 -364
  96. package/dist/docs-site/mythical-pro-agents.html +0 -360
  97. package/dist/docs-site/notifier.html +0 -338
  98. package/dist/docs-site/openai-endpoints.html +0 -387
  99. package/dist/docs-site/openrouter.html +0 -344
  100. package/dist/docs-site/quickstart.html +0 -369
  101. package/dist/docs-site/refactor/ux.html +0 -532
  102. package/dist/docs-site/sessions.html +0 -388
  103. package/dist/docs-site/social-card.png +0 -0
  104. package/dist/docs-site/social-card.svg +0 -79
  105. package/dist/docs-site/spec.html +0 -363
  106. package/dist/docs-site/testing.html +0 -320
  107. package/dist/docs-site/tui-debug.html +0 -326
  108. package/dist/docs-site/windows-work.html +0 -323
  109. package/dist/docs-site/windows.html +0 -320
  110. package/dist/markdansi/types/index.js +0 -4
  111. package/dist/oracle/bin/oracle-cli.js +0 -472
  112. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  113. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  114. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  115. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  116. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  117. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  118. package/dist/oracle/src/browser/config.js +0 -33
  119. package/dist/oracle/src/browser/constants.js +0 -40
  120. package/dist/oracle/src/browser/cookies.js +0 -210
  121. package/dist/oracle/src/browser/domDebug.js +0 -36
  122. package/dist/oracle/src/browser/index.js +0 -331
  123. package/dist/oracle/src/browser/pageActions.js +0 -5
  124. package/dist/oracle/src/browser/prompt.js +0 -88
  125. package/dist/oracle/src/browser/promptSummary.js +0 -20
  126. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  127. package/dist/oracle/src/browser/types.js +0 -1
  128. package/dist/oracle/src/browser/utils.js +0 -62
  129. package/dist/oracle/src/browserMode.js +0 -1
  130. package/dist/oracle/src/cli/browserConfig.js +0 -44
  131. package/dist/oracle/src/cli/dryRun.js +0 -59
  132. package/dist/oracle/src/cli/engine.js +0 -17
  133. package/dist/oracle/src/cli/errorUtils.js +0 -9
  134. package/dist/oracle/src/cli/help.js +0 -70
  135. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  136. package/dist/oracle/src/cli/options.js +0 -103
  137. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  138. package/dist/oracle/src/cli/rootAlias.js +0 -30
  139. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  140. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  141. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  142. package/dist/oracle/src/heartbeat.js +0 -43
  143. package/dist/oracle/src/oracle/client.js +0 -48
  144. package/dist/oracle/src/oracle/config.js +0 -29
  145. package/dist/oracle/src/oracle/errors.js +0 -101
  146. package/dist/oracle/src/oracle/files.js +0 -220
  147. package/dist/oracle/src/oracle/format.js +0 -33
  148. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  149. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  150. package/dist/oracle/src/oracle/request.js +0 -48
  151. package/dist/oracle/src/oracle/run.js +0 -444
  152. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  153. package/dist/oracle/src/oracle/types.js +0 -1
  154. package/dist/oracle/src/oracle.js +0 -9
  155. package/dist/oracle/src/sessionManager.js +0 -205
  156. package/dist/oracle/src/version.js +0 -39
  157. package/dist/scripts/chrome/browser-tools.js +0 -295
  158. package/dist/src/browser/chromeCookies.js +0 -312
  159. package/dist/src/browser/keytarShim.js +0 -56
  160. package/dist/src/browser/profileSync.js +0 -141
  161. 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 &&
@@ -1,6 +1,35 @@
1
1
  import { MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR, MODEL_BUTTON_SELECTOR, } from "../constants.js";
2
2
  import { logDomFailure } from "../domDebug.js";
3
3
  import { buildClickDispatcher } from "./domEvents.js";
4
+ import { BrowserAutomationError } from "../../oracle/errors.js";
5
+ export class ThinkingTierUnavailableError extends BrowserAutomationError {
6
+ requestedLevel;
7
+ requestedLabel;
8
+ optionLabel;
9
+ notice;
10
+ confirmedTarget;
11
+ constructor(requestedLevel, requestedLabel, optionLabel, notice, confirmedTarget) {
12
+ const message = `Thinking time: ${optionLabel ?? requestedLabel} is unavailable on this account (${notice ?? "no reason given"}); refusing to submit without confirmed ${confirmedTarget}.`;
13
+ super(message, {
14
+ stage: "thinking-tier-unavailable",
15
+ requestedLevel,
16
+ requestedLabel,
17
+ optionLabel,
18
+ notice,
19
+ confirmedTarget,
20
+ });
21
+ this.name = "ThinkingTierUnavailableError";
22
+ this.requestedLevel = requestedLevel;
23
+ this.requestedLabel = requestedLabel;
24
+ this.optionLabel = optionLabel;
25
+ this.notice = notice;
26
+ this.confirmedTarget = confirmedTarget;
27
+ }
28
+ }
29
+ function confirmedThinkingTarget(level, capitalizedLevel, targetModelKind, observedModelKind) {
30
+ const strictModelKind = targetModelKind ?? observedModelKind;
31
+ return level === "pro" ? "Pro" : strictModelKind === "pro" ? "Pro Extended" : capitalizedLevel;
32
+ }
4
33
  const BROWSER_THINKING_LOG_PREFIX = "[browser] Thinking time:";
5
34
  function formatBrowserThinkingLog(message) {
6
35
  return `${BROWSER_THINKING_LOG_PREFIX} ${message.replace(/^Thinking time:\s*/, "")}`;
@@ -23,8 +52,8 @@ function logPickerDiagnostic(result, logger) {
23
52
  /**
24
53
  * Selects a thinking-time level in ChatGPT's composer.
25
54
  *
26
- * Missing controls remain best-effort except Pro Extended, which fails closed
27
- * 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.
28
57
  */
29
58
  export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
30
59
  const result = await evaluateThinkingTimeSelection(Runtime, level, desiredModel);
@@ -36,13 +65,36 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
36
65
  // own, independently of the legacy Pro-model + "extended" combination.
37
66
  const strictProEffort = level === "pro" ||
38
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
+ });
39
79
  switch (result?.status) {
40
80
  case "already-selected":
41
81
  logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} (already selected)`));
42
- return;
82
+ return evidence("already-selected", result.label ?? null);
43
83
  case "switched":
44
84
  logger(formatBrowserThinkingLog(result.label ?? capitalizedLevel));
45
- return;
85
+ return evidence("switched", result.label ?? null);
86
+ case "option-disabled": {
87
+ await logDomFailure(Runtime, logger, "thinking-option-disabled");
88
+ logPickerDiagnostic(result, logger);
89
+ if (strictProEffort) {
90
+ throw new ThinkingTierUnavailableError(level, capitalizedLevel, result.label ?? null, result.notice ?? null, confirmedThinkingTarget(level, capitalizedLevel, targetModelKind, observedModelKind));
91
+ }
92
+ // A non-strict caller goes on to submit, so this log must not borrow the
93
+ // strict error's "refusing to submit" wording: the request really is sent,
94
+ // at whatever effort ChatGPT already had selected.
95
+ logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} is unavailable on this account (${result.notice ?? "no reason given"}); keeping the effort already selected in ChatGPT.`));
96
+ return evidence("unverified", null);
97
+ }
46
98
  case "chip-not-found":
47
99
  case "menu-not-found":
48
100
  case "option-not-found":
@@ -67,7 +119,7 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
67
119
  ? "the effort in ChatGPT is unconfirmed"
68
120
  : "keeping the effort already selected in ChatGPT";
69
121
  logger(formatBrowserThinkingLog(`${message}; ${outcome}.`));
70
- return;
122
+ return evidence("unverified", null);
71
123
  }
72
124
  default: {
73
125
  await logDomFailure(Runtime, logger, "thinking-time-unknown");
@@ -77,7 +129,7 @@ export async function ensureThinkingTime(Runtime, level, logger, desiredModel) {
77
129
  throw new Error(`Thinking time: unknown outcome selecting ${capitalizedLevel}; refusing to submit without confirmed ${target}.`);
78
130
  }
79
131
  logger(formatBrowserThinkingLog(`unknown outcome selecting ${capitalizedLevel}; continuing with ChatGPT default.`));
80
- return;
132
+ return evidence("unverified", null);
81
133
  }
82
134
  }
83
135
  }
@@ -97,6 +149,9 @@ export async function ensureThinkingTimeIfAvailable(Runtime, level, logger, desi
97
149
  case "switched":
98
150
  logger(formatBrowserThinkingLog(result.label ?? capitalizedLevel));
99
151
  return true;
152
+ case "option-disabled":
153
+ logger(formatBrowserThinkingLog(`${result.label ?? capitalizedLevel} is unavailable on this account (${result.notice ?? "no reason given"}); keeping the effort already selected in ChatGPT.`));
154
+ return false;
100
155
  case "chip-not-found":
101
156
  case "menu-not-found":
102
157
  case "option-not-found":
@@ -147,12 +202,12 @@ function buildThinkingTimeExpression(level, desiredModel) {
147
202
  const TARGET_MODEL_KIND = ${targetModelKindLiteral};
148
203
  const TARGET_IS_GPT56_MODEL = ${targetIsGpt56ModelLiteral};
149
204
 
150
- // Multilingual matchers: English level token + observed German/Japanese/Chinese variants.
205
+ // Multilingual matchers: English level token + observed localized variants.
151
206
  const LEVEL_TOKENS = {
152
- light: ['light', 'instant', 'sofort', 'leicht', '最速', '轻', '极速'],
153
- standard: ['standard', 'medium', 'mittel', '中程度', '标准', '中'],
154
- extended: ['extended', 'high', 'hoch', 'erweitert', '高い', '扩展', '深度', '加强', '高'],
155
- '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', '非常に高い', '极高', '매우 높음'],
156
211
  heavy: ['heavy', 'schwer', '重度', '加重'],
157
212
  };
158
213
  // Pro is a tier you can request, but it is also a MODEL name, so it must never
@@ -171,7 +226,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
171
226
  const INTELLIGENCE_WAIT_MS = 2500;
172
227
 
173
228
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
174
- // Keep CJK characters, including Japanese kana, so localized labels survive
229
+ // Keep CJK characters, including Japanese kana and Hangul, so labels survive
175
230
  // normalization before being matched against LEVEL_TOKENS and picker controls.
176
231
  const normalize = (value) => (value || '')
177
232
  // Compose first so NFD umlauts fold too, then map them onto ASCII before
@@ -182,7 +237,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
182
237
  .replace(/ö/g, 'o')
183
238
  .replace(/ü/g, 'u')
184
239
  .replace(/ß/g, 'ss')
185
- .replace(/[^a-z0-9\\u3040-\\u30ff\\u4e00-\\u9fff]+/g, ' ')
240
+ .replace(/[^a-z0-9\\u3040-\\u30ff\\u4e00-\\u9fff\\uac00-\\ud7af]+/g, ' ')
186
241
  .replace(/\\s+/g, ' ')
187
242
  .trim();
188
243
  const hasToken = (text, token) => normalize(text).split(' ').includes(token);
@@ -211,6 +266,8 @@ function buildThinkingTimeExpression(level, desiredModel) {
211
266
  if (token === 'extra high') return hasPhrase(t, 'extra high');
212
267
  if (token === 'hoch') return hasPhrase(t, 'hoch') && !hasPhrase(t, 'sehr hoch');
213
268
  if (token === 'sehr hoch') return hasPhrase(t, 'sehr hoch');
269
+ if (token === '높음') return hasPhrase(t, '높음') && !hasPhrase(t, '매우 높음');
270
+ if (token === '매우 높음') return hasPhrase(t, '매우 높음');
214
271
  if (token === '高い' || token === '非常に高い') {
215
272
  return t === token || hasToken(t, token);
216
273
  }
@@ -292,6 +349,21 @@ function buildThinkingTimeExpression(level, desiredModel) {
292
349
  .replace(/\\s+/g, ' ')
293
350
  .trim()
294
351
  .slice(0, maxLength);
352
+ const isOptionDisabled = (node) => {
353
+ if (!node || typeof node.getAttribute !== 'function') return false;
354
+ // data-disabled is Radix's valueless-presence convention, but an explicit
355
+ // "false" must not read as disabled: that would refuse a perfectly usable
356
+ // tier and report it as unavailable.
357
+ const dataDisabled = node.getAttribute('data-disabled');
358
+ const dataDisabledOn = dataDisabled !== null && String(dataDisabled).toLowerCase() !== 'false';
359
+ return (
360
+ node.getAttribute('aria-disabled') === 'true' ||
361
+ dataDisabledOn ||
362
+ (node.getAttribute('data-state') || '').toLowerCase() === 'disabled' ||
363
+ Boolean(node.disabled) ||
364
+ node.getAttribute('disabled') !== null
365
+ );
366
+ };
295
367
  const describeNode = (el) => {
296
368
  if (!el || typeof el.getAttribute !== 'function') return null;
297
369
  let rect = null;
@@ -314,7 +386,10 @@ function buildThinkingTimeExpression(level, desiredModel) {
314
386
  ariaChecked: el.getAttribute('aria-checked'),
315
387
  ariaSelected: el.getAttribute('aria-selected'),
316
388
  ariaHaspopup: el.getAttribute('aria-haspopup'),
389
+ ariaDisabled: el.getAttribute('aria-disabled'),
390
+ dataDisabled: el.getAttribute('data-disabled'),
317
391
  dataState: el.getAttribute('data-state'),
392
+ disabled: isOptionDisabled(el),
318
393
  text: redactDiagnosticText(el.textContent, 80),
319
394
  rect,
320
395
  };
@@ -590,6 +665,77 @@ function buildThinkingTimeExpression(level, desiredModel) {
590
665
  }
591
666
  return matchesLevel(normalizedLabel);
592
667
  };
668
+ const nonEmptyNotice = (value) => {
669
+ const text = redactDiagnosticText(value, 160);
670
+ return text || null;
671
+ };
672
+ // The notice must be ROW-OWNED, and preferably this hover's own.
673
+ //
674
+ // NOTE: no backticks in this comment — it lives inside the injected template
675
+ // literal, where a backtick would terminate the string.
676
+ //
677
+ // A document-wide role=tooltip scan is what this must never become: novelty is
678
+ // not causality, and an unrelated control with an armed open-delay can mount its
679
+ // tooltip inside this hover's window. Every pass below is anchored on the row.
680
+ //
681
+ // Preference order, strongest provenance first:
682
+ // 1. an id this hover ADDED whose target is role=tooltip — Radix keeps an
683
+ // application-supplied description and APPENDS its tooltip id when opening,
684
+ // so the delta is what separates the real notice from a permanent blurb;
685
+ // 2. any other id this hover added;
686
+ // 3. an already-associated role=tooltip target, for a tooltip that was open
687
+ // before the probe arrived;
688
+ // 4. the row's static title, only once the poll has expired.
689
+ //
690
+ // Passes 3 and 4 are row-owned but NOT causal: a page that permanently points a
691
+ // disabled row at generic role=tooltip help, or gives it a generic title, will
692
+ // have that text reported. That is accepted deliberately — the value is an opaque
693
+ // notice for a human or a caller to interpret, not a parsed reset time — and the
694
+ // verified live target has neither at rest.
695
+ const describedIds = (option) =>
696
+ (option?.getAttribute?.('aria-describedby') || '').split(/\\s+/).filter(Boolean);
697
+ const isTooltipNode = (node) => node?.getAttribute?.('role') === 'tooltip';
698
+ const readDisabledNotice = (option, priorIds, allowTitle) => {
699
+ const ids = describedIds(option);
700
+ const prior = priorIds instanceof Set ? priorIds : new Set();
701
+ const fresh = ids.filter((id) => !prior.has(id));
702
+ for (const pass of [
703
+ fresh.filter((id) => isTooltipNode(document.getElementById?.(id))),
704
+ fresh,
705
+ ids.filter((id) => isTooltipNode(document.getElementById?.(id))),
706
+ ]) {
707
+ for (const id of pass) {
708
+ const notice = nonEmptyNotice(document.getElementById?.(id)?.textContent);
709
+ if (notice) return notice;
710
+ }
711
+ }
712
+ if (!allowTitle) return null;
713
+ return nonEmptyNotice(option?.getAttribute?.('title'));
714
+ };
715
+ const waitForDisabledNotice = async (option, priorIds) => {
716
+ const deadline = performance.now() + 400;
717
+ while (performance.now() < deadline) {
718
+ const notice = readDisabledNotice(option, priorIds, false);
719
+ if (notice) return notice;
720
+ await sleep(50);
721
+ }
722
+ // Only now may a static title speak: the association had its full window.
723
+ return readDisabledNotice(option, priorIds, true);
724
+ };
725
+ // Opening a tooltip stacks another Radix dismissable layer over the menu, and
726
+ // the topmost layer eats the Escape. One blind Escape therefore leaves the
727
+ // effort menu open, which matters for the non-strict caller that goes on to
728
+ // submit. Dismiss, let the layer unmount, and only Escape again while a menu is
729
+ // still there — never two unconditional Escapes, which could close an unrelated
730
+ // outer surface.
731
+ const closeMenusAfterTooltip = async () => {
732
+ closeOpenMenus();
733
+ for (let attempt = 0; attempt < 3; attempt += 1) {
734
+ await sleep(50);
735
+ if (!Array.from(document.querySelectorAll(MENU_CONTAINER_SELECTOR)).some(isVisible)) return;
736
+ closeOpenMenus();
737
+ }
738
+ };
593
739
  const selectAndVerify = async (trigger, findOption, modelKindOverride = null) => {
594
740
  const triggerModelKind =
595
741
  modelKindOverride ||
@@ -615,6 +761,22 @@ function buildThinkingTimeExpression(level, desiredModel) {
615
761
  }
616
762
  if (!option) return failure('option-not-found', { modelKind: triggerModelKind });
617
763
  const label = option.textContent?.trim?.() || null;
764
+ if (isOptionDisabled(option)) {
765
+ // Captured BEFORE the hover: the ids already here describe the row for other
766
+ // reasons and cannot be this hover's reason.
767
+ const priorIds = new Set(describedIds(option));
768
+ dispatchHoverSequence(option);
769
+ const notice = await waitForDisabledNotice(option, priorIds);
770
+ const result = failure('option-disabled', {
771
+ // The label is page text that reaches logs and diagnostics, so it is
772
+ // redacted like every other reported string.
773
+ label: redactDiagnosticText(option.textContent, 80) || null,
774
+ notice,
775
+ modelKind: triggerModelKind,
776
+ });
777
+ await closeMenusAfterTooltip();
778
+ return result;
779
+ }
618
780
  if (optionIsSelected(option)) {
619
781
  closeOpenMenus();
620
782
  return { status: 'already-selected', label };
@@ -693,11 +855,11 @@ function buildThinkingTimeExpression(level, desiredModel) {
693
855
  // token matching cannot be used here — substring is deliberate, as in
694
856
  // countEffortLevels above.
695
857
  const ADVANCED_WORDS = [
696
- 'advanced', 'erweitert', '高级', '詳細設定', '詳細表示',
858
+ 'advanced', 'erweitert', '高级', '詳細設定', '詳細表示', '고급',
697
859
  'avanzado', 'avancado', 'avance',
698
860
  ];
699
861
  const EFFORT_WORDS = [
700
- 'effort', 'aufwand', '强度', '努力', '推論レベル',
862
+ 'effort', 'aufwand', '强度', '努力', '推論レベル', '思考量', '추론 수준',
701
863
  'esfuerzo', 'esforco', 'sforzo', 'inspanning', 'wysilek',
702
864
  ];
703
865
  const containsAny = (label, words) => words.some((word) => label.includes(word));
@@ -796,6 +958,94 @@ function buildThinkingTimeExpression(level, desiredModel) {
796
958
  return null;
797
959
  };
798
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
+
799
1049
  // Current ChatGPT exposes a standalone Pro or Thinking composer pill whose
800
1050
  // controlled menu contains the effort levels. Prefer this ownership boundary
801
1051
  // before probing older model-picker layouts.
@@ -920,6 +1170,8 @@ function buildThinkingTimeExpression(level, desiredModel) {
920
1170
  while (performance.now() < deadline) {
921
1171
  const menu = findVisibleEffortMenu(composerEffortPill);
922
1172
  if (menu) {
1173
+ const sliderResult = await selectDirectEffortSlider(menu);
1174
+ if (sliderResult) return sliderResult;
923
1175
  const proEffortResult = await selectProEffortFromSubmenu();
924
1176
  if (proEffortResult) {
925
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
+ }