@steipete/oracle 0.19.0 → 0.20.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 (41) hide show
  1. package/dist/bin/oracle-cli.js +72 -29
  2. package/dist/src/browser/actions/deepResearch.js +168 -44
  3. package/dist/src/browser/actions/modelSelection.js +78 -1
  4. package/dist/src/browser/actions/promptComposer.js +3 -0
  5. package/dist/src/browser/actions/thinkingTime.js +49 -6
  6. package/dist/src/browser/actions/webSearch.js +96 -0
  7. package/dist/src/browser/chromeLifecycle.js +28 -16
  8. package/dist/src/browser/config.js +1 -1
  9. package/dist/src/browser/index.js +144 -49
  10. package/dist/src/browser/liveTabs.js +11 -4
  11. package/dist/src/browser/profileState.js +6 -1
  12. package/dist/src/browser/promptFingerprint.js +54 -0
  13. package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
  14. package/dist/src/browser/reattach.js +178 -109
  15. package/dist/src/browser/recoveryTarget.js +156 -0
  16. package/dist/src/browser/sessionRunner.js +33 -8
  17. package/dist/src/browser/tabLeaseRegistry.js +20 -0
  18. package/dist/src/browser/targetClaim.js +54 -0
  19. package/dist/src/cli/browserConfig.js +33 -3
  20. package/dist/src/cli/browserDefaults.js +3 -0
  21. package/dist/src/cli/browserTabs.js +38 -3
  22. package/dist/src/cli/detach.js +10 -1
  23. package/dist/src/cli/detachedSession.js +36 -0
  24. package/dist/src/cli/options.js +15 -0
  25. package/dist/src/cli/recoveredBrowserHarvest.js +50 -0
  26. package/dist/src/cli/runOptions.js +19 -4
  27. package/dist/src/cli/sessionDisplay.js +15 -8
  28. package/dist/src/cli/sessionRunner.js +153 -41
  29. package/dist/src/mcp/tools/consult.js +2 -2
  30. package/dist/src/mcp/types.js +1 -1
  31. package/dist/src/oracle/config.js +14 -0
  32. package/dist/src/oracle/geminiModels.js +1 -0
  33. package/dist/src/oracle/run.js +27 -4
  34. package/dist/src/remote/client.js +3 -0
  35. package/dist/src/remote/server.js +2 -0
  36. package/dist/src/sessionManager.js +8 -1
  37. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  38. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  39. package/package.json +10 -10
  40. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  41. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -13,9 +13,10 @@ import { CHATGPT_URL } from "../src/browser/constants.js";
13
13
  import { applyHelpStyling } from "../src/cli/help.js";
14
14
  import { collectPaths, collectModelList, collectTextValues, parseFloatOption, parseIntOption, parseSearchOption, parseThinkingTimeOption, usesDefaultStatusFilters, resolvePreviewMode, normalizeModelOption, normalizeBaseUrl, resolveApiModel, inferModelFromLabel, parseHeartbeatOption, parseTimeoutOption, parseDurationOption, mergePathLikeOptions, dedupePathInputs, } from "../src/cli/options.js";
15
15
  import { copyToClipboard } from "../src/cli/clipboard.js";
16
+ import { isGpt6ProAlias } from "../src/cli/browserConfig.js";
16
17
  import { buildMarkdownBundle } from "../src/cli/markdownBundle.js";
17
- import { shouldDetachSession, stopDetachedWorker } from "../src/cli/detach.js";
18
- import { launchDetachedSession } from "../src/cli/detachedSession.js";
18
+ import { detachedCancellationExitCode, shouldDetachSession, shouldExitAfterTopLevelSigint, stopDetachedWorker, } from "../src/cli/detach.js";
19
+ import { clearDetachedSessionCancellation, detachedSessionCancellationPath, launchDetachedSession, requestDetachedSessionCancellation, waitForDetachedSessionCancellation, } from "../src/cli/detachedSession.js";
19
20
  import { applyHiddenAliases } from "../src/cli/hiddenAliases.js";
20
21
  import { isMediaFile } from "../src/browser/prompt.js";
21
22
  import { formatCompactNumber } from "../src/cli/format.js";
@@ -36,6 +37,7 @@ import { isAzureOpenAICandidateModel, validateProviderRouting, } from "../src/or
36
37
  import { buildSessionLifecycle, formatSessionLifecycleBlock } from "../src/cli/sessionLifecycle.js";
37
38
  import { buildDetachedPerfTraceEnv, createPerfTrace, isTraceValueFlag, } from "../src/cli/perfTrace.js";
38
39
  import { resolveBrowserFollowupReference } from "../src/cli/followup.js";
40
+ import { BrowserRunCancelledError } from "../src/oracle/errors.js";
39
41
  const VERSION = getCliVersion();
40
42
  const CLI_ENTRYPOINT = fileURLToPath(import.meta.url);
41
43
  const LEGACY_FLAG_ALIASES = new Map([
@@ -225,15 +227,8 @@ program
225
227
  .addOption(new Option("--models <models>", 'Comma-separated API model list to query in parallel (e.g., "gpt-5.5-pro,gemini-3-pro").')
226
228
  .argParser(collectModelList)
227
229
  .default([]))
228
- .addOption(new Option("--reasoning-effort <effort>", "Reasoning effort for GPT-5.6 API models.").choices([
229
- "none",
230
- "low",
231
- "medium",
232
- "high",
233
- "xhigh",
234
- "max",
235
- ]))
236
- .addOption(new Option("--reasoning-mode <mode>", 'Responses API reasoning execution mode for GPT-5.6 models ("standard" or "pro").').choices(["standard", "pro"]))
230
+ .addOption(new Option("--reasoning-effort <effort>", "Reasoning effort for GPT-6 Astra and GPT-5.6 API models (Astra requires low or higher).").choices(["none", "low", "medium", "high", "xhigh", "max"]))
231
+ .addOption(new Option("--reasoning-mode <mode>", 'Responses API reasoning execution mode for GPT-6 Astra and GPT-5.6 models ("standard" or "pro").').choices(["standard", "pro"]))
237
232
  .addOption(new Option("-e, --engine <mode>", "Execution engine (api | browser). Browser engine: GPT models automate ChatGPT; Gemini models use a cookie-based client for gemini.google.com. If omitted, oracle picks api when OPENAI_API_KEY is set, otherwise browser.").choices(["api", "browser"]))
238
233
  .addOption(new Option("--mode <mode>", "Alias for --engine (api | browser).")
239
234
  .choices(["api", "browser"])
@@ -339,7 +334,7 @@ program
339
334
  .addOption(new Option("--browser-thinking-time <level>", "Thinking time intensity for Thinking/Pro models: light, standard, extended, extra-high (Extra High), pro (Pro tier of the active model), heavy, or ChatGPT UI aliases.")
340
335
  .argParser(parseThinkingTimeOption)
341
336
  .hideHelp())
342
- .addOption(new Option("--browser-research <mode>", "Browser research mode: deep activates ChatGPT Deep Research.").choices(["off", "deep"]))
337
+ .addOption(new Option("--browser-research <mode>", "Browser research mode: search activates Web Search; deep activates Deep Research.").choices(["off", "search", "deep"]))
343
338
  .addOption(new Option("--browser-archive <mode>", "Archive completed ChatGPT browser conversations after local artifacts are saved (auto archives successful non-project one-shots only).").choices(["auto", "always", "never"]))
344
339
  .addOption(new Option("--browser-follow-up <prompt>", "Submit an additional prompt in the same ChatGPT browser conversation after the initial answer; repeat for multi-turn consults.")
345
340
  .argParser(collectTextValues)
@@ -1064,9 +1059,13 @@ async function runRootCommand(options) {
1064
1059
  options.baseUrl = userConfig.apiBaseUrl;
1065
1060
  }
1066
1061
  const providerMode = resolveApiProviderMode(options);
1067
- const engineModels = multiModelProvided
1068
- ? Array.from(new Set(options.models.map((entry) => resolveApiModel(entry))))
1069
- : [resolveApiModel(normalizeModelOption(options.model) || DEFAULT_MODEL)];
1062
+ // Engine discovery must not apply API-only validation to browser aliases.
1063
+ const engineModelInputs = multiModelProvided
1064
+ ? options.models
1065
+ : [normalizeModelOption(options.model) || DEFAULT_MODEL];
1066
+ const engineModels = Array.from(new Set(engineModelInputs.map((entry) => isGpt6ProAlias(entry) && !options.route && !options.preflight
1067
+ ? "gpt-6-pro"
1068
+ : resolveApiModel(entry))));
1070
1069
  if (options.route || options.preflight) {
1071
1070
  const routeAzureEndpoint = firstNonEmpty(options.azureEndpoint, process.env.AZURE_OPENAI_ENDPOINT, userConfig.azure?.endpoint);
1072
1071
  const configuredAzureForRoute = routeAzureEndpoint
@@ -1675,18 +1674,28 @@ async function waitForDetachedStartGate() {
1675
1674
  }
1676
1675
  }
1677
1676
  async function attachToDetachedSession(sessionId, workerPid) {
1677
+ const cancellationMarker = sessionStore
1678
+ .getPaths(sessionId)
1679
+ .then((paths) => detachedSessionCancellationPath(paths.dir, workerPid));
1678
1680
  let cancelled = false;
1681
+ let cancellationRequest;
1679
1682
  const cancelWorker = () => {
1680
1683
  cancelled = true;
1681
- try {
1682
- stopDetachedWorker(workerPid);
1683
- }
1684
- catch (error) {
1684
+ cancellationRequest ??= cancellationMarker
1685
+ .then((markerPath) => requestDetachedSessionCancellation(markerPath))
1686
+ .catch((error) => {
1685
1687
  const message = error instanceof Error ? error.message : String(error);
1686
- console.error(chalk.red(`Unable to stop detached worker ${workerPid}: ${message}`));
1687
- }
1688
+ console.error(chalk.red(`Unable to request cancellation from worker ${workerPid}: ${message}`));
1689
+ try {
1690
+ stopDetachedWorker(workerPid);
1691
+ }
1692
+ catch (stopError) {
1693
+ const stopMessage = stopError instanceof Error ? stopError.message : String(stopError);
1694
+ console.error(chalk.red(`Unable to stop detached worker ${workerPid}: ${stopMessage}`));
1695
+ }
1696
+ });
1688
1697
  };
1689
- process.once("SIGINT", cancelWorker);
1698
+ process.on("SIGINT", cancelWorker);
1690
1699
  try {
1691
1700
  const { attachSession } = await import("../src/cli/sessionDisplay.js");
1692
1701
  await attachSession(sessionId, {
@@ -1697,9 +1706,15 @@ async function attachToDetachedSession(sessionId, workerPid) {
1697
1706
  }
1698
1707
  finally {
1699
1708
  process.off("SIGINT", cancelWorker);
1700
- if (cancelled) {
1701
- process.exitCode = 130;
1709
+ await cancellationRequest;
1710
+ const finalStatus = (await sessionStore.readSession(sessionId).catch(() => null))?.status;
1711
+ if (cancellationRequest &&
1712
+ finalStatus &&
1713
+ ["completed", "partial", "cancelled", "error"].includes(finalStatus)) {
1714
+ // The parent may publish its request after the worker's final cleanup.
1715
+ await clearDetachedSessionCancellation(await cancellationMarker);
1702
1716
  }
1717
+ process.exitCode = detachedCancellationExitCode(cancelled, finalStatus, process.exitCode);
1703
1718
  }
1704
1719
  }
1705
1720
  async function restartSession(sessionId, options) {
@@ -1881,6 +1896,10 @@ async function restartSession(sessionId, options) {
1881
1896
  async function executeSession(sessionId) {
1882
1897
  let metadata = null;
1883
1898
  let writer = null;
1899
+ const cancellation = new AbortController();
1900
+ const stopCancellationMonitor = new AbortController();
1901
+ let cancellationMarker;
1902
+ let cancellationMonitor;
1884
1903
  try {
1885
1904
  metadata = await sessionStore.readSession(sessionId);
1886
1905
  if (!metadata) {
@@ -1894,6 +1913,18 @@ async function executeSession(sessionId) {
1894
1913
  const sessionMode = getSessionMode(metadata);
1895
1914
  const browserConfig = getBrowserConfigFromMetadata(metadata);
1896
1915
  writer = sessionStore.createLogWriter(sessionId);
1916
+ if (sessionMode === "browser") {
1917
+ const paths = await sessionStore.getPaths(sessionId);
1918
+ cancellationMarker = detachedSessionCancellationPath(paths.dir, process.pid);
1919
+ cancellationMonitor = waitForDetachedSessionCancellation({
1920
+ markerPath: cancellationMarker,
1921
+ signal: stopCancellationMonitor.signal,
1922
+ }).then((requested) => {
1923
+ if (requested) {
1924
+ cancellation.abort(new BrowserRunCancelledError("Browser run cancelled by the user."));
1925
+ }
1926
+ });
1927
+ }
1897
1928
  const userConfig = (await loadUserConfig()).config;
1898
1929
  const notifications = deriveNotificationSettingsFromMetadata(metadata, process.env, userConfig.notify);
1899
1930
  const { performSessionRun } = await import("../src/cli/sessionRunner.js");
@@ -1907,18 +1938,22 @@ async function executeSession(sessionId) {
1907
1938
  write: writer.writeChunk,
1908
1939
  version: VERSION,
1909
1940
  notifications,
1941
+ signal: sessionMode === "browser" ? cancellation.signal : undefined,
1910
1942
  });
1911
1943
  }
1912
1944
  catch (error) {
1913
- process.exitCode = 1;
1945
+ const cancelled = error instanceof BrowserRunCancelledError;
1946
+ process.exitCode = cancelled ? 130 : 1;
1914
1947
  const message = error instanceof Error ? error.message : String(error);
1915
1948
  if (!metadata) {
1916
- console.error(chalk.red(message));
1949
+ if (!cancelled)
1950
+ console.error(chalk.red(message));
1917
1951
  return;
1918
1952
  }
1919
- writer?.logLine(`ERROR: Detached session worker failed: ${message}`);
1953
+ if (!cancelled)
1954
+ writer?.logLine(`ERROR: Detached session worker failed: ${message}`);
1920
1955
  const latest = await sessionStore.readSession(sessionId).catch(() => null);
1921
- if (latest && !["completed", "partial", "error"].includes(latest.status)) {
1956
+ if (latest && !["completed", "partial", "error", "cancelled"].includes(latest.status)) {
1922
1957
  await sessionStore.updateSession(sessionId, {
1923
1958
  status: "error",
1924
1959
  completedAt: new Date().toISOString(),
@@ -1932,6 +1967,14 @@ async function executeSession(sessionId) {
1932
1967
  }
1933
1968
  }
1934
1969
  finally {
1970
+ stopCancellationMonitor.abort();
1971
+ await cancellationMonitor?.catch((error) => {
1972
+ const message = error instanceof Error ? error.message : String(error);
1973
+ writer?.logLine(`ERROR: Detached cancellation monitor failed: ${message}`);
1974
+ });
1975
+ if (cancellationMarker) {
1976
+ await clearDetachedSessionCancellation(cancellationMarker).catch(() => undefined);
1977
+ }
1935
1978
  writer?.stream.end();
1936
1979
  }
1937
1980
  }
@@ -2050,7 +2093,7 @@ async function main() {
2050
2093
  console.log(chalk.yellow("\nCancelled."));
2051
2094
  process.exitCode = 130;
2052
2095
  // Browser/serve modes install their own SIGINT cleanup after this top-level handler.
2053
- if (process.listenerCount("SIGINT") <= 1) {
2096
+ if (shouldExitAfterTopLevelSigint(process.listenerCount("SIGINT"))) {
2054
2097
  process.exit(130);
2055
2098
  }
2056
2099
  };
@@ -79,65 +79,131 @@ async function waitForDeepResearchPill(Runtime, timeoutMs = 5000) {
79
79
  * After prompt submission, waits for the research plan to appear and
80
80
  * auto-confirm (~60s countdown + 10s safety margin).
81
81
  */
82
- export async function waitForResearchPlanAutoConfirm(Runtime, logger, autoConfirmWaitMs = DEEP_RESEARCH_AUTO_CONFIRM_WAIT_MS) {
82
+ export async function waitForResearchPlanAutoConfirm(Runtime, logger, autoConfirmWaitMs = DEEP_RESEARCH_AUTO_CONFIRM_WAIT_MS, options) {
83
+ const ignoredTargetKeys = new Set(options?.ignoredTargetKeys ?? []);
84
+ const minTurnIndex = typeof options?.minTurnIndex === "number" && Number.isFinite(options.minTurnIndex)
85
+ ? Math.floor(options.minTurnIndex)
86
+ : -1;
87
+ let capturedPlan = null;
88
+ let loggedPlan = false;
89
+ const targetOwnerMinTurnIndex = minTurnIndex >= 0 && options?.targetBaselineCaptured !== true ? minTurnIndex : -1;
90
+ const readPlanStatus = async () => {
91
+ const targetRead = options?.client
92
+ ? ((await readDeepResearchTargetResult(options.client, ignoredTargetKeys, targetOwnerMinTurnIndex).catch(() => null))?.read ?? null)
93
+ : null;
94
+ if (targetRead?.planTitle || targetRead?.researchStarted) {
95
+ return targetRead;
96
+ }
97
+ const inPageRead = options?.Page
98
+ ? await readDeepResearchFrameResult(Runtime, options.Page, options.client, minTurnIndex).catch(() => null)
99
+ : null;
100
+ return inPageRead?.read ?? targetRead;
101
+ };
102
+ const capturePlan = async (status) => {
103
+ if (!status.planTitle || !status.planSteps || status.planSteps.length === 0) {
104
+ return capturedPlan;
105
+ }
106
+ const next = {
107
+ title: status.planTitle,
108
+ steps: status.planSteps,
109
+ phase: status.researchStarted ? "researching" : "planning",
110
+ ...(status.planActionText ? { actionText: status.planActionText } : {}),
111
+ capturedAt: capturedPlan?.capturedAt ?? new Date().toISOString(),
112
+ };
113
+ const changed = !capturedPlan ||
114
+ capturedPlan.phase !== next.phase ||
115
+ capturedPlan.title !== next.title ||
116
+ capturedPlan.steps.join("\n") !== next.steps.join("\n") ||
117
+ capturedPlan.actionText !== next.actionText;
118
+ capturedPlan = next;
119
+ if (!loggedPlan) {
120
+ logger(`[browser] Deep Research plan detected:\n${[next.title, ...next.steps.map((step, index) => `${index + 1}. ${step}`)].join("\n")}`);
121
+ loggedPlan = true;
122
+ }
123
+ if (changed) {
124
+ await options?.onPlan?.(next);
125
+ }
126
+ return next;
127
+ };
128
+ const reportResearchStarted = async () => {
129
+ if (capturedPlan?.phase === "planning") {
130
+ capturedPlan = { ...capturedPlan, phase: "researching" };
131
+ await options?.onPlan?.(capturedPlan);
132
+ }
133
+ logger("[browser] Deep Research execution started; plan countdown is complete.");
134
+ return capturedPlan;
135
+ };
83
136
  // Phase A: Detect research plan appearance (up to 60s)
84
137
  const planDeadline = Date.now() + 60_000;
85
138
  let planDetected = false;
86
139
  while (Date.now() < planDeadline) {
140
+ const frameStatus = await readPlanStatus();
141
+ if (frameStatus) {
142
+ await capturePlan(frameStatus);
143
+ if (frameStatus.researchStarted) {
144
+ return reportResearchStarted();
145
+ }
146
+ if (capturedPlan) {
147
+ planDetected = true;
148
+ break;
149
+ }
150
+ }
151
+ // Legacy/inline fallback. Do not treat an arbitrary large iframe as a plan:
152
+ // ChatGPT projects, attachments, and other tools also render large iframes.
87
153
  const { result } = await Runtime.evaluate({
88
154
  expression: `(() => {
89
- const iframes = document.querySelectorAll('iframe');
90
- const hasResearchIframe = Array.from(iframes).some(f => {
91
- const rect = f.getBoundingClientRect();
92
- return rect.width > 200 && rect.height > 200;
93
- });
94
- const assistantText = (document.querySelector('[data-message-author-role="assistant"]')?.textContent || '').toLowerCase();
155
+ const turns = Array.from(document.querySelectorAll('[data-message-author-role="assistant"]'));
156
+ const assistantText = String(turns.at(-1)?.textContent || '').toLowerCase();
95
157
  const hasResearchText = assistantText.includes('researching') ||
96
158
  assistantText.includes('research plan') ||
97
- assistantText.includes('survey') ||
98
- assistantText.includes('analyze');
99
- return { hasResearchIframe, hasResearchText };
159
+ assistantText.includes('正在研究') ||
160
+ assistantText.includes('研究计划');
161
+ return { hasResearchText };
100
162
  })()`,
101
163
  returnByValue: true,
102
164
  });
103
165
  const val = result?.value;
104
- if (val?.hasResearchIframe || val?.hasResearchText) {
166
+ if (val?.hasResearchText) {
105
167
  planDetected = true;
106
- logger("Research plan detected, waiting for auto-confirm countdown...");
168
+ logger("[browser] Deep Research activity detected; waiting for plan auto-confirm...");
107
169
  break;
108
170
  }
109
171
  await delay(2_000);
110
172
  }
111
173
  if (!planDetected) {
112
174
  logger("Warning: Research plan not detected within 60s; continuing (may have auto-confirmed already)");
113
- return;
175
+ return capturedPlan;
114
176
  }
115
- // Phase B: Wait for auto-confirm countdown
177
+ // Phase B: Wait for the OOPIF's real execution state instead of sleeping for
178
+ // the full countdown. The main page cannot see this sandboxed iframe's text.
116
179
  const confirmStart = Date.now();
117
180
  while (Date.now() - confirmStart < autoConfirmWaitMs) {
181
+ const frameStatus = await readPlanStatus();
182
+ if (frameStatus) {
183
+ await capturePlan(frameStatus);
184
+ if (frameStatus.researchStarted) {
185
+ return reportResearchStarted();
186
+ }
187
+ }
118
188
  const { result } = await Runtime.evaluate({
119
189
  expression: `(() => {
120
- const iframes = document.querySelectorAll('iframe');
121
- const hasLargeIframe = Array.from(iframes).some(f => {
122
- const rect = f.getBoundingClientRect();
123
- return rect.width > 200 && rect.height > 200;
124
- });
125
190
  const text = (document.body?.innerText || '').toLowerCase();
126
191
  const isResearching = text.includes('researching...') ||
127
192
  text.includes('reading sources') ||
128
- text.includes('considering');
129
- return { hasLargeIframe, isResearching };
193
+ text.includes('正在研究') ||
194
+ text.includes('正在阅读来源');
195
+ return { isResearching };
130
196
  })()`,
131
197
  returnByValue: true,
132
198
  });
133
199
  const val = result?.value;
134
200
  if (val?.isResearching) {
135
- logger("Research plan confirmed, execution started");
136
- return;
201
+ return reportResearchStarted();
137
202
  }
138
- await delay(5_000);
203
+ await delay(2_000);
139
204
  }
140
- logger("Auto-confirm wait complete, proceeding to monitor research progress");
205
+ logger("[browser] Deep Research plan wait elapsed; proceeding to monitor research progress.");
206
+ return capturedPlan;
141
207
  }
142
208
  /**
143
209
  * Polls for Deep Research completion over 5-30+ minutes.
@@ -600,6 +666,44 @@ function buildDeepResearchFrameStatusExpression() {
600
666
  return `(() => {
601
667
  const rawText = document.body?.innerText || '';
602
668
  const html = document.body?.innerHTML || '';
669
+ const cleanText = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
670
+ const sections = typeof document.querySelectorAll === 'function'
671
+ ? Array.from(document.querySelectorAll('section'))
672
+ : [];
673
+ const planSection = sections.find((section) => {
674
+ if (typeof section.querySelector !== 'function' ||
675
+ typeof section.querySelectorAll !== 'function' ||
676
+ !cleanText(section.querySelector('h2')?.textContent) ||
677
+ section.querySelectorAll('ul li').length === 0) {
678
+ return false;
679
+ }
680
+ const buttons = Array.from(section.querySelectorAll('button'));
681
+ const hasPlanAction = buttons.some((button) =>
682
+ /^(edit|update|编辑|更新)$/i.test(cleanText(button.textContent))
683
+ );
684
+ const hasResearchStatus = Boolean(section.querySelector('p.loading-shimmer')) ||
685
+ buttons.some((button) =>
686
+ /stop research|停止研究/i.test(cleanText(button.getAttribute?.('aria-label')))
687
+ );
688
+ return hasPlanAction || hasResearchStatus;
689
+ });
690
+ const planTitle = cleanText(planSection?.querySelector?.('h2')?.textContent);
691
+ const planSteps = planSection && typeof planSection.querySelectorAll === 'function'
692
+ ? Array.from(planSection.querySelectorAll('ul li'))
693
+ .map((item) => cleanText(item.textContent))
694
+ .filter(Boolean)
695
+ : [];
696
+ const planActionText = planSection && typeof planSection.querySelectorAll === 'function'
697
+ ? Array.from(planSection.querySelectorAll('button'))
698
+ .map((button) => cleanText(button.textContent))
699
+ .find((text) => /^(edit|update|编辑|更新)$/i.test(text)) || ''
700
+ : '';
701
+ const hasResearchShimmer = Boolean(planSection?.querySelector?.('p.loading-shimmer'));
702
+ const hasStopResearchControl = typeof planSection?.querySelectorAll === 'function' &&
703
+ Array.from(planSection.querySelectorAll('button')).some((button) =>
704
+ /stop research|停止研究/i.test(cleanText(button.getAttribute?.('aria-label')))
705
+ );
706
+ const researchStarted = planSteps.length > 0 && (hasResearchShimmer || hasStopResearchControl);
603
707
  const isPlaceholder = (line) => /^(called tool|used tool|użyto narzędzia|narzędzie wywołane)$/i.test(line);
604
708
  const isCompletionLine = (line) =>
605
709
  /^(research completed|badanie ukończone)\\b/i.test(line);
@@ -641,6 +745,10 @@ function buildDeepResearchFrameStatusExpression() {
641
745
  return {
642
746
  completed,
643
747
  inProgress,
748
+ researchStarted,
749
+ planTitle: planTitle || undefined,
750
+ planSteps: planSteps.length > 0 ? planSteps : undefined,
751
+ planActionText: planActionText || undefined,
644
752
  textLength: reportText.length || rawText.trim().length,
645
753
  text: completed ? reportText : undefined,
646
754
  html: completed ? html : undefined,
@@ -773,9 +881,9 @@ export function buildDeepResearchCompletionPollExpressionForTest(minTurnIndex =
773
881
  return buildDeepResearchCompletionPollExpression(minTurnIndex);
774
882
  }
775
883
  function buildFindDeepResearchPillExpression(functionName = "findDeepResearchPill") {
776
- const pillLabel = JSON.stringify(DEEP_RESEARCH_PILL_LABEL);
884
+ const pillLabels = JSON.stringify([DEEP_RESEARCH_PILL_LABEL, "深度研究"]);
777
885
  return `const ${functionName} = () => {
778
- const label = ${pillLabel}.toLowerCase();
886
+ const labels = ${pillLabels}.map(label => label.toLowerCase());
779
887
  const selectors = [
780
888
  '.__composer-pill-composite',
781
889
  '.__composer-pill',
@@ -798,7 +906,7 @@ function buildFindDeepResearchPillExpression(functionName = "findDeepResearchPil
798
906
  pill.querySelector('button')?.getAttribute('aria-label') ||
799
907
  ''
800
908
  ).toLowerCase();
801
- if (text.includes(label) || aria.includes(label)) {
909
+ if (labels.some(label => text.includes(label) || aria.includes(label))) {
802
910
  return pill;
803
911
  }
804
912
  }
@@ -819,6 +927,18 @@ function buildWaitForDeepResearchPillExpression(timeoutMs) {
819
927
  function buildActivateDeepResearchExpression() {
820
928
  const plusBtnSelector = JSON.stringify(DEEP_RESEARCH_PLUS_BUTTON);
821
929
  const targetText = JSON.stringify(DEEP_RESEARCH_DROPDOWN_ITEM_TEXT);
930
+ const targetLabels = JSON.stringify([DEEP_RESEARCH_DROPDOWN_ITEM_TEXT, "深度研究"]);
931
+ const descriptionLabels = JSON.stringify(["Get a detailed report", "获取详细报告"]);
932
+ const addFilesLabels = JSON.stringify(["add files", "添加文件"]);
933
+ const dropdownReadyLabels = JSON.stringify([
934
+ "add photos",
935
+ "create image",
936
+ "web search",
937
+ "deep research",
938
+ "get a detailed report",
939
+ "深度研究",
940
+ "获取详细报告",
941
+ ]);
822
942
  return `(async () => {
823
943
  ${buildClickDispatcher()}
824
944
  ${buildFindDeepResearchPillExpression()}
@@ -864,8 +984,12 @@ function buildActivateDeepResearchExpression() {
864
984
  '[data-radix-popper-content-wrapper]',
865
985
  '[data-floating-ui-portal]',
866
986
  ].join(',');
867
- const target = ${targetText}.toLowerCase();
868
987
  const normalizeText = (value) => String(value || '').replace(/\\s+/g, ' ').trim().toLowerCase();
988
+ const compactText = (value) => normalizeText(value).replace(/\\s+/g, '');
989
+ const targetLabels = ${targetLabels}.map(normalizeText);
990
+ const descriptionLabels = ${descriptionLabels}.map(normalizeText);
991
+ const addFilesLabels = ${addFilesLabels}.map(normalizeText);
992
+ const dropdownReadyLabels = ${dropdownReadyLabels}.map(normalizeText);
869
993
  const getText = (item) => normalizeText(item.textContent || item.getAttribute?.('aria-label') || '');
870
994
  const isInPopover = (item) => Boolean(item.closest?.(popoverSelector));
871
995
  const isVisible = (item) => {
@@ -891,14 +1015,16 @@ function buildActivateDeepResearchExpression() {
891
1015
  input.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
892
1016
  input.dispatchEvent(new Event('change', { bubbles: true }));
893
1017
  };
894
- const isDeepResearchText = (text) => (
895
- text === target ||
896
- text.startsWith(target + ' ') ||
897
- text === 'get a detailed report' ||
898
- text.startsWith('get a detailed report ') ||
899
- (text.includes(target) && text.includes('detailed report')) ||
900
- text.replace(/\\s+/g, '').startsWith('deepresearch')
901
- );
1018
+ const isDeepResearchText = (text) => {
1019
+ const compact = compactText(text);
1020
+ const exactLabel = targetLabels.some(label => compact === compactText(label) || text.startsWith(label + ' '));
1021
+ const exactDescription = descriptionLabels.some(
1022
+ label => text === label || text.startsWith(label + ' ')
1023
+ );
1024
+ const combinedLabel = targetLabels.some(label => compact.startsWith(compactText(label))) &&
1025
+ descriptionLabels.some(label => compact.includes(compactText(label)));
1026
+ return exactLabel || exactDescription || combinedLabel;
1027
+ };
902
1028
  const getClickableItem = (item) => item.closest?.(
903
1029
  '[data-radix-collection-item], [role="option"], [cmdk-item], button, [role="menuitem"], [role="menuitemradio"], .__menu-item, [class*="__menu-item"], [class*="menu-item"]'
904
1030
  ) || item;
@@ -915,7 +1041,7 @@ function buildActivateDeepResearchExpression() {
915
1041
  .map(item => {
916
1042
  const text = getText(item);
917
1043
  const clickable = getClickableItem(item);
918
- const exact = text === target ? 0 : 1;
1044
+ const exact = targetLabels.includes(text) ? 0 : 1;
919
1045
  const menuRow = /(^|\\s)__menu-item(\\s|$)/.test(clickable.className || '') ? 0 : 1;
920
1046
  return { item: clickable, score: exact + menuRow, textLength: text.length };
921
1047
  })
@@ -946,7 +1072,9 @@ function buildActivateDeepResearchExpression() {
946
1072
  // mutate the main composer and can be submitted as normal prompt text.
947
1073
  const plusBtn = document.querySelector(${plusBtnSelector}) ||
948
1074
  Array.from(document.querySelectorAll('button')).find(
949
- b => (b.getAttribute('aria-label') || '').toLowerCase().includes('add files')
1075
+ b => addFilesLabels.some(label =>
1076
+ normalizeText(b.getAttribute('aria-label') || '').includes(label)
1077
+ )
950
1078
  );
951
1079
  if (!plusBtn) return { status: 'plus-button-missing' };
952
1080
  dispatchClickSequence(plusBtn);
@@ -958,11 +1086,7 @@ function buildActivateDeepResearchExpression() {
958
1086
  const items = collectAvailableItems({ requirePopover: true });
959
1087
  if (findDeepResearchItem({ requirePopover: true }) || items.some(text => {
960
1088
  const normalized = normalizeText(text);
961
- return normalized.includes('add photos') ||
962
- normalized.includes('create image') ||
963
- normalized.includes('web search') ||
964
- normalized.includes('deep research') ||
965
- normalized.includes('get a detailed report');
1089
+ return dropdownReadyLabels.some(label => normalized.includes(label));
966
1090
  })) { resolve(items); return; }
967
1091
  elapsed += 150;
968
1092
  if (elapsed > 3000) { resolve(items.length ? items : null); return; }