@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.
@@ -13,9 +13,21 @@ import { readDevToolsActivePortInfo } from "./detect.js";
13
13
  import { pickTarget, extractConversationIdFromUrl, buildConversationUrl, withTimeout, openConversationFromSidebar, openConversationFromSidebarWithRetry, waitForLocationChange, readConversationTurnIndex, buildPromptEchoMatcher, recoverPromptEcho, alignPromptEchoMarkdown, } from "./reattachHelpers.js";
14
14
  import { waitForDeepResearchCompletion } from "./actions/deepResearch.js";
15
15
  import { CHROME_COOKIE_SYNC_WARNING, shouldSyncBrowserCookies } from "./policies.js";
16
+ import { BrowserCancellation, withoutBrowserCancellation } from "./cancellation.js";
17
+ import { BrowserRunCancelledError } from "../oracle/errors.js";
16
18
  export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
17
- const recoverSession = deps.recoverSession ??
18
- (async (runtimeMeta, configMeta) => resumeBrowserSessionViaNewChrome(runtimeMeta, configMeta, logger, deps));
19
+ const cancellation = new BrowserCancellation(deps.signal, logger);
20
+ try {
21
+ return await cancellation.run(() => resumeBrowserSessionInternal(runtime, config, logger, deps, cancellation));
22
+ }
23
+ finally {
24
+ cancellation.dispose();
25
+ }
26
+ }
27
+ async function resumeBrowserSessionInternal(runtime, config, logger, deps, cancellation) {
28
+ const recoverSession = deps.recoverSession
29
+ ? (runtimeMeta, configMeta) => cancellation.call(() => deps.recoverSession(runtimeMeta, configMeta))
30
+ : (runtimeMeta, configMeta) => resumeBrowserSessionViaNewChrome(runtimeMeta, configMeta, logger, deps, cancellation);
19
31
  let closeAttachedConnection = null;
20
32
  const closeAttached = async () => {
21
33
  const close = closeAttachedConnection;
@@ -39,17 +51,18 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
39
51
  browserWSEndpoint,
40
52
  approvalWaitMs,
41
53
  logger,
54
+ signal: deps.signal,
42
55
  })));
43
- const targetList = (await listTargets());
56
+ const targetList = (await cancellation.call(listTargets));
44
57
  const target = pickTarget(targetList, liveRuntime);
45
- const connection = browserWSEndpoint && !deps.connect
46
- ? await connectToRemoteChromeTarget(host, port ?? 9222, logger, {
58
+ const connection = await cancellation.acquire(() => browserWSEndpoint && !deps.connect
59
+ ? connectToRemoteChromeTarget(host, port ?? 9222, logger, {
47
60
  browserWSEndpoint,
48
61
  targetId: target?.targetId ?? target?.id,
49
62
  closeTargetOnDispose: false,
50
63
  approvalWaitMs,
51
64
  })
52
- : await (async () => {
65
+ : (async () => {
53
66
  const client = (await (deps.connect ?? ((options) => CDP(options)))(browserWSEndpoint
54
67
  ? {
55
68
  target: browserWSEndpoint,
@@ -62,9 +75,9 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
62
75
  target: target?.targetId ?? target?.id,
63
76
  }));
64
77
  return { client, close: () => client.close() };
65
- })();
78
+ })(), (lateConnection) => lateConnection.close());
66
79
  closeAttachedConnection = () => connection.close();
67
- const client = connection.client;
80
+ const client = cancellation.client(connection.client);
68
81
  const { Runtime, DOM, Page } = client;
69
82
  const captureIdentity = async () => {
70
83
  const targetId = target?.targetId ?? target?.id;
@@ -118,18 +131,18 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
118
131
  await ensureConversationOpen();
119
132
  const waitForHydration = deps.waitForConversationHydration ?? waitForResumedConversationHydration;
120
133
  const expectedConversationUrl = buildConversationUrl(runtime, resolveBrowserConfig(config ?? {}).url);
121
- await waitForHydration(Runtime, timeoutMs, logger, {
134
+ await cancellation.call(() => waitForHydration(Runtime, timeoutMs, logger, {
122
135
  requirePriorTurns: true,
123
136
  requirePromptReady: false,
124
137
  expectedConversationUrl: expectedConversationUrl ?? undefined,
125
- });
138
+ }));
126
139
  const minTurnIndex = (await readPromptPreviewTurnIndex(Runtime, deps.promptPreview)) ??
127
140
  (deps.promptPreview ? null : await readConversationTurnIndex(Runtime, logger));
128
141
  if (config?.researchMode === "deep") {
129
142
  const waitForDeepResearch = deps.waitForDeepResearchCompletion ?? waitForDeepResearchCompletion;
130
- const researchResult = await withTimeout(waitForDeepResearch(Runtime, logger, timeoutMs, minTurnIndex ?? undefined, Page, client, {
143
+ const researchResult = await withTimeout(cancellation.call(() => waitForDeepResearch(Runtime, logger, timeoutMs, minTurnIndex ?? undefined, Page, client, {
131
144
  requireScopedTargetOwner: true,
132
- }), timeoutMs + 5_000, "Reattach Deep Research response timed out");
145
+ })), timeoutMs + 5_000, "Reattach Deep Research response timed out");
133
146
  const captureTarget = await captureIdentity().catch(() => undefined);
134
147
  await closeAttached();
135
148
  return {
@@ -139,9 +152,9 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
139
152
  };
140
153
  }
141
154
  const promptEcho = buildPromptEchoMatcher(deps.promptPreview);
142
- const answer = await withTimeout(waitForResponse(Runtime, timeoutMs, logger, minTurnIndex ?? undefined), timeoutMs + 5_000, "Reattach response timed out");
143
- const recovered = await recoverPromptEcho(Runtime, answer, promptEcho, logger, minTurnIndex, timeoutMs);
144
- const markdown = (await withTimeout(captureMarkdown(Runtime, recovered.meta, logger), 15_000, "Reattach markdown capture timed out")) ?? recovered.text;
155
+ const answer = await withTimeout(cancellation.call(() => waitForResponse(Runtime, timeoutMs, logger, minTurnIndex ?? undefined)), timeoutMs + 5_000, "Reattach response timed out");
156
+ const recovered = await cancellation.call(() => recoverPromptEcho(Runtime, answer, promptEcho, logger, minTurnIndex, timeoutMs));
157
+ const markdown = (await withTimeout(cancellation.call(() => captureMarkdown(Runtime, recovered.meta, logger)), 15_000, "Reattach markdown capture timed out")) ?? recovered.text;
145
158
  const aligned = alignPromptEchoMarkdown(recovered.text, markdown, promptEcho, logger);
146
159
  const captureTarget = await captureIdentity().catch(() => undefined);
147
160
  await closeAttached();
@@ -153,6 +166,9 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
153
166
  }
154
167
  catch (error) {
155
168
  await closeAttached();
169
+ if (deps.signal?.aborted || error instanceof BrowserRunCancelledError) {
170
+ throw new BrowserRunCancelledError();
171
+ }
156
172
  const message = error instanceof Error ? error.message : String(error);
157
173
  logger(`Existing Chrome reattach failed (${message}); reopening browser to locate the session.`);
158
174
  return recoverSession(runtime, config);
@@ -192,26 +208,64 @@ function inferPortFromBrowserWSEndpoint(browserWSEndpoint) {
192
208
  }
193
209
  return undefined;
194
210
  }
195
- async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
211
+ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps, cancellation) {
196
212
  const resolved = resolveBrowserConfig(config ?? {});
197
213
  const manualLogin = Boolean(resolved.manualLogin);
198
214
  const userDataDir = manualLogin
199
215
  ? (resolved.manualLoginProfileDir ?? path.join(os.homedir(), ".oracle", "browser-profile"))
200
- : await mkdtemp(path.join(os.tmpdir(), "oracle-reattach-"));
216
+ : await (deps.createTemporaryProfile ?? (() => mkdtemp(path.join(os.tmpdir(), "oracle-reattach-"))))();
201
217
  if (manualLogin) {
202
218
  await mkdir(userDataDir, { recursive: true });
203
219
  }
220
+ const cleanupProfile = async () => {
221
+ if (manualLogin) {
222
+ await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "never" }).catch(() => undefined);
223
+ }
224
+ else {
225
+ await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
226
+ }
227
+ };
228
+ if (deps.signal?.aborted) {
229
+ if (!manualLogin)
230
+ await cleanupProfile();
231
+ cancellation.check();
232
+ }
204
233
  const launch = deps.launchChrome ?? launchChrome;
205
234
  const connectToLaunchedChrome = deps.connectToChrome ?? connectToChrome;
206
- const chrome = await launch(resolved, userDataDir, logger);
235
+ const chrome = await cancellation.acquire(() => launch(resolved, userDataDir, logger), async (lateChrome) => {
236
+ if (!resolved.keepBrowser) {
237
+ try {
238
+ await lateChrome.kill();
239
+ }
240
+ catch {
241
+ // best-effort cleanup for a browser that finished launching after cancellation
242
+ }
243
+ await cleanupProfile();
244
+ }
245
+ });
207
246
  const chromeHost = chrome && typeof chrome === "object" && "host" in chrome && typeof chrome.host === "string"
208
247
  ? chrome.host
209
248
  : "127.0.0.1";
210
- const client = await connectToLaunchedChrome(chrome.port, logger, chromeHost);
249
+ let rawClient;
250
+ try {
251
+ rawClient = await cancellation.acquire(() => connectToLaunchedChrome(chrome.port, logger, chromeHost), (lateClient) => lateClient.close());
252
+ }
253
+ catch (error) {
254
+ if (!resolved.keepBrowser) {
255
+ try {
256
+ await withoutBrowserCancellation(() => chrome.kill());
257
+ }
258
+ catch {
259
+ // best-effort cleanup after a failed or cancelled CDP connection
260
+ }
261
+ await cleanupProfile();
262
+ }
263
+ throw error;
264
+ }
211
265
  const cleanup = async () => {
212
- if (client && typeof client.close === "function") {
266
+ if (rawClient && typeof rawClient.close === "function") {
213
267
  try {
214
- await client.close();
268
+ await rawClient.close();
215
269
  }
216
270
  catch {
217
271
  // ignore
@@ -224,34 +278,30 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
224
278
  catch {
225
279
  // ignore
226
280
  }
227
- if (manualLogin) {
228
- await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "never" }).catch(() => undefined);
229
- }
230
- else {
231
- await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
232
- }
281
+ await cleanupProfile();
233
282
  }
234
283
  };
284
+ const client = cancellation.client(rawClient);
235
285
  const { Network, Page, Runtime, DOM, Target } = client;
236
- if (Runtime?.enable) {
237
- await Runtime.enable();
238
- }
239
- if (DOM && typeof DOM.enable === "function") {
240
- await DOM.enable();
241
- }
242
- if (!resolved.headless && resolved.hideWindow) {
243
- await positionChromeWindowOffscreen(client, userDataDir, logger);
244
- }
245
- else if (!resolved.headless) {
246
- await positionChromeWindowOnscreen(client, userDataDir, logger);
247
- }
248
- let appliedCookies = 0;
249
- if (shouldSyncBrowserCookies(resolved, { manualLogin })) {
250
- if (!resolved.inlineCookies) {
251
- logger(CHROME_COOKIE_SYNC_WARNING);
286
+ try {
287
+ if (Runtime?.enable) {
288
+ await Runtime.enable();
289
+ }
290
+ if (DOM && typeof DOM.enable === "function") {
291
+ await DOM.enable();
292
+ }
293
+ if (!resolved.headless && resolved.hideWindow) {
294
+ await positionChromeWindowOffscreen(client, userDataDir, logger);
252
295
  }
253
- const sync = deps.syncCookies ?? syncCookies;
254
- try {
296
+ else if (!resolved.headless) {
297
+ await positionChromeWindowOnscreen(client, userDataDir, logger);
298
+ }
299
+ let appliedCookies = 0;
300
+ if (shouldSyncBrowserCookies(resolved, { manualLogin })) {
301
+ if (!resolved.inlineCookies) {
302
+ logger(CHROME_COOKIE_SYNC_WARNING);
303
+ }
304
+ const sync = deps.syncCookies ?? syncCookies;
255
305
  appliedCookies = await sync(Network, resolved.url, resolved.chromeProfile, logger, {
256
306
  allowErrors: resolved.allowCookieErrors,
257
307
  filterNames: resolved.cookieNames ?? undefined,
@@ -260,74 +310,73 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
260
310
  waitMs: resolved.cookieSyncWaitMs ?? 0,
261
311
  });
262
312
  }
263
- catch (error) {
264
- await cleanup();
265
- throw error;
266
- }
267
- }
268
- await clearStaleChatGptConversationCookies(Network, Target, logger, {
269
- preserveConversationIds: [
270
- runtime.conversationId,
271
- extractConversationIdFromUrl(runtime.tabUrl ?? ""),
272
- extractConversationIdFromUrl(resolved.url),
273
- ],
274
- });
275
- await navigateToChatGPT(Page, Runtime, CHATGPT_URL, logger);
276
- await ensureNotBlocked(Runtime, resolved.headless, logger);
277
- await ensureLoggedIn(Runtime, logger, { appliedCookies });
278
- if (resolved.url !== CHATGPT_URL) {
279
- await navigateToChatGPT(Page, Runtime, resolved.url, logger);
280
- await ensureNotBlocked(Runtime, resolved.headless, logger);
281
- }
282
- await ensurePromptReady(Runtime, resolved.inputTimeoutMs, logger);
283
- const conversationUrl = buildConversationUrl(runtime, resolved.url);
284
- if (conversationUrl) {
285
- logger(`Reopening conversation at ${conversationUrl}`);
286
- await navigateToChatGPT(Page, Runtime, conversationUrl, logger);
313
+ await clearStaleChatGptConversationCookies(Network, Target, logger, {
314
+ preserveConversationIds: [
315
+ runtime.conversationId,
316
+ extractConversationIdFromUrl(runtime.tabUrl ?? ""),
317
+ extractConversationIdFromUrl(resolved.url),
318
+ ],
319
+ });
320
+ await navigateToChatGPT(Page, Runtime, CHATGPT_URL, logger);
287
321
  await ensureNotBlocked(Runtime, resolved.headless, logger);
322
+ await ensureLoggedIn(Runtime, logger, { appliedCookies });
323
+ if (resolved.url !== CHATGPT_URL) {
324
+ await navigateToChatGPT(Page, Runtime, resolved.url, logger);
325
+ await ensureNotBlocked(Runtime, resolved.headless, logger);
326
+ }
288
327
  await ensurePromptReady(Runtime, resolved.inputTimeoutMs, logger);
289
- }
290
- else {
291
- const opened = await openConversationFromSidebarWithRetry(Runtime, {
292
- conversationId: runtime.conversationId ?? extractConversationIdFromUrl(runtime.tabUrl ?? ""),
293
- preferProjects: resolved.url !== CHATGPT_URL ||
294
- Boolean(runtime.tabUrl && (/\/g\//.test(runtime.tabUrl) || runtime.tabUrl.includes("/project"))),
295
- promptPreview: deps.promptPreview,
296
- }, 15_000);
297
- if (!opened) {
298
- throw new Error("Unable to locate prior ChatGPT conversation in sidebar.");
328
+ const conversationUrl = buildConversationUrl(runtime, resolved.url);
329
+ if (conversationUrl) {
330
+ logger(`Reopening conversation at ${conversationUrl}`);
331
+ await navigateToChatGPT(Page, Runtime, conversationUrl, logger);
332
+ await ensureNotBlocked(Runtime, resolved.headless, logger);
333
+ await ensurePromptReady(Runtime, resolved.inputTimeoutMs, logger);
299
334
  }
300
- await waitForLocationChange(Runtime, 15_000);
335
+ else {
336
+ const opened = await openConversationFromSidebarWithRetry(Runtime, {
337
+ conversationId: runtime.conversationId ?? extractConversationIdFromUrl(runtime.tabUrl ?? ""),
338
+ preferProjects: resolved.url !== CHATGPT_URL ||
339
+ Boolean(runtime.tabUrl &&
340
+ (/\/g\//.test(runtime.tabUrl) || runtime.tabUrl.includes("/project"))),
341
+ promptPreview: deps.promptPreview,
342
+ }, 15_000);
343
+ if (!opened) {
344
+ throw new Error("Unable to locate prior ChatGPT conversation in sidebar.");
345
+ }
346
+ await waitForLocationChange(Runtime, 15_000);
347
+ }
348
+ const waitForHydration = deps.waitForConversationHydration ?? waitForResumedConversationHydration;
349
+ await cancellation.call(() => waitForHydration(Runtime, resolved.inputTimeoutMs, logger, {
350
+ requirePriorTurns: true,
351
+ requirePromptReady: false,
352
+ expectedConversationUrl: conversationUrl ?? undefined,
353
+ }));
354
+ const waitForResponse = deps.waitForAssistantResponse ?? waitForAssistantResponse;
355
+ const captureMarkdown = deps.captureAssistantMarkdown ?? captureAssistantMarkdown;
356
+ const timeoutMs = resolved.timeoutMs ?? 120_000;
357
+ const minTurnIndex = (await readPromptPreviewTurnIndex(Runtime, deps.promptPreview)) ??
358
+ (deps.promptPreview ? null : await readConversationTurnIndex(Runtime, logger));
359
+ if (resolved.researchMode === "deep") {
360
+ const waitForDeepResearch = deps.waitForDeepResearchCompletion ?? waitForDeepResearchCompletion;
361
+ const researchResult = await cancellation.call(() => waitForDeepResearch(Runtime, logger, timeoutMs, minTurnIndex ?? undefined, Page, client, {
362
+ requireScopedTargetOwner: true,
363
+ }));
364
+ return {
365
+ answerText: researchResult.text,
366
+ answerMarkdown: researchResult.text,
367
+ };
368
+ }
369
+ const promptEcho = buildPromptEchoMatcher(deps.promptPreview);
370
+ const answer = await cancellation.call(() => waitForResponse(Runtime, timeoutMs, logger, minTurnIndex ?? undefined));
371
+ const recovered = await cancellation.call(() => recoverPromptEcho(Runtime, answer, promptEcho, logger, minTurnIndex, timeoutMs));
372
+ const markdown = (await cancellation.call(() => captureMarkdown(Runtime, recovered.meta, logger))) ??
373
+ recovered.text;
374
+ const aligned = alignPromptEchoMarkdown(recovered.text, markdown, promptEcho, logger);
375
+ return { answerText: aligned.answerText, answerMarkdown: aligned.answerMarkdown };
301
376
  }
302
- const waitForHydration = deps.waitForConversationHydration ?? waitForResumedConversationHydration;
303
- await waitForHydration(Runtime, resolved.inputTimeoutMs, logger, {
304
- requirePriorTurns: true,
305
- requirePromptReady: false,
306
- expectedConversationUrl: conversationUrl ?? undefined,
307
- });
308
- const waitForResponse = deps.waitForAssistantResponse ?? waitForAssistantResponse;
309
- const captureMarkdown = deps.captureAssistantMarkdown ?? captureAssistantMarkdown;
310
- const timeoutMs = resolved.timeoutMs ?? 120_000;
311
- const minTurnIndex = (await readPromptPreviewTurnIndex(Runtime, deps.promptPreview)) ??
312
- (deps.promptPreview ? null : await readConversationTurnIndex(Runtime, logger));
313
- if (resolved.researchMode === "deep") {
314
- const waitForDeepResearch = deps.waitForDeepResearchCompletion ?? waitForDeepResearchCompletion;
315
- const researchResult = await waitForDeepResearch(Runtime, logger, timeoutMs, minTurnIndex ?? undefined, Page, client, {
316
- requireScopedTargetOwner: true,
317
- });
318
- await cleanup();
319
- return {
320
- answerText: researchResult.text,
321
- answerMarkdown: researchResult.text,
322
- };
377
+ finally {
378
+ await withoutBrowserCancellation(cleanup);
323
379
  }
324
- const promptEcho = buildPromptEchoMatcher(deps.promptPreview);
325
- const answer = await waitForResponse(Runtime, timeoutMs, logger, minTurnIndex ?? undefined);
326
- const recovered = await recoverPromptEcho(Runtime, answer, promptEcho, logger, minTurnIndex, timeoutMs);
327
- const markdown = (await captureMarkdown(Runtime, recovered.meta, logger)) ?? recovered.text;
328
- const aligned = alignPromptEchoMarkdown(recovered.text, markdown, promptEcho, logger);
329
- await cleanup();
330
- return { answerText: aligned.answerText, answerMarkdown: aligned.answerMarkdown };
331
380
  }
332
381
  async function readPromptPreviewTurnIndex(Runtime, promptPreview) {
333
382
  const preview = promptPreview?.trim();
@@ -61,8 +61,28 @@ export function matchesOwnedRecoveryTarget(metadata, capture) {
61
61
  owned.port === capture.port &&
62
62
  owned.browserWSEndpoint === capture.browserWSEndpoint);
63
63
  }
64
+ export function recoveryCaptureFromRuntime(runtime) {
65
+ const owned = runtime?.ownedRecoveryTarget;
66
+ const conversationId = runtime?.conversationId ?? extractConversationIdFromUrl(runtime?.tabUrl ?? "");
67
+ return owned && conversationId ? { ...owned, conversationId } : undefined;
68
+ }
64
69
  /** Call only after the full recovered answer and completed session have been saved. */
65
70
  export async function retireRecoveredBrowserTarget(sessionId, capture, logger, deps = {}) {
71
+ await retireOwnedBrowserTarget(sessionId, capture, logger, {
72
+ ...deps,
73
+ terminalStatus: "completed",
74
+ requireIdle: true,
75
+ });
76
+ }
77
+ /** Call only after cancellation has been persisted for the owning session. */
78
+ export async function retireCancelledBrowserTarget(sessionId, capture, logger, deps = {}) {
79
+ await retireOwnedBrowserTarget(sessionId, capture, logger, {
80
+ ...deps,
81
+ terminalStatus: "cancelled",
82
+ requireIdle: false,
83
+ });
84
+ }
85
+ async function retireOwnedBrowserTarget(sessionId, capture, logger, options) {
66
86
  if (!capture)
67
87
  return;
68
88
  let connection;
@@ -72,14 +92,15 @@ export async function retireRecoveredBrowserTarget(sessionId, capture, logger, d
72
92
  try {
73
93
  const metadata = await sessionStore.readSession(sessionId);
74
94
  if (!metadata ||
75
- metadata.status !== "completed" ||
95
+ metadata.status !== options.terminalStatus ||
96
+ metadata.browser?.config?.keepBrowser === true ||
76
97
  hasOtherLiveBrowserController(metadata) ||
77
98
  !matchesOwnedRecoveryTarget(metadata, capture))
78
99
  return;
79
100
  const claimId = metadata.browser?.runtime?.ownedRecoveryTarget?.claimId;
80
101
  if (!claimId)
81
102
  return;
82
- const connect = deps.connect ?? connectToRemoteChromeTarget;
103
+ const connect = options.connect ?? connectToRemoteChromeTarget;
83
104
  connection = await connect(capture.host, capture.port, logger, {
84
105
  targetId: capture.targetId,
85
106
  browserWSEndpoint: capture.browserWSEndpoint,
@@ -93,7 +114,7 @@ export async function retireRecoveredBrowserTarget(sessionId, capture, logger, d
93
114
  });
94
115
  const value = state.result?.value;
95
116
  if (!value ||
96
- value.generating !== false ||
117
+ (options.requireIdle && value.generating !== false) ||
97
118
  extractConversationIdFromUrl(value.url ?? "") !== capture.conversationId)
98
119
  return;
99
120
  const { targetInfos } = await Target.getTargets();
@@ -106,7 +127,9 @@ export async function retireRecoveredBrowserTarget(sessionId, capture, logger, d
106
127
  return;
107
128
  lockedClaim = claimId;
108
129
  const locked = await Runtime.evaluate({
109
- expression: buildTargetRetirementExpression(claimId, capture.conversationId, reservationId),
130
+ expression: buildTargetRetirementExpression(claimId, capture.conversationId, reservationId, {
131
+ allowGenerating: !options.requireIdle,
132
+ }),
110
133
  returnByValue: true,
111
134
  });
112
135
  if (locked.exceptionDetails || locked.result?.value !== true)
@@ -115,10 +138,12 @@ export async function retireRecoveredBrowserTarget(sessionId, capture, logger, d
115
138
  if (!result.success)
116
139
  throw new Error("Chrome refused target retirement");
117
140
  retired = true;
118
- logger("Retired Oracle-owned browser tab after saving the recovered answer.");
141
+ logger(options.terminalStatus === "completed"
142
+ ? "Retired Oracle-owned browser tab after saving the recovered answer."
143
+ : "Retired Oracle-owned browser tab after cancellation.");
119
144
  }
120
145
  catch (error) {
121
- logger(`Recovered answer is saved; browser tab cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
146
+ logger(`${options.terminalStatus === "completed" ? "Recovered answer is saved" : "Cancellation is saved"}; browser tab cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
122
147
  }
123
148
  finally {
124
149
  if (connection && lockedClaim && !retired) {
@@ -4,7 +4,7 @@ import { formatFinishLine } from "../oracle/finishLine.js";
4
4
  import { runBrowserMode } from "../browserMode.js";
5
5
  import { DEFAULT_BROWSER_CONFIG } from "./config.js";
6
6
  import { assembleBrowserPrompt, cleanupGeneratedBrowserBundles, materializeBrowserFallback, } from "./prompt.js";
7
- import { BrowserAutomationError } from "../oracle/errors.js";
7
+ import { BrowserAutomationError, BrowserRunCancelledError } from "../oracle/errors.js";
8
8
  import { appendArtifacts, saveBrowserTranscriptArtifact, saveDeepResearchReportArtifact, } from "./artifacts.js";
9
9
  import { formatBrowserModelSelectionEvidence, formatBrowserThinkingSelectionEvidence, formatBrowserModelTarget, resolveBrowserModelDisplayName, } from "./modelDisplay.js";
10
10
  const LARGE_PRO_FAST_INPUT_TOKEN_THRESHOLD = 25_000;
@@ -55,24 +55,28 @@ function buildBrowserRunWarnings(args) {
55
55
  },
56
56
  ];
57
57
  }
58
- export async function runBrowserSessionExecution({ runOptions, browserConfig, cwd, log }, deps = {}) {
58
+ export async function runBrowserSessionExecution({ runOptions, browserConfig, cwd, log, signal }, deps = {}) {
59
59
  const assemblePrompt = deps.assemblePrompt ?? assembleBrowserPrompt;
60
60
  const executeBrowser = deps.executeBrowser ?? runBrowserMode;
61
61
  const persistRuntimeHint = deps.persistRuntimeHint ?? (() => { });
62
62
  const inputTimeoutMs = browserConfig.inputTimeoutMs ?? DEFAULT_BROWSER_CONFIG.inputTimeoutMs;
63
- let preparationTimedOut = false;
63
+ let preparationAbandoned = false;
64
64
  let preparationTimeout;
65
+ let removePreparationAbortListener;
65
66
  let promptArtifacts;
67
+ if (signal?.aborted) {
68
+ throw new BrowserRunCancelledError();
69
+ }
66
70
  try {
67
71
  promptArtifacts = await Promise.race([
68
72
  assemblePrompt(runOptions, { cwd }).then(async (artifacts) => {
69
- if (preparationTimedOut)
73
+ if (preparationAbandoned)
70
74
  await cleanupGeneratedBrowserBundles(artifacts);
71
75
  return artifacts;
72
76
  }),
73
77
  new Promise((_, reject) => {
74
78
  preparationTimeout = setTimeout(() => {
75
- preparationTimedOut = true;
79
+ preparationAbandoned = true;
76
80
  reject(new BrowserAutomationError(`Browser prompt preparation timed out after ${inputTimeoutMs}ms; increase --browser-input-timeout if local files need more time.`, {
77
81
  stage: "prepare-prompt",
78
82
  code: "prompt-preparation-timeout",
@@ -80,12 +84,27 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
80
84
  }));
81
85
  }, inputTimeoutMs);
82
86
  }),
87
+ new Promise((_, reject) => {
88
+ if (!signal)
89
+ return;
90
+ const abort = () => {
91
+ preparationAbandoned = true;
92
+ reject(new BrowserRunCancelledError());
93
+ };
94
+ if (signal.aborted) {
95
+ abort();
96
+ return;
97
+ }
98
+ signal.addEventListener("abort", abort, { once: true });
99
+ removePreparationAbortListener = () => signal.removeEventListener("abort", abort);
100
+ }),
83
101
  ]);
84
102
  }
85
103
  finally {
86
104
  if (preparationTimeout) {
87
105
  clearTimeout(preparationTimeout);
88
106
  }
107
+ removePreparationAbortListener?.();
89
108
  }
90
109
  try {
91
110
  return await executeAssembledBrowserSession({
@@ -95,13 +114,14 @@ export async function runBrowserSessionExecution({ runOptions, browserConfig, cw
95
114
  promptArtifacts,
96
115
  executeBrowser,
97
116
  persistRuntimeHint,
117
+ signal,
98
118
  });
99
119
  }
100
120
  finally {
101
121
  await cleanupGeneratedBrowserBundles(promptArtifacts);
102
122
  }
103
123
  }
104
- async function executeAssembledBrowserSession({ runOptions, browserConfig, log, promptArtifacts, executeBrowser, persistRuntimeHint, }) {
124
+ async function executeAssembledBrowserSession({ runOptions, browserConfig, log, promptArtifacts, executeBrowser, persistRuntimeHint, signal, }) {
105
125
  if (runOptions.verbose) {
106
126
  log(chalk.dim(`[verbose] Browser config: ${JSON.stringify({
107
127
  ...browserConfig,
@@ -180,6 +200,8 @@ async function executeAssembledBrowserSession({ runOptions, browserConfig, log,
180
200
  generateImagePath: runOptions.generateImage,
181
201
  outputPath: runOptions.outputPath,
182
202
  followUpPrompts: runOptions.browserFollowUps,
203
+ signal,
204
+ closeOwnedTabOnCancel: !executionBrowserConfig.keepBrowser,
183
205
  runtimeHintCb: async (runtime, modelSelection) => {
184
206
  const runtimeWithController = {
185
207
  ...runtime,
@@ -195,7 +217,7 @@ async function executeAssembledBrowserSession({ runOptions, browserConfig, log,
195
217
  });
196
218
  }
197
219
  catch (error) {
198
- if (error instanceof BrowserAutomationError) {
220
+ if (error instanceof BrowserAutomationError || error instanceof BrowserRunCancelledError) {
199
221
  throw error;
200
222
  }
201
223
  const message = error instanceof Error ? error.message : "Browser automation failed.";
@@ -282,6 +304,7 @@ async function executeAssembledBrowserSession({ runOptions, browserConfig, log,
282
304
  tabUrl: browserResult.tabUrl,
283
305
  conversationId: browserResult.conversationId,
284
306
  promptSubmitted: browserResult.promptSubmitted,
307
+ submittedPromptHash: browserResult.submittedPromptHash,
285
308
  researchPlan: browserResult.researchPlan,
286
309
  controllerPid: browserResult.controllerPid ?? process.pid,
287
310
  },
@@ -20,7 +20,7 @@ export function buildTargetClaimExpression(claimId) {
20
20
  } catch { return false; }
21
21
  })()`;
22
22
  }
23
- export function buildTargetRetirementExpression(claimId, conversationId, reservationId) {
23
+ export function buildTargetRetirementExpression(claimId, conversationId, reservationId, options = {}) {
24
24
  return `(() => {
25
25
  let claim;
26
26
  try { claim = JSON.parse(sessionStorage.getItem('oracle:target-claim') || 'null'); } catch { return false; }
@@ -28,7 +28,7 @@ export function buildTargetRetirementExpression(claimId, conversationId, reserva
28
28
  const id = location.pathname.match(/\\/c\\/([^/]+)/)?.[1];
29
29
  if (id !== ${JSON.stringify(conversationId)}) return false;
30
30
  const generating = Array.from(document.querySelectorAll(${JSON.stringify(STOP_BUTTON_SELECTORS.join(","))})).some(node => node.getBoundingClientRect().width > 0 && node.getBoundingClientRect().height > 0);
31
- if (generating) return false;
31
+ if (generating && !${JSON.stringify(options.allowGenerating === true)}) return false;
32
32
  claim.retiring = true;
33
33
  claim.retirementId = ${JSON.stringify(reservationId)};
34
34
  try { sessionStorage.setItem('oracle:target-claim', JSON.stringify(claim)); } catch { return false; }
@@ -1,6 +1,7 @@
1
1
  import { CHATGPT_URL } from "../browser/constants.js";
2
2
  import { normalizeChatgptUrl } from "../browser/utils.js";
3
3
  import { normalizeThinkingTimeLevel } from "../oracle/thinkingTime.js";
4
+ import { isGpt6ProAlias } from "./browserConfig.js";
4
5
  export function applyBrowserDefaultsFromConfig(options, config, getSource) {
5
6
  const browser = config.browser;
6
7
  if (!browser)
@@ -12,6 +13,7 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
12
13
  const attachRunningRequested = options.browserAttachRunning === true ||
13
14
  (isUnset("browserAttachRunning") && browser.attachRunning === true);
14
15
  const currentModelRequestedByCli = options.browserModelStrategy === "current" && getSource("browserModelStrategy") === "cli";
16
+ const gpt6ProRequestedByCli = getSource("model") === "cli" && isGpt6ProAlias(options.model);
15
17
  if (!options.copyProfile &&
16
18
  isUnset("remoteChrome") &&
17
19
  options.remoteChrome === undefined &&
@@ -104,6 +106,7 @@ export function applyBrowserDefaultsFromConfig(options, config, getSource) {
104
106
  options.browserModelStrategy = browser.modelStrategy;
105
107
  }
106
108
  if (!currentModelRequestedByCli &&
109
+ !gpt6ProRequestedByCli &&
107
110
  isUnset("browserThinkingTime") &&
108
111
  browser.thinkingTime !== undefined) {
109
112
  options.browserThinkingTime = normalizeThinkingTimeLevel(browser.thinkingTime) ?? undefined;