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.
- package/README.md +1 -1
- package/build/src/ToolHandler.js +9 -2
- package/build/src/bin/chrome-devtools.js +30 -97
- package/build/src/bin/opera-browser-cli.js +102 -0
- package/build/src/bin/opera-devtools-mcp.js +20 -1
- package/build/src/browser.js +18 -9
- package/build/src/daemon/client.js +46 -40
- package/build/src/daemon/daemon.js +62 -39
- package/build/src/opera/branding.js +4 -2
- package/build/src/opera/browserActivity.js +62 -0
- package/build/src/opera/browserCleanup.js +123 -0
- package/build/src/opera/browserErrors.js +66 -0
- package/build/src/opera/browserFlags.js +184 -38
- package/build/src/opera/browserTarget.js +513 -0
- package/build/src/opera/cdpErrors.js +391 -0
- package/build/src/opera/cliCommands.js +378 -0
- package/build/src/opera/cliOutput.js +284 -0
- package/build/src/opera/compactSnapshot.js +525 -0
- package/build/src/opera/config.js +166 -0
- package/build/src/opera/daemonLifecycle.js +257 -0
- package/build/src/opera/daemonLog.js +103 -0
- package/build/src/opera/daemonPidFile.js +83 -0
- package/build/src/opera/daemonShutdown.js +66 -0
- package/build/src/opera/daemonSocket.js +87 -0
- package/build/src/opera/daemonStreaming.js +130 -0
- package/build/src/opera/daemonToolCall.js +26 -0
- package/build/src/opera/detect.js +114 -0
- package/build/src/opera/doctor.js +317 -0
- package/build/src/opera/envConfig.js +229 -0
- package/build/src/opera/launcherNotice.js +116 -0
- package/build/src/opera/legacyBridgeCleanup.js +297 -0
- package/build/src/opera/logs.js +133 -0
- package/build/src/opera/mcpServerSupervisor.js +128 -0
- package/build/src/opera/migrationShared.js +164 -0
- package/build/src/opera/operaPages.js +56 -0
- package/build/src/opera/pageIdRouting.js +35 -0
- package/build/src/opera/pageRecovery.js +53 -0
- package/build/src/opera/profile.js +270 -0
- package/build/src/opera/refArgs.js +36 -0
- package/build/src/opera/serviceWorkerRetry.js +46 -4
- package/build/src/opera/setup.js +290 -0
- package/build/src/opera/skills/SKILL.md +160 -0
- package/build/src/opera/streamingTools.js +73 -0
- package/build/src/opera/suggestions.js +67 -0
- package/build/src/opera/toolHandlerHooks.js +25 -1
- package/build/src/opera/tools/opera.js +107 -38
- package/build/src/opera/urlResolver.js +69 -0
- package/build/src/opera/webStorageWarning.js +92 -0
- package/build/src/third_party/devtools-formatter-worker.js +1 -0
- package/build/src/third_party/devtools-heap-snapshot-worker.js +1 -0
- package/build/src/third_party/index.js +2 -1
- package/build/src/utils/url.js +6 -0
- package/build/src/version.js +1 -1
- package/package.json +12 -10
- 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
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|