@steipete/oracle 0.20.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.
@@ -15,8 +15,8 @@ import { collectPaths, collectModelList, collectTextValues, parseFloatOption, pa
15
15
  import { copyToClipboard } from "../src/cli/clipboard.js";
16
16
  import { isGpt6ProAlias } from "../src/cli/browserConfig.js";
17
17
  import { buildMarkdownBundle } from "../src/cli/markdownBundle.js";
18
- import { shouldDetachSession, stopDetachedWorker } from "../src/cli/detach.js";
19
- 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";
20
20
  import { applyHiddenAliases } from "../src/cli/hiddenAliases.js";
21
21
  import { isMediaFile } from "../src/browser/prompt.js";
22
22
  import { formatCompactNumber } from "../src/cli/format.js";
@@ -37,6 +37,7 @@ import { isAzureOpenAICandidateModel, validateProviderRouting, } from "../src/or
37
37
  import { buildSessionLifecycle, formatSessionLifecycleBlock } from "../src/cli/sessionLifecycle.js";
38
38
  import { buildDetachedPerfTraceEnv, createPerfTrace, isTraceValueFlag, } from "../src/cli/perfTrace.js";
39
39
  import { resolveBrowserFollowupReference } from "../src/cli/followup.js";
40
+ import { BrowserRunCancelledError } from "../src/oracle/errors.js";
40
41
  const VERSION = getCliVersion();
41
42
  const CLI_ENTRYPOINT = fileURLToPath(import.meta.url);
42
43
  const LEGACY_FLAG_ALIASES = new Map([
@@ -1673,18 +1674,28 @@ async function waitForDetachedStartGate() {
1673
1674
  }
1674
1675
  }
1675
1676
  async function attachToDetachedSession(sessionId, workerPid) {
1677
+ const cancellationMarker = sessionStore
1678
+ .getPaths(sessionId)
1679
+ .then((paths) => detachedSessionCancellationPath(paths.dir, workerPid));
1676
1680
  let cancelled = false;
1681
+ let cancellationRequest;
1677
1682
  const cancelWorker = () => {
1678
1683
  cancelled = true;
1679
- try {
1680
- stopDetachedWorker(workerPid);
1681
- }
1682
- catch (error) {
1684
+ cancellationRequest ??= cancellationMarker
1685
+ .then((markerPath) => requestDetachedSessionCancellation(markerPath))
1686
+ .catch((error) => {
1683
1687
  const message = error instanceof Error ? error.message : String(error);
1684
- console.error(chalk.red(`Unable to stop detached worker ${workerPid}: ${message}`));
1685
- }
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
+ });
1686
1697
  };
1687
- process.once("SIGINT", cancelWorker);
1698
+ process.on("SIGINT", cancelWorker);
1688
1699
  try {
1689
1700
  const { attachSession } = await import("../src/cli/sessionDisplay.js");
1690
1701
  await attachSession(sessionId, {
@@ -1695,9 +1706,15 @@ async function attachToDetachedSession(sessionId, workerPid) {
1695
1706
  }
1696
1707
  finally {
1697
1708
  process.off("SIGINT", cancelWorker);
1698
- if (cancelled) {
1699
- 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);
1700
1716
  }
1717
+ process.exitCode = detachedCancellationExitCode(cancelled, finalStatus, process.exitCode);
1701
1718
  }
1702
1719
  }
1703
1720
  async function restartSession(sessionId, options) {
@@ -1879,6 +1896,10 @@ async function restartSession(sessionId, options) {
1879
1896
  async function executeSession(sessionId) {
1880
1897
  let metadata = null;
1881
1898
  let writer = null;
1899
+ const cancellation = new AbortController();
1900
+ const stopCancellationMonitor = new AbortController();
1901
+ let cancellationMarker;
1902
+ let cancellationMonitor;
1882
1903
  try {
1883
1904
  metadata = await sessionStore.readSession(sessionId);
1884
1905
  if (!metadata) {
@@ -1892,6 +1913,18 @@ async function executeSession(sessionId) {
1892
1913
  const sessionMode = getSessionMode(metadata);
1893
1914
  const browserConfig = getBrowserConfigFromMetadata(metadata);
1894
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
+ }
1895
1928
  const userConfig = (await loadUserConfig()).config;
1896
1929
  const notifications = deriveNotificationSettingsFromMetadata(metadata, process.env, userConfig.notify);
1897
1930
  const { performSessionRun } = await import("../src/cli/sessionRunner.js");
@@ -1905,18 +1938,22 @@ async function executeSession(sessionId) {
1905
1938
  write: writer.writeChunk,
1906
1939
  version: VERSION,
1907
1940
  notifications,
1941
+ signal: sessionMode === "browser" ? cancellation.signal : undefined,
1908
1942
  });
1909
1943
  }
1910
1944
  catch (error) {
1911
- process.exitCode = 1;
1945
+ const cancelled = error instanceof BrowserRunCancelledError;
1946
+ process.exitCode = cancelled ? 130 : 1;
1912
1947
  const message = error instanceof Error ? error.message : String(error);
1913
1948
  if (!metadata) {
1914
- console.error(chalk.red(message));
1949
+ if (!cancelled)
1950
+ console.error(chalk.red(message));
1915
1951
  return;
1916
1952
  }
1917
- writer?.logLine(`ERROR: Detached session worker failed: ${message}`);
1953
+ if (!cancelled)
1954
+ writer?.logLine(`ERROR: Detached session worker failed: ${message}`);
1918
1955
  const latest = await sessionStore.readSession(sessionId).catch(() => null);
1919
- if (latest && !["completed", "partial", "error"].includes(latest.status)) {
1956
+ if (latest && !["completed", "partial", "error", "cancelled"].includes(latest.status)) {
1920
1957
  await sessionStore.updateSession(sessionId, {
1921
1958
  status: "error",
1922
1959
  completedAt: new Date().toISOString(),
@@ -1930,6 +1967,14 @@ async function executeSession(sessionId) {
1930
1967
  }
1931
1968
  }
1932
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
+ }
1933
1978
  writer?.stream.end();
1934
1979
  }
1935
1980
  }
@@ -2048,7 +2093,7 @@ async function main() {
2048
2093
  console.log(chalk.yellow("\nCancelled."));
2049
2094
  process.exitCode = 130;
2050
2095
  // Browser/serve modes install their own SIGINT cleanup after this top-level handler.
2051
- if (process.listenerCount("SIGINT") <= 1) {
2096
+ if (shouldExitAfterTopLevelSigint(process.listenerCount("SIGINT"))) {
2052
2097
  process.exit(130);
2053
2098
  }
2054
2099
  };
@@ -1030,11 +1030,12 @@ function buildThinkingTimeExpression(level, desiredModel) {
1030
1030
  .filter(Boolean);
1031
1031
  if (selections.length !== 1) return null;
1032
1032
  const { label, index, level } = selections[0];
1033
- // This adapter owns the observed five-tier layout only. A different range
1034
- // or contradictory announcement must not turn a numeric guess into proof.
1035
- if (thumb.getAttribute('aria-valuemin') !== '0' || thumb.getAttribute('aria-valuemax') !== '4' ||
1036
- thumb.getAttribute('aria-valuenow') !== String(index)) return null;
1037
- return { control, label, index, level };
1033
+ // Quota-limited accounts expose four tiers; the fourth remains Extra High.
1034
+ // Require an observed range and matching label/index before trusting either.
1035
+ const maximum = thumb.getAttribute('aria-valuemax');
1036
+ if (thumb.getAttribute('aria-valuemin') !== '0' || !['3', '4'].includes(maximum) ||
1037
+ index > Number(maximum) || thumb.getAttribute('aria-valuenow') !== String(index)) return null;
1038
+ return { control, label, index, level, maximum: Number(maximum) };
1038
1039
  };
1039
1040
  let current = resolve();
1040
1041
  const finish = (result) => { closeOpenMenus(); return result; };
@@ -1054,6 +1055,10 @@ function buildThinkingTimeExpression(level, desiredModel) {
1054
1055
  }
1055
1056
  return finish(failure('option-not-found'));
1056
1057
  }
1058
+ const unavailable = () => finish(failure('option-disabled', {
1059
+ label: 'Pro', notice: 'the available four-tier effort slider does not include Pro',
1060
+ }));
1061
+ if (targetIndex > current.maximum) return unavailable();
1057
1062
  if (current.level === target) return finish({ status: 'already-selected', label: current.label });
1058
1063
  const deadline = performance.now() + MAX_WAIT_MS;
1059
1064
  for (let attempt = 0; attempt < 4 && performance.now() < deadline; attempt += 1) {
@@ -1069,6 +1074,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
1069
1074
  if (next && next.index !== previousIndex) { current = next; break; }
1070
1075
  }
1071
1076
  if (!current) return finish(failure('selection-unverified'));
1077
+ if (targetIndex > current.maximum) return unavailable();
1072
1078
  if (current.level === target) return finish({ status: 'switched', label: current.label });
1073
1079
  }
1074
1080
  return finish(failure('selection-unverified'));
@@ -7,6 +7,7 @@ import { launch, Launcher, } from "chrome-launcher";
7
7
  import { cleanupStaleProfileState } from "./profileState.js";
8
8
  import { delay } from "./utils.js";
9
9
  import { isWsl, resolveWslChromeLaunchRoute } from "./wslHost.js";
10
+ import { BrowserCancellation } from "./cancellation.js";
10
11
  export async function launchChrome(config, userDataDir, logger) {
11
12
  const { connectHost, debugBindAddress, usePatchedLauncher } = resolveWslChromeLaunchRoute();
12
13
  const debugPort = config.debugPort ?? parseDebugPortEnv();
@@ -388,21 +389,31 @@ export async function closeRemoteChromeTarget(host, port, targetId, logger) {
388
389
  }
389
390
  }
390
391
  export async function listRemoteChromeTargets(options) {
391
- if (!options.browserWSEndpoint) {
392
- const targets = await CDP.List({ host: options.host, port: options.port });
393
- return targets;
394
- }
395
- const browser = await connectToBrowserWebSocket(options.host, options.port, options.browserWSEndpoint, options.logger ?? (() => { }), options.approvalWaitMs);
392
+ const logger = options.logger ?? (() => { });
393
+ const cancellation = new BrowserCancellation(options.signal, logger);
396
394
  try {
397
- const result = await browser.Target.getTargets();
398
- return (result.targetInfos ?? []).map((target) => ({
399
- targetId: target.targetId,
400
- type: target.type,
401
- url: target.url,
402
- }));
395
+ return await cancellation.run(async () => {
396
+ if (!options.browserWSEndpoint) {
397
+ const targets = await cancellation.call(() => CDP.List({ host: options.host, port: options.port }));
398
+ return targets;
399
+ }
400
+ const browser = await cancellation.acquire(() => connectToBrowserWebSocket(options.host, options.port, options.browserWSEndpoint, logger, options.approvalWaitMs), (lateBrowser) => lateBrowser.close());
401
+ try {
402
+ const client = cancellation.client(browser);
403
+ const result = await client.Target.getTargets();
404
+ return (result.targetInfos ?? []).map((target) => ({
405
+ targetId: target.targetId,
406
+ type: target.type,
407
+ url: target.url,
408
+ }));
409
+ }
410
+ finally {
411
+ await browser.close().catch(() => undefined);
412
+ }
413
+ });
403
414
  }
404
415
  finally {
405
- await browser.close().catch(() => undefined);
416
+ cancellation.dispose();
406
417
  }
407
418
  }
408
419
  export async function connectToRemoteChromeTarget(host, port, logger, options) {
@@ -1,3 +1,4 @@
1
+ import { readSubmittedPromptFingerprint, readUserMessageIds } from "./promptFingerprint.js";
1
2
  import { mkdtemp, rm, mkdir } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import os from "node:os";
@@ -708,6 +709,7 @@ async function runBrowserModeInternal(options, cancellation) {
708
709
  let lastTargetId;
709
710
  let lastUrl;
710
711
  let promptSubmitted = false;
712
+ let submittedPromptHash = null;
711
713
  let ownedRecoveryTarget;
712
714
  const targetClaimId = randomUUID();
713
715
  let modelSelectionEvidence;
@@ -728,6 +730,7 @@ async function runBrowserModeInternal(options, cancellation) {
728
730
  tabUrl: lastUrl,
729
731
  conversationId,
730
732
  promptSubmitted,
733
+ submittedPromptHash,
731
734
  ownedRecoveryTarget,
732
735
  userDataDir,
733
736
  controllerPid: process.pid,
@@ -748,10 +751,8 @@ async function runBrowserModeInternal(options, cancellation) {
748
751
  }
749
752
  };
750
753
  const markPromptSubmitted = async () => {
751
- if (promptSubmitted) {
752
- return;
753
- }
754
754
  promptSubmitted = true;
755
+ submittedPromptHash = null;
755
756
  await emitRuntimeHint();
756
757
  void conversationUrlMonitor?.schedule("post-submit", config.timeoutMs ?? 120_000);
757
758
  };
@@ -986,6 +987,7 @@ async function runBrowserModeInternal(options, cancellation) {
986
987
  ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
987
988
  : undefined,
988
989
  promptSubmitted,
990
+ submittedPromptHash,
989
991
  ownedRecoveryTarget,
990
992
  controllerPid: process.pid,
991
993
  researchPlan,
@@ -1332,6 +1334,7 @@ async function runBrowserModeInternal(options, cancellation) {
1332
1334
  const deepResearchTargetBaseline = deepResearch && client
1333
1335
  ? await captureDeepResearchTargetBaseline(client, logger)
1334
1336
  : undefined;
1337
+ const previousUserMessageIds = await readUserMessageIds(Runtime, config.inputTimeoutMs);
1335
1338
  await runProviderSubmissionFlow(chatgptDomProvider, {
1336
1339
  prompt,
1337
1340
  evaluate: async () => undefined,
@@ -1341,6 +1344,11 @@ async function runBrowserModeInternal(options, cancellation) {
1341
1344
  });
1342
1345
  await markPromptSubmitted();
1343
1346
  const providerBaselineTurns = providerState.baselineTurns;
1347
+ const renderedPromptHash = await readSubmittedPromptFingerprint(Runtime, previousUserMessageIds, config.inputTimeoutMs);
1348
+ if (renderedPromptHash) {
1349
+ submittedPromptHash = renderedPromptHash;
1350
+ await emitRuntimeHint();
1351
+ }
1344
1352
  if (typeof providerBaselineTurns === "number" && Number.isFinite(providerBaselineTurns)) {
1345
1353
  baselineTurns = providerBaselineTurns;
1346
1354
  }
@@ -1463,6 +1471,7 @@ async function runBrowserModeInternal(options, cancellation) {
1463
1471
  tabUrl: lastUrl,
1464
1472
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1465
1473
  promptSubmitted,
1474
+ submittedPromptHash,
1466
1475
  ownedRecoveryTarget,
1467
1476
  controllerPid: process.pid,
1468
1477
  researchPlan,
@@ -1555,6 +1564,7 @@ async function runBrowserModeInternal(options, cancellation) {
1555
1564
  tabUrl: lastUrl,
1556
1565
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1557
1566
  promptSubmitted,
1567
+ submittedPromptHash,
1558
1568
  ownedRecoveryTarget,
1559
1569
  controllerPid: process.pid,
1560
1570
  },
@@ -1612,6 +1622,7 @@ async function runBrowserModeInternal(options, cancellation) {
1612
1622
  tabUrl: lastUrl,
1613
1623
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1614
1624
  promptSubmitted,
1625
+ submittedPromptHash,
1615
1626
  ownedRecoveryTarget,
1616
1627
  controllerPid: process.pid,
1617
1628
  };
@@ -1838,6 +1849,7 @@ async function runBrowserModeInternal(options, cancellation) {
1838
1849
  tabUrl: lastUrl,
1839
1850
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1840
1851
  promptSubmitted,
1852
+ submittedPromptHash,
1841
1853
  ownedRecoveryTarget,
1842
1854
  controllerPid: process.pid,
1843
1855
  },
@@ -1906,6 +1918,7 @@ async function runBrowserModeInternal(options, cancellation) {
1906
1918
  tabUrl: lastUrl,
1907
1919
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
1908
1920
  promptSubmitted,
1921
+ submittedPromptHash,
1909
1922
  ownedRecoveryTarget,
1910
1923
  controllerPid: process.pid,
1911
1924
  };
@@ -1933,6 +1946,7 @@ async function runBrowserModeInternal(options, cancellation) {
1933
1946
  chromeTargetId: lastTargetId,
1934
1947
  tabUrl: lastUrl,
1935
1948
  promptSubmitted,
1949
+ submittedPromptHash,
1936
1950
  ownedRecoveryTarget,
1937
1951
  controllerPid: process.pid,
1938
1952
  };
@@ -1997,6 +2011,7 @@ async function runBrowserModeInternal(options, cancellation) {
1997
2011
  ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
1998
2012
  : undefined,
1999
2013
  promptSubmitted,
2014
+ submittedPromptHash,
2000
2015
  ownedRecoveryTarget,
2001
2016
  controllerPid: process.pid,
2002
2017
  researchPlan,
@@ -2397,6 +2412,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2397
2412
  let tabLease = null;
2398
2413
  let lastUrl;
2399
2414
  let promptSubmitted = false;
2415
+ let submittedPromptHash = null;
2400
2416
  let ownedRecoveryTarget;
2401
2417
  const targetClaimId = randomUUID();
2402
2418
  let modelSelectionEvidence;
@@ -2419,6 +2435,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2419
2435
  tabUrl: lastUrl,
2420
2436
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2421
2437
  promptSubmitted,
2438
+ submittedPromptHash,
2422
2439
  ownedRecoveryTarget,
2423
2440
  controllerPid: process.pid,
2424
2441
  researchPlan,
@@ -2436,10 +2453,8 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2436
2453
  }
2437
2454
  };
2438
2455
  const markPromptSubmitted = async () => {
2439
- if (promptSubmitted) {
2440
- return;
2441
- }
2442
2456
  promptSubmitted = true;
2457
+ submittedPromptHash = null;
2443
2458
  await emitRuntimeHint();
2444
2459
  void conversationUrlMonitor?.schedule("post-submit", config.timeoutMs ?? 120_000);
2445
2460
  };
@@ -2685,6 +2700,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2685
2700
  const deepResearchTargetBaseline = deepResearch && client
2686
2701
  ? await captureDeepResearchTargetBaseline(client, logger)
2687
2702
  : undefined;
2703
+ const previousUserMessageIds = await readUserMessageIds(Runtime, config.inputTimeoutMs);
2688
2704
  await runProviderSubmissionFlow(chatgptDomProvider, {
2689
2705
  prompt,
2690
2706
  evaluate: async () => undefined,
@@ -2694,6 +2710,11 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2694
2710
  });
2695
2711
  await markPromptSubmitted();
2696
2712
  const providerBaselineTurns = providerState.baselineTurns;
2713
+ const renderedPromptHash = await readSubmittedPromptFingerprint(Runtime, previousUserMessageIds, config.inputTimeoutMs);
2714
+ if (renderedPromptHash) {
2715
+ submittedPromptHash = renderedPromptHash;
2716
+ await emitRuntimeHint();
2717
+ }
2697
2718
  if (typeof providerBaselineTurns === "number" && Number.isFinite(providerBaselineTurns)) {
2698
2719
  baselineTurns = providerBaselineTurns;
2699
2720
  }
@@ -2790,6 +2811,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2790
2811
  tabUrl: lastUrl,
2791
2812
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2792
2813
  promptSubmitted,
2814
+ submittedPromptHash,
2793
2815
  ownedRecoveryTarget,
2794
2816
  controllerPid: process.pid,
2795
2817
  researchPlan,
@@ -2881,6 +2903,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2881
2903
  tabUrl: lastUrl,
2882
2904
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2883
2905
  promptSubmitted,
2906
+ submittedPromptHash,
2884
2907
  ownedRecoveryTarget,
2885
2908
  controllerPid: process.pid,
2886
2909
  },
@@ -2940,6 +2963,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
2940
2963
  tabUrl: lastUrl,
2941
2964
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
2942
2965
  promptSubmitted,
2966
+ submittedPromptHash,
2943
2967
  ownedRecoveryTarget,
2944
2968
  controllerPid: process.pid,
2945
2969
  };
@@ -3124,6 +3148,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
3124
3148
  tabUrl: lastUrl,
3125
3149
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
3126
3150
  promptSubmitted,
3151
+ submittedPromptHash,
3127
3152
  ownedRecoveryTarget,
3128
3153
  controllerPid: process.pid,
3129
3154
  },
@@ -3187,6 +3212,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
3187
3212
  tabUrl: lastUrl,
3188
3213
  conversationId: lastUrl ? extractConversationIdFromUrl(lastUrl) : undefined,
3189
3214
  promptSubmitted,
3215
+ submittedPromptHash,
3190
3216
  ownedRecoveryTarget,
3191
3217
  artifacts: savedArtifacts,
3192
3218
  generatedImages: imageArtifacts.generatedImages,
@@ -3236,6 +3262,7 @@ async function runRemoteBrowserMode(promptText, attachments, config, logger, opt
3236
3262
  ? extractConversationIdFromUrl(liveness.matchedUrl ?? lastUrl ?? "")
3237
3263
  : undefined,
3238
3264
  promptSubmitted,
3265
+ submittedPromptHash,
3239
3266
  ownedRecoveryTarget,
3240
3267
  controllerPid: process.pid,
3241
3268
  researchPlan,
@@ -162,6 +162,8 @@ function buildTabInspectionExpression() {
162
162
  const assistantCount = new Set(assistantOwners).size;
163
163
  const lastAssistantText = normalize(lastAssistantNode?.textContent);
164
164
  const lastUserText = normalize(lastUserTurn?.textContent);
165
+ const lastUserMessage = lastUserTurn?.matches?.('[data-message-author-role="user"]')
166
+ ? lastUserTurn : lastUserTurn?.querySelector?.('[data-message-author-role="user"]');
165
167
  const authenticated = !loginButtonExists && (promptReady || sendExists || stopExists || assistantCount > 0);
166
168
  return {
167
169
  title: normalize(document.title),
@@ -178,6 +180,8 @@ function buildTabInspectionExpression() {
178
180
  lastAssistantTurnIndex,
179
181
  lastUserTurnIndex,
180
182
  lastUserText,
183
+ lastUserTextRaw: lastUserMessage?.textContent,
184
+ lastUserMessageId: lastUserMessage?.getAttribute?.('data-message-id'),
181
185
  visibilityState: document.visibilityState,
182
186
  focused: Boolean(document.hasFocus?.()),
183
187
  };
@@ -263,6 +267,8 @@ export async function inspectChatGptTab(options) {
263
267
  : undefined,
264
268
  lastAssistantSnippet: trimToSnippet(lastAssistantText),
265
269
  lastUserText,
270
+ lastUserTextRaw: info.lastUserTextRaw,
271
+ lastUserMessageId: info.lastUserMessageId,
266
272
  lastUserSnippet: trimToSnippet(lastUserText),
267
273
  focused: Boolean(info.focused),
268
274
  visibilityState: typeof info.visibilityState === "string" ? info.visibilityState : "",
@@ -461,6 +467,8 @@ export async function harvestChatGptTab(options = {}) {
461
467
  harvested.authenticated = followup.authenticated;
462
468
  harvested.loginButtonExists = followup.loginButtonExists;
463
469
  harvested.lastUserText = followup.lastUserText;
470
+ harvested.lastUserTextRaw = followup.lastUserTextRaw;
471
+ harvested.lastUserMessageId = followup.lastUserMessageId;
464
472
  harvested.lastUserSnippet = followup.lastUserSnippet;
465
473
  harvested.assistantFollowsLatestUser = followup.assistantFollowsLatestUser;
466
474
  harvested.lastAssistantTurnIndex = followup.lastAssistantTurnIndex;
@@ -224,7 +224,12 @@ export async function readProcessStartTimeMs(pid) {
224
224
  if (Math.trunc(pid) === process.pid) {
225
225
  // Use the same OS identity as peer controllers, not wall time minus uptime.
226
226
  // Cache our own PID only: it cannot be reused during this process's lifetime.
227
- return (ownProcessStartTime ??= queryProcessStartTimeMs(process.pid));
227
+ return (ownProcessStartTime ??= queryProcessStartTimeMs(process.pid).then((startedAt) => {
228
+ // A transient probe failure is not a permanent process identity.
229
+ if (startedAt === null)
230
+ ownProcessStartTime = undefined;
231
+ return startedAt;
232
+ }));
228
233
  }
229
234
  return queryProcessStartTimeMs(pid);
230
235
  }
@@ -0,0 +1,54 @@
1
+ import { createHash } from "node:crypto";
2
+ import { buildConversationTurnListExpression } from "./conversationTurns.js";
3
+ export function browserPromptFingerprint(value, messageId) {
4
+ return createHash("sha256")
5
+ .update(JSON.stringify([messageId, String(value ?? "").replace(/\r\n?/g, "\n")]))
6
+ .digest("hex");
7
+ }
8
+ export function readUserMessageIds(runtime, timeoutMs = 0) {
9
+ return readDomUntil(runtime, `Array.from(document.querySelectorAll('[data-message-author-role="user"]'), user => user.getAttribute('data-message-id'))`, timeoutMs, (value) => Array.isArray(value) && value.every((id) => typeof id === "string" && id.trim())
10
+ ? value
11
+ : undefined);
12
+ }
13
+ export async function readSubmittedPromptFingerprint(runtime, previousMessageIds, timeoutMs = 0) {
14
+ if (previousMessageIds === undefined)
15
+ return undefined;
16
+ const previous = new Set(previousMessageIds);
17
+ return readDomUntil(runtime, `(() => {
18
+ const turns = ${buildConversationTurnListExpression()};
19
+ for (let index = turns.length - 1; index >= 0; index--) {
20
+ const turn = turns[index];
21
+ const user = turn.matches('[data-message-author-role="user"]') ? turn : turn.querySelector('[data-message-author-role="user"]');
22
+ if (user) return { text: user.textContent, messageId: user.getAttribute('data-message-id') };
23
+ }
24
+ return null;
25
+ })()`, timeoutMs, (value) => {
26
+ const turn = value;
27
+ if (typeof turn?.text === "string" &&
28
+ turn.text.trim() &&
29
+ typeof turn.messageId === "string" &&
30
+ turn.messageId.trim() &&
31
+ !previous.has(turn.messageId))
32
+ return browserPromptFingerprint(turn.text, turn.messageId);
33
+ return undefined;
34
+ });
35
+ }
36
+ async function readDomUntil(runtime, expression, timeoutMs, select) {
37
+ const deadline = Date.now() + Math.max(0, timeoutMs);
38
+ for (;;) {
39
+ try {
40
+ const result = await runtime.evaluate({ expression, returnByValue: true });
41
+ const selected = select(result.result?.value);
42
+ if (selected !== undefined)
43
+ return selected;
44
+ }
45
+ catch (error) {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ if (!/Cannot find (?:default )?(?:execution )?context|Execution context (?:was destroyed|is not available)/i.test(message))
48
+ return undefined;
49
+ }
50
+ if (Date.now() >= deadline)
51
+ return undefined;
52
+ await new Promise((resolve) => setTimeout(resolve, Math.min(100, deadline - Date.now())));
53
+ }
54
+ }