opera-devtools-mcp 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,160 @@
1
+ ---
2
+ name: opera-browser-cli
3
+ description: Browser automation and web interaction using the opera-browser-cli tool. Use for navigating pages, clicking elements, filling forms, taking screenshots, inspecting console/network, running performance audits, and Opera AI features (chat available on any Opera browser; invoke_do, opera_make, opera_research require Opera Neon).
4
+ metadata: {'openclaw': {'requires': {'bins': ['opera-browser-cli']}}}
5
+ ---
6
+
7
+ # Skill: opera-browser-cli Browser Automation
8
+
9
+ `opera-browser-cli` drives an Opera browser session through the
10
+ `opera-devtools-mcp` daemon. Every command is an MCP tool name.
11
+
12
+ - **Page and DevTools commands** (`new_page`, `take_snapshot`, `click`, `fill`,
13
+ `take_screenshot`, `list_pages`, `list_console_messages`,
14
+ `list_network_requests`, `lighthouse_audit`, the `*_heapsnapshot_*` family,
15
+ `emulate`, `screencast_start`, …) work with any Opera browser.
16
+ - **`opera_chat`** — available on any Opera browser. Pass `--model <id>` to
17
+ select a model and `--conversation_id <id>` to continue a conversation. List
18
+ models with `opera_list_models`.
19
+ - **`opera_do`, `opera_make`, `opera_research`** — require **Opera Neon** with an
20
+ active sign-in. `opera_make` accepts `--conversation_id <id>` to continue an
21
+ existing conversation.
22
+ - **Opera AI MCP passthrough** (`opera_list_mcp_servers`, `opera_list_mcp_tools`,
23
+ `opera_call_mcp_tool`, `opera_register_mcp_server`, `opera_authenticate_mcp_server`,
24
+ `opera_unregister_mcp_server`, `opera_enable_mcp_server`,
25
+ `opera_disable_mcp_server`, `opera_connect_mcp_server`) — require Opera Neon.
26
+ - **Research conversations are not resumable**: each research run creates a fresh
27
+ conversation with no `--conversation_id` flag. The ID it prints can be used
28
+ with `opera_chat` or `opera_make` for follow-ups in the same context.
29
+
30
+ Run `opera-browser-cli --help` for the full command list, or
31
+ `opera-browser-cli <command> --help` for one command's positionals and flags.
32
+
33
+ ```bash
34
+ opera-browser-cli new_page https://example.com # start here
35
+ ```
36
+
37
+ ## Calling convention
38
+
39
+ Required parameters are **positional**; optional ones are `--flags`. Tool
40
+ parameter names are snake_case, exactly as the MCP tool declares them.
41
+
42
+ ```bash
43
+ opera-browser-cli click 1_4 --dblClick true # NOT: click --uid 1_4
44
+ ```
45
+
46
+ Run `opera-browser-cli <command> --help` to see the exact shape.
47
+
48
+ Element refs are accepted in either form — `@2.4` as the snapshot prints it, or
49
+ the wire form `2_4` — on `click`, `fill`, `hover`, `drag`, `upload_file`,
50
+ `take_screenshot --uid`, and `url`.
51
+
52
+ ## Snapshot format
53
+
54
+ Snapshots are **compact** by default: internal role names are shortened, refs use
55
+ the `@PAGE.ELEM` form (e.g. `@2.4`), headings become markdown, and redundant ARIA
56
+ attributes are stripped. Every command that returns a snapshot also prints
57
+ contextual `help[N]:` suggestions for the next step.
58
+
59
+ Pass `--raw` on any command to get the unprocessed MCP output instead, or
60
+ `--full` to keep the complete snapshot without truncation.
61
+
62
+ Repeated or very long URLs in compact output are replaced with `$uN` tokens, and
63
+ a `urls:` trailer lists what each token resolves to. Both the body and the
64
+ trailer keep the shortened (origin-stripped) form; `url` prints the full URL:
65
+
66
+ ```
67
+ @2.4 link "Download" url=$u1
68
+ ...
69
+ urls:
70
+ $u1 /downloads/installer-v3.2.1-x86_64.tar.gz
71
+ ```
72
+
73
+ ```bash
74
+ opera-browser-cli url $u1 # answered from the last snapshot's token map — no round-trip
75
+ opera-browser-cli url @2.4 # a ref is page state, so this takes a fresh snapshot
76
+ ```
77
+
78
+ ## The configured profile is already open
79
+
80
+ If Opera is already running on the profile `setup` selected — normally, without
81
+ a debugging port — the CLI cannot launch a second browser on it. It asks before
82
+ each command that has to pick a browser:
83
+
84
+ - On a terminal: `[1]` restarts Opera with a debugging port (tabs are restored),
85
+ `[2]` runs this command on a separate profile where the user is not signed in.
86
+ - With no terminal (which is how agents run it): `[2]`, no prompt. A note on
87
+ stderr names the profile it used instead.
88
+
89
+ `--takeover` restarts Opera without asking, for scripted callers. It closes and
90
+ reopens a browser the user may be using — pass it only with the user's
91
+ agreement, never as a retry. A daemon that is already running keeps the browser
92
+ it started with, so the question only comes up when one is being started, or on
93
+ `start`.
94
+
95
+ The same settling happens when a command fails with `A browser is already
96
+ running with the profile …`: the command is retried once on the browser that was
97
+ chosen, so that error is not something to work around. If it comes back again,
98
+ the conflict could not be settled — report it rather than retrying.
99
+
100
+ ## Long-running Opera AI commands stream
101
+
102
+ `opera_chat`, `opera_do`, `opera_make`, `opera_research`,
103
+ `opera_call_mcp_tool`, and `opera_authenticate_mcp_server` write their partial
104
+ output to **stderr** as it arrives; the final result goes to stdout.
105
+
106
+ A dropped connection mid-call is **not** retried for these six. They are
107
+ long-running, may be billable, and may already have acted on the page, so a
108
+ silent second run could double a booking as easily as it could double a bill.
109
+ Ask the user before re-running one.
110
+
111
+ ## Exit codes
112
+
113
+ Branch on the exit code rather than parsing messages:
114
+
115
+ | Code | Meaning | What to do |
116
+ | ---- | --------------------------------------------------- | -------------------------------------------------- |
117
+ | 0 | Success | — |
118
+ | 2 | Bad arguments, or the browser cannot do this | Fix the command; do not retry as-is |
119
+ | 3 | Environment not ready (daemon, browser, connection) | Run `opera-browser-cli doctor` |
120
+ | 4 | Sign-in, subscription, or consent needed | Ask the user — you cannot fix this |
121
+ | 5 | Timed out | Retry |
122
+ | 6 | Stale element ref, or a closed page | Re-run `take_snapshot`, then retry with fresh refs |
123
+ | 1 | Anything else | Report it |
124
+
125
+ ## Configuration
126
+
127
+ ```bash
128
+ opera-browser-cli setup # interactive; writes ~/.opera-browser-cli/config
129
+ opera-browser-cli setup --non-interactive # detect and write, no prompts
130
+ opera-browser-cli doctor # inspect config, browser, daemon, log
131
+ opera-browser-cli doctor --fix # repair what needs no decision
132
+ opera-browser-cli logs # tail the daemon log
133
+ opera-browser-cli logs --errors --follow # just the failures, streaming
134
+ ```
135
+
136
+ Settings live in `~/.opera-browser-cli/config` as `KEY="VALUE"` lines, and are
137
+ also readable from the environment (the environment wins):
138
+
139
+ | Key | Meaning |
140
+ | --------------------------- | -------------------------------------------------------------------- |
141
+ | `OPERA_CLI_EXECUTABLE_PATH` | Opera binary to launch |
142
+ | `OPERA_CLI_USER_DATA_DIR` | Persistent profile directory (an explicit `--isolated` wins over it) |
143
+ | `OPERA_CLI_HEADED` | `1` to run headed (visible) |
144
+ | `OPERA_CLI_CHROME_ARGS` | Whitespace-separated Chromium flags |
145
+ | `OPERA_CLI_BROWSER_URL` | Attach to an already-running browser instead of launching |
146
+
147
+ The config file is a cache of decisions, never a prerequisite: the first command
148
+ on a fresh machine detects the installed browser and configures itself.
149
+
150
+ ## Recovery is automatic
151
+
152
+ The daemon starts on demand and restarts itself on version skew, a crash, or a
153
+ dropped connection. Do not run `stop` speculatively — re-run the command. `stop`
154
+ exists for cleanup at the end of a session.
155
+
156
+ ## Sign-in errors
157
+
158
+ If an Opera AI command exits `4`, tell the user to sign in to their Opera account
159
+ in a visible window. `opera-browser-cli doctor` reports the configuration that
160
+ the AI tools depend on.
@@ -0,0 +1,73 @@
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 tools whose output arrives in pieces, and the ones that must not be
9
+ * replayed.
10
+ *
11
+ * Ported from opera-browser-cli's `src/client.ts` (`OPERA_AI_TOOLS`,
12
+ * `NON_REPLAYABLE_TOOLS`, `OPERA_AI_TIMEOUT`). The set is the same on both
13
+ * sides of the bridge: the browser streams `notifications/message` chunks while
14
+ * these run, and a dropped connection part-way through is never retried — the
15
+ * call may already have acted on the page or billed the account, so a silent
16
+ * second run could double a booking as easily as it could double a bill.
17
+ */
18
+ /** Tools that stream partial output and get the long timeout. */
19
+ export const OPERA_AI_TOOLS = {
20
+ opera_chat: true,
21
+ opera_do: true,
22
+ opera_research: true,
23
+ opera_make: true,
24
+ opera_call_mcp_tool: true,
25
+ opera_authenticate_mcp_server: true,
26
+ };
27
+ /**
28
+ * Tools that must never be replayed after a dropped connection.
29
+ *
30
+ * `sendCommand` is single-shot by construction (no retry loop), so this is the
31
+ * contract a test pins rather than a branch in the CLI.
32
+ */
33
+ export const NON_REPLAYABLE_TOOLS = OPERA_AI_TOOLS;
34
+ /** 20 minutes: a research run can legitimately take longer than any other tool. */
35
+ export const OPERA_AI_TIMEOUT_MS = 1_200_000;
36
+ /**
37
+ * How long a streamed action may report *nothing at all* before it is treated
38
+ * as one that never started.
39
+ *
40
+ * The browser's contract for these actions is an ack, then events: chunks while
41
+ * it works, and a completion or a failure at the end. `do` streams within
42
+ * seconds of its ack. An action that has emitted no event at all for five
43
+ * minutes is not a slow run — it is the failure a research tab sitting open
44
+ * with no prompt in it represents — and waiting out the twenty-minute cap only
45
+ * hides it. The deadline covers the first event alone: once one arrives the run
46
+ * is the browser's to finish, and a research run may legitimately be quiet
47
+ * before and between chunks.
48
+ *
49
+ * Mutable so tests can drive the deadline without waiting on real time, like
50
+ * `serviceWorkerRetryPolicy`.
51
+ */
52
+ export const operaAiStreamPolicy = {
53
+ firstEventTimeoutMs: 300_000,
54
+ };
55
+ /** Whether `toolName` is one of those long-running, streamed tools. */
56
+ export function isOperaAiTool(toolName) {
57
+ return Object.hasOwn(OPERA_AI_TOOLS, toolName);
58
+ }
59
+ /**
60
+ * The request timeout `toolName` gets, or `undefined` for the caller's default.
61
+ *
62
+ * Both ends of the chain have to ask this question, because both enforce a
63
+ * timeout of their own and the *shorter* one decides: the CLI's `sendCommand`
64
+ * on the socket, and the daemon's MCP client around `tools/call`. The daemon
65
+ * used to be left on the SDK's 60-second default, so a research run was killed
66
+ * there while the CLI was still waiting out its twenty minutes — and because a
67
+ * timed-out MCP request is cancelled, the tool was aborted mid-run in the
68
+ * browser too.
69
+ */
70
+ export function operaAiTimeoutMs(toolName) {
71
+ return isOperaAiTool(toolName) ? OPERA_AI_TIMEOUT_MS : undefined;
72
+ }
73
+ //# sourceMappingURL=streamingTools.js.map
@@ -0,0 +1,67 @@
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
+ import { CLI_BIN_NAME } from './branding.js';
8
+ import { extractRefs, isInputType } from './compactSnapshot.js';
9
+ /**
10
+ * A label that means "this button submits the form".
11
+ *
12
+ * Anchored, because the label is prose: `Sign in` and `Sign up` are submits, but
13
+ * `Sponsor`, `Booking` and `Register` merely contain `ok`, `go` and `sign`, and
14
+ * treating them as submits both suggests the wrong button after a `fill` and
15
+ * skips them when suggesting what else to click.
16
+ */
17
+ const SUBMIT_LABEL_PATTERN = /\b(?:submit|search|go|send|login|sign|ok)\b/i;
18
+ export function getSuggestions(ctx) {
19
+ // Commands without auto-snapshot — suggest viewing page state
20
+ if (ctx.command === 'wait' || ctx.command === 'eval') {
21
+ return [`Run \`${CLI_BIN_NAME} snapshot\` to see current page state`];
22
+ }
23
+ const refs = ctx.snapshot ? extractRefs(ctx.snapshot) : [];
24
+ const links = refs.filter(r => r.type === 'link');
25
+ const buttons = refs.filter(r => r.type === 'button');
26
+ const inputs = refs.filter(r => isInputType(r.type));
27
+ const lines = [];
28
+ // After filling a field, suggest submitting
29
+ if (ctx.command === 'fill') {
30
+ const submitBtn = buttons.find(r => SUBMIT_LABEL_PATTERN.test(r.label));
31
+ if (submitBtn) {
32
+ lines.push(`Run \`${CLI_BIN_NAME} click @${submitBtn.ref}\` to click "${submitBtn.label}"`);
33
+ }
34
+ else {
35
+ lines.push(`Run \`${CLI_BIN_NAME} press Enter\` to submit the form`);
36
+ }
37
+ }
38
+ // Suggest filling inputs (unless we just filled one)
39
+ if (inputs.length > 0 && ctx.command !== 'fill') {
40
+ const inp = inputs[0];
41
+ const label = inp.label ? `the "${inp.label}" field` : 'the input field';
42
+ lines.push(`Run \`${CLI_BIN_NAME} fill @${inp.ref} "text"\` to fill ${label}`);
43
+ }
44
+ // Suggest clicking buttons
45
+ if (buttons.length > 0) {
46
+ const btn = ctx.command === 'fill'
47
+ ? (buttons.find(r => !SUBMIT_LABEL_PATTERN.test(r.label)) ?? buttons[0])
48
+ : buttons[0];
49
+ if (btn && !lines.some(l => l.includes(`@${btn.ref}`))) {
50
+ const label = btn.label ? `"${btn.label}" ` : '';
51
+ lines.push(`Run \`${CLI_BIN_NAME} click @${btn.ref}\` to click the ${label}button`);
52
+ }
53
+ }
54
+ // Suggest clicking links
55
+ if (links.length > 0) {
56
+ const link = links[0];
57
+ lines.push(`Run \`${CLI_BIN_NAME} click @${link.ref}\` to click the "${link.label}" link`);
58
+ }
59
+ // Suggest scrolling if page has many elements
60
+ if (refs.length > 5) {
61
+ lines.push(`Run \`${CLI_BIN_NAME} scroll down\` to scroll down`);
62
+ }
63
+ // Teach eval syntax — use IIFE for multi-statement logic
64
+ lines.push(`Use \`${CLI_BIN_NAME} eval <expr>\` for JS expressions. For multi-statement code, wrap in an IIFE: \`eval "(() => { ...; return result })()"\``);
65
+ return lines;
66
+ }
67
+ //# sourceMappingURL=suggestions.js.map
@@ -5,6 +5,8 @@
5
5
  * This file is an original work developed by Opera.
6
6
  */
7
7
  import { ToolCategory } from '../tools/categories.js';
8
+ import { logger } from '../utils/logger.js';
9
+ import { noteToolFinished, noteToolStarted } from './browserActivity.js';
8
10
  import { ensureBrowserFlagsForTool } from './browserFlags.js';
9
11
  export function createOperaToolHooks(deps) {
10
12
  return {
@@ -12,25 +14,47 @@ export function createOperaToolHooks(deps) {
12
14
  return tool.annotations.category === ToolCategory.OPERA;
13
15
  },
14
16
  async beforeInvoke(tool) {
17
+ // Claimed before the flags are ensured, so the relaunch this may perform
18
+ // can see who else is using the browser — itself included.
19
+ noteToolStarted(tool.name);
15
20
  await ensureBrowserFlagsForTool(tool.name, deps.serverArgs, deps.logFile, { resetContext: deps.resetContext });
16
21
  },
22
+ afterInvoke(tool) {
23
+ noteToolFinished(tool.name);
24
+ },
17
25
  makeLogCallback(extra) {
18
26
  const sendNotification = extra?.sendNotification;
19
27
  if (!sendNotification) {
20
28
  return undefined;
21
29
  }
30
+ const streamToken = extra?._meta?.streamToken;
22
31
  return (message) => {
23
32
  // `logger` carries the MCP request ID so the opera-cli bridge can route
24
33
  // this chunk to the correct HTTP response (see bridge.ts requestLoggers).
25
34
  // `data` stays a plain string so non-bridge MCP hosts (Claude Desktop,
26
35
  // VS Code, etc.) continue to render it as readable text.
27
- void sendNotification.call(extra, {
36
+ sendNotification
37
+ .call(extra, {
28
38
  method: 'notifications/message',
29
39
  params: {
30
40
  level: 'info',
31
41
  data: message,
32
42
  logger: String(extra?.requestId),
43
+ // Echoed back so the daemon can route the chunk to the request
44
+ // that asked for streaming — see `opera/daemonStreaming.ts`.
45
+ ...(typeof streamToken === 'string'
46
+ ? { _meta: { streamToken } }
47
+ : {}),
33
48
  },
49
+ })
50
+ // Best-effort, and never a rejection: `sendNotification` rejects when
51
+ // the transport has already closed, and an unhandled rejection lands in
52
+ // the daemon's `unhandledRejection` handler, which tears the session
53
+ // down — so one undeliverable chunk would cost the whole run, and the
54
+ // supervisor respawning the MCP server would make a burst of them a
55
+ // supervision loop. Losing the chunk costs the user that line only.
56
+ .catch(error => {
57
+ logger?.('Opera AI chunk not delivered:', error);
34
58
  });
35
59
  };
36
60
  },
@@ -4,55 +4,124 @@
4
4
  *
5
5
  * This file is an original work developed by Opera.
6
6
  */
7
- import { zod } from '../../third_party/index.js';
7
+ import { CDPSessionEvent, zod } from '../../third_party/index.js';
8
8
  import { ToolCategory } from '../../tools/categories.js';
9
9
  import { definePageTool } from '../../tools/ToolDefinition.js';
10
10
  import { withServiceWorkerRetry } from '../serviceWorkerRetry.js';
11
+ import { operaAiStreamPolicy } from '../streamingTools.js';
11
12
  const getCDPSession = (page) => page._client();
12
13
  const dispatchAction = async (session, payload) => {
13
14
  const response = (await withServiceWorkerRetry(() => session.send('Opera.dispatchAction', { payload })));
14
15
  return response.result;
15
16
  };
17
+ /**
18
+ * Dispatch a streamed action and wait for the browser to report it finished.
19
+ *
20
+ * The dispatch reply and the action's own events are separate messages, and the
21
+ * events can be delivered before the reply — or in the same read as it, which
22
+ * the SDK dispatches before the promise callback that learns the correlationId
23
+ * can run. Until that id arrives there is nothing to match an event against, so
24
+ * the listeners are attached before the dispatch is sent and whatever arrives
25
+ * meanwhile is held and replayed. Without that, an action that answers inside
26
+ * the dispatch round trip loses its completion and the caller waits for an
27
+ * event the browser has already sent.
28
+ */
16
29
  const dispatchWithStreamedResponse = (session, payload, onChunkCallback, signal) => {
17
- return withServiceWorkerRetry(() => session.send('Opera.dispatchWithStreamedResponse', { payload })).then(raw => {
18
- const { correlationId } = raw;
19
- return new Promise((resolve, reject) => {
20
- const onChunk = (params) => {
21
- const { correlationId: id, chunk } = params;
22
- if (id === correlationId && onChunkCallback) {
23
- onChunkCallback(chunk);
24
- }
25
- };
26
- const onCompleted = (params) => {
27
- const { correlationId: id, result } = params;
28
- if (id === correlationId) {
29
- cleanup();
30
- resolve(result);
31
- }
32
- };
33
- const onFailed = (params) => {
34
- const { correlationId: id, error } = params;
35
- if (id === correlationId) {
36
- cleanup();
37
- reject(new Error(error));
38
- }
39
- };
40
- const cleanup = () => {
41
- session.off('Opera.actionChunk', onChunk);
42
- session.off('Opera.actionCompleted', onCompleted);
43
- session.off('Opera.actionFailed', onFailed);
44
- };
45
- if (signal?.aborted) {
46
- reject(signal.reason);
30
+ return new Promise((resolve, reject) => {
31
+ /** Events that arrived before `correlationId` was known, in arrival order. */
32
+ const early = [];
33
+ let correlationId;
34
+ let settled = false;
35
+ let firstEventTimer;
36
+ const cleanup = () => {
37
+ clearTimeout(firstEventTimer);
38
+ session.off('Opera.actionChunk', onChunk);
39
+ session.off('Opera.actionCompleted', onCompleted);
40
+ session.off('Opera.actionFailed', onFailed);
41
+ session.off(CDPSessionEvent.Disconnected, onDisconnected);
42
+ signal?.removeEventListener('abort', onAbort);
43
+ };
44
+ const settle = (finish) => {
45
+ if (settled) {
46
+ return;
47
+ }
48
+ settled = true;
49
+ cleanup();
50
+ finish();
51
+ };
52
+ /** Any event at all means the browser started the run; stop watching. */
53
+ const noteEvent = () => {
54
+ clearTimeout(firstEventTimer);
55
+ firstEventTimer = undefined;
56
+ };
57
+ const apply = (event) => {
58
+ if (settled) {
59
+ return;
60
+ }
61
+ const { correlationId: id } = event.params;
62
+ if (id !== correlationId) {
63
+ return;
64
+ }
65
+ noteEvent();
66
+ if (event.kind === 'chunk') {
67
+ const { chunk } = event.params;
68
+ onChunkCallback?.(chunk);
47
69
  return;
48
70
  }
49
- signal?.addEventListener('abort', () => {
50
- cleanup();
51
- reject(signal.reason);
52
- }, { once: true });
53
- session.on('Opera.actionChunk', onChunk);
54
- session.on('Opera.actionCompleted', onCompleted);
55
- session.on('Opera.actionFailed', onFailed);
71
+ if (event.kind === 'completed') {
72
+ const { result } = event.params;
73
+ settle(() => resolve(result));
74
+ return;
75
+ }
76
+ const { error } = event.params;
77
+ settle(() => reject(new Error(error)));
78
+ };
79
+ const receive = (event) => {
80
+ if (settled) {
81
+ return;
82
+ }
83
+ if (correlationId === undefined) {
84
+ // Held, not attributable — but it is still the browser working, and
85
+ // attributing it later must not re-arm a deadline it already beat.
86
+ noteEvent();
87
+ early.push(event);
88
+ return;
89
+ }
90
+ apply(event);
91
+ };
92
+ // Named rather than inlined into `session.on`: `cleanup` has to hand the
93
+ // same identities back to `off`, or the listeners outlive the call.
94
+ const onChunk = (params) => receive({ kind: 'chunk', params });
95
+ const onCompleted = (params) => receive({ kind: 'completed', params });
96
+ const onFailed = (params) => receive({ kind: 'failed', params });
97
+ const onDisconnected = () => settle(() => reject(new Error('CDP session disconnected')));
98
+ const onAbort = () => settle(() => reject(signal?.reason));
99
+ session.on('Opera.actionChunk', onChunk);
100
+ session.on('Opera.actionCompleted', onCompleted);
101
+ session.on('Opera.actionFailed', onFailed);
102
+ session.on(CDPSessionEvent.Disconnected, onDisconnected);
103
+ signal?.addEventListener('abort', onAbort, { once: true });
104
+ if (signal?.aborted) {
105
+ settle(() => reject(signal.reason));
106
+ return;
107
+ }
108
+ // Armed before the dispatch is sent, not before its ack: a browser that
109
+ // took the dispatch and did nothing with it — no ack, no events — is the
110
+ // same failure from here, and this is the only deadline that sees it.
111
+ const { firstEventTimeoutMs } = operaAiStreamPolicy;
112
+ firstEventTimer = setTimeout(() => {
113
+ settle(() => reject(new Error(`Opera did not start the ${String(payload['action'])} action: no progress was reported for ${Math.round(firstEventTimeoutMs / 1000)}s after the dispatch`)));
114
+ }, firstEventTimeoutMs);
115
+ withServiceWorkerRetry(() => session.send('Opera.dispatchWithStreamedResponse', { payload }))
116
+ .then(raw => {
117
+ const { correlationId: id } = raw;
118
+ correlationId = id;
119
+ for (const event of early.splice(0)) {
120
+ apply(event);
121
+ }
122
+ })
123
+ .catch(error => {
124
+ settle(() => reject(error));
56
125
  });
57
126
  });
58
127
  };
@@ -0,0 +1,69 @@
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
+ * `url` — resolve a `$uN` URL token or an `@ref` element ref back to the full
9
+ * URL. Both forms are shortened for the snapshot (same-site URLs lose their
10
+ * origin, repeated/long ones become tokens), so the page origin is re-attached
11
+ * here before the URL is printed.
12
+ *
13
+ * Ported from opera-browser-cli's `src/cli.ts` `handleUrl`. The source read the
14
+ * bridge's cached `/last-snapshot`; here the token→URL map and the origin are
15
+ * persisted next to the config on every rendered snapshot (`cliOutput.ts`'s
16
+ * `writeUrlMapSidecar`, one file per session), so a token is answerable with no
17
+ * round-trip at all.
18
+ *
19
+ * An element ref is different: it is only resolvable against the tree it came
20
+ * from, so that path always asks for one. The narrowed `take_snapshot` fetch is
21
+ * injected by the CLI, which knows how to bring the daemon up.
22
+ */
23
+ import { absolutizeUrl, applyUrlLut, compactSnapshot, extractPageOrigin, refToDisplay, refToMcp, resolveUrl, } from './compactSnapshot.js';
24
+ import { CLI_BIN_NAME } from './branding.js';
25
+ import { loadUrlMapSidecar } from './cliOutput.js';
26
+ import { CdpError, EXIT_CODES } from './cdpErrors.js';
27
+ export async function handleUrl(args, fetchSnapshot, sessionId) {
28
+ const target = args[0];
29
+ if (!target) {
30
+ throw new CdpError('Missing argument', 'VALIDATION_ERROR', [
31
+ `Run \`${CLI_BIN_NAME} url $u3\` to resolve a URL token`,
32
+ `Run \`${CLI_BIN_NAME} url @11.57\` to resolve an element ref`,
33
+ ]);
34
+ }
35
+ const persisted = loadUrlMapSidecar(sessionId);
36
+ // A token is `$uN` and never carries `@` — that is a ref marker, so the two
37
+ // shapes stay disjoint and `url @$u2` is answered like any other bad ref.
38
+ if (persisted && target.startsWith('$u')) {
39
+ const resolved = resolveUrl('', persisted.tokens, target);
40
+ if (resolved !== null) {
41
+ return {
42
+ output: absolutizeUrl(resolved, persisted.origin),
43
+ exitCode: 0,
44
+ };
45
+ }
46
+ }
47
+ // The body is the compact, *non-LUT* tree: a ref lookup searches for the
48
+ // element's literal `url="..."`, so token-index alignment with the map is not
49
+ // needed. The map is only consulted for refs whose URL was tokenised.
50
+ //
51
+ // The raw tree is kept because it is the only place the page origin still
52
+ // exists: compaction rewrites same-site URLs (root `url=` included) to
53
+ // relative paths, and `url`'s contract is the full URL.
54
+ const raw = await fetchSnapshot();
55
+ const origin = extractPageOrigin(raw) ?? persisted?.origin ?? null;
56
+ const body = compactSnapshot(raw);
57
+ const urlMap = persisted?.tokens ?? applyUrlLut(body).urlMap;
58
+ // The same translation the tool commands apply (refArgs.ts): the tree carries
59
+ // display-form refs, and `--raw` prints the wire form `4_11`, so accept both.
60
+ const resolved = resolveUrl(body, urlMap, `@${refToDisplay(refToMcp(target))}`);
61
+ if (resolved === null) {
62
+ return {
63
+ output: `url: "${target}" not found in last snapshot`,
64
+ exitCode: EXIT_CODES.REF_NOT_FOUND,
65
+ };
66
+ }
67
+ return { output: absolutizeUrl(resolved, origin), exitCode: 0 };
68
+ }
69
+ //# sourceMappingURL=urlResolver.js.map