@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.
- package/dist/bin/oracle-cli.js +66 -25
- package/dist/src/browser/actions/modelSelection.js +36 -4
- package/dist/src/browser/actions/thinkingTime.js +11 -5
- package/dist/src/browser/browserConnection.js +59 -0
- package/dist/src/browser/chatgptImages.js +1 -1
- package/dist/src/browser/chromeLifecycle.js +105 -36
- package/dist/src/browser/index.js +41 -8
- package/dist/src/browser/liveTabs.js +94 -29
- package/dist/src/browser/profileState.js +6 -1
- package/dist/src/browser/promptFingerprint.js +54 -0
- package/dist/src/browser/reattach.js +157 -108
- package/dist/src/browser/recoveryTarget.js +31 -6
- package/dist/src/browser/sessionRunner.js +30 -7
- package/dist/src/browser/targetClaim.js +2 -2
- package/dist/src/cli/browserDefaults.js +3 -0
- package/dist/src/cli/browserTabs.js +105 -20
- package/dist/src/cli/detach.js +10 -1
- package/dist/src/cli/detachedSession.js +36 -0
- package/dist/src/cli/errorUtils.js +9 -0
- package/dist/src/cli/sessionDisplay.js +11 -3
- package/dist/src/cli/sessionRunner.js +150 -37
- package/dist/src/mcp/tools/consult.js +1 -7
- package/dist/src/remote/client.js +71 -14
- package/dist/src/remote/health.js +1 -0
- package/dist/src/remote/server.js +52 -10
- package/dist/src/remote/types.js +13 -0
- package/dist/src/sessionManager.js +8 -1
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/package.json +9 -9
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
package/dist/bin/oracle-cli.js
CHANGED
|
@@ -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
|
-
|
|
1680
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
1699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1951
|
+
if (!cancelled)
|
|
1952
|
+
console.error(chalk.red(message));
|
|
1915
1953
|
return;
|
|
1916
1954
|
}
|
|
1917
|
-
|
|
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")
|
|
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
|
|
2065
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1034
|
-
//
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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 (
|
|
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 =
|
|
467
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
591
|
-
once:
|
|
592
|
-
off:
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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) {
|