opencode-browser-v2 2.0.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/LICENSE +21 -0
- package/README.md +386 -0
- package/bin/opencode-browser.js +362 -0
- package/package.json +54 -0
- package/src/index.ts +345 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# OpenCode Browser MCP Plugin (v2)
|
|
2
|
+
|
|
3
|
+
An OpenCode plugin that integrates [Browser MCP](https://browsermcp.io) to enable browser automation capabilities within OpenCode. This plugin allows the AI to control a browser, navigate websites, fill forms, click elements, and perform other browser automation tasks.
|
|
4
|
+
|
|
5
|
+
Published on npm as [`opencode-browser-v2`](https://www.npmjs.com/package/opencode-browser-v2). It is a fork of [michaljach/opencode-browser](https://github.com/michaljach/opencode-browser), ported to the **OpenCode v2 plugin API**. If you are still on OpenCode v1, use the original `opencode-browser` package instead.
|
|
6
|
+
|
|
7
|
+
## Demo
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- Full browser automation support through Browser MCP
|
|
14
|
+
- **Speed-oriented browser guidance** injected into the model prompt
|
|
15
|
+
- **Tool-specific performance hints** for expensive browser actions
|
|
16
|
+
- **Fast retry behavior** with no artificial reconnect backoff in the plugin
|
|
17
|
+
- Automatic detection of browser-related tasks
|
|
18
|
+
- Context preservation for browser state across session compactions
|
|
19
|
+
- Seamless integration with OpenCode's existing tools
|
|
20
|
+
|
|
21
|
+
## Prerequisites
|
|
22
|
+
|
|
23
|
+
Before using this plugin, you need:
|
|
24
|
+
|
|
25
|
+
1. **Node.js** installed on your system
|
|
26
|
+
2. **OpenCode** installed and configured
|
|
27
|
+
3. **Browser MCP extension** installed in your browser (Chrome/Edge)
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
### Step 1: Install Browser MCP Extension
|
|
32
|
+
|
|
33
|
+
1. Visit [https://browsermcp.io/install](https://browsermcp.io/install)
|
|
34
|
+
2. Install the Browser MCP extension for your browser (Chrome or Edge)
|
|
35
|
+
3. Follow the extension setup instructions
|
|
36
|
+
|
|
37
|
+
### Step 2: Configure OpenCode
|
|
38
|
+
|
|
39
|
+
Fastest path:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx opencode-browser-v2 init
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
This creates or updates `./opencode.json` with the required `plugins` and `mcp.servers.browsermcp` entries while preserving any unrelated config you already have.
|
|
46
|
+
It also migrates a v1 config in place: `plugin` becomes `plugins`, servers move from `mcp.<name>` to `mcp.servers.<name>`, `agent` becomes `agents`, and the server's `enabled` flag becomes v2's `disabled` flag.
|
|
47
|
+
|
|
48
|
+
For a global setup instead of a project-local one:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx opencode-browser-v2 init --global
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Create or update your `opencode.json` configuration file. You can create this file in one of two locations:
|
|
55
|
+
|
|
56
|
+
- **Global configuration** (applies to all projects): `~/.config/opencode/opencode.json`
|
|
57
|
+
- **Project-specific configuration** (applies to current project only): `./opencode.json` (in your project root)
|
|
58
|
+
|
|
59
|
+
Learn more about OpenCode configuration at [https://opencode.ai/docs/config](https://opencode.ai/docs/config)
|
|
60
|
+
|
|
61
|
+
Add this configuration to your `opencode.json`:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"$schema": "https://opencode.ai/config.json",
|
|
66
|
+
"plugins": ["opencode-browser-v2"],
|
|
67
|
+
"mcp": {
|
|
68
|
+
"servers": {
|
|
69
|
+
"browsermcp": {
|
|
70
|
+
"type": "local",
|
|
71
|
+
"command": ["npx", "-y", "@browsermcp/mcp@0.1.3"]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This configuration does two things:
|
|
79
|
+
1. **Installs the plugin** - OpenCode automatically downloads `opencode-browser-v2` from npm
|
|
80
|
+
2. **Configures Browser MCP** - Sets up the MCP server that actually controls the browser
|
|
81
|
+
|
|
82
|
+
That's it! No manual file copying required. OpenCode handles everything automatically.
|
|
83
|
+
|
|
84
|
+
The generated command pins the Browser MCP package version to avoid the extra `@latest` resolution step on startup and keep launches reproducible.
|
|
85
|
+
|
|
86
|
+
If you prefer to preview the generated config without writing it yet:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npx opencode-browser-v2 init --print
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### Alternative: Install Locally (for development/testing)
|
|
93
|
+
|
|
94
|
+
If you want to modify the plugin or test changes:
|
|
95
|
+
|
|
96
|
+
**For global installation:**
|
|
97
|
+
```bash
|
|
98
|
+
mkdir -p ~/.config/opencode/plugins
|
|
99
|
+
cp src/index.ts ~/.config/opencode/plugins/browser-mcp.ts
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**For project-specific installation:**
|
|
103
|
+
```bash
|
|
104
|
+
mkdir -p .opencode/plugins
|
|
105
|
+
cp src/index.ts .opencode/plugins/browser-mcp.ts
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The plugin will be automatically loaded on OpenCode startup.
|
|
109
|
+
|
|
110
|
+
## Configuration
|
|
111
|
+
|
|
112
|
+
### Basic Configuration
|
|
113
|
+
|
|
114
|
+
The minimal configuration requires only the MCP server setup:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"$schema": "https://opencode.ai/config.json",
|
|
119
|
+
"mcp": {
|
|
120
|
+
"servers": {
|
|
121
|
+
"browsermcp": {
|
|
122
|
+
"type": "local",
|
|
123
|
+
"command": ["npx", "-y", "@browsermcp/mcp@0.1.3"]
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
MCP servers are enabled by default in v2. Set `"disabled": true` on a server to turn it off.
|
|
131
|
+
|
|
132
|
+
### Advanced Configuration
|
|
133
|
+
|
|
134
|
+
Define a dedicated agent for browser work under the v2 `agents` key:
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"$schema": "https://opencode.ai/config.json",
|
|
139
|
+
"mcp": {
|
|
140
|
+
"servers": {
|
|
141
|
+
"browsermcp": {
|
|
142
|
+
"type": "local",
|
|
143
|
+
"command": ["npx", "-y", "@browsermcp/mcp@0.1.3"]
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
"agents": {
|
|
148
|
+
"browser-agent": {
|
|
149
|
+
"description": "Agent specialized in browser automation tasks",
|
|
150
|
+
"mode": "primary"
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The v1 `tools` allow/deny maps (both the global one and the per-agent one) were replaced in v2 by the
|
|
157
|
+
`permissions` policy list on an agent. See the OpenCode agent configuration docs for the action and
|
|
158
|
+
resource names to use when scoping Browser MCP tools to a single agent.
|
|
159
|
+
|
|
160
|
+
### Performance Behavior
|
|
161
|
+
|
|
162
|
+
The plugin improves Browser MCP speed by shaping how the model uses browser tools:
|
|
163
|
+
|
|
164
|
+
- Adds system guidance that prefers direct navigation and fewer browser calls
|
|
165
|
+
- Annotates expensive tools like snapshots, screenshots, and waits with performance hints
|
|
166
|
+
- Preserves fast-resume guidance during session compaction
|
|
167
|
+
- Avoids plugin-side reconnect delays so you can retry immediately when the browser extension is ready
|
|
168
|
+
|
|
169
|
+
### Environment Variables
|
|
170
|
+
|
|
171
|
+
If you need to pass environment variables to the Browser MCP server:
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
{
|
|
175
|
+
"$schema": "https://opencode.ai/config.json",
|
|
176
|
+
"mcp": {
|
|
177
|
+
"servers": {
|
|
178
|
+
"browsermcp": {
|
|
179
|
+
"type": "local",
|
|
180
|
+
"command": ["npx", "-y", "@browsermcp/mcp@0.1.3"],
|
|
181
|
+
"environment": {
|
|
182
|
+
"BROWSER_MCP_DEBUG": "true"
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Usage
|
|
191
|
+
|
|
192
|
+
Once installed and configured, you can use browser automation in your OpenCode prompts:
|
|
193
|
+
|
|
194
|
+
### Basic Browser Navigation
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
Navigate to https://github.com and search for "opencode"
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Form Filling
|
|
201
|
+
|
|
202
|
+
```
|
|
203
|
+
Go to the contact form at https://example.com/contact and fill in:
|
|
204
|
+
- Name: John Doe
|
|
205
|
+
- Email: john@example.com
|
|
206
|
+
- Message: Hello from OpenCode!
|
|
207
|
+
Then submit the form.
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Web Scraping
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
Visit https://news.ycombinator.com and get the titles of the top 5 stories
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Complex Automation
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
Go to https://example.com/login, log in with the test credentials,
|
|
220
|
+
navigate to the dashboard, and screenshot the main metrics panel
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Prompt Tips
|
|
224
|
+
|
|
225
|
+
For best results when using browser automation:
|
|
226
|
+
|
|
227
|
+
1. **Be specific** about URLs and actions
|
|
228
|
+
2. **Prefer direct URLs** instead of clicking through intermediate pages
|
|
229
|
+
3. **Reuse page state** instead of rechecking the same screen repeatedly
|
|
230
|
+
4. **Ask for verification only when needed** because snapshots and screenshots are slower than targeted extraction
|
|
231
|
+
5. **Specify selectors** when needed (CSS selectors, text content, etc.)
|
|
232
|
+
|
|
233
|
+
You can also add browser automation guidelines to your `AGENTS.md` file:
|
|
234
|
+
|
|
235
|
+
```markdown
|
|
236
|
+
## Browser Automation
|
|
237
|
+
|
|
238
|
+
When performing browser automation tasks:
|
|
239
|
+
- Always confirm the page has loaded before interacting
|
|
240
|
+
- Use descriptive selectors (prefer text content over CSS selectors)
|
|
241
|
+
- Take screenshots when verification is needed
|
|
242
|
+
- Handle errors gracefully (page not found, element not visible, etc.)
|
|
243
|
+
- Close tabs when the task is complete
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Plugin Features
|
|
247
|
+
|
|
248
|
+
### Speed-Oriented Guidance
|
|
249
|
+
|
|
250
|
+
The plugin biases the model toward faster browser workflows:
|
|
251
|
+
|
|
252
|
+
- Prefers direct `navigate` calls when the destination URL is known
|
|
253
|
+
- Reuses the current tab and page state instead of redoing navigation
|
|
254
|
+
- Minimizes `snapshot`, `screenshot`, and `wait` calls unless they are actually needed
|
|
255
|
+
- Encourages targeted extraction and direct actions over broad inspection
|
|
256
|
+
|
|
257
|
+
### Lightweight Connection Recovery
|
|
258
|
+
|
|
259
|
+
The plugin still detects browser connection issues, but it no longer adds artificial retry sleeps:
|
|
260
|
+
|
|
261
|
+
- Detects common Browser MCP connection failures from tool output
|
|
262
|
+
- Adds immediate retry guidance to the result instead of pausing inside the plugin
|
|
263
|
+
- Marks the connection as restored after the next successful browser action
|
|
264
|
+
|
|
265
|
+
### Automatic Browser Tool Detection
|
|
266
|
+
|
|
267
|
+
The plugin automatically detects when Browser MCP tools are being used and applies browser-specific guidance.
|
|
268
|
+
|
|
269
|
+
### Session Context Preservation
|
|
270
|
+
|
|
271
|
+
During session compaction, the plugin preserves browser automation context, ensuring the AI remembers:
|
|
272
|
+
- Browser interactions that occurred
|
|
273
|
+
- Current browser state considerations
|
|
274
|
+
- Fast-resume guidance so it can avoid repeating navigation and inspection
|
|
275
|
+
|
|
276
|
+
### Tool Definition Hints
|
|
277
|
+
|
|
278
|
+
The plugin annotates Browser MCP tool definitions with performance notes, especially for slower tools like snapshots, screenshots, and waits.
|
|
279
|
+
|
|
280
|
+
## Troubleshooting
|
|
281
|
+
|
|
282
|
+
### Browser MCP Connection Lost
|
|
283
|
+
|
|
284
|
+
If you see connection errors:
|
|
285
|
+
|
|
286
|
+
1. **Check extension status**: Verify the Browser MCP extension is enabled in Chrome
|
|
287
|
+
2. **Re-enable extension**: If you disabled it, simply re-enable it and retry the browser action immediately
|
|
288
|
+
3. **Check browser is running**: Ensure Chrome/Edge is actually running
|
|
289
|
+
4. **Retry after readiness**: The plugin does not add extra backoff delay, so the next attempt can run right away
|
|
290
|
+
5. **Restart only if needed**: Restart OpenCode only if the browser stays unavailable after retrying
|
|
291
|
+
|
|
292
|
+
The plugin will display messages like:
|
|
293
|
+
- `[Browser MCP] The browser connection looks unavailable. Re-enable the Browser MCP extension or browser, then retry.`
|
|
294
|
+
- `[Browser MCP] Connection restored. Continuing without extra retry delay.`
|
|
295
|
+
|
|
296
|
+
### Browser MCP Not Working
|
|
297
|
+
|
|
298
|
+
1. **Check extension is installed**: Open your browser and verify the Browser MCP extension is installed and enabled
|
|
299
|
+
2. **Verify MCP server config**: Ensure your `opencode.json` has the correct MCP configuration
|
|
300
|
+
3. **Check Node.js**: Ensure Node.js is installed: `node --version`
|
|
301
|
+
4. **Test MCP connection**: Restart OpenCode after adding the MCP configuration
|
|
302
|
+
|
|
303
|
+
### Plugin Not Loading
|
|
304
|
+
|
|
305
|
+
1. **Check file location**: Ensure the plugin file is in the correct directory
|
|
306
|
+
2. **Check file name**: Plugin files should end in `.ts` or `.js`
|
|
307
|
+
3. **Check syntax**: Ensure the TypeScript/JavaScript syntax is valid
|
|
308
|
+
4. **Check logs**: Look for plugin initialization messages in OpenCode output
|
|
309
|
+
|
|
310
|
+
### Tools Not Available
|
|
311
|
+
|
|
312
|
+
1. **Check MCP server status**: Ensure the Browser MCP server started successfully
|
|
313
|
+
2. **Check tool configuration**: Verify tools aren't disabled in your config
|
|
314
|
+
3. **Restart OpenCode**: Try restarting OpenCode after configuration changes
|
|
315
|
+
|
|
316
|
+
### Debug Mode
|
|
317
|
+
|
|
318
|
+
Enable debug logging by modifying the plugin or checking OpenCode logs:
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
# Check OpenCode logs
|
|
322
|
+
opencode --verbose
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
## Development
|
|
326
|
+
|
|
327
|
+
### Building from Source
|
|
328
|
+
|
|
329
|
+
If you want to modify the plugin:
|
|
330
|
+
|
|
331
|
+
1. Clone the repository
|
|
332
|
+
2. Run `npm install`
|
|
333
|
+
3. Make your changes to `src/index.ts`
|
|
334
|
+
4. Run `npm run typecheck`
|
|
335
|
+
5. Test locally by copying to your OpenCode plugin directory
|
|
336
|
+
6. Submit a PR if you'd like to contribute!
|
|
337
|
+
|
|
338
|
+
### Plugin Architecture
|
|
339
|
+
|
|
340
|
+
The plugin is an OpenCode **v2** plugin: it default-exports a `Plugin.define({ id, setup })` definition
|
|
341
|
+
and registers everything through the setup context.
|
|
342
|
+
|
|
343
|
+
| Registration | Purpose |
|
|
344
|
+
| --- | --- |
|
|
345
|
+
| `ctx.session.hook("context", ...)` | Inject speed-oriented browser guidance and append performance hints to Browser MCP tool descriptions |
|
|
346
|
+
| `ctx.session.hook("generate", ...)` | Apply the same guidance to auxiliary generate requests |
|
|
347
|
+
| `ctx.tool.hook("execute.after", ...)` | Post-process browser tool results and annotate connection failures |
|
|
348
|
+
| `ctx.session.hook("compaction", ...)` | Preserve browser context across compaction |
|
|
349
|
+
| `ctx.event.subscribe()` | Drop per-session state on `session.deleted` |
|
|
350
|
+
|
|
351
|
+
The cleanup function returned by `setup` aborts the event subscription and clears session state; hook
|
|
352
|
+
registrations are disposed by OpenCode automatically.
|
|
353
|
+
|
|
354
|
+
Requires `@opencode/plugin` v2. For OpenCode v1, use the original [`opencode-browser`](https://www.npmjs.com/package/opencode-browser) package, which this project is a fork of.
|
|
355
|
+
|
|
356
|
+
## Contributing
|
|
357
|
+
|
|
358
|
+
Contributions are welcome! Please:
|
|
359
|
+
|
|
360
|
+
1. Fork the repository
|
|
361
|
+
2. Create a feature branch
|
|
362
|
+
3. Make your changes
|
|
363
|
+
4. Submit a pull request
|
|
364
|
+
|
|
365
|
+
## Resources
|
|
366
|
+
|
|
367
|
+
- [Browser MCP Documentation](https://docs.browsermcp.io/)
|
|
368
|
+
- [OpenCode Documentation](https://opencode.ai/docs/)
|
|
369
|
+
- [OpenCode Plugin Guide](https://opencode.ai/docs/plugins/)
|
|
370
|
+
- [MCP Servers in OpenCode](https://opencode.ai/docs/mcp-servers/)
|
|
371
|
+
|
|
372
|
+
## License
|
|
373
|
+
|
|
374
|
+
MIT License - See LICENSE file for details
|
|
375
|
+
|
|
376
|
+
## Support
|
|
377
|
+
|
|
378
|
+
For issues and questions:
|
|
379
|
+
|
|
380
|
+
- Browser MCP issues: [Browser MCP GitHub](https://github.com/browsermcp/browser-mcp)
|
|
381
|
+
- OpenCode issues: [OpenCode GitHub](https://github.com/anomalyco/opencode)
|
|
382
|
+
- Plugin issues: [Open an issue](https://github.com/barrenechea/opencode-browser-v2/issues) in this repository
|
|
383
|
+
|
|
384
|
+
## Changelog
|
|
385
|
+
|
|
386
|
+
See [CHANGELOG.md](CHANGELOG.md) for a detailed list of changes in each version.
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
4
|
+
import { homedir } from "node:os"
|
|
5
|
+
import { dirname, resolve } from "node:path"
|
|
6
|
+
|
|
7
|
+
const schemaUrl = "https://opencode.ai/config.json"
|
|
8
|
+
const pluginName = "opencode-browser-v2"
|
|
9
|
+
const legacyPluginName = "opencode-browser"
|
|
10
|
+
const serverName = "browsermcp"
|
|
11
|
+
const browserMcpVersion = "0.1.3"
|
|
12
|
+
const legacyBrowserMcpCommand = ["npx", "-y", "@browsermcp/mcp@latest"]
|
|
13
|
+
const defaultBrowserMcpConfig = {
|
|
14
|
+
type: "local",
|
|
15
|
+
command: ["npx", "-y", `@browsermcp/mcp@${browserMcpVersion}`],
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isSameCommand(actual, expected) {
|
|
19
|
+
return Array.isArray(actual) &&
|
|
20
|
+
actual.length === expected.length &&
|
|
21
|
+
actual.every((value, index) => value === expected[index])
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function printUsage() {
|
|
25
|
+
console.log(`Usage: opencode-browser-v2 [init] [--project|--global|--path <file>] [--print]\n\n` +
|
|
26
|
+
`Examples:\n` +
|
|
27
|
+
` npx opencode-browser-v2 init\n` +
|
|
28
|
+
` npx opencode-browser-v2 init --global\n` +
|
|
29
|
+
` npx opencode-browser-v2 init --path ./opencode.json\n` +
|
|
30
|
+
` npx opencode-browser-v2 init --print`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseArgs(argv) {
|
|
34
|
+
const args = [...argv]
|
|
35
|
+
let command = "init"
|
|
36
|
+
|
|
37
|
+
if (args[0] && !args[0].startsWith("-")) {
|
|
38
|
+
command = args.shift()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const options = {
|
|
42
|
+
mode: "project",
|
|
43
|
+
configPath: undefined,
|
|
44
|
+
printOnly: false,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
while (args.length > 0) {
|
|
48
|
+
const arg = args.shift()
|
|
49
|
+
|
|
50
|
+
if (arg === "--global") {
|
|
51
|
+
options.mode = "global"
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (arg === "--project") {
|
|
56
|
+
options.mode = "project"
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (arg === "--path") {
|
|
61
|
+
const customPath = args.shift()
|
|
62
|
+
|
|
63
|
+
if (!customPath) {
|
|
64
|
+
throw new Error("Missing value for --path")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
options.configPath = customPath
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (arg === "--print") {
|
|
72
|
+
options.printOnly = true
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (arg === "--help" || arg === "-h") {
|
|
77
|
+
options.help = true
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
throw new Error(`Unknown argument: ${arg}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { command, options }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function loadConfig(targetPath) {
|
|
88
|
+
if (!existsSync(targetPath)) {
|
|
89
|
+
return {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const raw = readFileSync(targetPath, "utf8")
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(raw)
|
|
96
|
+
|
|
97
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
98
|
+
throw new Error("Config must be a JSON object")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return parsed
|
|
102
|
+
} catch (error) {
|
|
103
|
+
throw new Error(`Unable to parse ${targetPath}: ${error.message}`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function ensureObject(value, fieldName) {
|
|
108
|
+
if (value === undefined) {
|
|
109
|
+
return {}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
113
|
+
throw new Error(`The "${fieldName}" field must be an object`)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { ...value }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* V2 plugin entries are either a package name or `{ package, options }`. V1 also allowed a
|
|
121
|
+
* `[package, options]` tuple, so tuples are folded into the object form on the way through.
|
|
122
|
+
*/
|
|
123
|
+
function normalizePluginEntry(entry, fieldName) {
|
|
124
|
+
if (typeof entry === "string") {
|
|
125
|
+
return { entry, package: entry, migrated: false }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (Array.isArray(entry)) {
|
|
129
|
+
const [packageName, packageOptions] = entry
|
|
130
|
+
|
|
131
|
+
if (typeof packageName !== "string") {
|
|
132
|
+
throw new Error(`Every "${fieldName}" tuple must start with a package name`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
entry: packageOptions === undefined
|
|
137
|
+
? packageName
|
|
138
|
+
: { package: packageName, options: packageOptions },
|
|
139
|
+
package: packageName,
|
|
140
|
+
migrated: true,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (entry && typeof entry === "object" && typeof entry.package === "string") {
|
|
145
|
+
return { entry: { ...entry }, package: entry.package, migrated: false }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
throw new Error(`Every "${fieldName}" entry must be a package name, a { package, options } object, or a [package, options] tuple`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function normalizePlugins(pluginField, fieldName) {
|
|
152
|
+
if (pluginField === undefined) {
|
|
153
|
+
return { entries: [], migrated: false }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const rawEntries = Array.isArray(pluginField) ? pluginField : [pluginField]
|
|
157
|
+
const entries = []
|
|
158
|
+
let migrated = false
|
|
159
|
+
|
|
160
|
+
for (const rawEntry of rawEntries) {
|
|
161
|
+
const normalized = normalizePluginEntry(rawEntry, fieldName)
|
|
162
|
+
migrated = migrated || normalized.migrated
|
|
163
|
+
|
|
164
|
+
if (entries.some((existing) => existing.package === normalized.package)) {
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
entries.push(normalized)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { entries, migrated }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function mergePlugins(config, changes) {
|
|
175
|
+
const fromV2 = normalizePlugins(config.plugins, "plugins")
|
|
176
|
+
const fromV1 = normalizePlugins(config.plugin, "plugin")
|
|
177
|
+
|
|
178
|
+
const entries = [...fromV2.entries]
|
|
179
|
+
|
|
180
|
+
for (const candidate of fromV1.entries) {
|
|
181
|
+
if (entries.some((existing) => existing.package === candidate.package)) {
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
entries.push(candidate)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (config.plugin !== undefined) {
|
|
189
|
+
delete config.plugin
|
|
190
|
+
changes.push('migrated "plugin" to the v2 "plugins" field')
|
|
191
|
+
} else if (fromV2.migrated) {
|
|
192
|
+
changes.push('normalized "plugins" entries to the v2 object form')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The v1 package does not run under OpenCode v2, so point an existing entry at this package.
|
|
196
|
+
const legacy = entries.find((existing) => existing.package === legacyPluginName)
|
|
197
|
+
|
|
198
|
+
if (legacy && !entries.some((existing) => existing.package === pluginName)) {
|
|
199
|
+
legacy.package = pluginName
|
|
200
|
+
legacy.entry = typeof legacy.entry === "string"
|
|
201
|
+
? pluginName
|
|
202
|
+
: { ...legacy.entry, package: pluginName }
|
|
203
|
+
changes.push(`replaced the v1 "${legacyPluginName}" plugin with "${pluginName}"`)
|
|
204
|
+
} else if (legacy) {
|
|
205
|
+
entries.splice(entries.indexOf(legacy), 1)
|
|
206
|
+
changes.push(`removed the superseded v1 "${legacyPluginName}" plugin`)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!entries.some((existing) => existing.package === pluginName)) {
|
|
210
|
+
entries.push({ entry: pluginName, package: pluginName })
|
|
211
|
+
changes.push(`enabled ${pluginName} plugin`)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
config.plugins = entries.map((existing) => existing.entry)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* V2 nests servers under `mcp.servers`; V1 put them directly on `mcp`. Anything on `mcp` other
|
|
219
|
+
* than the two v2 keys is therefore a v1 server entry that needs relocating.
|
|
220
|
+
*/
|
|
221
|
+
function mergeMcp(config, changes) {
|
|
222
|
+
const mcp = ensureObject(config.mcp, "mcp")
|
|
223
|
+
const servers = ensureObject(mcp.servers, "mcp.servers")
|
|
224
|
+
const legacyNames = Object.keys(mcp).filter((key) => key !== "servers" && key !== "timeout")
|
|
225
|
+
|
|
226
|
+
for (const name of legacyNames) {
|
|
227
|
+
servers[name] = { ...servers[name], ...ensureObject(mcp[name], `mcp.${name}`) }
|
|
228
|
+
delete mcp[name]
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (legacyNames.length > 0) {
|
|
232
|
+
changes.push(`moved ${legacyNames.length} MCP server${legacyNames.length === 1 ? "" : "s"} under "mcp.servers"`)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const browsermcp = ensureObject(servers[serverName], `mcp.servers.${serverName}`)
|
|
236
|
+
|
|
237
|
+
if (browsermcp.type === undefined) {
|
|
238
|
+
browsermcp.type = defaultBrowserMcpConfig.type
|
|
239
|
+
changes.push("set Browser MCP type")
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (browsermcp.command === undefined) {
|
|
243
|
+
browsermcp.command = [...defaultBrowserMcpConfig.command]
|
|
244
|
+
changes.push("set Browser MCP command")
|
|
245
|
+
} else if (isSameCommand(browsermcp.command, legacyBrowserMcpCommand)) {
|
|
246
|
+
browsermcp.command = [...defaultBrowserMcpConfig.command]
|
|
247
|
+
changes.push("pinned Browser MCP command version")
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// V2 replaced the `enabled` flag with `disabled`; servers are enabled by default.
|
|
251
|
+
if (browsermcp.enabled !== undefined) {
|
|
252
|
+
const wasEnabled = browsermcp.enabled !== false
|
|
253
|
+
delete browsermcp.enabled
|
|
254
|
+
|
|
255
|
+
if (wasEnabled) {
|
|
256
|
+
delete browsermcp.disabled
|
|
257
|
+
} else {
|
|
258
|
+
browsermcp.disabled = true
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
changes.push('replaced the v1 "enabled" flag with the v2 "disabled" flag')
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
servers[serverName] = browsermcp
|
|
265
|
+
mcp.servers = servers
|
|
266
|
+
config.mcp = mcp
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function mergeAgents(config, changes) {
|
|
270
|
+
if (config.agent === undefined) {
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const legacyAgents = ensureObject(config.agent, "agent")
|
|
275
|
+
const agents = ensureObject(config.agents, "agents")
|
|
276
|
+
|
|
277
|
+
for (const [name, definition] of Object.entries(legacyAgents)) {
|
|
278
|
+
if (agents[name] === undefined) {
|
|
279
|
+
agents[name] = definition
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
delete config.agent
|
|
284
|
+
config.agents = agents
|
|
285
|
+
changes.push('migrated "agent" to the v2 "agents" field')
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function mergeConfig(config) {
|
|
289
|
+
const nextConfig = { ...config }
|
|
290
|
+
const changes = []
|
|
291
|
+
|
|
292
|
+
if (!nextConfig.$schema) {
|
|
293
|
+
nextConfig.$schema = schemaUrl
|
|
294
|
+
changes.push("added OpenCode schema")
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
mergePlugins(nextConfig, changes)
|
|
298
|
+
mergeMcp(nextConfig, changes)
|
|
299
|
+
mergeAgents(nextConfig, changes)
|
|
300
|
+
|
|
301
|
+
return { nextConfig, changes }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function getTargetPath(mode, customPath) {
|
|
305
|
+
if (customPath) {
|
|
306
|
+
return resolve(customPath)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (mode === "global") {
|
|
310
|
+
return resolve(homedir(), ".config/opencode/opencode.json")
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return resolve(process.cwd(), "opencode.json")
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function main() {
|
|
317
|
+
try {
|
|
318
|
+
const { command, options } = parseArgs(process.argv.slice(2))
|
|
319
|
+
|
|
320
|
+
if (options.help) {
|
|
321
|
+
printUsage()
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (command !== "init") {
|
|
326
|
+
throw new Error(`Unknown command: ${command}`)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const targetPath = getTargetPath(options.mode, options.configPath)
|
|
330
|
+
const hadExistingConfig = existsSync(targetPath)
|
|
331
|
+
const config = loadConfig(targetPath)
|
|
332
|
+
const { nextConfig, changes } = mergeConfig(config)
|
|
333
|
+
const output = `${JSON.stringify(nextConfig, null, 2)}\n`
|
|
334
|
+
|
|
335
|
+
if (options.printOnly) {
|
|
336
|
+
process.stdout.write(output)
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
mkdirSync(dirname(targetPath), { recursive: true })
|
|
341
|
+
writeFileSync(targetPath, output)
|
|
342
|
+
|
|
343
|
+
const action = hadExistingConfig ? "Updated" : "Created"
|
|
344
|
+
console.log(`${action} ${targetPath}`)
|
|
345
|
+
|
|
346
|
+
if (changes.length === 0) {
|
|
347
|
+
console.log("No changes were needed; Browser MCP is already configured.")
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
console.log(`Applied ${changes.length} change${changes.length === 1 ? "" : "s"}:`)
|
|
352
|
+
for (const change of changes) {
|
|
353
|
+
console.log(`- ${change}`)
|
|
354
|
+
}
|
|
355
|
+
} catch (error) {
|
|
356
|
+
console.error(`[opencode-browser-v2] ${error.message}`)
|
|
357
|
+
printUsage()
|
|
358
|
+
process.exitCode = 1
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
await main()
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-browser-v2",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "OpenCode plugin that integrates Browser MCP for browser automation",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"opencode-browser-v2": "bin/opencode-browser.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"opencode",
|
|
15
|
+
"opencode-plugin",
|
|
16
|
+
"plugin",
|
|
17
|
+
"browser",
|
|
18
|
+
"browser-automation",
|
|
19
|
+
"mcp",
|
|
20
|
+
"model-context-protocol",
|
|
21
|
+
"automation",
|
|
22
|
+
"browser-control",
|
|
23
|
+
"web-automation",
|
|
24
|
+
"puppeteer-alternative",
|
|
25
|
+
"opencode-v2"
|
|
26
|
+
],
|
|
27
|
+
"author": "barrenechea",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@opencode/plugin": ">=2.0.0"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/barrenechea/opencode-browser-v2.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/barrenechea/opencode-browser-v2#readme",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/barrenechea/opencode-browser-v2/issues"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"bin/",
|
|
42
|
+
"src/",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18.0.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@opencode/plugin": "^2.0.11",
|
|
51
|
+
"@types/node": "^26.6.2",
|
|
52
|
+
"typescript": "^7.0.2"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin"
|
|
2
|
+
import { Error as ToolError } from "@opencode/plugin/promise/tool"
|
|
3
|
+
import type { Result as ToolResult } from "@opencode/plugin/promise/tool"
|
|
4
|
+
import type { Context } from "@opencode/plugin/promise/plugin"
|
|
5
|
+
import type { SessionContext } from "@opencode/plugin/promise/session"
|
|
6
|
+
|
|
7
|
+
/** One entry of the structured `content` list a tool result may carry. */
|
|
8
|
+
type ToolContent = Exclude<ToolResult["content"], string | undefined>[number]
|
|
9
|
+
|
|
10
|
+
interface ConnectionState {
|
|
11
|
+
isConnected: boolean
|
|
12
|
+
lastError?: string
|
|
13
|
+
failureCount: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const BROWSER_TOOL_PREFIX = "browsermcp_"
|
|
17
|
+
|
|
18
|
+
const browserSpeedGuidance = `When using Browser MCP, optimize for speed:
|
|
19
|
+
- Prefer direct URL navigation over click-through flows when the destination is known.
|
|
20
|
+
- Reuse the current tab and page state instead of repeating navigation.
|
|
21
|
+
- Minimize snapshots, screenshots, and waits; use them only after a page change or when visual confirmation is required.
|
|
22
|
+
- Prefer targeted extraction or direct actions over broad inspection.
|
|
23
|
+
- Finish the task in the fewest browser actions that still preserve correctness.`
|
|
24
|
+
|
|
25
|
+
const browserCompactionContext = `## Browser Automation Context
|
|
26
|
+
|
|
27
|
+
Browser MCP was used in this session. When resuming:
|
|
28
|
+
- Assume the current browser tab may still be useful.
|
|
29
|
+
- Check browser state once, then reuse it instead of repeating navigation.
|
|
30
|
+
- Prefer direct navigation, extraction, and targeted actions over repeated snapshots or screenshots.
|
|
31
|
+
- Use waits only when the page is still loading or an interaction has not settled yet.`
|
|
32
|
+
|
|
33
|
+
const browserToolHints = [
|
|
34
|
+
{
|
|
35
|
+
suffixes: ["_browser_navigate", "_navigate"],
|
|
36
|
+
hint: "Prefer this when you already know the destination URL instead of clicking through intermediate pages.",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
suffixes: ["_browser_snapshot", "_snapshot"],
|
|
40
|
+
hint: "This is relatively expensive. Reuse the latest snapshot unless the page changed or you need fresh element references.",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
suffixes: ["_browser_screenshot", "_screenshot"],
|
|
44
|
+
hint: "Use only when the user needs visual confirmation. Prefer extraction or targeted checks for faster workflows.",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
suffixes: ["_browser_wait", "_wait"],
|
|
48
|
+
hint: "Use only when content is still loading or an interaction has not settled. Avoid fixed waits when the next action can validate readiness.",
|
|
49
|
+
},
|
|
50
|
+
] as const
|
|
51
|
+
|
|
52
|
+
const connectionErrorPatterns = [
|
|
53
|
+
/econnrefused/i,
|
|
54
|
+
/connection refused/i,
|
|
55
|
+
/failed to connect/i,
|
|
56
|
+
/could not connect/i,
|
|
57
|
+
/browser\s*mcp.*(?:disconnected|unavailable|not connected)/i,
|
|
58
|
+
/extension.*(?:disabled|disconnected|not connected|unavailable)/i,
|
|
59
|
+
/websocket.*(?:closed|failed)/i,
|
|
60
|
+
/timed out while connecting/i,
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
const isBrowserTool = (toolID: string): boolean => toolID.startsWith(BROWSER_TOOL_PREFIX)
|
|
64
|
+
|
|
65
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => {
|
|
66
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const appendSection = (base: string, section: string): string => {
|
|
70
|
+
const trimmedSection = section.trim()
|
|
71
|
+
|
|
72
|
+
if (!trimmedSection) {
|
|
73
|
+
return base
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!base) {
|
|
77
|
+
return trimmedSection
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (base.includes(trimmedSection)) {
|
|
81
|
+
return base
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return `${base.trimEnd()}\n\n${trimmedSection}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const stringifyOutput = (value: unknown): string => {
|
|
88
|
+
if (typeof value === "string") {
|
|
89
|
+
return value
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
return JSON.stringify(value)
|
|
94
|
+
} catch {
|
|
95
|
+
return String(value)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const getFailureFlag = (value: Record<string, unknown>): boolean => {
|
|
100
|
+
if (value.success === false || value.ok === false) {
|
|
101
|
+
return true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (value.isError === true || value.error === true) {
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const getConnectionErrorText = (value: unknown): string | undefined => {
|
|
112
|
+
if (typeof value === "string") {
|
|
113
|
+
return value
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!isRecord(value)) {
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (typeof value.error === "string") {
|
|
121
|
+
return value.error
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (typeof value.stderr === "string") {
|
|
125
|
+
return value.stderr
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!getFailureFlag(value)) {
|
|
129
|
+
return undefined
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const field of ["message", "details"] as const) {
|
|
133
|
+
if (typeof value[field] === "string") {
|
|
134
|
+
return value[field]
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return undefined
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const matchesConnectionError = (text: string | undefined): boolean => {
|
|
142
|
+
if (!text) {
|
|
143
|
+
return false
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return connectionErrorPatterns.some((pattern) => pattern.test(text))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const isConnectionError = (value: unknown): boolean => matchesConnectionError(getConnectionErrorText(value))
|
|
150
|
+
|
|
151
|
+
const getToolHint = (toolID: string): string => {
|
|
152
|
+
for (const { suffixes, hint } of browserToolHints) {
|
|
153
|
+
if (suffixes.some((suffix) => toolID.endsWith(suffix))) {
|
|
154
|
+
return hint
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return "Prefer the smallest action that advances the task, and avoid redundant browser calls when the current page state is already known."
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Tool results carry `content` as either a plain string or a list of content parts.
|
|
163
|
+
* Both shapes need the hint appended without dropping any other part of the result.
|
|
164
|
+
*/
|
|
165
|
+
const appendResultSection = (result: ToolResult, section: string): ToolResult => {
|
|
166
|
+
const { content } = result
|
|
167
|
+
|
|
168
|
+
if (content === undefined) {
|
|
169
|
+
return { ...result, content: section }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (typeof content === "string") {
|
|
173
|
+
return { ...result, content: appendSection(content, section) }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const alreadyPresent = content.some((part: ToolContent) => part.type === "text" && part.text.includes(section))
|
|
177
|
+
|
|
178
|
+
if (alreadyPresent) {
|
|
179
|
+
return result
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { ...result, content: [...content, { type: "text", text: section }] }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A tool result is a success shape, so connection failures surface either as a
|
|
187
|
+
* thrown `ToolError` or as an error-flavoured payload inside the result.
|
|
188
|
+
*/
|
|
189
|
+
const resultHasConnectionError = (result: ToolResult): boolean => {
|
|
190
|
+
if (isConnectionError(result.output)) {
|
|
191
|
+
return true
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const { content } = result
|
|
195
|
+
|
|
196
|
+
if (content === undefined) {
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (typeof content === "string") {
|
|
201
|
+
return matchesConnectionError(content)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return content.some((part: ToolContent) => part.type === "text" && matchesConnectionError(part.text))
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export default Plugin.define({
|
|
208
|
+
id: "opencode-browser-v2",
|
|
209
|
+
async setup(ctx: Context) {
|
|
210
|
+
const browserSessions = new Set<string>()
|
|
211
|
+
const connectionStates = new Map<string, ConnectionState>()
|
|
212
|
+
const controller = new AbortController()
|
|
213
|
+
|
|
214
|
+
const getConnectionState = (sessionID: string): ConnectionState => {
|
|
215
|
+
const existingState = connectionStates.get(sessionID)
|
|
216
|
+
|
|
217
|
+
if (existingState) {
|
|
218
|
+
return existingState
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const nextState: ConnectionState = {
|
|
222
|
+
isConnected: true,
|
|
223
|
+
failureCount: 0,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
connectionStates.set(sessionID, nextState)
|
|
227
|
+
return nextState
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const markConnectionFailed = (sessionID: string, error: unknown) => {
|
|
231
|
+
const connectionState = getConnectionState(sessionID)
|
|
232
|
+
connectionState.isConnected = false
|
|
233
|
+
connectionState.failureCount += 1
|
|
234
|
+
connectionState.lastError = stringifyOutput(error)
|
|
235
|
+
return connectionState
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const resetConnectionState = (sessionID: string) => {
|
|
239
|
+
const connectionState = getConnectionState(sessionID)
|
|
240
|
+
connectionState.isConnected = true
|
|
241
|
+
connectionState.failureCount = 0
|
|
242
|
+
connectionState.lastError = undefined
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const disconnectedHint = (failureCount: number): string =>
|
|
246
|
+
failureCount === 1
|
|
247
|
+
? "[Browser MCP] The browser connection looks unavailable. Re-enable the Browser MCP extension or browser, then retry. The plugin skips delayed backoff so the next attempt can run immediately."
|
|
248
|
+
: `[Browser MCP] Browser connection is still unavailable (failure ${failureCount}). Retry as soon as the extension is ready.`
|
|
249
|
+
|
|
250
|
+
const restoredHint = "[Browser MCP] Connection restored. Continuing without extra retry delay."
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Applied to every model-request kind that carries tools, so the guidance and the
|
|
254
|
+
* per-tool performance hints reach the model no matter which loop is running.
|
|
255
|
+
*/
|
|
256
|
+
const applyBrowserContext = (event: SessionContext) => {
|
|
257
|
+
const last = event.system.length - 1
|
|
258
|
+
|
|
259
|
+
if (last >= 0) {
|
|
260
|
+
const part = event.system[last]
|
|
261
|
+
|
|
262
|
+
if (!part.text.includes(browserSpeedGuidance)) {
|
|
263
|
+
event.system[last] = { ...part, text: appendSection(part.text, browserSpeedGuidance) }
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
event.system.push({ type: "text", text: browserSpeedGuidance })
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for (const [toolID, definition] of Object.entries(event.tools)) {
|
|
270
|
+
if (!isBrowserTool(toolID)) {
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
definition.description = appendSection(definition.description, `Performance: ${getToolHint(toolID)}`)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
await ctx.session.hook("context", applyBrowserContext)
|
|
279
|
+
await ctx.session.hook("generate", applyBrowserContext)
|
|
280
|
+
|
|
281
|
+
await ctx.tool.hook("execute.after", (event) => {
|
|
282
|
+
if (!isBrowserTool(event.tool)) {
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
browserSessions.add(event.sessionID)
|
|
287
|
+
const connectionState = getConnectionState(event.sessionID)
|
|
288
|
+
|
|
289
|
+
if (event.status === "error") {
|
|
290
|
+
if (!matchesConnectionError(event.error.message)) {
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const { failureCount } = markConnectionFailed(event.sessionID, event.error.message)
|
|
295
|
+
|
|
296
|
+
event.error = new ToolError({
|
|
297
|
+
message: appendSection(event.error.message, disconnectedHint(failureCount)),
|
|
298
|
+
error: event.error.error,
|
|
299
|
+
metadata: event.error.metadata,
|
|
300
|
+
})
|
|
301
|
+
return
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (resultHasConnectionError(event.result)) {
|
|
305
|
+
const { failureCount } = markConnectionFailed(event.sessionID, event.result.output ?? event.result.content)
|
|
306
|
+
event.result = appendResultSection(event.result, disconnectedHint(failureCount))
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (!connectionState.isConnected) {
|
|
311
|
+
resetConnectionState(event.sessionID)
|
|
312
|
+
event.result = appendResultSection(event.result, restoredHint)
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
await ctx.session.hook("compaction", (event) => {
|
|
317
|
+
if (browserSessions.has(event.sessionID)) {
|
|
318
|
+
event.system.push({ type: "text", text: browserCompactionContext })
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
void (async () => {
|
|
323
|
+
try {
|
|
324
|
+
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
|
325
|
+
if (event.type !== "session.deleted") {
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
browserSessions.delete(event.data.sessionID)
|
|
330
|
+
connectionStates.delete(event.data.sessionID)
|
|
331
|
+
}
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if (!controller.signal.aborted) {
|
|
334
|
+
throw error
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
})()
|
|
338
|
+
|
|
339
|
+
return () => {
|
|
340
|
+
controller.abort()
|
|
341
|
+
browserSessions.clear()
|
|
342
|
+
connectionStates.clear()
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
})
|