@steipete/oracle 0.19.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/bin/oracle-cli.js +72 -29
  2. package/dist/src/browser/actions/deepResearch.js +168 -44
  3. package/dist/src/browser/actions/modelSelection.js +78 -1
  4. package/dist/src/browser/actions/promptComposer.js +3 -0
  5. package/dist/src/browser/actions/thinkingTime.js +49 -6
  6. package/dist/src/browser/actions/webSearch.js +96 -0
  7. package/dist/src/browser/chromeLifecycle.js +28 -16
  8. package/dist/src/browser/config.js +1 -1
  9. package/dist/src/browser/index.js +144 -49
  10. package/dist/src/browser/liveTabs.js +11 -4
  11. package/dist/src/browser/profileState.js +6 -1
  12. package/dist/src/browser/promptFingerprint.js +54 -0
  13. package/dist/src/browser/providers/chatgptDomProvider.js +1 -0
  14. package/dist/src/browser/reattach.js +178 -109
  15. package/dist/src/browser/recoveryTarget.js +156 -0
  16. package/dist/src/browser/sessionRunner.js +33 -8
  17. package/dist/src/browser/tabLeaseRegistry.js +20 -0
  18. package/dist/src/browser/targetClaim.js +54 -0
  19. package/dist/src/cli/browserConfig.js +33 -3
  20. package/dist/src/cli/browserDefaults.js +3 -0
  21. package/dist/src/cli/browserTabs.js +38 -3
  22. package/dist/src/cli/detach.js +10 -1
  23. package/dist/src/cli/detachedSession.js +36 -0
  24. package/dist/src/cli/options.js +15 -0
  25. package/dist/src/cli/recoveredBrowserHarvest.js +50 -0
  26. package/dist/src/cli/runOptions.js +19 -4
  27. package/dist/src/cli/sessionDisplay.js +15 -8
  28. package/dist/src/cli/sessionRunner.js +153 -41
  29. package/dist/src/mcp/tools/consult.js +2 -2
  30. package/dist/src/mcp/types.js +1 -1
  31. package/dist/src/oracle/config.js +14 -0
  32. package/dist/src/oracle/geminiModels.js +1 -0
  33. package/dist/src/oracle/run.js +27 -4
  34. package/dist/src/remote/client.js +3 -0
  35. package/dist/src/remote/server.js +2 -0
  36. package/dist/src/sessionManager.js +8 -1
  37. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  38. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  39. package/package.json +10 -10
  40. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  41. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -13,9 +13,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,10 +75,23 @@ 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;
82
+ const captureIdentity = async () => {
83
+ const targetId = target?.targetId ?? target?.id;
84
+ if (!targetId || !port)
85
+ return undefined;
86
+ const { result } = await withTimeout(Runtime.evaluate({ expression: "location.href", returnByValue: true }), 2_000, "Recovery target identity unavailable");
87
+ return {
88
+ host,
89
+ port,
90
+ targetId,
91
+ browserWSEndpoint,
92
+ conversationId: extractConversationIdFromUrl(typeof result?.value === "string" ? result.value : ""),
93
+ };
94
+ };
69
95
  if (Runtime?.enable) {
70
96
  await Runtime.enable();
71
97
  }
@@ -105,34 +131,44 @@ export async function resumeBrowserSession(runtime, config, logger, deps = {}) {
105
131
  await ensureConversationOpen();
106
132
  const waitForHydration = deps.waitForConversationHydration ?? waitForResumedConversationHydration;
107
133
  const expectedConversationUrl = buildConversationUrl(runtime, resolveBrowserConfig(config ?? {}).url);
108
- await waitForHydration(Runtime, timeoutMs, logger, {
134
+ await cancellation.call(() => waitForHydration(Runtime, timeoutMs, logger, {
109
135
  requirePriorTurns: true,
110
136
  requirePromptReady: false,
111
137
  expectedConversationUrl: expectedConversationUrl ?? undefined,
112
- });
138
+ }));
113
139
  const minTurnIndex = (await readPromptPreviewTurnIndex(Runtime, deps.promptPreview)) ??
114
140
  (deps.promptPreview ? null : await readConversationTurnIndex(Runtime, logger));
115
141
  if (config?.researchMode === "deep") {
116
142
  const waitForDeepResearch = deps.waitForDeepResearchCompletion ?? waitForDeepResearchCompletion;
117
- 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, {
118
144
  requireScopedTargetOwner: true,
119
- }), timeoutMs + 5_000, "Reattach Deep Research response timed out");
145
+ })), timeoutMs + 5_000, "Reattach Deep Research response timed out");
146
+ const captureTarget = await captureIdentity().catch(() => undefined);
120
147
  await closeAttached();
121
148
  return {
122
149
  answerText: researchResult.text,
123
150
  answerMarkdown: researchResult.text,
151
+ captureTarget,
124
152
  };
125
153
  }
126
154
  const promptEcho = buildPromptEchoMatcher(deps.promptPreview);
127
- const answer = await withTimeout(waitForResponse(Runtime, timeoutMs, logger, minTurnIndex ?? undefined), timeoutMs + 5_000, "Reattach response timed out");
128
- const recovered = await recoverPromptEcho(Runtime, answer, promptEcho, logger, minTurnIndex, timeoutMs);
129
- 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;
130
158
  const aligned = alignPromptEchoMarkdown(recovered.text, markdown, promptEcho, logger);
159
+ const captureTarget = await captureIdentity().catch(() => undefined);
131
160
  await closeAttached();
132
- return { answerText: aligned.answerText, answerMarkdown: aligned.answerMarkdown };
161
+ return {
162
+ answerText: aligned.answerText,
163
+ answerMarkdown: aligned.answerMarkdown,
164
+ captureTarget,
165
+ };
133
166
  }
134
167
  catch (error) {
135
168
  await closeAttached();
169
+ if (deps.signal?.aborted || error instanceof BrowserRunCancelledError) {
170
+ throw new BrowserRunCancelledError();
171
+ }
136
172
  const message = error instanceof Error ? error.message : String(error);
137
173
  logger(`Existing Chrome reattach failed (${message}); reopening browser to locate the session.`);
138
174
  return recoverSession(runtime, config);
@@ -172,26 +208,64 @@ function inferPortFromBrowserWSEndpoint(browserWSEndpoint) {
172
208
  }
173
209
  return undefined;
174
210
  }
175
- async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
211
+ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps, cancellation) {
176
212
  const resolved = resolveBrowserConfig(config ?? {});
177
213
  const manualLogin = Boolean(resolved.manualLogin);
178
214
  const userDataDir = manualLogin
179
215
  ? (resolved.manualLoginProfileDir ?? path.join(os.homedir(), ".oracle", "browser-profile"))
180
- : await mkdtemp(path.join(os.tmpdir(), "oracle-reattach-"));
216
+ : await (deps.createTemporaryProfile ?? (() => mkdtemp(path.join(os.tmpdir(), "oracle-reattach-"))))();
181
217
  if (manualLogin) {
182
218
  await mkdir(userDataDir, { recursive: true });
183
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
+ }
184
233
  const launch = deps.launchChrome ?? launchChrome;
185
234
  const connectToLaunchedChrome = deps.connectToChrome ?? connectToChrome;
186
- 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
+ });
187
246
  const chromeHost = chrome && typeof chrome === "object" && "host" in chrome && typeof chrome.host === "string"
188
247
  ? chrome.host
189
248
  : "127.0.0.1";
190
- 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
+ }
191
265
  const cleanup = async () => {
192
- if (client && typeof client.close === "function") {
266
+ if (rawClient && typeof rawClient.close === "function") {
193
267
  try {
194
- await client.close();
268
+ await rawClient.close();
195
269
  }
196
270
  catch {
197
271
  // ignore
@@ -204,34 +278,30 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
204
278
  catch {
205
279
  // ignore
206
280
  }
207
- if (manualLogin) {
208
- await cleanupStaleProfileState(userDataDir, logger, { lockRemovalMode: "never" }).catch(() => undefined);
209
- }
210
- else {
211
- await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
212
- }
281
+ await cleanupProfile();
213
282
  }
214
283
  };
284
+ const client = cancellation.client(rawClient);
215
285
  const { Network, Page, Runtime, DOM, Target } = client;
216
- if (Runtime?.enable) {
217
- await Runtime.enable();
218
- }
219
- if (DOM && typeof DOM.enable === "function") {
220
- await DOM.enable();
221
- }
222
- if (!resolved.headless && resolved.hideWindow) {
223
- await positionChromeWindowOffscreen(client, userDataDir, logger);
224
- }
225
- else if (!resolved.headless) {
226
- await positionChromeWindowOnscreen(client, userDataDir, logger);
227
- }
228
- let appliedCookies = 0;
229
- if (shouldSyncBrowserCookies(resolved, { manualLogin })) {
230
- if (!resolved.inlineCookies) {
231
- logger(CHROME_COOKIE_SYNC_WARNING);
286
+ try {
287
+ if (Runtime?.enable) {
288
+ await Runtime.enable();
232
289
  }
233
- const sync = deps.syncCookies ?? syncCookies;
234
- try {
290
+ if (DOM && typeof DOM.enable === "function") {
291
+ await DOM.enable();
292
+ }
293
+ if (!resolved.headless && resolved.hideWindow) {
294
+ await positionChromeWindowOffscreen(client, userDataDir, logger);
295
+ }
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;
235
305
  appliedCookies = await sync(Network, resolved.url, resolved.chromeProfile, logger, {
236
306
  allowErrors: resolved.allowCookieErrors,
237
307
  filterNames: resolved.cookieNames ?? undefined,
@@ -240,74 +310,73 @@ async function resumeBrowserSessionViaNewChrome(runtime, config, logger, deps) {
240
310
  waitMs: resolved.cookieSyncWaitMs ?? 0,
241
311
  });
242
312
  }
243
- catch (error) {
244
- await cleanup();
245
- throw error;
246
- }
247
- }
248
- await clearStaleChatGptConversationCookies(Network, Target, logger, {
249
- preserveConversationIds: [
250
- runtime.conversationId,
251
- extractConversationIdFromUrl(runtime.tabUrl ?? ""),
252
- extractConversationIdFromUrl(resolved.url),
253
- ],
254
- });
255
- await navigateToChatGPT(Page, Runtime, CHATGPT_URL, logger);
256
- await ensureNotBlocked(Runtime, resolved.headless, logger);
257
- await ensureLoggedIn(Runtime, logger, { appliedCookies });
258
- if (resolved.url !== CHATGPT_URL) {
259
- await navigateToChatGPT(Page, Runtime, resolved.url, logger);
260
- await ensureNotBlocked(Runtime, resolved.headless, logger);
261
- }
262
- await ensurePromptReady(Runtime, resolved.inputTimeoutMs, logger);
263
- const conversationUrl = buildConversationUrl(runtime, resolved.url);
264
- if (conversationUrl) {
265
- logger(`Reopening conversation at ${conversationUrl}`);
266
- 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);
267
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
+ }
268
327
  await ensurePromptReady(Runtime, resolved.inputTimeoutMs, logger);
269
- }
270
- else {
271
- const opened = await openConversationFromSidebarWithRetry(Runtime, {
272
- conversationId: runtime.conversationId ?? extractConversationIdFromUrl(runtime.tabUrl ?? ""),
273
- preferProjects: resolved.url !== CHATGPT_URL ||
274
- Boolean(runtime.tabUrl && (/\/g\//.test(runtime.tabUrl) || runtime.tabUrl.includes("/project"))),
275
- promptPreview: deps.promptPreview,
276
- }, 15_000);
277
- if (!opened) {
278
- 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);
334
+ }
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
+ };
279
368
  }
280
- await waitForLocationChange(Runtime, 15_000);
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 };
281
376
  }
282
- const waitForHydration = deps.waitForConversationHydration ?? waitForResumedConversationHydration;
283
- await waitForHydration(Runtime, resolved.inputTimeoutMs, logger, {
284
- requirePriorTurns: true,
285
- requirePromptReady: false,
286
- expectedConversationUrl: conversationUrl ?? undefined,
287
- });
288
- const waitForResponse = deps.waitForAssistantResponse ?? waitForAssistantResponse;
289
- const captureMarkdown = deps.captureAssistantMarkdown ?? captureAssistantMarkdown;
290
- const timeoutMs = resolved.timeoutMs ?? 120_000;
291
- const minTurnIndex = (await readPromptPreviewTurnIndex(Runtime, deps.promptPreview)) ??
292
- (deps.promptPreview ? null : await readConversationTurnIndex(Runtime, logger));
293
- if (resolved.researchMode === "deep") {
294
- const waitForDeepResearch = deps.waitForDeepResearchCompletion ?? waitForDeepResearchCompletion;
295
- const researchResult = await waitForDeepResearch(Runtime, logger, timeoutMs, minTurnIndex ?? undefined, Page, client, {
296
- requireScopedTargetOwner: true,
297
- });
298
- await cleanup();
299
- return {
300
- answerText: researchResult.text,
301
- answerMarkdown: researchResult.text,
302
- };
377
+ finally {
378
+ await withoutBrowserCancellation(cleanup);
303
379
  }
304
- const promptEcho = buildPromptEchoMatcher(deps.promptPreview);
305
- const answer = await waitForResponse(Runtime, timeoutMs, logger, minTurnIndex ?? undefined);
306
- const recovered = await recoverPromptEcho(Runtime, answer, promptEcho, logger, minTurnIndex, timeoutMs);
307
- const markdown = (await captureMarkdown(Runtime, recovered.meta, logger)) ?? recovered.text;
308
- const aligned = alignPromptEchoMarkdown(recovered.text, markdown, promptEcho, logger);
309
- await cleanup();
310
- return { answerText: aligned.answerText, answerMarkdown: aligned.answerMarkdown };
311
380
  }
312
381
  async function readPromptPreviewTurnIndex(Runtime, promptPreview) {
313
382
  const preview = promptPreview?.trim();
@@ -0,0 +1,156 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { sessionStore } from "../sessionStore.js";
3
+ import { connectToRemoteChromeTarget } from "./chromeLifecycle.js";
4
+ import { resolveBrowserApprovalWait } from "./config.js";
5
+ import { extractConversationIdFromUrl } from "./reattachHelpers.js";
6
+ import { STOP_BUTTON_SELECTORS } from "./constants.js";
7
+ import { hasActiveBrowserTargetLease } from "./tabLeaseRegistry.js";
8
+ import { buildTargetRetirementExpression, buildTargetRetirementRollbackExpression, normalizeChromeHost, } from "./targetClaim.js";
9
+ function processMayBeAlive(pid) {
10
+ try {
11
+ process.kill(pid, 0);
12
+ return true;
13
+ }
14
+ catch (error) {
15
+ return error.code !== "ESRCH";
16
+ }
17
+ }
18
+ export function hasOtherLiveBrowserController(metadata) {
19
+ for (const pid of [metadata.browser?.runtime?.controllerPid, metadata.lifecycle?.workerPid]) {
20
+ if (!pid || pid === process.pid)
21
+ continue;
22
+ if (processMayBeAlive(pid))
23
+ return true;
24
+ }
25
+ return false;
26
+ }
27
+ async function targetHasActiveController(metadata, capture) {
28
+ for (const other of await sessionStore.listSessions()) {
29
+ if (other.id === metadata.id)
30
+ continue;
31
+ const runtime = other.browser?.runtime;
32
+ if (runtime?.chromeTargetId !== capture.targetId ||
33
+ runtime.chromePort !== capture.port ||
34
+ normalizeChromeHost(runtime.chromeHost ?? "127.0.0.1") !== normalizeChromeHost(capture.host))
35
+ continue;
36
+ if ([runtime.controllerPid, other.lifecycle?.workerPid].some((pid) => pid && processMayBeAlive(pid)))
37
+ return true;
38
+ }
39
+ const runtime = metadata.browser?.runtime;
40
+ const profileDirs = new Set([
41
+ runtime?.userDataDir,
42
+ runtime?.chromeProfileRoot,
43
+ metadata.browser?.config?.manualLoginProfileDir,
44
+ ].filter((dir) => Boolean(dir)));
45
+ for (const dir of profileDirs) {
46
+ if (await hasActiveBrowserTargetLease(dir, capture))
47
+ return true;
48
+ }
49
+ return false;
50
+ }
51
+ export function matchesOwnedRecoveryTarget(metadata, capture) {
52
+ const runtime = metadata.browser?.runtime;
53
+ const owned = runtime?.ownedRecoveryTarget;
54
+ const conversationId = runtime?.conversationId ?? extractConversationIdFromUrl(runtime?.tabUrl ?? "");
55
+ return Boolean(owned &&
56
+ capture &&
57
+ conversationId &&
58
+ capture.conversationId === conversationId &&
59
+ owned.targetId === capture.targetId &&
60
+ normalizeChromeHost(owned.host) === normalizeChromeHost(capture.host) &&
61
+ owned.port === capture.port &&
62
+ owned.browserWSEndpoint === capture.browserWSEndpoint);
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
+ }
69
+ /** Call only after the full recovered answer and completed session have been saved. */
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) {
86
+ if (!capture)
87
+ return;
88
+ let connection;
89
+ let lockedClaim;
90
+ const reservationId = randomUUID();
91
+ let retired = false;
92
+ try {
93
+ const metadata = await sessionStore.readSession(sessionId);
94
+ if (!metadata ||
95
+ metadata.status !== options.terminalStatus ||
96
+ metadata.browser?.config?.keepBrowser === true ||
97
+ hasOtherLiveBrowserController(metadata) ||
98
+ !matchesOwnedRecoveryTarget(metadata, capture))
99
+ return;
100
+ const claimId = metadata.browser?.runtime?.ownedRecoveryTarget?.claimId;
101
+ if (!claimId)
102
+ return;
103
+ const connect = options.connect ?? connectToRemoteChromeTarget;
104
+ connection = await connect(capture.host, capture.port, logger, {
105
+ targetId: capture.targetId,
106
+ browserWSEndpoint: capture.browserWSEndpoint,
107
+ closeTargetOnDispose: false,
108
+ approvalWaitMs: resolveBrowserApprovalWait(metadata.browser?.config?.approvalWaitMs),
109
+ });
110
+ const { Runtime, Target } = connection.client;
111
+ const state = await Runtime.evaluate({
112
+ expression: `(() => ({ url: location.href, generating: Array.from(document.querySelectorAll(${JSON.stringify(STOP_BUTTON_SELECTORS.join(","))})).some(node => node.getBoundingClientRect().width > 0 && node.getBoundingClientRect().height > 0) }))()`,
113
+ returnByValue: true,
114
+ });
115
+ const value = state.result?.value;
116
+ if (!value ||
117
+ (options.requireIdle && value.generating !== false) ||
118
+ extractConversationIdFromUrl(value.url ?? "") !== capture.conversationId)
119
+ return;
120
+ const { targetInfos } = await Target.getTargets();
121
+ if (!targetInfos.some((target) => target.type === "page" && target.targetId !== capture.targetId)) {
122
+ const replacement = await Target.createTarget({ url: "about:blank" });
123
+ if (!replacement.targetId)
124
+ return;
125
+ }
126
+ if (await targetHasActiveController(metadata, capture))
127
+ return;
128
+ lockedClaim = claimId;
129
+ const locked = await Runtime.evaluate({
130
+ expression: buildTargetRetirementExpression(claimId, capture.conversationId, reservationId, {
131
+ allowGenerating: !options.requireIdle,
132
+ }),
133
+ returnByValue: true,
134
+ });
135
+ if (locked.exceptionDetails || locked.result?.value !== true)
136
+ return;
137
+ const result = await Target.closeTarget({ targetId: capture.targetId });
138
+ if (!result.success)
139
+ throw new Error("Chrome refused target retirement");
140
+ retired = true;
141
+ logger(options.terminalStatus === "completed"
142
+ ? "Retired Oracle-owned browser tab after saving the recovered answer."
143
+ : "Retired Oracle-owned browser tab after cancellation.");
144
+ }
145
+ catch (error) {
146
+ logger(`${options.terminalStatus === "completed" ? "Recovered answer is saved" : "Cancellation is saved"}; browser tab cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
147
+ }
148
+ finally {
149
+ if (connection && lockedClaim && !retired) {
150
+ await connection.client.Runtime.evaluate({
151
+ expression: buildTargetRetirementRollbackExpression(lockedClaim, reservationId),
152
+ }).catch(() => undefined);
153
+ }
154
+ await connection?.close().catch(() => undefined);
155
+ }
156
+ }
@@ -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,
@@ -135,7 +155,7 @@ async function executeAssembledBrowserSession({ runOptions, browserConfig, log,
135
155
  if (typeof message !== "string")
136
156
  return;
137
157
  const shouldAlwaysPrint = message.startsWith("[browser] ") &&
138
- /archive|fallback|follow-up|retry|thinking|waiting for chatgpt|remote debugging approval|browser slot|browser control|browser guidance|model selection|model picker/i.test(message);
158
+ /archive|fallback|follow-up|retry|thinking|research|waiting for chatgpt|remote debugging approval|browser slot|browser control|browser guidance|model selection|model picker/i.test(message);
139
159
  if (!runOptions.verbose && !shouldAlwaysPrint)
140
160
  return;
141
161
  log(message);
@@ -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.";
@@ -278,9 +300,12 @@ async function executeAssembledBrowserSession({ runOptions, browserConfig, log,
278
300
  chromeProfileRoot: browserResult.chromeProfileRoot,
279
301
  userDataDir: browserResult.userDataDir,
280
302
  chromeTargetId: browserResult.chromeTargetId,
303
+ ownedRecoveryTarget: browserResult.ownedRecoveryTarget,
281
304
  tabUrl: browserResult.tabUrl,
282
305
  conversationId: browserResult.conversationId,
283
306
  promptSubmitted: browserResult.promptSubmitted,
307
+ submittedPromptHash: browserResult.submittedPromptHash,
308
+ researchPlan: browserResult.researchPlan,
284
309
  controllerPid: browserResult.controllerPid ?? process.pid,
285
310
  },
286
311
  archive: browserResult.archive,