@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.
@@ -2,6 +2,8 @@ import fs from "node:fs/promises";
2
2
  import { createHash } from "node:crypto";
3
3
  import chalk from "chalk";
4
4
  import { sessionStore } from "../sessionStore.js";
5
+ import { resolveBrowserConfig } from "../browser/config.js";
6
+ import { browserPromptFingerprint } from "../browser/promptFingerprint.js";
5
7
  import { collectChatGptTabs, DEFAULT_REMOTE_CHROME_HOST, DEFAULT_REMOTE_CHROME_PORT, formatBrowserTabState, harvestChatGptTab, sessionMatchesTab, } from "../browser/liveTabs.js";
6
8
  import { isRecoveredConversationHarvestReady, recoverConversationTab, } from "../browser/recoverConversation.js";
7
9
  import { resolveOutputPath } from "./writeOutputPath.js";
@@ -9,6 +11,7 @@ import { persistBrowserHarvest } from "./harvestIntegrity.js";
9
11
  import { completeOwnedBrowserHarvest } from "./recoveredBrowserHarvest.js";
10
12
  const LIVE_POLL_MS = 2000;
11
13
  const DEFAULT_STALL_THRESHOLD_MS = 60_000;
14
+ const HARVEST_FRESHNESS_POLL_MS = 250;
12
15
  function isRecoverableMissingTabError(message) {
13
16
  return (message.includes("No ChatGPT tab matched") ||
14
17
  message.includes("No live ChatGPT tabs found") ||
@@ -31,6 +34,35 @@ function finishRecoveredChrome(recoveredChrome, closeAfterRecover) {
31
34
  // best-effort cleanup
32
35
  }
33
36
  }
37
+ function harvestMatchesSessionPrompt(harvested, fingerprint) {
38
+ const answer = harvested.lastAssistantMarkdown ?? harvested.lastAssistantText;
39
+ if (harvested.assistantFollowsLatestUser !== true || !answer?.trim())
40
+ return false;
41
+ return (fingerprint === undefined ||
42
+ (typeof harvested.lastUserMessageId === "string" &&
43
+ harvested.lastUserMessageId.trim().length > 0 &&
44
+ browserPromptFingerprint(harvested.lastUserTextRaw ?? harvested.lastUserText, harvested.lastUserMessageId) === fingerprint));
45
+ }
46
+ async function harvestSessionPrompt(meta, options, requireSessionPrompt = true) {
47
+ const fingerprint = requireSessionPrompt ? meta.browser?.runtime?.submittedPromptHash : undefined;
48
+ if (fingerprint === null) {
49
+ throw new Error("This browser session has no confirmed submitted user turn; retry after submission or use --browser-tab to inspect a specific tab.");
50
+ }
51
+ if (requireSessionPrompt && fingerprint === undefined) {
52
+ console.warn("Legacy browser session: submitted-turn identity is unavailable; verifying only latest user/assistant pairing.");
53
+ }
54
+ const freshnessTimeoutMs = resolveBrowserConfig(meta.browser?.config).inputTimeoutMs;
55
+ const deadline = Date.now() + freshnessTimeoutMs;
56
+ let harvested = await harvestChatGptTab(options);
57
+ while (!harvestMatchesSessionPrompt(harvested, fingerprint) && Date.now() < deadline) {
58
+ await new Promise((resolve) => setTimeout(resolve, HARVEST_FRESHNESS_POLL_MS));
59
+ harvested = await harvestChatGptTab(options);
60
+ }
61
+ if (!harvestMatchesSessionPrompt(harvested, fingerprint)) {
62
+ throw new Error(`Latest ChatGPT turn did not contain an assistant answer paired with this session prompt after ${Math.ceil(freshnessTimeoutMs / 1000)}s; refusing to harvest stale output.`);
63
+ }
64
+ return harvested;
65
+ }
34
66
  function sessionBrowserEndpoint(meta) {
35
67
  const runtime = meta?.browser?.runtime ?? {};
36
68
  const remote = meta?.browser?.config?.remoteChrome ?? {};
@@ -167,12 +199,12 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
167
199
  try {
168
200
  let harvested;
169
201
  try {
170
- harvested = await harvestChatGptTab({
202
+ harvested = await harvestSessionPrompt(meta, {
171
203
  host: initialEndpoint.host,
172
204
  port: initialEndpoint.port,
173
205
  ref,
174
206
  stallWindowMs: options.stallWindowMs,
175
- });
207
+ }, !options.browserTabRef);
176
208
  }
177
209
  catch (error) {
178
210
  const message = error instanceof Error ? error.message : String(error);
@@ -184,7 +216,7 @@ export async function harvestSessionBrowserOutput(sessionId, options = {}) {
184
216
  existingEndpoint: recordedEndpoint ?? undefined,
185
217
  });
186
218
  recoveredChrome = recovered.chrome;
187
- harvested = await harvestChatGptTab({
219
+ harvested = await harvestSessionPrompt(meta, {
188
220
  host: recovered.host,
189
221
  port: recovered.port,
190
222
  ref: recovered.ref,
@@ -1,4 +1,5 @@
1
1
  import { isProModel } from "../oracle/modelResolver.js";
2
+ import { isGpt6ProAlias } from "./browserConfig.js";
2
3
  export function shouldDetachSession({
3
4
  // Params kept for policy tweaks.
4
5
  engine, model, reasoningMode, waitPreference, disableDetachEnv, }) {
@@ -7,7 +8,7 @@ engine, model, reasoningMode, waitPreference, disableDetachEnv, }) {
7
8
  // Keep long local browser Pro work in a separate process even while the CLI
8
9
  // stays attached to its session log. If the foreground stream is interrupted,
9
10
  // the worker can still finish the browser run and persist the answer.
10
- if (engine === "browser" && isProModel(model))
11
+ if (engine === "browser" && (isProModel(model) || isGpt6ProAlias(model)))
11
12
  return true;
12
13
  // For API runs, explicit --wait keeps execution in the foreground.
13
14
  if (waitPreference)
@@ -17,6 +18,14 @@ engine, model, reasoningMode, waitPreference, disableDetachEnv, }) {
17
18
  return true;
18
19
  return false;
19
20
  }
21
+ export function shouldExitAfterTopLevelSigint(remainingListenerCount) {
22
+ return remainingListenerCount === 0;
23
+ }
24
+ export function detachedCancellationExitCode(cancelled, finalStatus, currentExitCode) {
25
+ if (!cancelled)
26
+ return currentExitCode;
27
+ return finalStatus === "completed" || finalStatus === "partial" ? 0 : 130;
28
+ }
20
29
  export function stopDetachedWorker(workerPid, kill = process.kill) {
21
30
  try {
22
31
  kill(workerPid, "SIGTERM");
@@ -1,5 +1,41 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { access, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { setTimeout as delay } from "node:timers/promises";
2
5
  import { fileURLToPath } from "node:url";
6
+ const CANCELLATION_MARKER = ".cancel-requested";
7
+ export function detachedSessionCancellationPath(sessionDir, workerPid) {
8
+ return path.join(sessionDir, `${CANCELLATION_MARKER}-${workerPid}`);
9
+ }
10
+ export async function requestDetachedSessionCancellation(markerPath) {
11
+ await writeFile(markerPath, "cancel\n", "utf8");
12
+ }
13
+ export async function clearDetachedSessionCancellation(markerPath) {
14
+ await rm(markerPath, { force: true });
15
+ }
16
+ export async function waitForDetachedSessionCancellation({ markerPath, signal, pollIntervalMs = 100, }) {
17
+ while (!signal.aborted) {
18
+ try {
19
+ await access(markerPath);
20
+ return true;
21
+ }
22
+ catch (error) {
23
+ if (!(error instanceof Error) || error.code !== "ENOENT") {
24
+ throw error;
25
+ }
26
+ }
27
+ try {
28
+ await delay(pollIntervalMs, undefined, { signal });
29
+ }
30
+ catch (error) {
31
+ if (signal.aborted && error instanceof Error && error.name === "AbortError") {
32
+ return false;
33
+ }
34
+ throw error;
35
+ }
36
+ }
37
+ return false;
38
+ }
3
39
  export function resolveOracleCliEntrypoint(moduleUrl = import.meta.url) {
4
40
  const extension = fileURLToPath(moduleUrl).endsWith(".ts") ? "ts" : "js";
5
41
  return fileURLToPath(new URL(`../../bin/oracle-cli.${extension}`, moduleUrl));
@@ -359,7 +359,10 @@ export async function attachSession(sessionId, options) {
359
359
  console.log(dim(`User error: ${userErrorSummary}`));
360
360
  }
361
361
  }
362
- const shouldTrimIntro = initialStatus === "completed" || initialStatus === "partial" || initialStatus === "error";
362
+ const shouldTrimIntro = initialStatus === "completed" ||
363
+ initialStatus === "partial" ||
364
+ initialStatus === "error" ||
365
+ initialStatus === "cancelled";
363
366
  if (options?.renderPrompt !== false) {
364
367
  const prompt = await readStoredPrompt(sessionId);
365
368
  if (prompt) {
@@ -485,7 +488,10 @@ export async function attachSession(sessionId, options) {
485
488
  if (!latest) {
486
489
  break;
487
490
  }
488
- if (latest.status === "completed" || latest.status === "partial" || latest.status === "error") {
491
+ if (latest.status === "completed" ||
492
+ latest.status === "partial" ||
493
+ latest.status === "error" ||
494
+ latest.status === "cancelled") {
489
495
  await printNew();
490
496
  flushRemainder();
491
497
  if (!options?.suppressMetadata) {
@@ -516,7 +522,9 @@ export async function attachSession(sessionId, options) {
516
522
  if (!settled) {
517
523
  break;
518
524
  }
519
- if (settled.status === "completed" || settled.status === "partial") {
525
+ if (settled.status === "completed" ||
526
+ settled.status === "partial" ||
527
+ settled.status === "cancelled") {
520
528
  continue;
521
529
  }
522
530
  await printNew();
@@ -9,7 +9,6 @@ import { formatResponseMetadata, formatTransportMetadata } from "./sessionDispla
9
9
  import { markErrorLogged } from "./errorUtils.js";
10
10
  import { sendSessionNotification, deriveNotificationSettingsFromMetadata, } from "./notifier.js";
11
11
  import { sessionStore } from "../sessionStore.js";
12
- import { wait } from "../sessionManager.js";
13
12
  import { runMultiModelApiSession } from "../oracle/multiModelRunner.js";
14
13
  import { MODEL_CONFIGS, DEFAULT_SYSTEM_PROMPT } from "../oracle/config.js";
15
14
  import { isKnownModel } from "../oracle/modelResolver.js";
@@ -22,31 +21,71 @@ import { sanitizeOscProgress } from "./oscUtils.js";
22
21
  import { readFiles } from "../oracle/files.js";
23
22
  import { cwd as getCwd } from "node:process";
24
23
  import { resumeBrowserSession } from "../browser/reattach.js";
25
- import { retireRecoveredBrowserTarget } from "../browser/recoveryTarget.js";
24
+ import { recoveryCaptureFromRuntime, retireCancelledBrowserTarget, retireRecoveredBrowserTarget, } from "../browser/recoveryTarget.js";
26
25
  import { hasRecoverableChatGptConversation } from "../browser/reattachability.js";
27
- import { estimateTokenCount } from "../browser/utils.js";
26
+ import { delay, estimateTokenCount } from "../browser/utils.js";
28
27
  import { computeFileSha256, sanitizeArtifactFilename } from "../browser/artifacts.js";
29
28
  import { formatElapsed } from "../oracle/format.js";
30
29
  import { formatBrowserReattachGuidance } from "./reattachGuidance.js";
30
+ import { BrowserRunCancelledError } from "../oracle/errors.js";
31
31
  const isTty = process.stdout.isTTY;
32
32
  const dim = (text) => (isTty ? kleur.dim(text) : text);
33
- export async function performSessionRun({ sessionMeta, runOptions, mode, browserConfig, cwd, log, write, version, notifications, browserDeps, muteStdout = false, }) {
33
+ export async function performSessionRun({ sessionMeta, runOptions, mode, browserConfig, cwd, log, write, version, notifications, browserDeps, muteStdout = false, signal, }) {
34
34
  const writeInline = (chunk) => {
35
35
  // Keep session logs intact while still echoing inline output to the user.
36
36
  write(chunk);
37
37
  return muteStdout ? true : process.stdout.write(chunk);
38
38
  };
39
39
  let currentBrowser = browserConfig
40
- ? { config: browserConfig }
40
+ ? {
41
+ config: browserConfig,
42
+ ...(mode === "browser" && sessionMeta.browser?.runtime?.submittedPromptHash !== undefined
43
+ ? { runtime: { submittedPromptHash: null } }
44
+ : {}),
45
+ }
41
46
  : sessionMeta.browser;
42
47
  await sessionStore.updateSession(sessionMeta.id, {
43
48
  status: "running",
44
49
  startedAt: new Date().toISOString(),
45
50
  mode,
46
- ...(browserConfig ? { browser: { config: browserConfig } } : {}),
51
+ ...(browserConfig ? { browser: currentBrowser } : {}),
47
52
  });
48
53
  const notificationSettings = notifications ?? deriveNotificationSettingsFromMetadata(sessionMeta, process.env);
49
54
  const modelForStatus = runOptions.model ?? sessionMeta.model;
55
+ const checkBrowserCancellation = () => {
56
+ if (mode === "browser" && signal?.aborted)
57
+ throw new BrowserRunCancelledError();
58
+ };
59
+ const finalizeBrowserCancellation = async (cancellationError) => {
60
+ const completedAt = new Date().toISOString();
61
+ log("Browser run cancelled.");
62
+ if (modelForStatus) {
63
+ await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
64
+ status: "cancelled",
65
+ completedAt,
66
+ response: { status: "cancelled" },
67
+ error: undefined,
68
+ transport: undefined,
69
+ });
70
+ }
71
+ await sessionStore.updateSession(sessionMeta.id, {
72
+ status: "cancelled",
73
+ completedAt,
74
+ errorMessage: undefined,
75
+ mode,
76
+ browser: browserConfig
77
+ ? {
78
+ ...currentBrowser,
79
+ config: browserConfig,
80
+ }
81
+ : undefined,
82
+ response: { status: "cancelled" },
83
+ error: undefined,
84
+ transport: undefined,
85
+ });
86
+ await retireCancelledBrowserTarget(sessionMeta.id, recoveryCaptureFromRuntime(currentBrowser?.runtime), log);
87
+ throw cancellationError;
88
+ };
50
89
  try {
51
90
  if (mode === "browser") {
52
91
  if (!browserConfig) {
@@ -80,13 +119,17 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
80
119
  browserConfig,
81
120
  cwd,
82
121
  log,
122
+ signal,
83
123
  }, runnerDeps);
124
+ checkBrowserCancellation();
84
125
  const writtenOutputPath = await writeAssistantOutput(runOptions.writeOutputPath, result.answerText ?? "", log);
126
+ checkBrowserCancellation();
85
127
  const outputArtifacts = await copyBrowserOutputArtifacts({
86
128
  outputPath: writtenOutputPath,
87
129
  savedFiles: runOptions.writeArtifacts ? result.savedFiles : undefined,
88
130
  log,
89
131
  });
132
+ checkBrowserCancellation();
90
133
  const browserWarnings = [...(result.warnings ?? []), ...outputArtifacts.warnings];
91
134
  await sendSessionNotification({
92
135
  sessionId: sessionMeta.id,
@@ -96,13 +139,16 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
96
139
  usage: result.usage,
97
140
  characters: result.answerText?.length,
98
141
  }, notificationSettings, log, result.answerText?.slice(0, 140));
142
+ checkBrowserCancellation();
99
143
  if (modelForStatus) {
100
144
  await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
101
145
  status: "completed",
102
146
  completedAt: new Date().toISOString(),
103
147
  usage: result.usage,
104
148
  });
149
+ checkBrowserCancellation();
105
150
  }
151
+ checkBrowserCancellation();
106
152
  await sessionStore.updateSession(sessionMeta.id, {
107
153
  status: "completed",
108
154
  completedAt: new Date().toISOString(),
@@ -401,6 +447,9 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
401
447
  });
402
448
  }
403
449
  catch (error) {
450
+ if (error instanceof BrowserRunCancelledError) {
451
+ return await finalizeBrowserCancellation(error);
452
+ }
404
453
  const message = formatError(error);
405
454
  log(`ERROR: ${message}`);
406
455
  markErrorLogged(error);
@@ -426,6 +475,11 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
426
475
  const runtime = userError.details
427
476
  ?.runtime;
428
477
  const recoverableRuntime = runtime ?? currentBrowser?.runtime;
478
+ currentBrowser = {
479
+ ...currentBrowser,
480
+ config: browserConfig,
481
+ runtime: recoverableRuntime,
482
+ };
429
483
  if (!hasRecoverableChatGptConversation(recoverableRuntime) &&
430
484
  recoverableRuntime?.promptSubmitted !== true) {
431
485
  log(dim("Chrome disconnected before a ChatGPT conversation was created; marking session error."));
@@ -495,22 +549,32 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
495
549
  const connectionLostIntervalMs = configuredIntervalMs > 0
496
550
  ? configuredIntervalMs
497
551
  : Math.max(1_000, Math.min(browserConfig?.timeoutMs ?? 30_000, 30_000));
498
- const success = await autoReattachUntilComplete({
499
- sessionMeta,
500
- runtime: recoverableRuntime ?? undefined,
501
- browserConfig: {
502
- ...browserConfig,
503
- autoReattachIntervalMs: connectionLostIntervalMs,
504
- autoReattachDelayMs: browserConfig?.autoReattachDelayMs ?? 0,
505
- autoReattachTimeoutMs: browserConfig?.autoReattachTimeoutMs ?? browserConfig?.timeoutMs ?? 120_000,
506
- },
507
- browserMetadata: currentBrowser,
508
- runOptions,
509
- modelForStatus,
510
- notificationSettings,
511
- log,
512
- maxAttempts: configuredIntervalMs > 0 ? undefined : 1,
513
- });
552
+ let success;
553
+ try {
554
+ success = await autoReattachUntilComplete({
555
+ sessionMeta,
556
+ runtime: recoverableRuntime ?? undefined,
557
+ browserConfig: {
558
+ ...browserConfig,
559
+ autoReattachIntervalMs: connectionLostIntervalMs,
560
+ autoReattachDelayMs: browserConfig?.autoReattachDelayMs ?? 0,
561
+ autoReattachTimeoutMs: browserConfig?.autoReattachTimeoutMs ?? browserConfig?.timeoutMs ?? 120_000,
562
+ },
563
+ browserMetadata: currentBrowser,
564
+ runOptions,
565
+ modelForStatus,
566
+ notificationSettings,
567
+ log,
568
+ maxAttempts: configuredIntervalMs > 0 ? undefined : 1,
569
+ signal,
570
+ });
571
+ }
572
+ catch (recoveryError) {
573
+ if (signal?.aborted || recoveryError instanceof BrowserRunCancelledError) {
574
+ return await finalizeBrowserCancellation(new BrowserRunCancelledError());
575
+ }
576
+ throw recoveryError;
577
+ }
514
578
  if (success) {
515
579
  return;
516
580
  }
@@ -531,6 +595,11 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
531
595
  };
532
596
  const autoReattachIntervalMs = browserConfig?.autoReattachIntervalMs ?? 0;
533
597
  const autoRuntime = runtime ?? currentBrowser?.runtime;
598
+ currentBrowser = {
599
+ ...currentBrowser,
600
+ config: browserConfig,
601
+ runtime: autoRuntime,
602
+ };
534
603
  const willAutoReattach = autoReattachIntervalMs > 0 && Boolean(autoRuntime);
535
604
  if (willAutoReattach) {
536
605
  if (modelForStatus) {
@@ -554,16 +623,26 @@ export async function performSessionRun({ sessionMeta, runOptions, mode, browser
554
623
  response: timeoutResponse,
555
624
  error: timeoutError,
556
625
  });
557
- const success = await autoReattachUntilComplete({
558
- sessionMeta,
559
- runtime: autoRuntime,
560
- browserConfig,
561
- browserMetadata: currentBrowser,
562
- runOptions,
563
- modelForStatus,
564
- notificationSettings,
565
- log,
566
- });
626
+ let success;
627
+ try {
628
+ success = await autoReattachUntilComplete({
629
+ sessionMeta,
630
+ runtime: autoRuntime,
631
+ browserConfig,
632
+ browserMetadata: currentBrowser,
633
+ runOptions,
634
+ modelForStatus,
635
+ notificationSettings,
636
+ log,
637
+ signal,
638
+ });
639
+ }
640
+ catch (recoveryError) {
641
+ if (signal?.aborted || recoveryError instanceof BrowserRunCancelledError) {
642
+ return await finalizeBrowserCancellation(new BrowserRunCancelledError());
643
+ }
644
+ throw recoveryError;
645
+ }
567
646
  if (success) {
568
647
  return;
569
648
  }
@@ -976,7 +1055,7 @@ async function writeAssistantOutput(targetPath, content, log) {
976
1055
  log(dim(`write-output failed (${reason}); session completed anyway.`));
977
1056
  }
978
1057
  }
979
- async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig, browserMetadata, runOptions, modelForStatus, notificationSettings, log, maxAttempts, }) {
1058
+ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig, browserMetadata, runOptions, modelForStatus, notificationSettings, log, maxAttempts, signal, }) {
980
1059
  if (!runtime || !browserConfig) {
981
1060
  log(dim("Auto-reattach disabled: missing runtime or browser config."));
982
1061
  return false;
@@ -991,12 +1070,16 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
991
1070
  120_000;
992
1071
  const maxTotalMs = 2 * 60 * 60 * 1000; // 2h hard cap; avoid infinite polling by default.
993
1072
  const maxDeadline = Date.now() + maxTotalMs;
1073
+ const checkCancellation = () => {
1074
+ if (signal?.aborted)
1075
+ throw new BrowserRunCancelledError();
1076
+ };
994
1077
  const attemptLimit = typeof maxAttempts === "number" && maxAttempts > 0
995
1078
  ? Math.floor(maxAttempts)
996
1079
  : Number.POSITIVE_INFINITY;
997
1080
  if (delayMs > 0) {
998
1081
  log(dim(`Auto-reattach starting in ${formatElapsed(delayMs)}...`));
999
- await wait(delayMs);
1082
+ await delay(delayMs, signal);
1000
1083
  }
1001
1084
  if (Number.isFinite(attemptLimit)) {
1002
1085
  log(dim(`Auto-reattach will try up to ${attemptLimit} attempt(s).`));
@@ -1012,6 +1095,7 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1012
1095
  logger.verbose = true;
1013
1096
  let attempt = 0;
1014
1097
  for (;;) {
1098
+ checkCancellation();
1015
1099
  const remainingBudgetMs = maxDeadline - Date.now();
1016
1100
  if (remainingBudgetMs <= 0) {
1017
1101
  log(dim(`Auto-reattach stopped after ${formatElapsed(maxTotalMs)} without capturing an answer.`));
@@ -1025,9 +1109,11 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1025
1109
  ...browserConfig,
1026
1110
  timeoutMs,
1027
1111
  };
1028
- const result = await resumeBrowserSession(runtime, reattachConfig, logger, {
1112
+ const result = await awaitBrowserCancellation(resumeBrowserSession(runtime, reattachConfig, logger, {
1029
1113
  promptPreview: sessionMeta.promptPreview,
1030
- });
1114
+ signal,
1115
+ }), signal);
1116
+ checkCancellation();
1031
1117
  captureSucceeded = true;
1032
1118
  const answerText = result.answerMarkdown || result.answerText || "";
1033
1119
  const outputTokens = estimateTokenCount(answerText);
@@ -1040,8 +1126,11 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1040
1126
  existingArtifacts: sessionMeta.artifacts,
1041
1127
  logger,
1042
1128
  });
1129
+ checkCancellation();
1043
1130
  const paths = await sessionStore.getPaths(sessionMeta.id);
1131
+ checkCancellation();
1044
1132
  await fs.appendFile(paths.log, `[auto-reattach] captured assistant response on attempt ${attempt}\nAnswer:\n${answerText}\n`, "utf8");
1133
+ checkCancellation();
1045
1134
  if (modelForStatus) {
1046
1135
  await sessionStore.updateModelRun(sessionMeta.id, modelForStatus, {
1047
1136
  status: "completed",
@@ -1053,8 +1142,10 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1053
1142
  totalTokens: outputTokens,
1054
1143
  },
1055
1144
  });
1145
+ checkCancellation();
1056
1146
  }
1057
1147
  await writeAssistantOutput(runOptions.writeOutputPath, answerText, log);
1148
+ checkCancellation();
1058
1149
  await sendSessionNotification({
1059
1150
  sessionId: sessionMeta.id,
1060
1151
  sessionName: sessionMeta.options?.slug ?? sessionMeta.id,
@@ -1066,6 +1157,7 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1066
1157
  },
1067
1158
  characters: answerText.length,
1068
1159
  }, notificationSettings, log, answerText.slice(0, 140));
1160
+ checkCancellation();
1069
1161
  await sessionStore.updateSession(sessionMeta.id, {
1070
1162
  status: "completed",
1071
1163
  completedAt: new Date().toISOString(),
@@ -1091,6 +1183,9 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1091
1183
  return true;
1092
1184
  }
1093
1185
  catch (error) {
1186
+ if (signal?.aborted || error instanceof BrowserRunCancelledError) {
1187
+ throw new BrowserRunCancelledError();
1188
+ }
1094
1189
  if (captureSucceeded) {
1095
1190
  const message = formatError(error);
1096
1191
  if (modelForStatus) {
@@ -1128,7 +1223,25 @@ async function autoReattachUntilComplete({ sessionMeta, runtime, browserConfig,
1128
1223
  log(dim(`Auto-reattach stopped after ${formatElapsed(maxTotalMs)} without capturing an answer.`));
1129
1224
  return false;
1130
1225
  }
1131
- await wait(Math.min(intervalMs, remainingAfterAttemptMs));
1226
+ await delay(Math.min(intervalMs, remainingAfterAttemptMs), signal);
1227
+ }
1228
+ }
1229
+ async function awaitBrowserCancellation(task, signal) {
1230
+ if (!signal)
1231
+ return task;
1232
+ if (signal.aborted)
1233
+ throw new BrowserRunCancelledError();
1234
+ let abort;
1235
+ const cancelled = new Promise((_, reject) => {
1236
+ abort = () => reject(new BrowserRunCancelledError());
1237
+ signal.addEventListener("abort", abort, { once: true });
1238
+ });
1239
+ try {
1240
+ return await Promise.race([task, cancelled]);
1241
+ }
1242
+ finally {
1243
+ if (abort)
1244
+ signal.removeEventListener("abort", abort);
1132
1245
  }
1133
1246
  }
1134
1247
  export function deriveModelOutputPath(basePath, model) {
@@ -819,6 +819,7 @@ function sanitizeResult(result, warnings = []) {
819
819
  tabUrl: result.tabUrl,
820
820
  conversationId: result.conversationId,
821
821
  promptSubmitted: result.promptSubmitted,
822
+ submittedPromptHash: result.submittedPromptHash,
822
823
  warnings: warnings.length > 0 ? warnings : undefined,
823
824
  chromePid: undefined,
824
825
  chromePort: undefined,
@@ -304,7 +304,14 @@ export async function initializeSession(options, cwd, notifications, baseSlugOve
304
304
  })),
305
305
  cwd,
306
306
  mode,
307
- browser: browserConfig ? { config: browserConfig } : undefined,
307
+ browser: mode === "browser"
308
+ ? {
309
+ ...(browserConfig ? { config: browserConfig } : {}),
310
+ runtime: { submittedPromptHash: null },
311
+ }
312
+ : browserConfig
313
+ ? { config: browserConfig }
314
+ : undefined,
308
315
  notifications,
309
316
  options: {
310
317
  prompt: options.prompt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@steipete/oracle",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "CLI wrapper around OpenAI Responses API with GPT-5.6 Sol, GPT-5.6, GPT-5.5 Pro, GPT-5.5, GPT-5.4, GPT-5.2, GPT-5.1, and GPT-5.1 Codex high reasoning modes.",
5
5
  "keywords": [],
6
6
  "homepage": "https://askoracle.sh",
@@ -70,17 +70,17 @@
70
70
  "dotenv": "^17.4.2",
71
71
  "fast-glob": "^3.3.3",
72
72
  "gpt-tokenizer": "^4.0.0",
73
- "inquirer": "14.2.1",
73
+ "inquirer": "14.2.2",
74
74
  "json5": "^2.2.3",
75
75
  "kleur": "^4.1.5",
76
76
  "markdansi": "0.3.3",
77
- "openai": "^7.10.0",
77
+ "openai": "^7.13.0",
78
78
  "osc-progress": "^0.3.3",
79
79
  "qs": "^6.16.0",
80
80
  "shiki": "^4.4.3",
81
81
  "toasted-notifier": "^10.1.0",
82
- "tokentally": "^0.1.5",
83
- "zod": "^4.5.4"
82
+ "tokentally": "^0.1.6",
83
+ "zod": "^4.6.0"
84
84
  },
85
85
  "devDependencies": {
86
86
  "@anthropic-ai/tokenizer": "^0.0.4",
@@ -88,13 +88,13 @@
88
88
  "@modelcontextprotocol/sdk": "^1.30.0",
89
89
  "@types/chrome-remote-interface": "^0.34.0",
90
90
  "@types/inquirer": "^9.0.10",
91
- "@types/node": "^26.4.1",
91
+ "@types/node": "^26.5.1",
92
92
  "@vitest/coverage-v8": "5.0.0",
93
- "devtools-protocol": "0.0.1692173",
93
+ "devtools-protocol": "0.0.1694333",
94
94
  "es-toolkit": "^1.52.0",
95
95
  "esbuild": "^0.28.2",
96
- "oxfmt": "0.66.0",
97
- "oxlint": "^1.81.0",
96
+ "oxfmt": "0.67.0",
97
+ "oxlint": "^1.82.0",
98
98
  "puppeteer-core": "^25.10.0",
99
99
  "tsx": "^4.23.13",
100
100
  "typescript": "^7.0.2",