opera-devtools-mcp 0.7.0 → 0.8.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 (55) hide show
  1. package/README.md +1 -1
  2. package/build/src/ToolHandler.js +9 -2
  3. package/build/src/bin/chrome-devtools.js +30 -97
  4. package/build/src/bin/opera-browser-cli.js +102 -0
  5. package/build/src/bin/opera-devtools-mcp.js +20 -1
  6. package/build/src/browser.js +18 -9
  7. package/build/src/daemon/client.js +46 -40
  8. package/build/src/daemon/daemon.js +62 -39
  9. package/build/src/opera/branding.js +4 -2
  10. package/build/src/opera/browserActivity.js +62 -0
  11. package/build/src/opera/browserCleanup.js +123 -0
  12. package/build/src/opera/browserErrors.js +66 -0
  13. package/build/src/opera/browserFlags.js +184 -38
  14. package/build/src/opera/browserTarget.js +513 -0
  15. package/build/src/opera/cdpErrors.js +391 -0
  16. package/build/src/opera/cliCommands.js +378 -0
  17. package/build/src/opera/cliOutput.js +284 -0
  18. package/build/src/opera/compactSnapshot.js +525 -0
  19. package/build/src/opera/config.js +166 -0
  20. package/build/src/opera/daemonLifecycle.js +257 -0
  21. package/build/src/opera/daemonLog.js +103 -0
  22. package/build/src/opera/daemonPidFile.js +83 -0
  23. package/build/src/opera/daemonShutdown.js +66 -0
  24. package/build/src/opera/daemonSocket.js +87 -0
  25. package/build/src/opera/daemonStreaming.js +130 -0
  26. package/build/src/opera/daemonToolCall.js +26 -0
  27. package/build/src/opera/detect.js +114 -0
  28. package/build/src/opera/doctor.js +317 -0
  29. package/build/src/opera/envConfig.js +229 -0
  30. package/build/src/opera/launcherNotice.js +116 -0
  31. package/build/src/opera/legacyBridgeCleanup.js +297 -0
  32. package/build/src/opera/logs.js +133 -0
  33. package/build/src/opera/mcpServerSupervisor.js +128 -0
  34. package/build/src/opera/migrationShared.js +164 -0
  35. package/build/src/opera/operaPages.js +56 -0
  36. package/build/src/opera/pageIdRouting.js +35 -0
  37. package/build/src/opera/pageRecovery.js +53 -0
  38. package/build/src/opera/profile.js +270 -0
  39. package/build/src/opera/refArgs.js +36 -0
  40. package/build/src/opera/serviceWorkerRetry.js +46 -4
  41. package/build/src/opera/setup.js +290 -0
  42. package/build/src/opera/skills/SKILL.md +160 -0
  43. package/build/src/opera/streamingTools.js +73 -0
  44. package/build/src/opera/suggestions.js +67 -0
  45. package/build/src/opera/toolHandlerHooks.js +25 -1
  46. package/build/src/opera/tools/opera.js +107 -38
  47. package/build/src/opera/urlResolver.js +69 -0
  48. package/build/src/opera/webStorageWarning.js +92 -0
  49. package/build/src/third_party/devtools-formatter-worker.js +1 -0
  50. package/build/src/third_party/devtools-heap-snapshot-worker.js +1 -0
  51. package/build/src/third_party/index.js +2 -1
  52. package/build/src/utils/url.js +6 -0
  53. package/build/src/version.js +1 -1
  54. package/package.json +12 -10
  55. package/build/src/bin/opera-devtools.js +0 -10
@@ -0,0 +1,133 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * `logs` — tail the daemon's log, optionally following it or filtering to the
9
+ * lines that matter.
10
+ *
11
+ * Ported from opera-browser-cli's `src/cli.ts`. The file is the daemon's own
12
+ * stdout/stderr (`opera/daemonLog.ts`), so everything the source read from the
13
+ * bridge log is here: no extra state, and one less process to keep alive.
14
+ *
15
+ * Rotation is the daemon's, done on startup, so unlike the source there is no
16
+ * rotation command — `followLog` still handles a shrinking file, because
17
+ * `doctor --fix` can rotate under a running tail.
18
+ */
19
+ import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, } from 'node:fs';
20
+ import { CLI_BIN_NAME } from './branding.js';
21
+ import { encode, renderHelp, renderOutput } from './cliOutput.js';
22
+ import { getDaemonLogPath } from './daemonLog.js';
23
+ const LOGS_DEFAULT_LINES = 50;
24
+ export function parseLogsArgs(args) {
25
+ let lines = LOGS_DEFAULT_LINES;
26
+ let follow = false;
27
+ let errorsOnly = false;
28
+ for (let i = 0; i < args.length; i++) {
29
+ if ((args[i] === '-n' || args[i] === '--lines') && i + 1 < args.length) {
30
+ const parsed = Number.parseInt(args[++i] ?? '', 10);
31
+ if (Number.isFinite(parsed) && parsed > 0) {
32
+ lines = parsed;
33
+ }
34
+ }
35
+ else if (args[i] === '-f' || args[i] === '--follow') {
36
+ follow = true;
37
+ }
38
+ else if (args[i] === '--errors') {
39
+ errorsOnly = true;
40
+ }
41
+ }
42
+ return { lines, follow, errorsOnly };
43
+ }
44
+ /** The lines worth looking at when something has gone wrong. */
45
+ const LOG_ERROR_PATTERN = /error|failed|fatal|exception|refused|denied|timeout|timed out|in use|EADDRINUSE|ECONNREFUSED|EACCES|not found|unauthorized|cannot/i;
46
+ export function filterLogLines(lines, errorsOnly) {
47
+ return errorsOnly
48
+ ? lines.filter(line => LOG_ERROR_PATTERN.test(line))
49
+ : lines;
50
+ }
51
+ /** Stream appended log output until interrupted. */
52
+ async function followLog(logFile, errorsOnly) {
53
+ let offset = existsSync(logFile) ? statSync(logFile).size : 0;
54
+ let stop = false;
55
+ const onSigint = () => {
56
+ stop = true;
57
+ };
58
+ process.on('SIGINT', onSigint);
59
+ try {
60
+ while (!stop) {
61
+ await new Promise(resolve => setTimeout(resolve, 500));
62
+ if (!existsSync(logFile)) {
63
+ continue;
64
+ }
65
+ const size = statSync(logFile).size;
66
+ // A rotation shrinks the file; start over from the top of the new one.
67
+ if (size < offset) {
68
+ offset = 0;
69
+ }
70
+ if (size === offset) {
71
+ continue;
72
+ }
73
+ const fd = openSync(logFile, 'r');
74
+ try {
75
+ const buffer = Buffer.alloc(size - offset);
76
+ readSync(fd, buffer, 0, buffer.length, offset);
77
+ offset = size;
78
+ const fresh = filterLogLines(buffer.toString('utf-8').split('\n').filter(Boolean), errorsOnly);
79
+ if (fresh.length > 0) {
80
+ process.stdout.write(fresh.join('\n') + '\n');
81
+ }
82
+ }
83
+ finally {
84
+ closeSync(fd);
85
+ }
86
+ }
87
+ }
88
+ finally {
89
+ process.off('SIGINT', onSigint);
90
+ }
91
+ }
92
+ export async function handleLogs(args, sessionId) {
93
+ const { lines, follow, errorsOnly } = parseLogsArgs(args);
94
+ const logFile = getDaemonLogPath(sessionId);
95
+ if (!existsSync(logFile)) {
96
+ return renderOutput([
97
+ await encode({ logs: 'no log file yet', path: logFile }),
98
+ renderHelp([
99
+ `Run any command (e.g. \`${CLI_BIN_NAME} start\`) to start the daemon`,
100
+ ]),
101
+ ]);
102
+ }
103
+ const allLines = readFileSync(logFile, 'utf-8').split('\n');
104
+ // Drop the trailing empty line from the final newline.
105
+ if (allLines.length > 0 && allLines[allLines.length - 1] === '') {
106
+ allLines.pop();
107
+ }
108
+ const matched = filterLogLines(allLines, errorsOnly);
109
+ const tail = matched.slice(-lines);
110
+ if (follow) {
111
+ process.stdout.write(renderOutput([
112
+ await encode({ path: logFile, following: true, errors_only: errorsOnly }),
113
+ tail.join('\n'),
114
+ ]) + '\n');
115
+ await followLog(logFile, errorsOnly);
116
+ return '';
117
+ }
118
+ return renderOutput([
119
+ await encode({
120
+ path: logFile,
121
+ lines: tail.length,
122
+ total: allLines.length,
123
+ ...(errorsOnly ? { matched: matched.length } : {}),
124
+ }),
125
+ tail.join('\n'),
126
+ renderHelp([
127
+ `Run \`${CLI_BIN_NAME} logs --lines <N>\` to show more (default ${LOGS_DEFAULT_LINES})`,
128
+ `Run \`${CLI_BIN_NAME} logs --errors\` to show only failure lines`,
129
+ `Run \`${CLI_BIN_NAME} logs --follow\` to stream new output`,
130
+ ]),
131
+ ]);
132
+ }
133
+ //# sourceMappingURL=logs.js.map
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * Keeps the daemon's MCP server alive.
9
+ *
10
+ * The MCP server is a stdio child of the daemon, and the SDK's transport already
11
+ * reports its exit through `onclose` — upstream simply never sets that callback.
12
+ * The daemon therefore outlives its only means of serving tools: it keeps
13
+ * answering `status` while every tool call fails, and the CLI has no way to tell
14
+ * that the session is useless.
15
+ *
16
+ * Two things make the callback harder than it looks. The close that the respawn
17
+ * itself causes — releasing the dead server — lands in the same callback as a
18
+ * real failure, so the transport object identifies itself and its own retirement
19
+ * is ignored. And a replacement can die while `connect()` is still running,
20
+ * before it is anybody's idea of "the current server": that close arrives while
21
+ * the respawn is in flight and is recorded, so the attempt is not mistaken for a
22
+ * healthy one.
23
+ *
24
+ * `giveUp` exists for the same reason in the other direction: a daemon that
25
+ * cannot be brought back to health must not keep answering. It records why,
26
+ * tears itself down, and leaves a working daemon to be started in its place
27
+ * instead of a zombie that reports itself ready.
28
+ */
29
+ import { setTimeout as sleep } from 'node:timers/promises';
30
+ import { logger } from '../utils/logger.js';
31
+ import { writeExitReason } from './daemonLifecycle.js';
32
+ const RESPAWN_ATTEMPTS = 3;
33
+ const RESPAWN_DELAY_MS = 500;
34
+ export function superviseMcpServer(options) {
35
+ let stopped = false;
36
+ let respawning = false;
37
+ /** A close that arrived while a respawn was already in flight. */
38
+ let respawnRequested = false;
39
+ /** The transport this supervisor closed itself, whose close is not a failure. */
40
+ let retired = null;
41
+ /**
42
+ * Let go of the server that just died, and remember it, so the close this
43
+ * causes is not read back as a second failure.
44
+ */
45
+ async function releaseHandles() {
46
+ const { client, transport } = options.handles();
47
+ if (transport) {
48
+ retired = transport;
49
+ }
50
+ try {
51
+ await client?.close();
52
+ }
53
+ catch (error) {
54
+ logger?.('Error closing MCP client:', error);
55
+ }
56
+ try {
57
+ // The client closes the transport too. The transport is still closed on
58
+ // its own because it is the thing that must not outlive the respawn, and
59
+ // a client that failed between construction and `connect` left it loose.
60
+ await transport?.close();
61
+ }
62
+ catch (error) {
63
+ logger?.('Error closing MCP transport:', error);
64
+ }
65
+ }
66
+ async function respawn() {
67
+ // `stop()` comes from the daemon's own teardown, which closes the client and
68
+ // therefore the transport and therefore lands here: a teardown must not
69
+ // spawn a replacement. `respawning` keeps a burst of closes to one attempt,
70
+ // remembering that another one arrived.
71
+ if (stopped) {
72
+ return;
73
+ }
74
+ if (respawning) {
75
+ respawnRequested = true;
76
+ return;
77
+ }
78
+ respawning = true;
79
+ try {
80
+ let lastError;
81
+ for (let attempt = 1; attempt <= RESPAWN_ATTEMPTS; attempt++) {
82
+ await releaseHandles();
83
+ // Any close from here on belongs to the server we are about to spawn,
84
+ // not to the one just released.
85
+ respawnRequested = false;
86
+ try {
87
+ await options.connect();
88
+ // A server that died while `connect` was still running reported itself
89
+ // through `onclose`, which set the flag while it was already in flight.
90
+ // `connect` resolving then proves nothing: retry rather than sit here
91
+ // believing a dead server is up.
92
+ if (!respawnRequested) {
93
+ return;
94
+ }
95
+ lastError = new Error('the MCP server died during startup');
96
+ }
97
+ catch (error) {
98
+ lastError = error;
99
+ }
100
+ if (attempt < RESPAWN_ATTEMPTS) {
101
+ await sleep(RESPAWN_DELAY_MS);
102
+ }
103
+ }
104
+ throw lastError;
105
+ }
106
+ catch (err) {
107
+ const reason = `MCP server respawn failed after ${RESPAWN_ATTEMPTS} attempts: ${err instanceof Error ? err.message : String(err)}`;
108
+ logger?.(reason);
109
+ writeExitReason(options.sessionId, reason);
110
+ await options.giveUp();
111
+ }
112
+ finally {
113
+ respawning = false;
114
+ }
115
+ }
116
+ return {
117
+ onTransportClosed: transport => {
118
+ if (transport === retired) {
119
+ return;
120
+ }
121
+ void respawn();
122
+ },
123
+ stop: () => {
124
+ stopped = true;
125
+ },
126
+ };
127
+ }
128
+ //# sourceMappingURL=mcpServerSupervisor.js.map
@@ -0,0 +1,164 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * Plumbing shared by the one-time two-package migration.
9
+ *
10
+ * `opera-browser-cli` used to ship as its own npm package; it now ships as a
11
+ * second bin inside `opera-devtools-mcp`, reached through a launcher published
12
+ * under the old name. The runtime migration guard (`legacyBridgeCleanup`) and
13
+ * the notices it prints (`launcherNotice`) need the same four facts:
14
+ *
15
+ * 1. Whether the migration is still worth running at all — the guard must not
16
+ * outlive its reason for existing (see `MIGRATION_ACTIVE_UNTIL`).
17
+ * 2. Which home directory to look in. The old bridge is rarely started under
18
+ * `sudo`, but the machine may be, and `os.homedir()` then answers for root
19
+ * (`/var/root`, `/root`) while the invoking user's
20
+ * `~/.opera-browser-cli/bridge.pid` is never found.
21
+ * 3. Where a global install put the old package — without assuming npm, whose
22
+ * `npm_config_prefix` is not set by pnpm, yarn or bun.
23
+ * 4. How to say something. The guard must never print to stdout, which carries
24
+ * the CLI's machine-readable result and the MCP server's transport.
25
+ */
26
+ import { execFileSync } from 'node:child_process';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
29
+ import process from 'node:process';
30
+ import { setTimeout as sleep } from 'node:timers/promises';
31
+ import { PACKAGE_NAME } from './branding.js';
32
+ /**
33
+ * The last day the migration guard does its work. Past it the guard returns
34
+ * immediately, so a cleanup written for one transition cannot keep probing port
35
+ * windows — or keep printing its notices — forever.
36
+ */
37
+ export const MIGRATION_ACTIVE_UNTIL = new Date('2027-03-01T00:00:00Z');
38
+ /** Whether the migration guard should still do its work. */
39
+ export function isMigrationActive(now = new Date()) {
40
+ return now.getTime() <= MIGRATION_ACTIVE_UNTIL.getTime();
41
+ }
42
+ /** Say something to the user without touching the CLI's stdout. */
43
+ export function warn(message) {
44
+ process.stderr.write(`${message}\n`);
45
+ }
46
+ /** How long a SIGTERMed process gets before the kill escalates. */
47
+ const STOP_GRACE_MS = 5_000;
48
+ const STOP_POLL_MS = 100;
49
+ /** `process.kill(pid, 0)`, treating `EPERM` (another user's live process) as alive. */
50
+ export function isProcessAlive(pid) {
51
+ try {
52
+ process.kill(pid, 0);
53
+ return true;
54
+ }
55
+ catch (error) {
56
+ return error.code === 'EPERM';
57
+ }
58
+ }
59
+ /**
60
+ * SIGTERM, wait, then SIGKILL — and true once the process is gone.
61
+ *
62
+ * `group: true` escalates to the whole process group, which is what a session
63
+ * leader's teardown needs: the daemon and the legacy bridge are both spawned
64
+ * `detached: true`, so the browser and the MCP server they spawned share their
65
+ * group and survive a signal aimed at the leader alone. Windows has no process
66
+ * groups, so the pid is all there is to signal.
67
+ */
68
+ export async function terminateProcess(pid, options = {}) {
69
+ try {
70
+ process.kill(pid, 'SIGTERM');
71
+ }
72
+ catch {
73
+ // `ESRCH` is the process we meant to stop being gone already. Anything else
74
+ // — `EPERM` on a process owned by another user, the common case for a
75
+ // bridge started with `sudo` — means the signal was not delivered, and
76
+ // reporting success there would hide a bridge that is still running and let
77
+ // its caller drop the PID file that records it.
78
+ return !isProcessAlive(pid);
79
+ }
80
+ const deadline = Date.now() + STOP_GRACE_MS;
81
+ while (Date.now() < deadline) {
82
+ if (!isProcessAlive(pid)) {
83
+ return true;
84
+ }
85
+ await sleep(STOP_POLL_MS);
86
+ }
87
+ try {
88
+ if (options.group && process.platform !== 'win32') {
89
+ process.kill(-pid, 'SIGKILL');
90
+ }
91
+ else {
92
+ process.kill(pid, 'SIGKILL');
93
+ }
94
+ }
95
+ catch {
96
+ return !isProcessAlive(pid);
97
+ }
98
+ await sleep(200);
99
+ return !isProcessAlive(pid);
100
+ }
101
+ /**
102
+ * The home directory of the user who invoked the install.
103
+ *
104
+ * Under `sudo`, that is not `os.homedir()`: `SUDO_USER`/`SUDO_UID` name the
105
+ * real user, and their home is what holds the legacy bridge's PID file. The
106
+ * lookup goes through the login shell because `~user` expansion is the only
107
+ * portable way to ask (Node exposes no uid→home call), and the username is
108
+ * validated first so it cannot smuggle shell syntax into the command.
109
+ *
110
+ * Windows is skipped: it has no `sudo`, and the `sh` that happens to be on PATH
111
+ * (Git Bash) answers `~user` with a POSIX path like `/c/Users/someone` — not the
112
+ * `os.homedir()` every other caller of this function builds its paths with.
113
+ */
114
+ export function getEffectiveHome() {
115
+ const user = process.env.SUDO_USER;
116
+ if (process.platform !== 'win32' &&
117
+ user &&
118
+ process.env.SUDO_UID &&
119
+ /^[A-Za-z0-9._-]+$/.test(user)) {
120
+ try {
121
+ const home = execFileSync('sh', ['-c', `echo ~${user}`], {
122
+ encoding: 'utf8',
123
+ timeout: 5_000,
124
+ }).trim();
125
+ if (home.startsWith('/')) {
126
+ return home;
127
+ }
128
+ }
129
+ catch {
130
+ // Fall through to the process's own home.
131
+ }
132
+ }
133
+ return os.homedir();
134
+ }
135
+ /**
136
+ * The global install prefix, or null when it cannot be determined.
137
+ *
138
+ * Two sources, neither of which spawns a process: the environment npm and pnpm
139
+ * set during a global install, and this file's own location —
140
+ * `<prefix>/lib/node_modules/<pkg>/build/src/opera`. Asking npm was the old
141
+ * fallback, but this runs on every start now, and a second npm costs ~1s of
142
+ * process start and contends on the arborist lock npm 7+ holds during a global
143
+ * install. A development checkout, whose path is not inside a global
144
+ * `node_modules` tree, correctly reports nothing.
145
+ */
146
+ export function resolveGlobalPrefix() {
147
+ const fromEnv = process.env.npm_config_prefix ?? process.env.NPM_CONFIG_PREFIX ?? '';
148
+ if (fromEnv) {
149
+ return fromEnv;
150
+ }
151
+ // Symlinks are resolved by default, so a pnpm/bun-linked install reports the
152
+ // store path and this finds no `lib/node_modules` — no prefix, no notice.
153
+ const parts = path.resolve(import.meta.dirname).split(path.sep);
154
+ const packageDir = parts.lastIndexOf(PACKAGE_NAME);
155
+ if (packageDir < 2 || parts[packageDir - 1] !== 'node_modules') {
156
+ return null;
157
+ }
158
+ const prefix = parts.slice(0, packageDir - 1);
159
+ if (prefix[prefix.length - 1] === 'lib') {
160
+ prefix.pop();
161
+ }
162
+ return prefix.join(path.sep) || path.sep;
163
+ }
164
+ //# sourceMappingURL=migrationShared.js.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * Opera's own pages, as far as the URL policy is concerned.
9
+ *
10
+ * Upstream lets Chrome's start pages through — `chrome://newtab` and
11
+ * `chrome://new-tab-page` — because the page a browser opens by itself is often
12
+ * the only page there is, and refusing it would leave `list_pages` reporting an
13
+ * empty browser. Opera's equivalent is the Speed Dial, and it is not a corner
14
+ * case: `chrome://startpageshared/` is the URL of *every* tab a user opens from
15
+ * the tab strip, before that tab navigates anywhere.
16
+ *
17
+ * Rejecting it costs more than a missing line of output, because the same
18
+ * predicate is also the Puppeteer target filter (`makeTargetFilter`). A target
19
+ * the filter refuses is detached at attach time and recorded as ignored
20
+ * (`TargetManager`), and `TargetManager` skips a target that is ignored on
21
+ * every later `Target.targetInfoChanged` — so the tab is never attached, never
22
+ * tracked, and stays invisible to `list_pages` for the rest of its life, even
23
+ * after the user navigates it to a real site.
24
+ *
25
+ * Upstream's own file keeps its shape; this is the one rule that decides which
26
+ * Opera URLs it must additionally accept.
27
+ */
28
+ /**
29
+ * Speed Dial paths under `chrome://`. `startpageshared` is the one Opera 100+
30
+ * loads (and the one Opera records for its tabs); `startpage` is the older
31
+ * spelling, kept so a build that still uses it is not filtered.
32
+ */
33
+ const OPERA_START_PAGE_PATHS = {
34
+ startpage: true,
35
+ startpageshared: true,
36
+ };
37
+ /**
38
+ * Whether the URL is one of Opera's Speed Dial pages.
39
+ *
40
+ * Accepts both the `chrome://startpageshared/` and the single-slash
41
+ * `chrome:startpageshared` spelling: `new URL` parses the latter with an empty
42
+ * host, which is how upstream's `newtab` allowance is written too.
43
+ */
44
+ export function isOperaStartPage(url) {
45
+ if (url.protocol !== 'chrome:') {
46
+ return false;
47
+ }
48
+ const host = url.hostname.toLowerCase();
49
+ if (OPERA_START_PAGE_PATHS[host] === true) {
50
+ return true;
51
+ }
52
+ return (host === '' &&
53
+ OPERA_START_PAGE_PATHS[url.pathname.toLowerCase().split('/')[0] ?? ''] ===
54
+ true);
55
+ }
56
+ //# sourceMappingURL=operaPages.js.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * The routing `pageId` positional is the one upstream injects into page-scoped
9
+ * commands; its description always opens with this sentence. Upstream appends a
10
+ * caveat for `evaluate_script` ("Required when not evaluating in a service
11
+ * worker."), so match the prefix rather than the whole string. The two commands
12
+ * with a `pageId` of their own (`select_page`, `close_page`) describe it in
13
+ * their own words and are left alone.
14
+ *
15
+ * Upstream owns this string, so `tests/opera/pageIdRouting.test.ts` pins it
16
+ * against the generated command table: if an intake merge rewords it, that test
17
+ * fails instead of the CLI silently regaining a pageId positional. The durable
18
+ * fix is an explicit marker on the upstream `ArgDef`.
19
+ */
20
+ const ROUTING_PAGE_ID_DESCRIPTION = 'Targets a specific page by ID.';
21
+ /**
22
+ * Drop the routing-injected `pageId` positional from a command's arg map. The
23
+ * CLI never routes by pageId, and the daemon it spawns runs with
24
+ * `--no-page-id-routing`, so a pageId left on the CLI surface is not merely
25
+ * useless — the daemon rejects it as an unknown argument.
26
+ */
27
+ export function withoutRoutingPageId(args) {
28
+ if (args.pageId?.description.startsWith(ROUTING_PAGE_ID_DESCRIPTION)) {
29
+ // `_pageId` is the discarded key; the eslint config ignores `^_` vars.
30
+ const { pageId: _pageId, ...rest } = args;
31
+ return rest;
32
+ }
33
+ return args;
34
+ }
35
+ //# sourceMappingURL=pageIdRouting.js.map
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Opera Norway AS. All rights reserved.
4
+ *
5
+ * This file is an original work developed by Opera.
6
+ */
7
+ /**
8
+ * The in-flight recovery, per context. Scoped to the context because that is all
9
+ * the sharing there is: Opera tools bypass the tool mutex, so two of them
10
+ * resolving a page at the same moment share one new page rather than open one
11
+ * each, and each caller reports the note in its own response.
12
+ */
13
+ const recoveries = new WeakMap();
14
+ async function openRecoveryPage(context) {
15
+ const inFlight = recoveries.get(context);
16
+ if (inFlight) {
17
+ return await inFlight;
18
+ }
19
+ // Cleared as soon as it settles — on rejection as well as on success, so a
20
+ // failed recovery never poisons the entry: the next caller tries again.
21
+ const started = context.newPage().finally(() => {
22
+ recoveries.delete(context);
23
+ });
24
+ recoveries.set(context, started);
25
+ return await started;
26
+ }
27
+ /**
28
+ * The page a page-scoped tool should act on.
29
+ *
30
+ * Cheap on the happy path: the strict accessor answers it without listing pages
31
+ * or touching CDP, so only a selection that has actually gone costs anything.
32
+ */
33
+ export async function resolveSelectedPage(context, response) {
34
+ try {
35
+ return context.getSelectedMcpPage();
36
+ }
37
+ catch {
38
+ // The selection is gone. The snapshot below is what can replace it.
39
+ }
40
+ await context.createPagesSnapshot();
41
+ try {
42
+ const selected = context.getSelectedMcpPage();
43
+ response?.appendResponseLine(`Note: the previously selected page was closed. Page ${selected.id} is now selected.`);
44
+ return selected;
45
+ }
46
+ catch {
47
+ // Still nothing to select: the browser has no pages at all.
48
+ }
49
+ const opened = await openRecoveryPage(context);
50
+ response?.appendResponseLine(`Note: the browser had no open pages, so a new one was opened. Page ${opened.id} is now selected.`);
51
+ return opened;
52
+ }
53
+ //# sourceMappingURL=pageRecovery.js.map