@steipete/oracle 0.8.5 → 0.8.6

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.
package/README.md CHANGED
@@ -42,6 +42,7 @@ npx -y @steipete/oracle --engine browser --model gemini-3-pro --prompt "a cute r
42
42
  # Sessions (list and replay)
43
43
  npx -y @steipete/oracle status --hours 72
44
44
  npx -y @steipete/oracle session <id> --render
45
+ npx -y @steipete/oracle restart <id>
45
46
 
46
47
  # TUI (interactive, only for humans)
47
48
  npx -y @steipete/oracle tui
@@ -100,6 +101,24 @@ npx -y @steipete/oracle oracle-mcp
100
101
  - Sessions you can replay (`oracle status`, `oracle session <id> --render`).
101
102
  - Session logs and bundles live in `~/.oracle/sessions` (override with `ORACLE_HOME_DIR`).
102
103
 
104
+ ## Browser auto-reattach (long Pro runs)
105
+
106
+ When browser runs time out (common with long GPT‑5.x Pro responses), Oracle can keep polling the existing ChatGPT tab and capture the final answer without manual `oracle session <id>` commands.
107
+
108
+ Enable auto-reattach by setting a non-zero interval:
109
+ - `--browser-auto-reattach-delay` — wait before the first retry (e.g. `30s`)
110
+ - `--browser-auto-reattach-interval` — how often to retry (e.g. `2m`)
111
+ - `--browser-auto-reattach-timeout` — per-attempt budget (default `2m`)
112
+
113
+ ```bash
114
+ oracle --engine browser \
115
+ --browser-timeout 6m \
116
+ --browser-auto-reattach-delay 30s \
117
+ --browser-auto-reattach-interval 2m \
118
+ --browser-auto-reattach-timeout 2m \
119
+ -p "Run the long UI audit" --file "src/**/*.ts"
120
+ ```
121
+
103
122
  ## Flags you’ll actually use
104
123
 
105
124
  | Flag | Purpose |
@@ -117,6 +136,9 @@ npx -y @steipete/oracle oracle-mcp
117
136
  | `--browser-port <port>` | Pin the Chrome DevTools port (WSL/Windows firewall helper). |
118
137
  | `--browser-inline-cookies[(-file)] <payload|path>` | Supply cookies without Chrome/Keychain (browser). |
119
138
  | `--browser-timeout`, `--browser-input-timeout` | Control overall/browser input timeouts (supports h/m/s/ms). |
139
+ | `--browser-recheck-delay`, `--browser-recheck-timeout` | Delayed recheck for long Pro runs: wait then retry capture after timeout (supports h/m/s/ms). |
140
+ | `--browser-reuse-wait` | Wait for a shared Chrome profile before launching (parallel browser runs). |
141
+ | `--browser-profile-lock-timeout` | Wait for the shared manual-login profile lock before sending (serializes parallel runs). |
120
142
  | `--render`, `--copy` | Print and/or copy the assembled markdown bundle. |
121
143
  | `--wait` | Block for background API runs (e.g., GPT‑5.1 Pro) instead of detaching. |
122
144
  | `--timeout <seconds\|auto>` | Overall API deadline (auto = 60m for pro, 120s otherwise). |
@@ -154,7 +176,7 @@ Advanced flags
154
176
 
155
177
  | Area | Flags |
156
178
  | --- | --- |
157
- | Browser | `--browser-manual-login`, `--browser-thinking-time`, `--browser-timeout`, `--browser-input-timeout`, `--browser-cookie-wait`, `--browser-inline-cookies[(-file)]`, `--browser-attachments`, `--browser-inline-files`, `--browser-bundle-files`, `--browser-keep-browser`, `--browser-headless`, `--browser-hide-window`, `--browser-no-cookie-sync`, `--browser-allow-cookie-errors`, `--browser-chrome-path`, `--browser-cookie-path`, `--chatgpt-url` |
179
+ | Browser | `--browser-manual-login`, `--browser-thinking-time`, `--browser-timeout`, `--browser-input-timeout`, `--browser-recheck-delay`, `--browser-recheck-timeout`, `--browser-reuse-wait`, `--browser-profile-lock-timeout`, `--browser-auto-reattach-delay`, `--browser-auto-reattach-interval`, `--browser-auto-reattach-timeout`, `--browser-cookie-wait`, `--browser-inline-cookies[(-file)]`, `--browser-attachments`, `--browser-inline-files`, `--browser-bundle-files`, `--browser-keep-browser`, `--browser-headless`, `--browser-hide-window`, `--browser-no-cookie-sync`, `--browser-allow-cookie-errors`, `--browser-chrome-path`, `--browser-cookie-path`, `--chatgpt-url` |
158
180
  | Run control | `--background`, `--no-background`, `--http-timeout`, `--zombie-timeout`, `--zombie-last-activity` |
159
181
  | Azure/OpenAI | `--azure-endpoint`, `--azure-deployment`, `--azure-api-version`, `--base-url` |
160
182
 
@@ -183,7 +183,14 @@ program
183
183
  .addOption(new Option('--chatgpt-url <url>', `Override the ChatGPT web URL (e.g., workspace/folder like https://chatgpt.com/g/.../project; default ${CHATGPT_URL}).`))
184
184
  .addOption(new Option('--browser-url <url>', `Alias for --chatgpt-url (default ${CHATGPT_URL}).`).hideHelp())
185
185
  .addOption(new Option('--browser-timeout <ms|s|m>', 'Maximum time to wait for an answer (default 1200s / 20m).').hideHelp())
186
- .addOption(new Option('--browser-input-timeout <ms|s|m>', 'Maximum time to wait for the prompt textarea (default 30s).').hideHelp())
186
+ .addOption(new Option('--browser-input-timeout <ms|s|m>', 'Maximum time to wait for the prompt textarea (default 60s).').hideHelp())
187
+ .addOption(new Option('--browser-recheck-delay <ms|s|m|h>', 'After an assistant timeout, wait this long then revisit the conversation to retry capture.').hideHelp())
188
+ .addOption(new Option('--browser-recheck-timeout <ms|s|m|h>', 'Time budget for the delayed recheck attempt (default 120s).').hideHelp())
189
+ .addOption(new Option('--browser-reuse-wait <ms|s|m|h>', 'Wait for a shared Chrome profile to appear before launching a new one (helps parallel runs).').hideHelp())
190
+ .addOption(new Option('--browser-profile-lock-timeout <ms|s|m|h>', 'Wait for the shared manual-login profile lock before sending (serializes parallel runs).').hideHelp())
191
+ .addOption(new Option('--browser-auto-reattach-delay <ms|s|m|h>', 'Delay before starting periodic auto-reattach attempts after a timeout.').hideHelp())
192
+ .addOption(new Option('--browser-auto-reattach-interval <ms|s|m|h>', 'Interval between auto-reattach attempts (0 disables).').hideHelp())
193
+ .addOption(new Option('--browser-auto-reattach-timeout <ms|s|m|h>', 'Time budget for each auto-reattach attempt (default 120s).').hideHelp())
187
194
  .addOption(new Option('--browser-cookie-wait <ms|s|m>', 'Wait before retrying cookie sync when Chrome cookies are empty or locked.').hideHelp())
188
195
  .addOption(new Option('--browser-port <port>', 'Use a fixed Chrome DevTools port (helpful on WSL firewalls).')
189
196
  .argParser(parseIntOption))
@@ -379,6 +386,17 @@ const statusCommand = program
379
386
  showExamples,
380
387
  });
381
388
  });
389
+ program
390
+ .command('restart <id>')
391
+ .description('Re-run a stored session as a new session (clones options).')
392
+ .addOption(new Option('--wait').default(undefined))
393
+ .addOption(new Option('--no-wait').default(undefined).hideHelp())
394
+ .option('--remote-host <host:port>', 'Delegate browser runs to a remote `oracle serve` instance.')
395
+ .option('--remote-token <token>', 'Access token for the remote `oracle serve` instance.')
396
+ .action(async (sessionId, _options, cmd) => {
397
+ const restartOptions = cmd.opts();
398
+ await restartSession(sessionId, restartOptions);
399
+ });
382
400
  function buildRunOptions(options, overrides = {}) {
383
401
  if (!options.prompt) {
384
402
  throw new Error('Prompt is required.');
@@ -665,11 +683,10 @@ async function runRootCommand(options) {
665
683
  // - otherwise block for fast models (gpt-5.1, browser) and detach by default for pro API runs
666
684
  let waitPreference = resolveWaitFlag({
667
685
  waitFlag: options.wait,
668
- noWaitFlag: options.noWait,
669
686
  model: resolvedModel,
670
687
  engine,
671
688
  });
672
- if (remoteHost && !waitPreference) {
689
+ if (remoteHost && waitPreference === false) {
673
690
  console.log(chalk.dim('Remote browser runs require --wait; ignoring --no-wait.'));
674
691
  waitPreference = true;
675
692
  }
@@ -857,6 +874,13 @@ async function runRootCommand(options) {
857
874
  ...baseRunOptions,
858
875
  mode: sessionMode,
859
876
  browserConfig,
877
+ waitPreference,
878
+ youtube: options.youtube,
879
+ generateImage: options.generateImage,
880
+ editImage: options.editImage,
881
+ outputPath: options.output,
882
+ aspectRatio: options.aspect,
883
+ geminiShowThoughts: options.geminiShowThoughts,
860
884
  }, process.cwd(), notifications);
861
885
  const liveRunOptions = {
862
886
  ...baseRunOptions,
@@ -898,7 +922,7 @@ async function runRootCommand(options) {
898
922
  await attachSession(sessionMeta.id, { suppressMetadata: true });
899
923
  }
900
924
  }
901
- async function runInteractiveSession(sessionMeta, runOptions, mode, browserConfig, showReattachHint = true, notifications, userConfig, suppressSummary = false, browserDeps) {
925
+ async function runInteractiveSession(sessionMeta, runOptions, mode, browserConfig, showReattachHint = true, notifications, userConfig, suppressSummary = false, browserDeps, cwd = process.cwd()) {
902
926
  const { logLine, writeChunk, stream } = sessionStore.createLogWriter(sessionMeta.id);
903
927
  let headerAugmented = false;
904
928
  const combinedLog = (message = '') => {
@@ -927,7 +951,7 @@ async function runInteractiveSession(sessionMeta, runOptions, mode, browserConfi
927
951
  runOptions,
928
952
  mode,
929
953
  browserConfig,
930
- cwd: process.cwd(),
954
+ cwd,
931
955
  log: combinedLog,
932
956
  write: combinedWrite,
933
957
  version: VERSION,
@@ -970,6 +994,140 @@ async function launchDetachedSession(sessionId) {
970
994
  }
971
995
  });
972
996
  }
997
+ async function restartSession(sessionId, options) {
998
+ const metadata = await sessionStore.readSession(sessionId);
999
+ if (!metadata) {
1000
+ console.error(chalk.red(`No session found with ID ${sessionId}`));
1001
+ process.exitCode = 1;
1002
+ return;
1003
+ }
1004
+ const runOptions = buildRunOptionsFromMetadata(metadata);
1005
+ if (!runOptions.prompt) {
1006
+ console.error(chalk.red(`Session ${sessionId} has no stored prompt; cannot restart.`));
1007
+ process.exitCode = 1;
1008
+ return;
1009
+ }
1010
+ const sessionMode = getSessionMode(metadata);
1011
+ const engine = sessionMode === 'browser' ? 'browser' : 'api';
1012
+ const browserConfig = getBrowserConfigFromMetadata(metadata);
1013
+ if (sessionMode === 'browser' && !browserConfig) {
1014
+ console.error(chalk.red(`Session ${sessionId} is missing browser config; cannot restart.`));
1015
+ process.exitCode = 1;
1016
+ return;
1017
+ }
1018
+ const userConfig = (await loadUserConfig()).config;
1019
+ const cwd = metadata.cwd ?? process.cwd();
1020
+ const storedOptions = metadata.options ?? {};
1021
+ if (runOptions.file && runOptions.file.length > 0) {
1022
+ const isBrowserMode = engine === 'browser';
1023
+ const filesToValidate = isBrowserMode ? runOptions.file.filter((f) => !isMediaFile(f)) : runOptions.file;
1024
+ if (filesToValidate.length > 0) {
1025
+ await readFiles(filesToValidate, { cwd });
1026
+ }
1027
+ }
1028
+ enforceBrowserSearchFlag(runOptions, sessionMode, console.log);
1029
+ let waitPreference = resolveRestartWaitPreference({
1030
+ waitFlag: options.wait,
1031
+ storedPreference: storedOptions.waitPreference,
1032
+ model: runOptions.model,
1033
+ engine,
1034
+ });
1035
+ const remoteConfig = resolveRemoteServiceConfig({
1036
+ cliHost: options.remoteHost,
1037
+ cliToken: options.remoteToken,
1038
+ userConfig,
1039
+ env: process.env,
1040
+ });
1041
+ const remoteHost = remoteConfig.host;
1042
+ const remoteToken = remoteConfig.token;
1043
+ if (remoteHost && engine !== 'browser') {
1044
+ throw new Error('--remote-host requires a browser session.');
1045
+ }
1046
+ if (remoteHost) {
1047
+ console.log(chalk.dim(`Remote browser host detected: ${remoteHost}`));
1048
+ }
1049
+ if (remoteHost && waitPreference === false) {
1050
+ console.log(chalk.dim('Remote browser runs require --wait; ignoring --no-wait.'));
1051
+ waitPreference = true;
1052
+ }
1053
+ let browserDeps;
1054
+ if (browserConfig && remoteHost) {
1055
+ browserDeps = {
1056
+ executeBrowser: createRemoteBrowserExecutor({ host: remoteHost, token: remoteToken }),
1057
+ };
1058
+ console.log(chalk.dim(`Routing browser automation to remote host ${remoteHost}`));
1059
+ }
1060
+ else if (browserConfig && runOptions.model.startsWith('gemini')) {
1061
+ browserDeps = {
1062
+ executeBrowser: createGeminiWebExecutor({
1063
+ youtube: storedOptions.youtube,
1064
+ generateImage: storedOptions.generateImage,
1065
+ editImage: storedOptions.editImage,
1066
+ outputPath: storedOptions.outputPath,
1067
+ aspectRatio: storedOptions.aspectRatio,
1068
+ showThoughts: storedOptions.geminiShowThoughts,
1069
+ }),
1070
+ };
1071
+ console.log(chalk.dim('Using Gemini web client for browser automation'));
1072
+ if (browserConfig.modelStrategy && browserConfig.modelStrategy !== 'select') {
1073
+ console.log(chalk.dim('Browser model strategy is ignored for Gemini web runs.'));
1074
+ }
1075
+ }
1076
+ const remoteExecutionActive = Boolean(browserDeps);
1077
+ await sessionStore.ensureStorage();
1078
+ const notifications = deriveNotificationSettingsFromMetadata(metadata, process.env, userConfig.notify);
1079
+ const sessionMeta = await sessionStore.createSession({
1080
+ ...runOptions,
1081
+ mode: sessionMode,
1082
+ browserConfig,
1083
+ waitPreference,
1084
+ youtube: storedOptions.youtube,
1085
+ generateImage: storedOptions.generateImage,
1086
+ editImage: storedOptions.editImage,
1087
+ outputPath: storedOptions.outputPath,
1088
+ aspectRatio: storedOptions.aspectRatio,
1089
+ geminiShowThoughts: storedOptions.geminiShowThoughts,
1090
+ }, cwd, notifications, sessionId);
1091
+ const liveRunOptions = {
1092
+ ...runOptions,
1093
+ sessionId: sessionMeta.id,
1094
+ effectiveModelId: resolveEffectiveModelIdForRun(runOptions.model, runOptions.effectiveModelId),
1095
+ };
1096
+ const disableDetachEnv = process.env.ORACLE_NO_DETACH === '1';
1097
+ const detachAllowed = remoteExecutionActive
1098
+ ? false
1099
+ : shouldDetachSession({
1100
+ engine,
1101
+ model: runOptions.model,
1102
+ waitPreference,
1103
+ disableDetachEnv,
1104
+ });
1105
+ const detached = !detachAllowed
1106
+ ? false
1107
+ : await launchDetachedSession(sessionMeta.id).catch((error) => {
1108
+ const message = error instanceof Error ? error.message : String(error);
1109
+ console.log(chalk.yellow(`Unable to detach session runner (${message}). Running inline...`));
1110
+ return false;
1111
+ });
1112
+ if (!waitPreference) {
1113
+ if (!detached) {
1114
+ console.log(chalk.red('Unable to start in background; use --wait to run inline.'));
1115
+ process.exitCode = 1;
1116
+ return;
1117
+ }
1118
+ console.log(chalk.blue(`Session running in background. Reattach via: oracle session ${sessionMeta.id}`));
1119
+ console.log(chalk.dim('Pro runs can take up to 60 minutes (usually 10-15). Add --wait to stay attached.'));
1120
+ return;
1121
+ }
1122
+ if (detached === false) {
1123
+ await runInteractiveSession(sessionMeta, liveRunOptions, sessionMode, browserConfig, false, notifications, userConfig, true, browserDeps, cwd);
1124
+ return;
1125
+ }
1126
+ if (detached) {
1127
+ console.log(chalk.blue(`Reattach via: oracle session ${sessionMeta.id}`));
1128
+ await attachSession(sessionMeta.id, { suppressMetadata: true });
1129
+ }
1130
+ }
973
1131
  async function executeSession(sessionId) {
974
1132
  const metadata = await sessionStore.readSession(sessionId);
975
1133
  if (!metadata) {
@@ -1020,6 +1178,13 @@ function printDebugHelp(cliName) {
1020
1178
  ['--browser-url <url>', 'Alias for --chatgpt-url.'],
1021
1179
  ['--browser-timeout <ms|s|m>', 'Cap total wait time for the assistant response.'],
1022
1180
  ['--browser-input-timeout <ms|s|m>', 'Cap how long we wait for the composer textarea.'],
1181
+ ['--browser-recheck-delay <ms|s|m|h>', 'After timeout, wait then revisit the conversation to retry capture.'],
1182
+ ['--browser-recheck-timeout <ms|s|m|h>', 'Time budget for the delayed recheck attempt.'],
1183
+ ['--browser-reuse-wait <ms|s|m|h>', 'Wait for a shared Chrome profile before launching (parallel runs).'],
1184
+ ['--browser-profile-lock-timeout <ms|s|m|h>', 'Wait for the manual-login profile lock before sending.'],
1185
+ ['--browser-auto-reattach-delay <ms|s|m|h>', 'Delay before periodic auto-reattach attempts after a timeout.'],
1186
+ ['--browser-auto-reattach-interval <ms|s|m|h>', 'Interval between auto-reattach attempts (0 disables).'],
1187
+ ['--browser-auto-reattach-timeout <ms|s|m|h>', 'Time budget for each auto-reattach attempt.'],
1023
1188
  ['--browser-cookie-wait <ms|s|m>', 'Wait before retrying cookie sync when Chrome cookies are empty or locked.'],
1024
1189
  ['--browser-no-cookie-sync', 'Skip copying cookies from your main profile.'],
1025
1190
  ['--browser-manual-login', 'Skip cookie copy; reuse a persistent automation profile and log in manually.'],
@@ -1037,13 +1202,30 @@ function printDebugOptionGroup(entries) {
1037
1202
  console.log(` ${label}${description}`);
1038
1203
  });
1039
1204
  }
1040
- function resolveWaitFlag({ waitFlag, noWaitFlag, model, engine, }) {
1205
+ function resolveWaitFlag({ waitFlag, model, engine, }) {
1041
1206
  if (waitFlag === true)
1042
1207
  return true;
1043
- if (noWaitFlag === true)
1208
+ if (waitFlag === false)
1044
1209
  return false;
1045
1210
  return defaultWaitPreference(model, engine);
1046
1211
  }
1212
+ function resolveRestartWaitPreference({ waitFlag, storedPreference, model, engine, }) {
1213
+ if (waitFlag === true)
1214
+ return true;
1215
+ if (waitFlag === false)
1216
+ return false;
1217
+ if (typeof storedPreference === 'boolean')
1218
+ return storedPreference;
1219
+ return defaultWaitPreference(model, engine);
1220
+ }
1221
+ function resolveEffectiveModelIdForRun(model, stored) {
1222
+ if (stored)
1223
+ return stored;
1224
+ if (model.startsWith('gemini')) {
1225
+ return resolveGeminiModelId(model);
1226
+ }
1227
+ return isKnownModel(model) ? MODEL_CONFIGS[model].apiModel ?? model : model;
1228
+ }
1047
1229
  program.action(async function () {
1048
1230
  const options = this.optsWithGlobals();
1049
1231
  await runRootCommand(options);
@@ -27,23 +27,26 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
27
27
  const raceReadyEvaluation = evaluationPromise.then((value) => ({ kind: 'evaluation', value }), (error) => {
28
28
  throw { source: 'evaluation', error };
29
29
  });
30
- const pollerPromise = pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex).then((value) => {
31
- if (!value) {
32
- throw { source: 'poll', error: new Error(ASSISTANT_POLL_TIMEOUT_ERROR) };
33
- }
34
- return { kind: 'poll', value };
35
- }, (error) => {
30
+ // Use AbortController to stop the poller when the evaluation wins the race,
31
+ // preventing abandoned polling loops from consuming resources.
32
+ const pollerAbort = new AbortController();
33
+ const pollerPromise = pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, pollerAbort.signal).then((value) => ({ kind: 'poll', value }), (error) => {
36
34
  throw { source: 'poll', error };
37
35
  });
38
36
  let evaluation = null;
39
37
  try {
40
38
  const winner = await Promise.race([raceReadyEvaluation, pollerPromise]);
41
39
  if (winner.kind === 'poll') {
40
+ if (!winner.value) {
41
+ throw { source: 'poll', error: new Error(ASSISTANT_POLL_TIMEOUT_ERROR) };
42
+ }
42
43
  logger('Captured assistant response via snapshot watchdog');
43
44
  evaluationPromise.catch(() => undefined);
44
45
  await terminateRuntimeExecution(Runtime);
45
46
  return winner.value;
46
47
  }
48
+ // Evaluation won - abort the poller to prevent it from running until timeout
49
+ pollerAbort.abort();
47
50
  evaluation = winner.value;
48
51
  }
49
52
  catch (wrappedError) {
@@ -86,7 +89,7 @@ export async function waitForAssistantResponse(Runtime, timeoutMs, logger, minTu
86
89
  pollerPromise.catch(() => null),
87
90
  delay(remainingMs).then(() => null),
88
91
  ]);
89
- if (polled && polled.kind === 'poll') {
92
+ if (polled && polled.kind === 'poll' && polled.value) {
90
93
  return polled.value;
91
94
  }
92
95
  }
@@ -263,12 +266,16 @@ async function terminateRuntimeExecution(Runtime) {
263
266
  // ignore termination failures
264
267
  }
265
268
  }
266
- async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex) {
269
+ async function pollAssistantCompletion(Runtime, timeoutMs, minTurnIndex, abortSignal) {
267
270
  const watchdogDeadline = Date.now() + timeoutMs;
268
271
  let previousLength = 0;
269
272
  let stableCycles = 0;
270
273
  let lastChangeAt = Date.now();
271
274
  while (Date.now() < watchdogDeadline) {
275
+ // Check abort signal to stop polling when another path won the race
276
+ if (abortSignal?.aborted) {
277
+ return null;
278
+ }
272
279
  const snapshot = await readAssistantSnapshot(Runtime, minTurnIndex);
273
280
  const normalized = normalizeAssistantSnapshot(snapshot);
274
281
  if (normalized) {
@@ -483,33 +490,63 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex) {
483
490
  new Promise((resolve, reject) => {
484
491
  const deadline = Date.now() + ${timeoutMs};
485
492
  let stopInterval = null;
486
- const observer = new MutationObserver(() => {
487
- const extractedRaw = extractFromTurns();
488
- const extractedCandidate =
489
- extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
490
- let extracted = acceptSnapshot(extractedCandidate);
491
- if (!extracted) {
492
- const fallbackRaw = extractFromMarkdownFallback();
493
- const fallbackCandidate =
494
- fallbackRaw && !isAnswerNowPlaceholder(fallbackRaw) ? fallbackRaw : null;
495
- extracted = acceptSnapshot(fallbackCandidate);
493
+ let timeoutId = null;
494
+ let cleanedUp = false;
495
+ let observer = null;
496
+
497
+ // Centralized cleanup to prevent resource leaks
498
+ const cleanup = () => {
499
+ if (cleanedUp) return;
500
+ cleanedUp = true;
501
+ if (stopInterval) {
502
+ clearInterval(stopInterval);
503
+ stopInterval = null;
504
+ }
505
+ if (timeoutId) {
506
+ clearTimeout(timeoutId);
507
+ timeoutId = null;
508
+ }
509
+ if (observer) {
510
+ try {
511
+ observer.disconnect();
512
+ } catch {
513
+ // ignore disconnect errors
514
+ }
515
+ observer = null;
496
516
  }
497
- if (extracted) {
498
- observer.disconnect();
499
- if (stopInterval) {
500
- clearInterval(stopInterval);
517
+ };
518
+
519
+ const observerCallback = () => {
520
+ if (cleanedUp) return;
521
+ try {
522
+ const extractedRaw = extractFromTurns();
523
+ const extractedCandidate =
524
+ extractedRaw && !isAnswerNowPlaceholder(extractedRaw) ? extractedRaw : null;
525
+ let extracted = acceptSnapshot(extractedCandidate);
526
+ if (!extracted) {
527
+ const fallbackRaw = extractFromMarkdownFallback();
528
+ const fallbackCandidate =
529
+ fallbackRaw && !isAnswerNowPlaceholder(fallbackRaw) ? fallbackRaw : null;
530
+ extracted = acceptSnapshot(fallbackCandidate);
501
531
  }
502
- resolve(extracted);
503
- } else if (Date.now() > deadline) {
504
- observer.disconnect();
505
- if (stopInterval) {
506
- clearInterval(stopInterval);
532
+ if (extracted) {
533
+ cleanup();
534
+ resolve(extracted);
535
+ } else if (Date.now() > deadline) {
536
+ cleanup();
537
+ reject(new Error('Response timeout'));
507
538
  }
508
- reject(new Error('Response timeout'));
539
+ } catch (error) {
540
+ cleanup();
541
+ reject(error);
509
542
  }
510
- });
543
+ };
544
+
545
+ observer = new MutationObserver(observerCallback);
511
546
  observer.observe(document.body, { childList: true, subtree: true, characterData: true });
547
+
512
548
  stopInterval = setInterval(() => {
549
+ if (cleanedUp) return;
513
550
  const stop = document.querySelector(STOP_SELECTOR);
514
551
  if (!stop) {
515
552
  return;
@@ -521,11 +558,9 @@ function buildResponseObserverExpression(timeoutMs, minTurnIndex) {
521
558
  }
522
559
  dispatchClickSequence(stop);
523
560
  }, 500);
524
- setTimeout(() => {
525
- if (stopInterval) {
526
- clearInterval(stopInterval);
527
- }
528
- observer.disconnect();
561
+
562
+ timeoutId = setTimeout(() => {
563
+ cleanup();
529
564
  reject(new Error('Response timeout'));
530
565
  }, ${timeoutMs});
531
566
  });
@@ -686,7 +721,7 @@ function buildAssistantExtractor(functionName) {
686
721
  function buildMarkdownFallbackExtractor(minTurnLiteral) {
687
722
  const turnIndexValue = minTurnLiteral ? `(${minTurnLiteral} >= 0 ? ${minTurnLiteral} : null)` : 'null';
688
723
  return `(() => {
689
- const MIN_TURN_INDEX = ${turnIndexValue};
724
+ const __minTurn = ${turnIndexValue};
690
725
  const roots = [
691
726
  document.querySelector('section[data-testid="screen-threadFlyOut"]'),
692
727
  document.querySelector('[data-testid="chat-thread"]'),
@@ -728,10 +763,10 @@ function buildMarkdownFallbackExtractor(minTurnLiteral) {
728
763
  return idx >= 0 ? idx : null;
729
764
  };
730
765
  const isAfterMinTurn = (node) => {
731
- if (MIN_TURN_INDEX === null) return true;
766
+ if (__minTurn === null) return true;
732
767
  if (!hasTurns) return true;
733
768
  const idx = resolveTurnIndex(node);
734
- return idx !== null && idx >= MIN_TURN_INDEX;
769
+ return idx !== null && idx >= __minTurn;
735
770
  };
736
771
  const normalize = (value) => String(value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
737
772
  const collectUserText = (scope) => {