@steipete/oracle 0.20.0 → 0.20.2

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 (32) hide show
  1. package/dist/bin/oracle-cli.js +66 -25
  2. package/dist/src/browser/actions/modelSelection.js +36 -4
  3. package/dist/src/browser/actions/thinkingTime.js +11 -5
  4. package/dist/src/browser/browserConnection.js +59 -0
  5. package/dist/src/browser/chatgptImages.js +1 -1
  6. package/dist/src/browser/chromeLifecycle.js +105 -36
  7. package/dist/src/browser/index.js +41 -8
  8. package/dist/src/browser/liveTabs.js +94 -29
  9. package/dist/src/browser/profileState.js +6 -1
  10. package/dist/src/browser/promptFingerprint.js +54 -0
  11. package/dist/src/browser/reattach.js +157 -108
  12. package/dist/src/browser/recoveryTarget.js +31 -6
  13. package/dist/src/browser/sessionRunner.js +30 -7
  14. package/dist/src/browser/targetClaim.js +2 -2
  15. package/dist/src/cli/browserDefaults.js +3 -0
  16. package/dist/src/cli/browserTabs.js +105 -20
  17. package/dist/src/cli/detach.js +10 -1
  18. package/dist/src/cli/detachedSession.js +36 -0
  19. package/dist/src/cli/errorUtils.js +9 -0
  20. package/dist/src/cli/sessionDisplay.js +11 -3
  21. package/dist/src/cli/sessionRunner.js +150 -37
  22. package/dist/src/mcp/tools/consult.js +1 -7
  23. package/dist/src/remote/client.js +71 -14
  24. package/dist/src/remote/health.js +1 -0
  25. package/dist/src/remote/server.js +52 -10
  26. package/dist/src/remote/types.js +13 -0
  27. package/dist/src/sessionManager.js +8 -1
  28. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  29. package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
  30. package/package.json +9 -9
  31. package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
  32. package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
@@ -15,8 +15,8 @@ import { collectPaths, collectModelList, collectTextValues, parseFloatOption, pa
15
15
  import { copyToClipboard } from "../src/cli/clipboard.js";
16
16
  import { isGpt6ProAlias } from "../src/cli/browserConfig.js";
17
17
  import { buildMarkdownBundle } from "../src/cli/markdownBundle.js";
18
- import { shouldDetachSession, stopDetachedWorker } from "../src/cli/detach.js";
19
- import { launchDetachedSession } from "../src/cli/detachedSession.js";
18
+ import { detachedCancellationExitCode, shouldDetachSession, shouldExitAfterTopLevelSigint, stopDetachedWorker, } from "../src/cli/detach.js";
19
+ import { clearDetachedSessionCancellation, detachedSessionCancellationPath, launchDetachedSession, requestDetachedSessionCancellation, waitForDetachedSessionCancellation, } from "../src/cli/detachedSession.js";
20
20
  import { applyHiddenAliases } from "../src/cli/hiddenAliases.js";
21
21
  import { isMediaFile } from "../src/browser/prompt.js";
22
22
  import { formatCompactNumber } from "../src/cli/format.js";
@@ -25,7 +25,7 @@ import { warnIfOversizeBundle } from "../src/cli/bundleWarnings.js";
25
25
  import { formatRenderedMarkdown } from "../src/cli/renderOutput.js";
26
26
  import { resolveRenderFlag, resolveRenderPlain } from "../src/cli/renderFlags.js";
27
27
  import { resolveGeminiModelId } from "../src/oracle/geminiModels.js";
28
- import { isErrorLogged } from "../src/cli/errorUtils.js";
28
+ import { formatCliError, isErrorLogged } from "../src/cli/errorUtils.js";
29
29
  import { resolveOutputPath } from "../src/cli/writeOutputPath.js";
30
30
  import { getCliVersion } from "../src/version.js";
31
31
  import { resolveNotificationSettings, deriveNotificationSettingsFromMetadata, } from "../src/cli/notifier.js";
@@ -37,6 +37,7 @@ import { isAzureOpenAICandidateModel, validateProviderRouting, } from "../src/or
37
37
  import { buildSessionLifecycle, formatSessionLifecycleBlock } from "../src/cli/sessionLifecycle.js";
38
38
  import { buildDetachedPerfTraceEnv, createPerfTrace, isTraceValueFlag, } from "../src/cli/perfTrace.js";
39
39
  import { resolveBrowserFollowupReference } from "../src/cli/followup.js";
40
+ import { BrowserRunCancelledError } from "../src/oracle/errors.js";
40
41
  const VERSION = getCliVersion();
41
42
  const CLI_ENTRYPOINT = fileURLToPath(import.meta.url);
42
43
  const LEGACY_FLAG_ALIASES = new Map([
@@ -1357,6 +1358,8 @@ async function runRootCommand(options) {
1357
1358
  browserRequestedModel: cliModelArg,
1358
1359
  browserModelLabel: resolveBrowserModelLabel(cliModelArg, activeModel),
1359
1360
  });
1361
+ config.modelIsImplicitDefault =
1362
+ optionUsesDefault("model") && !userConfig.model && !options.browserModelLabel;
1360
1363
  return resolvedOptions.browserResumeConversationUrl
1361
1364
  ? { ...config, resumeConversationUrl: resolvedOptions.browserResumeConversationUrl }
1362
1365
  : config;
@@ -1673,18 +1676,28 @@ async function waitForDetachedStartGate() {
1673
1676
  }
1674
1677
  }
1675
1678
  async function attachToDetachedSession(sessionId, workerPid) {
1679
+ const cancellationMarker = sessionStore
1680
+ .getPaths(sessionId)
1681
+ .then((paths) => detachedSessionCancellationPath(paths.dir, workerPid));
1676
1682
  let cancelled = false;
1683
+ let cancellationRequest;
1677
1684
  const cancelWorker = () => {
1678
1685
  cancelled = true;
1679
- try {
1680
- stopDetachedWorker(workerPid);
1681
- }
1682
- catch (error) {
1686
+ cancellationRequest ??= cancellationMarker
1687
+ .then((markerPath) => requestDetachedSessionCancellation(markerPath))
1688
+ .catch((error) => {
1683
1689
  const message = error instanceof Error ? error.message : String(error);
1684
- console.error(chalk.red(`Unable to stop detached worker ${workerPid}: ${message}`));
1685
- }
1690
+ console.error(chalk.red(`Unable to request cancellation from worker ${workerPid}: ${message}`));
1691
+ try {
1692
+ stopDetachedWorker(workerPid);
1693
+ }
1694
+ catch (stopError) {
1695
+ const stopMessage = stopError instanceof Error ? stopError.message : String(stopError);
1696
+ console.error(chalk.red(`Unable to stop detached worker ${workerPid}: ${stopMessage}`));
1697
+ }
1698
+ });
1686
1699
  };
1687
- process.once("SIGINT", cancelWorker);
1700
+ process.on("SIGINT", cancelWorker);
1688
1701
  try {
1689
1702
  const { attachSession } = await import("../src/cli/sessionDisplay.js");
1690
1703
  await attachSession(sessionId, {
@@ -1695,9 +1708,15 @@ async function attachToDetachedSession(sessionId, workerPid) {
1695
1708
  }
1696
1709
  finally {
1697
1710
  process.off("SIGINT", cancelWorker);
1698
- if (cancelled) {
1699
- process.exitCode = 130;
1711
+ await cancellationRequest;
1712
+ const finalStatus = (await sessionStore.readSession(sessionId).catch(() => null))?.status;
1713
+ if (cancellationRequest &&
1714
+ finalStatus &&
1715
+ ["completed", "partial", "cancelled", "error"].includes(finalStatus)) {
1716
+ // The parent may publish its request after the worker's final cleanup.
1717
+ await clearDetachedSessionCancellation(await cancellationMarker);
1700
1718
  }
1719
+ process.exitCode = detachedCancellationExitCode(cancelled, finalStatus, process.exitCode);
1701
1720
  }
1702
1721
  }
1703
1722
  async function restartSession(sessionId, options) {
@@ -1879,6 +1898,10 @@ async function restartSession(sessionId, options) {
1879
1898
  async function executeSession(sessionId) {
1880
1899
  let metadata = null;
1881
1900
  let writer = null;
1901
+ const cancellation = new AbortController();
1902
+ const stopCancellationMonitor = new AbortController();
1903
+ let cancellationMarker;
1904
+ let cancellationMonitor;
1882
1905
  try {
1883
1906
  metadata = await sessionStore.readSession(sessionId);
1884
1907
  if (!metadata) {
@@ -1892,6 +1915,18 @@ async function executeSession(sessionId) {
1892
1915
  const sessionMode = getSessionMode(metadata);
1893
1916
  const browserConfig = getBrowserConfigFromMetadata(metadata);
1894
1917
  writer = sessionStore.createLogWriter(sessionId);
1918
+ if (sessionMode === "browser") {
1919
+ const paths = await sessionStore.getPaths(sessionId);
1920
+ cancellationMarker = detachedSessionCancellationPath(paths.dir, process.pid);
1921
+ cancellationMonitor = waitForDetachedSessionCancellation({
1922
+ markerPath: cancellationMarker,
1923
+ signal: stopCancellationMonitor.signal,
1924
+ }).then((requested) => {
1925
+ if (requested) {
1926
+ cancellation.abort(new BrowserRunCancelledError("Browser run cancelled by the user."));
1927
+ }
1928
+ });
1929
+ }
1895
1930
  const userConfig = (await loadUserConfig()).config;
1896
1931
  const notifications = deriveNotificationSettingsFromMetadata(metadata, process.env, userConfig.notify);
1897
1932
  const { performSessionRun } = await import("../src/cli/sessionRunner.js");
@@ -1905,18 +1940,22 @@ async function executeSession(sessionId) {
1905
1940
  write: writer.writeChunk,
1906
1941
  version: VERSION,
1907
1942
  notifications,
1943
+ signal: sessionMode === "browser" ? cancellation.signal : undefined,
1908
1944
  });
1909
1945
  }
1910
1946
  catch (error) {
1911
- process.exitCode = 1;
1947
+ const cancelled = error instanceof BrowserRunCancelledError;
1948
+ process.exitCode = cancelled ? 130 : 1;
1912
1949
  const message = error instanceof Error ? error.message : String(error);
1913
1950
  if (!metadata) {
1914
- console.error(chalk.red(message));
1951
+ if (!cancelled)
1952
+ console.error(chalk.red(message));
1915
1953
  return;
1916
1954
  }
1917
- writer?.logLine(`ERROR: Detached session worker failed: ${message}`);
1955
+ if (!cancelled)
1956
+ writer?.logLine(`ERROR: Detached session worker failed: ${message}`);
1918
1957
  const latest = await sessionStore.readSession(sessionId).catch(() => null);
1919
- if (latest && !["completed", "partial", "error"].includes(latest.status)) {
1958
+ if (latest && !["completed", "partial", "error", "cancelled"].includes(latest.status)) {
1920
1959
  await sessionStore.updateSession(sessionId, {
1921
1960
  status: "error",
1922
1961
  completedAt: new Date().toISOString(),
@@ -1930,6 +1969,14 @@ async function executeSession(sessionId) {
1930
1969
  }
1931
1970
  }
1932
1971
  finally {
1972
+ stopCancellationMonitor.abort();
1973
+ await cancellationMonitor?.catch((error) => {
1974
+ const message = error instanceof Error ? error.message : String(error);
1975
+ writer?.logLine(`ERROR: Detached cancellation monitor failed: ${message}`);
1976
+ });
1977
+ if (cancellationMarker) {
1978
+ await clearDetachedSessionCancellation(cancellationMarker).catch(() => undefined);
1979
+ }
1933
1980
  writer?.stream.end();
1934
1981
  }
1935
1982
  }
@@ -2048,7 +2095,7 @@ async function main() {
2048
2095
  console.log(chalk.yellow("\nCancelled."));
2049
2096
  process.exitCode = 130;
2050
2097
  // Browser/serve modes install their own SIGINT cleanup after this top-level handler.
2051
- if (process.listenerCount("SIGINT") <= 1) {
2098
+ if (shouldExitAfterTopLevelSigint(process.listenerCount("SIGINT"))) {
2052
2099
  process.exit(130);
2053
2100
  }
2054
2101
  };
@@ -2061,13 +2108,7 @@ async function main() {
2061
2108
  }
2062
2109
  }
2063
2110
  void main().catch((error) => {
2064
- if (error instanceof Error) {
2065
- if (!isErrorLogged(error)) {
2066
- console.error(chalk.red("✖"), error.message);
2067
- }
2068
- }
2069
- else {
2070
- console.error(chalk.red("✖"), error);
2071
- }
2111
+ if (!isErrorLogged(error))
2112
+ console.error(chalk.red("✖"), formatCliError(error));
2072
2113
  process.exitCode = 1;
2073
2114
  });
@@ -15,10 +15,28 @@ const MODEL_BUTTON_POLL_MS = 250;
15
15
  export async function ensureModelSelection(Runtime, desiredModel, logger, strategy = "select", options = {}) {
16
16
  const buttonWaitMs = options.buttonWaitMs ?? MODEL_BUTTON_WAIT_MS;
17
17
  const buttonPollMs = options.buttonPollMs ?? MODEL_BUTTON_POLL_MS;
18
- const deadline = Date.now() + Math.max(0, buttonWaitMs);
18
+ const probeDeadline = Date.now() + Math.max(0, buttonWaitMs);
19
+ let deadline;
19
20
  let result;
20
21
  let announcedWait = false;
21
22
  for (;;) {
23
+ if (options.implicitDefault && strategy === "select") {
24
+ // Wait for observable selection before a default-driven switch, just as selection waits for the picker.
25
+ const observed = await Runtime.evaluate({
26
+ expression: buildModelSelectionExpression(desiredModel, "current"),
27
+ awaitPromise: true,
28
+ returnByValue: true,
29
+ }).catch(() => null);
30
+ const label = observed?.result?.value?.label;
31
+ if ((typeof label !== "string" || !label.trim()) && Date.now() < probeDeadline) {
32
+ await delay(buttonPollMs);
33
+ continue;
34
+ }
35
+ if (typeof label === "string" && isNewerModelLabel(label, desiredModel)) {
36
+ logger(`[browser] Model selection warning: no model was specified, so Oracle's default will switch ChatGPT from "${label}" to "${desiredModel}" before submission. Pass --model explicitly or --browser-model-strategy current to keep the selected model.`);
37
+ }
38
+ }
39
+ deadline ??= Date.now() + Math.max(0, buttonWaitMs);
22
40
  const outcome = await Runtime.evaluate({
23
41
  expression: buildModelSelectionExpression(desiredModel, strategy),
24
42
  awaitPromise: true,
@@ -76,6 +94,18 @@ export async function ensureModelSelection(Runtime, desiredModel, logger, strate
76
94
  }
77
95
  }
78
96
  }
97
+ function isNewerModelLabel(current, target) {
98
+ const latest = /^(?:Latest|最新|최신)$/i;
99
+ if (latest.test(current.trim()))
100
+ return !latest.test(target.trim());
101
+ const version = (label) => {
102
+ const match = label.match(/(?:^|gpt[- ]*|thinking\s+)(\d+)(?:\.(\d+))?/i);
103
+ return match ? [Number(match[1]), Number(match[2] ?? 0)] : null;
104
+ };
105
+ const from = version(current);
106
+ const to = version(target);
107
+ return Boolean(from && to && (from[0] > to[0] || (from[0] === to[0] && from[1] > to[1])));
108
+ }
79
109
  function assertResolvedModelSelection(desiredModel, resolvedLabel) {
80
110
  const desired = desiredModel.toLowerCase();
81
111
  const resolved = resolvedLabel.toLowerCase();
@@ -85,7 +115,8 @@ function assertResolvedModelSelection(desiredModel, resolvedLabel) {
85
115
  // The advanced radio is localized, but only the documented exact labels are
86
116
  // evidence of GPT-6 Astra. Do not let a generic picker result verify Latest.
87
117
  if (resolvedLabel.normalize("NFC").trim() === "Latest" ||
88
- resolvedLabel.normalize("NFC").trim() === "最新") {
118
+ resolvedLabel.normalize("NFC").trim() === "最新" ||
119
+ resolvedLabel.normalize("NFC").trim() === "최신") {
89
120
  return;
90
121
  }
91
122
  throw new Error(`Model picker selected "${resolvedLabel}" while "${desiredModel}" requires GPT-6 Astra (Latest).`);
@@ -187,7 +218,7 @@ function buildModelSelectionExpression(targetModel, strategy) {
187
218
  // Sol or an arbitrary localized menu row can never satisfy a Latest request.
188
219
  const isLatestModelLabel = (value) => {
189
220
  const label = String(value ?? '').normalize('NFC').trim();
190
- return label === 'Latest' || label === '最新';
221
+ return label === 'Latest' || label === '最新' || label === '최신';
191
222
  };
192
223
  const normalizedTokens = Array.from(new Set([normalizedTarget, ...LABEL_TOKENS]))
193
224
  .map((token) => normalizeText(token))
@@ -1442,8 +1473,9 @@ function buildModelMatchersLiteral(targetModel) {
1442
1473
  testIdTokens.add("gpt56");
1443
1474
  }
1444
1475
  if (base === "latest") {
1445
- // ChatGPT's Japanese advanced-model radio is named exactly "最新".
1476
+ // Exact Japanese and Korean labels for the advanced-model Latest radio.
1446
1477
  push("最新", labelTokens);
1478
+ push("최신", labelTokens);
1447
1479
  }
1448
1480
  // Numeric variations (5.5 <-> 55 <-> gpt-5-5)
1449
1481
  if (base.includes("5.5") || base.includes("5-5") || base.includes("55")) {
@@ -1030,11 +1030,12 @@ function buildThinkingTimeExpression(level, desiredModel) {
1030
1030
  .filter(Boolean);
1031
1031
  if (selections.length !== 1) return null;
1032
1032
  const { label, index, level } = selections[0];
1033
- // This adapter owns the observed five-tier layout only. A different range
1034
- // or contradictory announcement must not turn a numeric guess into proof.
1035
- if (thumb.getAttribute('aria-valuemin') !== '0' || thumb.getAttribute('aria-valuemax') !== '4' ||
1036
- thumb.getAttribute('aria-valuenow') !== String(index)) return null;
1037
- return { control, label, index, level };
1033
+ // Quota-limited accounts expose four tiers; the fourth remains Extra High.
1034
+ // Require an observed range and matching label/index before trusting either.
1035
+ const maximum = thumb.getAttribute('aria-valuemax');
1036
+ if (thumb.getAttribute('aria-valuemin') !== '0' || !['3', '4'].includes(maximum) ||
1037
+ index > Number(maximum) || thumb.getAttribute('aria-valuenow') !== String(index)) return null;
1038
+ return { control, label, index, level, maximum: Number(maximum) };
1038
1039
  };
1039
1040
  let current = resolve();
1040
1041
  const finish = (result) => { closeOpenMenus(); return result; };
@@ -1054,6 +1055,10 @@ function buildThinkingTimeExpression(level, desiredModel) {
1054
1055
  }
1055
1056
  return finish(failure('option-not-found'));
1056
1057
  }
1058
+ const unavailable = () => finish(failure('option-disabled', {
1059
+ label: 'Pro', notice: 'the available four-tier effort slider does not include Pro',
1060
+ }));
1061
+ if (targetIndex > current.maximum) return unavailable();
1057
1062
  if (current.level === target) return finish({ status: 'already-selected', label: current.label });
1058
1063
  const deadline = performance.now() + MAX_WAIT_MS;
1059
1064
  for (let attempt = 0; attempt < 4 && performance.now() < deadline; attempt += 1) {
@@ -1069,6 +1074,7 @@ function buildThinkingTimeExpression(level, desiredModel) {
1069
1074
  if (next && next.index !== previousIndex) { current = next; break; }
1070
1075
  }
1071
1076
  if (!current) return finish(failure('selection-unverified'));
1077
+ if (targetIndex > current.maximum) return unavailable();
1072
1078
  if (current.level === target) return finish({ status: 'switched', label: current.label });
1073
1079
  }
1074
1080
  return finish(failure('selection-unverified'));
@@ -0,0 +1,59 @@
1
+ import { withoutBrowserCancellation } from "./cancellation.js";
2
+ const connections = new Map();
3
+ // CRI exposes no ref API. Retain approval while idle without keeping a CLI alive.
4
+ function refTransport(client, active) {
5
+ const socket = client._ws?._socket;
6
+ if (active)
7
+ socket?.ref();
8
+ else
9
+ socket?.unref();
10
+ }
11
+ export async function acquireBrowserConnection(endpoint, connect) {
12
+ let pending = connections.get(endpoint);
13
+ if (!pending) {
14
+ pending = Promise.resolve().then(async () => {
15
+ const client = await withoutBrowserCancellation(connect);
16
+ const connection = { client, users: 0 };
17
+ client.on?.("disconnect", () => {
18
+ if (connections.get(endpoint) === pending)
19
+ connections.delete(endpoint);
20
+ });
21
+ return connection;
22
+ });
23
+ connections.set(endpoint, pending);
24
+ }
25
+ let connection;
26
+ try {
27
+ connection = await pending;
28
+ }
29
+ catch (error) {
30
+ if (connections.get(endpoint) === pending)
31
+ connections.delete(endpoint);
32
+ throw error;
33
+ }
34
+ connection.users += 1;
35
+ refTransport(connection.client, true);
36
+ let released = false;
37
+ return new Proxy({}, {
38
+ ownKeys: () => Reflect.ownKeys(connection.client),
39
+ getOwnPropertyDescriptor: (_target, key) => {
40
+ const descriptor = Object.getOwnPropertyDescriptor(connection.client, key);
41
+ return descriptor ? { ...descriptor, configurable: true } : undefined;
42
+ },
43
+ get(_target, key) {
44
+ const target = connection.client;
45
+ if (key === "close") {
46
+ return async () => {
47
+ if (released)
48
+ return;
49
+ released = true;
50
+ connection.users -= 1;
51
+ if (connection.users === 0)
52
+ refTransport(target, false);
53
+ };
54
+ }
55
+ const value = Reflect.get(target, key, target);
56
+ return typeof value === "function" ? value.bind(target) : value;
57
+ },
58
+ });
59
+ }
@@ -209,7 +209,7 @@ function detectImageFile(buffer) {
209
209
  }
210
210
  return null;
211
211
  }
212
- function resolveSiblingImagePath(basePath, index, extension) {
212
+ export function resolveSiblingImagePath(basePath, index, extension) {
213
213
  const ext = path.extname(basePath);
214
214
  const dir = path.dirname(basePath);
215
215
  const stem = ext ? path.basename(basePath, ext) : path.basename(basePath);
@@ -1,4 +1,5 @@
1
1
  import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { EventEmitter } from "node:events";
2
3
  import * as childProcess from "node:child_process";
3
4
  import net from "node:net";
4
5
  import path from "node:path";
@@ -7,6 +8,8 @@ import { launch, Launcher, } from "chrome-launcher";
7
8
  import { cleanupStaleProfileState } from "./profileState.js";
8
9
  import { delay } from "./utils.js";
9
10
  import { isWsl, resolveWslChromeLaunchRoute } from "./wslHost.js";
11
+ import { BrowserCancellation } from "./cancellation.js";
12
+ import { acquireBrowserConnection } from "./browserConnection.js";
10
13
  export async function launchChrome(config, userDataDir, logger) {
11
14
  const { connectHost, debugBindAddress, usePatchedLauncher } = resolveWslChromeLaunchRoute();
12
15
  const debugPort = config.debugPort ?? parseDebugPortEnv();
@@ -388,21 +391,32 @@ export async function closeRemoteChromeTarget(host, port, targetId, logger) {
388
391
  }
389
392
  }
390
393
  export async function listRemoteChromeTargets(options) {
391
- if (!options.browserWSEndpoint) {
392
- const targets = await CDP.List({ host: options.host, port: options.port });
393
- return targets;
394
- }
395
- const browser = await connectToBrowserWebSocket(options.host, options.port, options.browserWSEndpoint, options.logger ?? (() => { }), options.approvalWaitMs);
394
+ const logger = options.logger ?? (() => { });
395
+ const cancellation = new BrowserCancellation(options.signal, logger);
396
396
  try {
397
- const result = await browser.Target.getTargets();
398
- return (result.targetInfos ?? []).map((target) => ({
399
- targetId: target.targetId,
400
- type: target.type,
401
- url: target.url,
402
- }));
397
+ return await cancellation.run(async () => {
398
+ if (!options.browserWSEndpoint) {
399
+ const targets = await cancellation.call(() => CDP.List({ host: options.host, port: options.port }));
400
+ return targets;
401
+ }
402
+ const browser = await cancellation.acquire(() => connectToBrowserWebSocket(options.host, options.port, options.browserWSEndpoint, logger, options.approvalWaitMs), (lateBrowser) => lateBrowser.close());
403
+ try {
404
+ const client = cancellation.client(browser);
405
+ const result = await client.Target.getTargets();
406
+ return (result.targetInfos ?? []).map((target) => ({
407
+ targetId: target.targetId,
408
+ type: target.type,
409
+ url: target.url,
410
+ title: target.title,
411
+ }));
412
+ }
413
+ finally {
414
+ await browser.close().catch(() => undefined);
415
+ }
416
+ });
403
417
  }
404
418
  finally {
405
- await browser.close().catch(() => undefined);
419
+ cancellation.dispose();
406
420
  }
407
421
  }
408
422
  export async function connectToRemoteChromeTarget(host, port, logger, options) {
@@ -418,37 +432,43 @@ export async function connectToRemoteChromeTarget(host, port, logger, options) {
418
432
  }
419
433
  const browser = await connectToBrowserWebSocket(host, port, options.browserWSEndpoint, logger, options.approvalWaitMs);
420
434
  let targetId = options.targetId;
435
+ let createdTargetId;
421
436
  try {
422
437
  if (!targetId) {
423
438
  const created = await browser.Target.createTarget({
424
439
  url: options.targetUrl ?? "about:blank",
425
440
  });
426
441
  targetId = created.targetId;
442
+ createdTargetId = targetId;
427
443
  logger(`Opened dedicated remote Chrome tab targeting ${options.targetUrl ?? "about:blank"}`);
428
444
  }
429
445
  const attached = await browser.Target.attachToTarget({ targetId, flatten: true });
430
446
  const client = createSessionBoundChromeClient(browser, attached.sessionId);
447
+ let closing;
431
448
  return {
432
449
  client,
433
450
  targetId,
434
451
  browserWSEndpoint: options.browserWSEndpoint,
435
- close: async (closeOptions) => {
436
- await browser.Target.detachFromTarget({ sessionId: attached.sessionId }).catch(() => undefined);
452
+ close: (closeOptions) => (closing ??= (async () => {
437
453
  if (options.closeTargetOnDispose && targetId && !closeOptions?.preserveTarget) {
438
454
  await browser.Target.closeTarget({ targetId }).catch(() => undefined);
439
455
  }
440
- await browser.close().catch(() => undefined);
441
- },
456
+ await client.close();
457
+ })()),
442
458
  };
443
459
  }
444
460
  catch (error) {
461
+ if (createdTargetId) {
462
+ await browser.Target.closeTarget({ targetId: createdTargetId }).catch(() => undefined);
463
+ }
445
464
  await browser.close().catch(() => undefined);
446
465
  throw error;
447
466
  }
448
467
  }
449
468
  async function connectToBrowserWebSocket(host, port, browserWSEndpoint, logger, approvalWaitMs) {
469
+ const acquire = () => acquireBrowserConnection(browserWSEndpoint, async () => (await CDP({ target: browserWSEndpoint, local: true })));
450
470
  if (!approvalWaitMs || approvalWaitMs <= 0) {
451
- return (await CDP({ target: browserWSEndpoint, local: true }));
471
+ return acquire();
452
472
  }
453
473
  logger(`[browser] Waiting for Chrome remote debugging approval for ${host}:${port}...`);
454
474
  const startedAt = Date.now();
@@ -463,8 +483,8 @@ async function connectToBrowserWebSocket(host, port, browserWSEndpoint, logger,
463
483
  let timeout;
464
484
  let expired = false;
465
485
  try {
466
- const connecting = CDP({ target: browserWSEndpoint, local: true }).then(async (client) => {
467
- // An approval arriving after our deadline must not leak a connection.
486
+ const connecting = acquire().then(async (client) => {
487
+ // Release this waiter; another request may still be awaiting the same approval.
468
488
  if (expired)
469
489
  await client.close().catch(() => undefined);
470
490
  return client;
@@ -543,6 +563,37 @@ async function connectToNewTarget(host, port, url, logger, messages) {
543
563
  }
544
564
  function createSessionBoundChromeClient(browser, sessionId) {
545
565
  const browserWithEvents = browser;
566
+ const events = new EventEmitter();
567
+ const bridges = new Map();
568
+ let closing;
569
+ const remove = (name, listener) => {
570
+ events.removeListener(name, listener);
571
+ if (events.listenerCount(name) === 0) {
572
+ const bridge = bridges.get(name);
573
+ if (bridge)
574
+ browserWithEvents.removeListener(name, bridge);
575
+ bridges.delete(name);
576
+ }
577
+ };
578
+ const listen = (name, listener, once = false) => {
579
+ if (closing)
580
+ return () => { };
581
+ if (!bridges.has(name)) {
582
+ const bridge = (...args) => events.emit(name, ...args);
583
+ bridges.set(name, bridge);
584
+ browserWithEvents.on(name, bridge);
585
+ }
586
+ if (once)
587
+ events.once(name, listener);
588
+ else
589
+ events.on(name, listener);
590
+ return () => remove(name, listener);
591
+ };
592
+ const onDetached = (event) => {
593
+ if (event.sessionId === sessionId)
594
+ events.emit("disconnect");
595
+ };
596
+ browserWithEvents.on("Target.detachedFromTarget", onDetached);
546
597
  const bindDomain = (domainName) => {
547
598
  const domain = browser[domainName];
548
599
  const eventName = (name) => `${domainName}.${name}.${sessionId}`;
@@ -550,25 +601,34 @@ function createSessionBoundChromeClient(browser, sessionId) {
550
601
  get(target, prop, receiver) {
551
602
  if (prop === "on") {
552
603
  return (name, listener) => {
553
- const domainEvent = target[name];
554
- if (typeof domainEvent === "function") {
555
- return domainEvent(sessionId, listener);
556
- }
557
- browserWithEvents.on(eventName(name), listener);
558
- return () => browserWithEvents.removeListener(eventName(name), listener);
604
+ return listen(eventName(name), listener);
559
605
  };
560
606
  }
561
607
  if (prop === "off" || prop === "removeListener") {
562
608
  return (name, listener) => {
563
- const off = browserWithEvents.off ?? browserWithEvents.removeListener.bind(browserWithEvents);
564
- off(eventName(name), listener);
609
+ remove(eventName(name), listener);
565
610
  };
566
611
  }
567
612
  const value = Reflect.get(target, prop, receiver);
568
613
  if (typeof value !== "function") {
569
614
  return value;
570
615
  }
571
- return (...args) => value(...args, sessionId);
616
+ if (value.category === "event") {
617
+ return (listener) => listener
618
+ ? listen(eventName(String(prop)), listener)
619
+ : new Promise((resolve) => listen(eventName(String(prop)), resolve, true));
620
+ }
621
+ return (...args) => {
622
+ if (closing)
623
+ return Promise.reject(new Error("Chrome page session is closed."));
624
+ if (typeof args[0] === "function") {
625
+ return value({}, sessionId, args[0]);
626
+ }
627
+ if (typeof args[1] === "function") {
628
+ return value(args[0], sessionId, args[1]);
629
+ }
630
+ return value(...args, sessionId);
631
+ };
572
632
  },
573
633
  });
574
634
  };
@@ -587,14 +647,23 @@ function createSessionBoundChromeClient(browser, sessionId) {
587
647
  Input: bindDomain("Input"),
588
648
  DOM: bindDomain("DOM"),
589
649
  Emulation: bindDomain("Emulation"),
590
- on: browserWithEvents.on.bind(browserWithEvents),
591
- once: browserWithEvents.once.bind(browserWithEvents),
592
- off: browserWithEvents.off?.bind(browserWithEvents) ??
593
- browserWithEvents.removeListener.bind(browserWithEvents),
594
- removeListener: browserWithEvents.removeListener.bind(browserWithEvents),
595
- close: async () => {
596
- await browser.Target.detachFromTarget({ sessionId }).catch(() => undefined);
597
- },
650
+ on: (name, listener) => listen(name, listener),
651
+ once: (name, listener) => listen(name, listener, true),
652
+ off: remove,
653
+ removeListener: remove,
654
+ close: () => (closing ??= (async () => {
655
+ for (const [name, bridge] of bridges)
656
+ browserWithEvents.removeListener(name, bridge);
657
+ bridges.clear();
658
+ events.removeAllListeners();
659
+ browserWithEvents.removeListener("Target.detachedFromTarget", onDetached);
660
+ try {
661
+ await browser.Target.detachFromTarget({ sessionId }).catch(() => undefined);
662
+ }
663
+ finally {
664
+ await browser.close();
665
+ }
666
+ })()),
598
667
  };
599
668
  }
600
669
  export async function connectWithNewTab(port, logger, initialUrl, host, options) {