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 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
+ ![Demo](assets/demo.gif)
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
+ })