@steipete/oracle 0.18.0 → 0.20.0

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 (169) hide show
  1. package/README.md +1 -1
  2. package/dist/bin/oracle-cli.js +76 -76
  3. package/dist/bin/oracle-mcp.js +0 -0
  4. package/dist/src/browser/actions/archiveConversation.js +31 -7
  5. package/dist/src/browser/actions/assistantResponse.js +43 -1
  6. package/dist/src/browser/actions/attachmentContext.js +239 -0
  7. package/dist/src/browser/actions/attachmentDataTransfer.js +19 -1
  8. package/dist/src/browser/actions/attachmentEvidence.js +78 -0
  9. package/dist/src/browser/actions/attachmentProgress.js +41 -0
  10. package/dist/src/browser/actions/attachmentPrompt.js +130 -0
  11. package/dist/src/browser/actions/attachments.js +306 -355
  12. package/dist/src/browser/actions/deepResearch.js +170 -45
  13. package/dist/src/browser/actions/modelSelection.js +91 -4
  14. package/dist/src/browser/actions/navigation.js +1 -1
  15. package/dist/src/browser/actions/promptComposer.js +364 -186
  16. package/dist/src/browser/actions/remoteFileTransfer.js +4 -2
  17. package/dist/src/browser/actions/thinkingStatus.js +2 -5
  18. package/dist/src/browser/actions/thinkingTime.js +156 -16
  19. package/dist/src/browser/actions/webSearch.js +96 -0
  20. package/dist/src/browser/attachRunning.js +46 -2
  21. package/dist/src/browser/attachmentValidation.js +53 -0
  22. package/dist/src/browser/cancellation.js +105 -0
  23. package/dist/src/browser/chatgptImages.js +2 -0
  24. package/dist/src/browser/chatgptThrottle.js +104 -0
  25. package/dist/src/browser/chromeLifecycle.js +272 -47
  26. package/dist/src/browser/config.js +14 -1
  27. package/dist/src/browser/detect.js +1 -1
  28. package/dist/src/browser/index.js +525 -274
  29. package/dist/src/browser/liveTabs.js +3 -4
  30. package/dist/src/browser/modelDisplay.js +9 -0
  31. package/dist/src/browser/pageActions.js +2 -2
  32. package/dist/src/browser/profileState.js +114 -9
  33. package/dist/src/browser/projectSourcesRunner.js +83 -15
  34. package/dist/src/browser/prompt.js +272 -113
  35. package/dist/src/browser/providers/chatgptDomProvider.js +3 -0
  36. package/dist/src/browser/reattach.js +31 -4
  37. package/dist/src/browser/recoveryTarget.js +131 -0
  38. package/dist/src/browser/sessionRunner.js +73 -11
  39. package/dist/src/browser/tabLeaseRegistry.js +434 -38
  40. package/dist/src/browser/targetClaim.js +54 -0
  41. package/dist/src/browser/utils.js +20 -2
  42. package/dist/src/cli/browserConfig.js +36 -4
  43. package/dist/src/cli/browserDefaults.js +10 -0
  44. package/dist/src/cli/browserTabs.js +15 -29
  45. package/dist/src/cli/detachedSession.js +69 -0
  46. package/dist/src/cli/dryRun.js +58 -48
  47. package/dist/src/cli/harvestIntegrity.js +140 -0
  48. package/dist/src/cli/options.js +15 -0
  49. package/dist/src/cli/recoveredBrowserHarvest.js +50 -0
  50. package/dist/src/cli/runOptions.js +19 -4
  51. package/dist/src/cli/serveBrowserConfig.js +24 -0
  52. package/dist/src/cli/sessionDisplay.js +13 -7
  53. package/dist/src/cli/sessionRunner.js +95 -12
  54. package/dist/src/config.js +1 -0
  55. package/dist/src/gemini-web/client.js +67 -21
  56. package/dist/src/gemini-web/executor.js +4 -0
  57. package/dist/src/gemini-web/http.js +15 -0
  58. package/dist/src/gemini-web/models.js +15 -11
  59. package/dist/src/mcp/server.js +11 -14
  60. package/dist/src/mcp/tools/chatgptImage.js +4 -4
  61. package/dist/src/mcp/tools/consult.js +86 -24
  62. package/dist/src/mcp/tools/projectSources.js +4 -6
  63. package/dist/src/mcp/tools/sessionResources.js +1 -1
  64. package/dist/src/mcp/tools/sessions.js +2 -2
  65. package/dist/src/mcp/tools/wait.js +182 -0
  66. package/dist/src/mcp/types.js +8 -1
  67. package/dist/src/oracle/config.js +14 -0
  68. package/dist/src/oracle/errors.js +11 -0
  69. package/dist/src/oracle/geminiModels.js +1 -0
  70. package/dist/src/oracle/providerRoutePlan.js +3 -2
  71. package/dist/src/oracle/run.js +27 -4
  72. package/dist/src/remote/client.js +73 -6
  73. package/dist/src/remote/health.js +6 -2
  74. package/dist/src/remote/runSlots.js +83 -0
  75. package/dist/src/remote/server.js +346 -71
  76. package/dist/src/sessionManager.js +36 -1
  77. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  78. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  79. package/package.json +25 -23
  80. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  81. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  82. package/dist/bin/oracle.js +0 -683
  83. package/dist/docs-site/.nojekyll +0 -0
  84. package/dist/docs-site/CNAME +0 -1
  85. package/dist/docs-site/RELEASING.html +0 -410
  86. package/dist/docs-site/agents.html +0 -374
  87. package/dist/docs-site/anthropic.html +0 -368
  88. package/dist/docs-site/bridge.html +0 -400
  89. package/dist/docs-site/browser-mode.html +0 -588
  90. package/dist/docs-site/chromium-forks.html +0 -347
  91. package/dist/docs-site/cli-reference.html +0 -346
  92. package/dist/docs-site/configuration.html +0 -436
  93. package/dist/docs-site/favicon.svg +0 -14
  94. package/dist/docs-site/followup.html +0 -368
  95. package/dist/docs-site/gemini.html +0 -379
  96. package/dist/docs-site/grok.html +0 -325
  97. package/dist/docs-site/index.html +0 -360
  98. package/dist/docs-site/install.html +0 -335
  99. package/dist/docs-site/linux.html +0 -321
  100. package/dist/docs-site/llms.txt +0 -43
  101. package/dist/docs-site/manual-tests.html +0 -595
  102. package/dist/docs-site/mcp.html +0 -368
  103. package/dist/docs-site/multimodel.html +0 -364
  104. package/dist/docs-site/mythical-pro-agents.html +0 -360
  105. package/dist/docs-site/notifier.html +0 -338
  106. package/dist/docs-site/openai-endpoints.html +0 -387
  107. package/dist/docs-site/openrouter.html +0 -344
  108. package/dist/docs-site/quickstart.html +0 -369
  109. package/dist/docs-site/refactor/ux.html +0 -532
  110. package/dist/docs-site/sessions.html +0 -388
  111. package/dist/docs-site/social-card.png +0 -0
  112. package/dist/docs-site/social-card.svg +0 -79
  113. package/dist/docs-site/spec.html +0 -363
  114. package/dist/docs-site/testing.html +0 -320
  115. package/dist/docs-site/tui-debug.html +0 -326
  116. package/dist/docs-site/windows-work.html +0 -323
  117. package/dist/docs-site/windows.html +0 -320
  118. package/dist/markdansi/types/index.js +0 -4
  119. package/dist/oracle/bin/oracle-cli.js +0 -472
  120. package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
  121. package/dist/oracle/src/browser/actions/attachments.js +0 -82
  122. package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
  123. package/dist/oracle/src/browser/actions/navigation.js +0 -75
  124. package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
  125. package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
  126. package/dist/oracle/src/browser/config.js +0 -33
  127. package/dist/oracle/src/browser/constants.js +0 -40
  128. package/dist/oracle/src/browser/cookies.js +0 -210
  129. package/dist/oracle/src/browser/domDebug.js +0 -36
  130. package/dist/oracle/src/browser/index.js +0 -331
  131. package/dist/oracle/src/browser/pageActions.js +0 -5
  132. package/dist/oracle/src/browser/prompt.js +0 -88
  133. package/dist/oracle/src/browser/promptSummary.js +0 -20
  134. package/dist/oracle/src/browser/sessionRunner.js +0 -80
  135. package/dist/oracle/src/browser/types.js +0 -1
  136. package/dist/oracle/src/browser/utils.js +0 -62
  137. package/dist/oracle/src/browserMode.js +0 -1
  138. package/dist/oracle/src/cli/browserConfig.js +0 -44
  139. package/dist/oracle/src/cli/dryRun.js +0 -59
  140. package/dist/oracle/src/cli/engine.js +0 -17
  141. package/dist/oracle/src/cli/errorUtils.js +0 -9
  142. package/dist/oracle/src/cli/help.js +0 -70
  143. package/dist/oracle/src/cli/markdownRenderer.js +0 -15
  144. package/dist/oracle/src/cli/options.js +0 -103
  145. package/dist/oracle/src/cli/promptRequirement.js +0 -14
  146. package/dist/oracle/src/cli/rootAlias.js +0 -30
  147. package/dist/oracle/src/cli/sessionCommand.js +0 -77
  148. package/dist/oracle/src/cli/sessionDisplay.js +0 -270
  149. package/dist/oracle/src/cli/sessionRunner.js +0 -94
  150. package/dist/oracle/src/heartbeat.js +0 -43
  151. package/dist/oracle/src/oracle/client.js +0 -48
  152. package/dist/oracle/src/oracle/config.js +0 -29
  153. package/dist/oracle/src/oracle/errors.js +0 -101
  154. package/dist/oracle/src/oracle/files.js +0 -220
  155. package/dist/oracle/src/oracle/format.js +0 -33
  156. package/dist/oracle/src/oracle/fsAdapter.js +0 -7
  157. package/dist/oracle/src/oracle/oscProgress.js +0 -60
  158. package/dist/oracle/src/oracle/request.js +0 -48
  159. package/dist/oracle/src/oracle/run.js +0 -444
  160. package/dist/oracle/src/oracle/tokenStats.js +0 -39
  161. package/dist/oracle/src/oracle/types.js +0 -1
  162. package/dist/oracle/src/oracle.js +0 -9
  163. package/dist/oracle/src/sessionManager.js +0 -205
  164. package/dist/oracle/src/version.js +0 -39
  165. package/dist/scripts/chrome/browser-tools.js +0 -295
  166. package/dist/src/browser/chromeCookies.js +0 -312
  167. package/dist/src/browser/keytarShim.js +0 -56
  168. package/dist/src/browser/profileSync.js +0 -141
  169. package/dist/src/browser/windowsCookies.js +0 -219
@@ -0,0 +1,105 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { BrowserRunCancelledError } from "../oracle/errors.js";
3
+ const context = new AsyncLocalStorage();
4
+ const cleanupContext = new AsyncLocalStorage();
5
+ export const currentBrowserAbortSignal = () => context.getStore();
6
+ export const withoutBrowserCancellation = (task) => cleanupContext.run(true, () => context.run(undefined, task));
7
+ /** Per-run cancellation, including polling and resources that arrive after their caller left. */
8
+ export class BrowserCancellation {
9
+ signal;
10
+ log;
11
+ aborted;
12
+ onAbort;
13
+ proxies = new WeakMap();
14
+ constructor(signal, log = () => { }) {
15
+ this.signal = signal;
16
+ this.log = log;
17
+ if (signal) {
18
+ let rejectAbort;
19
+ this.aborted = new Promise((_, reject) => {
20
+ rejectAbort = reject;
21
+ });
22
+ this.aborted.catch(() => undefined);
23
+ this.onAbort = () => rejectAbort(new BrowserRunCancelledError());
24
+ if (signal.aborted)
25
+ this.onAbort();
26
+ else
27
+ signal.addEventListener("abort", this.onAbort, { once: true });
28
+ }
29
+ }
30
+ check() {
31
+ if (this.signal?.aborted)
32
+ throw new BrowserRunCancelledError();
33
+ }
34
+ run(task) {
35
+ return cleanupContext.run(false, () => context.run(this.signal, task));
36
+ }
37
+ dispose() {
38
+ if (this.onAbort)
39
+ this.signal?.removeEventListener("abort", this.onAbort);
40
+ }
41
+ race(pending) {
42
+ return this.aborted ? Promise.race([pending, this.aborted]) : pending;
43
+ }
44
+ call(create) {
45
+ this.check();
46
+ return this.race(create());
47
+ }
48
+ acquire(create, releaseLate) {
49
+ this.check();
50
+ const pending = create().then(async (resource) => {
51
+ if (this.signal?.aborted) {
52
+ await withoutBrowserCancellation(() => releaseLate(resource)).catch((error) => {
53
+ this.log(`[browser] Late cancelled resource cleanup failed: ${error instanceof Error ? error.message : String(error)}`);
54
+ });
55
+ throw new BrowserRunCancelledError();
56
+ }
57
+ return resource;
58
+ });
59
+ return this.race(pending);
60
+ }
61
+ client(client) {
62
+ if (!this.signal)
63
+ return client;
64
+ const wrap = (target) => {
65
+ const cached = this.proxies.get(target);
66
+ if (cached)
67
+ return cached;
68
+ const proxy = new Proxy(target, {
69
+ get: (object, key) => {
70
+ const value = Reflect.get(object, key);
71
+ if (typeof value === "function") {
72
+ if ([
73
+ "on",
74
+ "once",
75
+ "off",
76
+ "emit",
77
+ "removeListener",
78
+ "removeAllListeners",
79
+ "addListener",
80
+ "close",
81
+ ].includes(String(key)))
82
+ return value.bind(object);
83
+ return (...args) => {
84
+ // CDP event subscriptions are synchronous and must remain removable during cleanup.
85
+ if (typeof args[0] === "function")
86
+ return Reflect.apply(value, object, args);
87
+ if (cleanupContext.getStore())
88
+ return Reflect.apply(value, object, args);
89
+ if (this.signal?.aborted)
90
+ return Promise.reject(new BrowserRunCancelledError());
91
+ const result = Reflect.apply(value, object, args);
92
+ return result && typeof result.then === "function"
93
+ ? this.race(Promise.resolve(result))
94
+ : result;
95
+ };
96
+ }
97
+ return value && typeof value === "object" ? wrap(value) : value;
98
+ },
99
+ });
100
+ this.proxies.set(target, proxy);
101
+ return proxy;
102
+ };
103
+ return wrap(client);
104
+ }
105
+ }
@@ -5,6 +5,7 @@ import { ASSISTANT_ROLE_SELECTOR } from "./constants.js";
5
5
  import { buildConversationTurnListExpression } from "./conversationTurns.js";
6
6
  import { delay } from "./utils.js";
7
7
  import { readAssistantSnapshot } from "./pageActions.js";
8
+ import { throwIfAssistantUiError } from "./actions/assistantResponse.js";
8
9
  import { getOracleHomeDir } from "../oracleHome.js";
9
10
  import { resolveSessionArtifactsDir } from "./artifacts.js";
10
11
  import { saveAssistantDownloadButtonArtifacts } from "./chatgptFiles.js";
@@ -456,6 +457,7 @@ export async function collectGeneratedImageArtifacts(params) {
456
457
  break;
457
458
  }
458
459
  const latestSnapshot = await readAssistantSnapshot(params.Runtime, params.minTurnIndex ?? undefined).catch(() => null);
460
+ throwIfAssistantUiError(latestSnapshot);
459
461
  const snapshotText = typeof latestSnapshot?.text === "string" ? latestSnapshot.text.trim() : "";
460
462
  if (snapshotText) {
461
463
  latestAnswerText = snapshotText;
@@ -0,0 +1,104 @@
1
+ import { BrowserAutomationError } from "../oracle/errors.js";
2
+ /**
3
+ * Detection for ChatGPT's own rate-limit notice.
4
+ *
5
+ * When ChatGPT decides a session is asking for too much at once it puts a modal
6
+ * over the page — "Too many requests … We've temporarily limited access to your
7
+ * conversations … Please wait a few minutes" — with a single "Got it" button.
8
+ *
9
+ * The modal is not marked as an error anywhere a DOM scrape would notice. It is
10
+ * a dialog full of ordinary text, so a menu reader walking the page finds its
11
+ * button and reports it as an available option. That is how a throttle came to be
12
+ * reported as `Unable to find model option matching "Thinking 5.5". Available:
13
+ * Got it` — a message that sends the reader looking for a model-naming bug that
14
+ * does not exist, when the correct response is to wait a few minutes.
15
+ *
16
+ * Observed live: six ChatGPT conversations opened at once triggered it
17
+ * repeatedly, while five did not, and the limit cleared on its own after a
18
+ * pause. Runs must therefore treat this as a transient, recoverable state rather
19
+ * than a failure of the request they were making.
20
+ */
21
+ const THROTTLE_PHRASES = [
22
+ "too many requests",
23
+ "making requests too quickly",
24
+ "temporarily limited access to your conversations",
25
+ ];
26
+ /** True when the text carries ChatGPT's rate-limit wording. */
27
+ export function matchesThrottleNotice(text) {
28
+ if (!text) {
29
+ return false;
30
+ }
31
+ const normalized = text.toLowerCase();
32
+ return THROTTLE_PHRASES.some((phrase) => normalized.includes(phrase));
33
+ }
34
+ export function buildThrottleProbeExpression() {
35
+ return `(() => {
36
+ const PHRASES = ${JSON.stringify(THROTTLE_PHRASES)};
37
+ const matches = (value) => {
38
+ const text = (value || '').toLowerCase();
39
+ return PHRASES.some((phrase) => text.includes(phrase));
40
+ };
41
+ // Dialogs first, because that is what this notice is; the body sweep is a
42
+ // fallback for a variant that does not carry the role.
43
+ const dialogs = Array.from(document.querySelectorAll('[role="dialog"], [role="alertdialog"]'));
44
+ for (const dialog of dialogs) {
45
+ const text = (dialog.innerText || '').trim();
46
+ if (matches(text)) return { message: text.slice(0, 400) };
47
+ }
48
+ const body = (document.body && document.body.innerText) || '';
49
+ if (matches(body)) {
50
+ const line = body
51
+ .split('\\n')
52
+ .map((entry) => entry.trim())
53
+ .find((entry) => matches(entry));
54
+ return { message: (line || 'ChatGPT is rate limiting this session.').slice(0, 400) };
55
+ }
56
+ return null;
57
+ })()`;
58
+ }
59
+ /**
60
+ * Looks for the rate-limit notice. Never throws: this runs on paths that are
61
+ * already reporting a different failure, and a probe that failed must not
62
+ * replace the original diagnosis with its own.
63
+ */
64
+ export async function detectChatGptThrottleNotice(Runtime) {
65
+ try {
66
+ const evaluated = await Runtime.evaluate({
67
+ expression: buildThrottleProbeExpression(),
68
+ returnByValue: true,
69
+ });
70
+ const value = evaluated.result?.value;
71
+ if (value && typeof value.message === "string" && value.message.trim()) {
72
+ return { message: value.message.trim() };
73
+ }
74
+ }
75
+ catch {
76
+ // fall through: no notice detected
77
+ }
78
+ return null;
79
+ }
80
+ export function buildThrottleError(notice, context) {
81
+ return new BrowserAutomationError(`ChatGPT is rate limiting this session: ${notice.message} ` +
82
+ "This is a temporary limit on the account, not a problem with the request — " +
83
+ "wait a few minutes and retry, and reduce how many conversations are started at once.", {
84
+ stage: "chatgpt-throttled",
85
+ details: {
86
+ // Named so a caller can branch on it rather than parsing the message.
87
+ retryable: true,
88
+ origin: context.stage,
89
+ notice: notice.message,
90
+ },
91
+ });
92
+ }
93
+ /**
94
+ * Replaces a misleading failure with the real one when the page is throttled.
95
+ * Returns nothing when it is not, leaving the caller's own error intact.
96
+ */
97
+ export async function throwIfThrottled(Runtime, context, logger) {
98
+ const notice = await detectChatGptThrottleNotice(Runtime);
99
+ if (!notice) {
100
+ return;
101
+ }
102
+ logger?.(`[browser] ChatGPT rate limit detected during ${context.stage}: ${notice.message}`);
103
+ throw buildThrottleError(notice, context);
104
+ }
@@ -1,19 +1,29 @@
1
- import { rm } from "node:fs/promises";
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import * as childProcess from "node:child_process";
2
3
  import net from "node:net";
4
+ import path from "node:path";
3
5
  import CDP from "chrome-remote-interface";
4
- import { launch, Launcher } from "chrome-launcher";
6
+ import { launch, Launcher, } from "chrome-launcher";
5
7
  import { cleanupStaleProfileState } from "./profileState.js";
6
8
  import { delay } from "./utils.js";
7
9
  import { isWsl, resolveWslChromeLaunchRoute } from "./wslHost.js";
8
10
  export async function launchChrome(config, userDataDir, logger) {
9
11
  const { connectHost, debugBindAddress, usePatchedLauncher } = resolveWslChromeLaunchRoute();
10
12
  const debugPort = config.debugPort ?? parseDebugPortEnv();
13
+ const usingCopiedProfile = Boolean(config.copyProfileSource);
14
+ const detachSharedChrome = shouldDetachSharedChrome(config);
15
+ const launchedProfileDirectory = usingCopiedProfile && config.chromeProfile ? config.chromeProfile : "Default";
16
+ await prepareChromeWindowStateForHiddenLaunch({
17
+ config,
18
+ userDataDir,
19
+ profileDirectory: launchedProfileDirectory,
20
+ logger,
21
+ });
11
22
  const chromeFlags = buildChromeFlags(config.headless ?? false, debugBindAddress, config.hideWindow ?? false);
12
23
  // copy-profile reuses a copied signed-in profile whose cookies are
13
24
  // Keychain-encrypted, so it must launch with the real Keychain (not mocked):
14
25
  // strip the keychain-mocking flags from both chrome-launcher's defaults and
15
26
  // Oracle's set, and ignore the defaults so they aren't re-added.
16
- const usingCopiedProfile = Boolean(config.copyProfileSource);
17
27
  if (usingCopiedProfile && config.chromeProfile) {
18
28
  chromeFlags.push(`--profile-directory=${config.chromeProfile}`);
19
29
  }
@@ -26,38 +36,231 @@ export async function launchChrome(config, userDataDir, logger) {
26
36
  host: connectHost ?? "127.0.0.1",
27
37
  requestedPort: debugPort ?? undefined,
28
38
  ignoreDefaultFlags: launchOptions.ignoreDefaultFlags,
39
+ detachSharedChrome,
29
40
  })
30
- : await launch({
41
+ : await launchWithStableProcessLifecycle({
31
42
  chromePath: config.chromePath ?? undefined,
32
43
  chromeFlags: launchOptions.chromeFlags,
33
44
  userDataDir,
34
45
  handleSIGINT: false,
35
46
  port: debugPort ?? undefined,
36
47
  ignoreDefaultFlags: launchOptions.ignoreDefaultFlags,
37
- });
48
+ }, detachSharedChrome);
38
49
  const pidLabel = typeof launcher.pid === "number" ? ` (pid ${launcher.pid})` : "";
39
50
  const hostLabel = connectHost ? ` on ${connectHost}` : "";
40
51
  logger(`Launched Chrome${pidLabel} on port ${launcher.port}${hostLabel}`);
52
+ if (detachSharedChrome) {
53
+ logger("[browser] Browser control: Windows Chrome lifecycle detached=true; windowsHide=true.");
54
+ }
41
55
  return Object.assign(launcher, { host: connectHost ?? "127.0.0.1" });
42
56
  }
43
- export async function positionChromeWindowOffscreen(client, logger) {
57
+ function shouldDetachSharedChrome(config, platform = process.platform) {
58
+ return platform === "win32" && config.manualLogin === true && !config.copyProfileSource;
59
+ }
60
+ export const shouldDetachSharedChromeForTest = shouldDetachSharedChrome;
61
+ const spawnDetachedChromeOnWindows = ((command, args, options) => {
62
+ const child = childProcess.spawn(command, args, resolveChromeChildSpawnOptions(options, "win32"));
63
+ child.unref();
64
+ return child;
65
+ });
66
+ function resolveChromeChildSpawnOptions(options, platform = process.platform) {
67
+ return platform === "win32"
68
+ ? {
69
+ ...options,
70
+ detached: true,
71
+ windowsHide: true,
72
+ }
73
+ : options;
74
+ }
75
+ export function resolveChromeChildSpawnOptionsForTest(options, platform) {
76
+ return resolveChromeChildSpawnOptions(options, platform);
77
+ }
78
+ function chromeLauncherModuleOverrides(detachSharedChrome, platform = process.platform) {
79
+ return detachSharedChrome && platform === "win32"
80
+ ? { spawn: spawnDetachedChromeOnWindows }
81
+ : undefined;
82
+ }
83
+ async function launchWithStableProcessLifecycle(options, detachSharedChrome) {
84
+ if (!detachSharedChrome) {
85
+ return launch(options);
86
+ }
87
+ const launcher = new Launcher(options, chromeLauncherModuleOverrides(detachSharedChrome));
88
+ await launcher.launch();
89
+ return launchedChromeFromLauncher(launcher);
90
+ }
91
+ function launchedChromeFromLauncher(launcher) {
92
+ return {
93
+ pid: launcher.pid ?? 0,
94
+ port: launcher.port ?? 0,
95
+ process: launcher.chromeProcess,
96
+ kill: () => launcher.kill(),
97
+ remoteDebuggingPipes: launcher.remoteDebuggingPipes,
98
+ };
99
+ }
100
+ export async function positionChromeWindowOffscreen(client, userDataDir, logger) {
44
101
  if (process.platform !== "darwin") {
45
102
  logger("Window hiding is only supported on macOS");
46
103
  return;
47
104
  }
105
+ let savedState = false;
48
106
  try {
49
107
  const { windowId } = await client.Browser.getWindowForTarget();
108
+ if (!(await readSavedChromeWindowState(userDataDir))) {
109
+ const { bounds } = await client.Browser.getWindowBounds({ windowId });
110
+ await writeSavedChromeWindowState(userDataDir, bounds);
111
+ savedState = true;
112
+ }
50
113
  await client.Browser.setWindowBounds({
51
114
  windowId,
52
115
  bounds: { left: -32_000, top: -32_000, windowState: "normal" },
53
116
  });
54
- logger("Chrome window positioned off-screen");
55
117
  }
56
118
  catch (error) {
119
+ if (savedState) {
120
+ await rm(chromeWindowStatePath(userDataDir), { force: true }).catch(() => undefined);
121
+ }
57
122
  const message = error instanceof Error ? error.message : String(error);
58
123
  logger(`Failed to position Chrome window off-screen: ${message}`);
124
+ return;
125
+ }
126
+ logger("Chrome window positioned off-screen");
127
+ }
128
+ export async function positionChromeWindowOnscreen(client, userDataDir, logger) {
129
+ if (process.platform !== "darwin") {
130
+ return;
131
+ }
132
+ try {
133
+ const savedState = await readSavedChromeWindowState(userDataDir);
134
+ if (!savedState) {
135
+ return;
136
+ }
137
+ const { windowId } = await client.Browser.getWindowForTarget();
138
+ await client.Browser.setWindowBounds({
139
+ windowId,
140
+ bounds: restoreWindowBounds(savedState.bounds),
141
+ });
142
+ await rm(chromeWindowStatePath(userDataDir), { force: true });
143
+ logger("Chrome window restored to its pre-hide bounds");
144
+ }
145
+ catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ logger(`Failed to position Chrome window on-screen: ${message}`);
148
+ }
149
+ }
150
+ const CHROME_WINDOW_STATE_FILENAME = "oracle-window-state.json";
151
+ const DEFAULT_VISIBLE_WINDOW_BOUNDS = {
152
+ left: 80,
153
+ top: 80,
154
+ width: 1280,
155
+ height: 720,
156
+ windowState: "normal",
157
+ };
158
+ function chromeWindowStatePath(userDataDir) {
159
+ return path.join(userDataDir, CHROME_WINDOW_STATE_FILENAME);
160
+ }
161
+ async function readSavedChromeWindowState(userDataDir) {
162
+ try {
163
+ const parsed = JSON.parse(await readFile(chromeWindowStatePath(userDataDir), "utf8"));
164
+ const bounds = parseChromeWindowBounds(parsed.bounds);
165
+ if (parsed.version !== 1 || !bounds) {
166
+ return null;
167
+ }
168
+ return { version: 1, bounds };
169
+ }
170
+ catch {
171
+ return null;
59
172
  }
60
173
  }
174
+ async function writeSavedChromeWindowState(userDataDir, bounds) {
175
+ await mkdir(userDataDir, { recursive: true });
176
+ await writeFile(chromeWindowStatePath(userDataDir), `${JSON.stringify({ version: 1, bounds })}\n`, "utf8");
177
+ }
178
+ async function prepareChromeWindowStateForHiddenLaunch({ config, userDataDir, profileDirectory, logger, }) {
179
+ if (process.platform !== "darwin" ||
180
+ config.headless ||
181
+ !config.hideWindow ||
182
+ (await readSavedChromeWindowState(userDataDir))) {
183
+ return;
184
+ }
185
+ const bounds = (await readPersistedChromeWindowBounds(userDataDir, profileDirectory)) ??
186
+ DEFAULT_VISIBLE_WINDOW_BOUNDS;
187
+ await writeSavedChromeWindowState(userDataDir, bounds);
188
+ logger("Recorded Chrome window placement before hidden launch");
189
+ }
190
+ async function readPersistedChromeWindowBounds(userDataDir, profileDirectory) {
191
+ const root = path.resolve(userDataDir);
192
+ const profile = path.resolve(root, profileDirectory);
193
+ if (path.dirname(profile) !== root) {
194
+ return null;
195
+ }
196
+ try {
197
+ const preferences = JSON.parse(await readFile(path.join(profile, "Preferences"), "utf8"));
198
+ return persistedPlacementToBounds(preferences.browser?.window_placement);
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ }
204
+ function persistedPlacementToBounds(placement) {
205
+ if (!placement) {
206
+ return null;
207
+ }
208
+ if (placement.maximized === true) {
209
+ return { windowState: "maximized" };
210
+ }
211
+ const left = finiteNumber(placement.left);
212
+ const top = finiteNumber(placement.top);
213
+ const right = finiteNumber(placement.right);
214
+ const bottom = finiteNumber(placement.bottom);
215
+ if (left === null || top === null || right === null || bottom === null) {
216
+ return null;
217
+ }
218
+ const width = right - left;
219
+ const height = bottom - top;
220
+ if (width <= 0 || height <= 0) {
221
+ return null;
222
+ }
223
+ return { left, top, width, height, windowState: "normal" };
224
+ }
225
+ function parseChromeWindowBounds(value) {
226
+ if (!value || typeof value !== "object") {
227
+ return null;
228
+ }
229
+ const bounds = value;
230
+ const windowState = bounds.windowState ?? "normal";
231
+ if (windowState !== "normal") {
232
+ return ["minimized", "maximized", "fullscreen"].includes(windowState) ? { windowState } : null;
233
+ }
234
+ const left = finiteNumber(bounds.left);
235
+ const top = finiteNumber(bounds.top);
236
+ const width = finiteNumber(bounds.width);
237
+ const height = finiteNumber(bounds.height);
238
+ if (left === null ||
239
+ top === null ||
240
+ width === null ||
241
+ height === null ||
242
+ width <= 0 ||
243
+ height <= 0) {
244
+ return null;
245
+ }
246
+ return { left, top, width, height, windowState: "normal" };
247
+ }
248
+ function finiteNumber(value) {
249
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
250
+ }
251
+ function restoreWindowBounds(bounds) {
252
+ const windowState = bounds.windowState ?? "normal";
253
+ if (windowState !== "normal") {
254
+ return { windowState };
255
+ }
256
+ return {
257
+ left: bounds.left ?? 80,
258
+ top: bounds.top ?? 80,
259
+ width: bounds.width,
260
+ height: bounds.height,
261
+ windowState: "normal",
262
+ };
263
+ }
61
264
  export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logger, opts) {
62
265
  const signals = ["SIGINT", "SIGTERM", "SIGQUIT"];
63
266
  let handling;
@@ -68,7 +271,8 @@ export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logge
68
271
  handling = true;
69
272
  const inFlight = opts?.isInFlight?.() ?? false;
70
273
  const forceCleanup = opts?.forceProfileCleanup ?? false;
71
- const leaveRunning = (keepBrowser || inFlight) && !forceCleanup;
274
+ const preserveSharedChrome = opts?.preserveSharedChromeOnSignal ?? false;
275
+ const leaveRunning = (keepBrowser || inFlight || preserveSharedChrome) && !forceCleanup;
72
276
  if (leaveRunning) {
73
277
  logger(`Received ${signal}; leaving Chrome running${inFlight ? " (assistant response pending)" : ""}`);
74
278
  }
@@ -136,23 +340,28 @@ export async function connectToRemoteChrome(host, port, logger, targetUrl, brows
136
340
  approvalWaitMs: options?.approvalWaitMs,
137
341
  });
138
342
  }
139
- if (targetUrl) {
140
- const targetConnection = await connectToNewTarget(host, port, targetUrl, logger, {
141
- opened: () => `Opened dedicated remote Chrome tab targeting ${targetUrl}`,
142
- openFailed: (message) => `Failed to open dedicated remote Chrome tab (${message}); falling back to first target.`,
143
- attachFailed: (targetId, message) => `Failed to attach to dedicated remote Chrome tab ${targetId} (${message}); falling back to first target.`,
343
+ const newTargetUrl = targetUrl || (options?.fallbackToDefault === false ? "about:blank" : undefined);
344
+ if (newTargetUrl) {
345
+ const targetConnection = await connectToNewTarget(host, port, newTargetUrl, logger, {
346
+ opened: () => `Opened dedicated remote Chrome tab targeting ${newTargetUrl}`,
347
+ openFailed: (message) => `Failed to open dedicated remote Chrome tab (${message}); ${options?.fallbackToDefault === false ? "refusing to reuse an unrelated tab" : "falling back to first target"}.`,
348
+ attachFailed: (targetId, message) => `Failed to attach to dedicated remote Chrome tab ${targetId} (${message}); ${options?.fallbackToDefault === false ? "refusing to reuse an unrelated tab" : "falling back to first target"}.`,
144
349
  closeFailed: (targetId, message) => `Failed to close unused remote Chrome tab ${targetId}: ${message}`,
145
350
  });
146
351
  if (targetConnection) {
147
352
  return {
148
353
  client: targetConnection.client,
149
354
  targetId: targetConnection.targetId,
150
- close: async () => {
355
+ close: async (closeOptions) => {
151
356
  await targetConnection.client.close().catch(() => undefined);
152
- await closeRemoteChromeTarget(host, port, targetConnection.targetId, logger);
357
+ if (!closeOptions?.preserveTarget)
358
+ await closeRemoteChromeTarget(host, port, targetConnection.targetId, logger);
153
359
  },
154
360
  };
155
361
  }
362
+ if (options?.fallbackToDefault === false) {
363
+ throw new Error("Unable to create a dedicated remote Chrome tab; refusing to reuse an unrelated conversation.");
364
+ }
156
365
  }
157
366
  const fallbackClient = await CDP({ host, port });
158
367
  logger(`Connected to remote Chrome DevTools protocol at ${host}:${port}`);
@@ -183,7 +392,7 @@ export async function listRemoteChromeTargets(options) {
183
392
  const targets = await CDP.List({ host: options.host, port: options.port });
184
393
  return targets;
185
394
  }
186
- const browser = await CDP({ target: options.browserWSEndpoint, local: true });
395
+ const browser = await connectToBrowserWebSocket(options.host, options.port, options.browserWSEndpoint, options.logger ?? (() => { }), options.approvalWaitMs);
187
396
  try {
188
397
  const result = await browser.Target.getTargets();
189
398
  return (result.targetInfos ?? []).map((target) => ({
@@ -223,9 +432,9 @@ export async function connectToRemoteChromeTarget(host, port, logger, options) {
223
432
  client,
224
433
  targetId,
225
434
  browserWSEndpoint: options.browserWSEndpoint,
226
- close: async () => {
435
+ close: async (closeOptions) => {
227
436
  await browser.Target.detachFromTarget({ sessionId: attached.sessionId }).catch(() => undefined);
228
- if (options.closeTargetOnDispose && targetId) {
437
+ if (options.closeTargetOnDispose && targetId && !closeOptions?.preserveTarget) {
229
438
  await browser.Target.closeTarget({ targetId }).catch(() => undefined);
230
439
  }
231
440
  await browser.close().catch(() => undefined);
@@ -241,33 +450,54 @@ async function connectToBrowserWebSocket(host, port, browserWSEndpoint, logger,
241
450
  if (!approvalWaitMs || approvalWaitMs <= 0) {
242
451
  return (await CDP({ target: browserWSEndpoint, local: true }));
243
452
  }
244
- logger(`Waiting for Chrome remote debugging approval for ${host}:${port}...`);
245
- const deadline = Date.now() + approvalWaitMs;
453
+ logger(`[browser] Waiting for Chrome remote debugging approval for ${host}:${port}...`);
454
+ const startedAt = Date.now();
455
+ const deadline = startedAt + approvalWaitMs;
456
+ const progress = setInterval(() => {
457
+ logger(`[browser] Still waiting for Chrome remote debugging approval for ${host}:${port} (${formatApprovalWait(Date.now() - startedAt)} elapsed). Click Allow in an open Chrome window.`);
458
+ }, 15_000);
246
459
  let lastApprovalError;
247
- while (Date.now() < deadline) {
248
- const remainingMs = Math.max(1, deadline - Date.now());
249
- try {
250
- return await Promise.race([
251
- CDP({ target: browserWSEndpoint, local: true }),
252
- new Promise((_, reject) => {
253
- setTimeout(() => {
254
- reject(new Error("__oracle_remote_debugging_approval_timeout__"));
255
- }, remainingMs);
256
- }),
257
- ]);
258
- }
259
- catch (error) {
260
- if (error instanceof Error &&
261
- error.message === "__oracle_remote_debugging_approval_timeout__") {
262
- break;
460
+ try {
461
+ while (Date.now() < deadline) {
462
+ const remainingMs = Math.max(1, deadline - Date.now());
463
+ let timeout;
464
+ let expired = false;
465
+ try {
466
+ const connecting = CDP({ target: browserWSEndpoint, local: true }).then(async (client) => {
467
+ // An approval arriving after our deadline must not leak a connection.
468
+ if (expired)
469
+ await client.close().catch(() => undefined);
470
+ return client;
471
+ });
472
+ return await Promise.race([
473
+ connecting,
474
+ new Promise((_, reject) => {
475
+ timeout = setTimeout(() => {
476
+ expired = true;
477
+ reject(new Error("__oracle_remote_debugging_approval_timeout__"));
478
+ }, remainingMs);
479
+ }),
480
+ ]);
481
+ }
482
+ catch (error) {
483
+ if (error instanceof Error &&
484
+ error.message === "__oracle_remote_debugging_approval_timeout__") {
485
+ break;
486
+ }
487
+ if (!isRemoteDebuggingApprovalError(error)) {
488
+ throw error;
489
+ }
490
+ lastApprovalError = error;
263
491
  }
264
- if (!isRemoteDebuggingApprovalError(error)) {
265
- throw error;
492
+ finally {
493
+ clearTimeout(timeout);
266
494
  }
267
- lastApprovalError = error;
268
495
  await delay(Math.min(500, Math.max(0, deadline - Date.now())));
269
496
  }
270
497
  }
498
+ finally {
499
+ clearInterval(progress);
500
+ }
271
501
  const suffix = lastApprovalError instanceof Error && lastApprovalError.message
272
502
  ? ` Last Chrome response: ${lastApprovalError.message}`
273
503
  : "";
@@ -598,7 +828,7 @@ function parseDebugPortEnv() {
598
828
  }
599
829
  return value;
600
830
  }
601
- async function launchWithCustomHost({ chromeFlags, chromePath, userDataDir, host, requestedPort, ignoreDefaultFlags, }) {
831
+ async function launchWithCustomHost({ chromeFlags, chromePath, userDataDir, host, requestedPort, ignoreDefaultFlags, detachSharedChrome, }) {
602
832
  const launcher = new Launcher({
603
833
  chromePath: chromePath ?? undefined,
604
834
  chromeFlags,
@@ -606,7 +836,7 @@ async function launchWithCustomHost({ chromeFlags, chromePath, userDataDir, host
606
836
  handleSIGINT: false,
607
837
  port: requestedPort ?? undefined,
608
838
  ignoreDefaultFlags,
609
- });
839
+ }, chromeLauncherModuleOverrides(detachSharedChrome));
610
840
  if (host) {
611
841
  const patched = launcher;
612
842
  patched.isDebuggerReady = function patchedIsDebuggerReady() {
@@ -634,13 +864,8 @@ async function launchWithCustomHost({ chromeFlags, chromePath, userDataDir, host
634
864
  };
635
865
  }
636
866
  await launcher.launch();
637
- const kill = async () => launcher.kill();
638
867
  return {
639
- pid: launcher.pid ?? undefined,
640
- port: launcher.port ?? 0,
641
- process: launcher.chromeProcess,
642
- kill,
868
+ ...launchedChromeFromLauncher(launcher),
643
869
  host: host ?? undefined,
644
- remoteDebuggingPipes: launcher.remoteDebuggingPipes,
645
870
  };
646
871
  }