You're Invited:Meet the Socket Team at RSAC and BSidesSF 2026, March 23–26.RSVP
Socket
Book a DemoSign in
Socket

@different-ai/opencode-browser

Package Overview
Dependencies
Maintainers
2
Versions
35
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@different-ai/opencode-browser - npm Package Compare versions

Comparing version
2.1.0
to
3.0.0
+440
src/mcp-server.ts
#!/usr/bin/env node
/**
* OpenCode Browser MCP Server
*
* MCP Server <--STDIO--> OpenCode
* MCP Server <--WebSocket:19222--> Chrome Extension
*
* This is a standalone MCP server that manages browser automation.
* It runs as a separate process and communicates with OpenCode via STDIO.
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";
const WS_PORT = 19222;
const BASE_DIR = join(homedir(), ".opencode-browser");
mkdirSync(BASE_DIR, { recursive: true });
// WebSocket state for Chrome extension connection
let ws: any = null;
let isConnected = false;
let server: ReturnType<typeof Bun.serve> | null = null;
let pendingRequests = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
let requestId = 0;
// Create MCP server
const mcpServer = new McpServer({
name: "opencode-browser",
version: "2.1.0",
});
// ============================================================================
// WebSocket Server for Chrome Extension
// ============================================================================
function handleMessage(message: { type: string; id?: number; result?: any; error?: any }): void {
if (message.type === "tool_response" && message.id !== undefined) {
const pending = pendingRequests.get(message.id);
if (!pending) return;
pendingRequests.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.content || String(message.error)));
} else {
pending.resolve(message.result?.content);
}
}
}
function sendToChrome(message: any): boolean {
if (ws && isConnected) {
ws.send(JSON.stringify(message));
return true;
}
return false;
}
async function isPortFree(port: number): Promise<boolean> {
try {
const testSocket = await Bun.connect({
hostname: "localhost",
port,
socket: {
data() {},
open(socket) {
socket.end();
},
close() {},
error() {},
},
});
testSocket.end();
return false;
} catch (e: any) {
if (e.code === "ECONNREFUSED" || e.message?.includes("ECONNREFUSED")) {
return true;
}
return true;
}
}
async function killProcessOnPort(port: number): Promise<boolean> {
try {
// Use lsof to find PID using the port
const proc = Bun.spawn(["lsof", "-t", `-i:${port}`], {
stdout: "pipe",
stderr: "pipe",
});
const output = await new Response(proc.stdout).text();
const pids = output.trim().split("\n").filter(Boolean);
if (pids.length === 0) {
return true; // No process found, port should be free
}
// Kill each PID found
for (const pid of pids) {
const pidNum = parseInt(pid, 10);
if (isNaN(pidNum)) continue;
console.error(`[browser-mcp] Killing existing process ${pidNum} on port ${port}`);
try {
process.kill(pidNum, "SIGTERM");
} catch (e) {
// Process may have already exited
}
}
// Wait a bit for process to die
await sleep(500);
// Verify port is now free
return await isPortFree(port);
} catch (e) {
console.error(`[browser-mcp] Failed to kill process on port:`, e);
return false;
}
}
async function startWebSocketServer(): Promise<boolean> {
if (server) return true;
if (!(await isPortFree(WS_PORT))) {
console.error(`[browser-mcp] Port ${WS_PORT} is in use, attempting to take over...`);
const killed = await killProcessOnPort(WS_PORT);
if (!killed) {
console.error(`[browser-mcp] Failed to free port ${WS_PORT}`);
return false;
}
console.error(`[browser-mcp] Successfully freed port ${WS_PORT}`);
}
try {
server = Bun.serve({
port: WS_PORT,
fetch(req, server) {
if (server.upgrade(req)) return;
return new Response("OpenCode Browser MCP Server", { status: 200 });
},
websocket: {
open(wsClient) {
console.error(`[browser-mcp] Chrome extension connected`);
ws = wsClient;
isConnected = true;
},
close() {
console.error(`[browser-mcp] Chrome extension disconnected`);
ws = null;
isConnected = false;
},
message(_wsClient, data) {
try {
const message = JSON.parse(data.toString());
handleMessage(message);
} catch (e) {
console.error(`[browser-mcp] Parse error:`, e);
}
},
},
});
console.error(`[browser-mcp] WebSocket server listening on port ${WS_PORT}`);
return true;
} catch (e) {
console.error(`[browser-mcp] Failed to start WebSocket server:`, e);
return false;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForExtensionConnection(timeoutMs: number): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (isConnected) return true;
await sleep(100);
}
return isConnected;
}
async function ensureConnection(): Promise<void> {
if (!server) {
const started = await startWebSocketServer();
if (!started) {
throw new Error("Failed to start WebSocket server. Port may be in use.");
}
}
if (!isConnected) {
const connected = await waitForExtensionConnection(5000);
if (!connected) {
throw new Error(
"Chrome extension not connected. Make sure Chrome is running with the OpenCode Browser extension enabled."
);
}
}
}
async function executeCommand(toolName: string, args: Record<string, any>): Promise<any> {
await ensureConnection();
const id = ++requestId;
return new Promise((resolve, reject) => {
pendingRequests.set(id, { resolve, reject });
sendToChrome({
type: "tool_request",
id,
tool: toolName,
args,
});
setTimeout(() => {
if (!pendingRequests.has(id)) return;
pendingRequests.delete(id);
reject(new Error("Tool execution timed out after 60 seconds"));
}, 60000);
});
}
// ============================================================================
// Register MCP Tools
// ============================================================================
mcpServer.tool(
"browser_status",
"Check if browser extension is connected. Returns connection status.",
{},
async () => {
const status = isConnected
? "Browser extension connected and ready."
: "Browser extension not connected. Make sure Chrome is running with the OpenCode Browser extension enabled.";
return {
content: [{ type: "text", text: status }],
};
}
);
mcpServer.tool(
"browser_navigate",
"Navigate to a URL in the browser",
{
url: z.string().describe("The URL to navigate to"),
tabId: z.number().optional().describe("Optional tab ID to navigate in"),
},
async ({ url, tabId }) => {
const result = await executeCommand("navigate", { url, tabId });
return {
content: [{ type: "text", text: result || `Navigated to ${url}` }],
};
}
);
mcpServer.tool(
"browser_click",
"Click an element on the page using a CSS selector",
{
selector: z.string().describe("CSS selector for element to click"),
tabId: z.number().optional().describe("Optional tab ID"),
},
async ({ selector, tabId }) => {
const result = await executeCommand("click", { selector, tabId });
return {
content: [{ type: "text", text: result || `Clicked ${selector}` }],
};
}
);
mcpServer.tool(
"browser_type",
"Type text into an input element",
{
selector: z.string().describe("CSS selector for input element"),
text: z.string().describe("Text to type"),
clear: z.boolean().optional().describe("Clear field before typing"),
tabId: z.number().optional().describe("Optional tab ID"),
},
async ({ selector, text, clear, tabId }) => {
const result = await executeCommand("type", { selector, text, clear, tabId });
return {
content: [{ type: "text", text: result || `Typed "${text}" into ${selector}` }],
};
}
);
mcpServer.tool(
"browser_screenshot",
"Take a screenshot of the current page. Returns base64 image data that can be viewed directly. Optionally saves to a file.",
{
tabId: z.number().optional().describe("Optional tab ID"),
save: z.boolean().optional().describe("Save to file (default: false, just returns base64)"),
path: z.string().optional().describe("Custom file path to save screenshot (implies save=true). Defaults to cwd if just save=true"),
},
async ({ tabId, save, path: savePath }) => {
const result = await executeCommand("screenshot", { tabId });
if (result && typeof result === "string" && result.startsWith("data:image")) {
const base64Data = result.replace(/^data:image\/\w+;base64,/, "");
const content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> = [
{
type: "image",
data: base64Data,
mimeType: "image/png",
},
];
// Optionally save to file
if (save || savePath) {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
let filepath: string;
if (savePath) {
// Use provided path (add .png if no extension)
filepath = savePath.endsWith(".png") ? savePath : `${savePath}.png`;
// If relative path, resolve from cwd
if (!savePath.startsWith("/")) {
filepath = join(process.cwd(), filepath);
}
} else {
// Default to cwd with timestamp
filepath = join(process.cwd(), `screenshot-${timestamp}.png`);
}
writeFileSync(filepath, Buffer.from(base64Data, "base64"));
content.push({ type: "text", text: `Saved: ${filepath}` });
}
return { content };
}
return {
content: [{ type: "text", text: result || "Screenshot failed" }],
};
}
);
mcpServer.tool(
"browser_snapshot",
"Get an accessibility tree snapshot of the page. Returns interactive elements with selectors for clicking, plus all links on the page.",
{
tabId: z.number().optional().describe("Optional tab ID"),
},
async ({ tabId }) => {
const result = await executeCommand("snapshot", { tabId });
return {
content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }],
};
}
);
mcpServer.tool(
"browser_get_tabs",
"List all open browser tabs",
{},
async () => {
const result = await executeCommand("get_tabs", {});
return {
content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }],
};
}
);
mcpServer.tool(
"browser_scroll",
"Scroll the page or scroll an element into view",
{
selector: z.string().optional().describe("CSS selector to scroll into view"),
x: z.number().optional().describe("Horizontal scroll amount in pixels"),
y: z.number().optional().describe("Vertical scroll amount in pixels"),
tabId: z.number().optional().describe("Optional tab ID"),
},
async ({ selector, x, y, tabId }) => {
const result = await executeCommand("scroll", { selector, x, y, tabId });
return {
content: [{ type: "text", text: result || `Scrolled ${selector ? `to ${selector}` : `by (${x || 0}, ${y || 0})`}` }],
};
}
);
mcpServer.tool(
"browser_wait",
"Wait for a specified duration",
{
ms: z.number().optional().describe("Milliseconds to wait (default: 1000)"),
},
async ({ ms }) => {
const waitMs = ms || 1000;
const result = await executeCommand("wait", { ms: waitMs });
return {
content: [{ type: "text", text: result || `Waited ${waitMs}ms` }],
};
}
);
mcpServer.tool(
"browser_execute",
"Execute JavaScript code in the page context and return the result. Note: May fail on pages with strict CSP.",
{
code: z.string().describe("JavaScript code to execute"),
tabId: z.number().optional().describe("Optional tab ID"),
},
async ({ code, tabId }) => {
const result = await executeCommand("execute_script", { code, tabId });
return {
content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }],
};
}
);
// ============================================================================
// Main
// ============================================================================
async function main() {
console.error("[browser-mcp] Starting OpenCode Browser MCP Server...");
// Start WebSocket server for Chrome extension
await startWebSocketServer();
// Connect MCP server to STDIO transport
const transport = new StdioServerTransport();
await mcpServer.connect(transport);
console.error("[browser-mcp] MCP Server running on STDIO");
}
main().catch((error) => {
console.error("[browser-mcp] Fatal error:", error);
process.exit(1);
});
+126
-80
#!/usr/bin/env node
/**
* OpenCode Browser - CLI Installer
* OpenCode Browser - CLI
*
* Installs the Chrome extension for browser automation.
* v2.0: Plugin-based architecture (no daemon, no MCP server)
* Commands:
* install - Install Chrome extension
* serve - Run MCP server (used by OpenCode)
* status - Check connection status
*/

@@ -13,3 +15,3 @@

import { fileURLToPath } from "url";
import { execSync } from "child_process";
import { execSync, spawn } from "child_process";
import { createInterface } from "readline";

@@ -75,16 +77,21 @@

async function main() {
console.log(`
${color("cyan", color("bright", "OpenCode Browser v2.0"))}
${color("cyan", "Browser automation for OpenCode")}
`);
const command = process.argv[2];
if (command === "install") {
if (command === "serve") {
// Run MCP server - this is called by OpenCode
await serve();
} else if (command === "install") {
await showHeader();
await install();
rl.close();
} else if (command === "uninstall") {
await showHeader();
await uninstall();
rl.close();
} else if (command === "status") {
await showHeader();
await status();
rl.close();
} else {
await showHeader();
log(`

@@ -94,13 +101,46 @@ ${color("bright", "Usage:")}

npx @different-ai/opencode-browser uninstall Remove installation
npx @different-ai/opencode-browser status Check lock status
npx @different-ai/opencode-browser status Check status
npx @different-ai/opencode-browser serve Run MCP server (internal)
${color("bright", "v2.0 Changes:")}
- Plugin-based architecture (no daemon needed)
- Add plugin to opencode.json, load extension in Chrome, done
${color("bright", "Quick Start:")}
1. Run: npx @different-ai/opencode-browser install
2. Add to your opencode.json:
${color("cyan", `"mcp": { "browser": { "type": "local", "command": ["bunx", "@different-ai/opencode-browser", "serve"] } }`)}
3. Restart OpenCode
`);
rl.close();
}
}
rl.close();
async function showHeader() {
console.log(`
${color("cyan", color("bright", "OpenCode Browser v2.1"))}
${color("cyan", "Browser automation MCP server for OpenCode")}
`);
}
async function serve() {
// Launch the MCP server
const serverPath = join(PACKAGE_ROOT, "src", "mcp-server.ts");
// Use bun to run the TypeScript server
const child = spawn("bun", ["run", serverPath], {
stdio: "inherit",
env: process.env,
});
child.on("error", (err) => {
console.error("[browser-mcp] Failed to start server:", err);
process.exit(1);
});
child.on("exit", (code) => {
process.exit(code || 0);
});
// Forward signals to child
process.on("SIGINT", () => child.kill("SIGINT"));
process.on("SIGTERM", () => child.kill("SIGTERM"));
}
async function install() {

@@ -163,14 +203,16 @@ header("Step 1: Check Platform");

const pluginConfig = `{
"$schema": "https://opencode.ai/config.json",
"plugin": ["@different-ai/opencode-browser"]
}`;
const mcpConfig = {
browser: {
type: "local",
command: ["bunx", "@different-ai/opencode-browser", "serve"],
},
};
log(`
Add the plugin to your ${color("cyan", "opencode.json")}:
Add the MCP server to your ${color("cyan", "opencode.json")}:
${color("bright", pluginConfig)}
${color("bright", JSON.stringify({ $schema: "https://opencode.ai/config.json", mcp: mcpConfig }, null, 2))}
Or if you already have an opencode.json, just add to the "plugin" array:
${color("bright", '"plugin": ["@different-ai/opencode-browser"]')}
Or if you already have an opencode.json, add to the "mcp" object:
${color("bright", JSON.stringify({ mcp: mcpConfig }, null, 2))}
`);

@@ -181,3 +223,3 @@

if (existsSync(opencodeJsonPath)) {
const shouldUpdate = await confirm(`Found opencode.json. Add plugin automatically?`);
const shouldUpdate = await confirm(`Found opencode.json. Add MCP server automatically?`);

@@ -187,19 +229,22 @@ if (shouldUpdate) {

const config = JSON.parse(readFileSync(opencodeJsonPath, "utf-8"));
config.plugin = config.plugin || [];
if (!config.plugin.includes("@different-ai/opencode-browser")) {
config.plugin.push("@different-ai/opencode-browser");
}
// Remove old MCP config if present
if (config.mcp?.browser) {
delete config.mcp.browser;
if (Object.keys(config.mcp).length === 0) {
delete config.mcp;
config.mcp = config.mcp || {};
config.mcp.browser = mcpConfig.browser;
// Remove old plugin config if present
if (config.plugin && Array.isArray(config.plugin)) {
const idx = config.plugin.indexOf("@different-ai/opencode-browser");
if (idx !== -1) {
config.plugin.splice(idx, 1);
warn("Removed old plugin entry (replaced by MCP)");
}
warn("Removed old MCP browser config (replaced by plugin)");
if (config.plugin.length === 0) {
delete config.plugin;
}
}
writeFileSync(opencodeJsonPath, JSON.stringify(config, null, 2) + "\n");
success("Updated opencode.json with plugin");
success("Updated opencode.json with MCP server");
} catch (e) {
error(`Failed to update opencode.json: ${e.message}`);
log("Please add the plugin manually.");
log("Please add the MCP config manually.");
}

@@ -214,6 +259,6 @@ }

$schema: "https://opencode.ai/config.json",
plugin: ["@different-ai/opencode-browser"],
mcp: mcpConfig,
};
writeFileSync(opencodeJsonPath, JSON.stringify(config, null, 2) + "\n");
success("Created opencode.json with plugin");
success("Created opencode.json with MCP server");
} catch (e) {

@@ -225,4 +270,4 @@ error(`Failed to create opencode.json: ${e.message}`);

// Clean up old daemon if present
header("Step 5: Cleanup (v1.x migration)");
// Clean up old daemon/plugin if present
header("Step 5: Cleanup (migration)");

@@ -234,10 +279,19 @@ const oldDaemonPlist = join(homedir(), "Library", "LaunchAgents", "com.opencode.browser-daemon.plist");

unlinkSync(oldDaemonPlist);
success("Removed old daemon (no longer needed in v2.0)");
success("Removed old daemon (no longer needed)");
} catch {
warn("Could not remove old daemon plist. Remove manually if needed.");
}
} else {
success("No old daemon to clean up");
}
// Remove old lock file
const oldLockFile = join(homedir(), ".opencode-browser", "lock.json");
if (existsSync(oldLockFile)) {
try {
unlinkSync(oldLockFile);
success("Removed old lock file (not needed with MCP)");
} catch {}
}
success("Cleanup complete");
header("Installation Complete!");

@@ -247,13 +301,12 @@

${color("green", "")} Extension: ${extensionDir}
${color("green", "")} Plugin: @different-ai/opencode-browser
${color("green", "")} MCP Server: @different-ai/opencode-browser
${color("bright", "How it works:")}
1. OpenCode loads the plugin on startup
2. Plugin starts WebSocket server on port 19222
1. OpenCode spawns MCP server on demand
2. MCP server starts WebSocket server on port 19222
3. Chrome extension connects automatically
4. Browser tools are available!
4. Browser tools are available to any OpenCode session!
${color("bright", "Available tools:")}
browser_status - Check if browser is available
browser_kill_session - Take over from another session
browser_status - Check if browser is connected
browser_navigate - Go to a URL

@@ -263,3 +316,3 @@ browser_click - Click an element

browser_screenshot - Capture the page
browser_snapshot - Get accessibility tree
browser_snapshot - Get accessibility tree + all links
browser_get_tabs - List open tabs

@@ -270,5 +323,6 @@ browser_scroll - Scroll the page

${color("bright", "Multi-session:")}
Only one OpenCode session can use browser at a time.
Use browser_status to check, browser_kill_session to take over.
${color("bright", "Benefits of MCP architecture:")}
- No session conflicts between OpenCode instances
- Server runs independently of OpenCode process
- Clean separation of concerns

@@ -281,32 +335,24 @@ ${color("bright", "Test it:")}

async function status() {
header("Browser Lock Status");
header("Browser Status");
const lockFile = join(homedir(), ".opencode-browser", "lock.json");
if (!existsSync(lockFile)) {
success("Browser available (no lock file)");
return;
}
// Check if port 19222 is in use
try {
const lock = JSON.parse(readFileSync(lockFile, "utf-8"));
log(`
Lock file: ${lockFile}
PID: ${lock.pid}
Session: ${lock.sessionId}
Started: ${lock.startedAt}
Working directory: ${lock.cwd}
`);
// Check if process is alive
try {
process.kill(lock.pid, 0);
warn(`Process ${lock.pid} is running. Browser is locked.`);
} catch {
success(`Process ${lock.pid} is dead. Lock is stale and will be auto-cleaned.`);
const result = execSync("lsof -i :19222 2>/dev/null || true", { encoding: "utf-8" });
if (result.trim()) {
success("WebSocket server is running on port 19222");
log(result);
} else {
warn("WebSocket server not running (starts on demand via MCP)");
}
} catch (e) {
error(`Could not read lock file: ${e.message}`);
} catch {
warn("Could not check port status");
}
// Check extension directory
const extensionDir = join(homedir(), ".opencode-browser", "extension");
if (existsSync(extensionDir)) {
success(`Extension installed at: ${extensionDir}`);
} else {
warn("Extension not installed. Run: npx @different-ai/opencode-browser install");
}
}

@@ -354,3 +400,3 @@

Also remove "@different-ai/opencode-browser" from your opencode.json plugin array.
Also remove the "browser" entry from your opencode.json mcp section.
`);

@@ -357,0 +403,0 @@ }

@@ -218,3 +218,4 @@ const PLUGIN_URL = "ws://localhost:19222";

name: getName(el).slice(0, 200), tag: el.tagName.toLowerCase() };
if (el.tagName === "A" && el.href) node.href = el.href;
// Capture href for any element that has one (links, area, base, etc.)
if (el.href) node.href = el.href;
if (el.tagName === "INPUT") { node.type = el.type; node.value = el.value; }

@@ -238,3 +239,23 @@ if (el.id) node.selector = `#${el.id}`;

return { url: location.href, title: document.title, nodes: build(document.body).nodes.slice(0, 500) };
// Collect all links on the page separately for easy access
function getAllLinks() {
const links = [];
const seen = new Set();
document.querySelectorAll("a[href]").forEach(a => {
const href = a.href;
if (href && !seen.has(href) && !href.startsWith("javascript:")) {
seen.add(href);
const text = a.innerText?.trim().slice(0, 100) || a.getAttribute("aria-label") || "";
links.push({ href, text });
}
});
return links.slice(0, 100); // Limit to 100 links
}
return {
url: location.href,
title: document.title,
nodes: build(document.body).nodes.slice(0, 500),
links: getAllLinks()
};
}

@@ -241,0 +262,0 @@ });

{
"name": "@different-ai/opencode-browser",
"version": "2.1.0",
"description": "Browser automation plugin for OpenCode. Control your real Chrome browser with existing logins and cookies.",
"version": "3.0.0",
"description": "Browser automation MCP server for OpenCode. Control your real Chrome browser with existing logins and cookies.",
"type": "module",

@@ -9,6 +9,5 @@ "bin": {

},
"main": "./src/plugin.ts",
"main": "./src/mcp-server.ts",
"exports": {
".": "./src/plugin.ts",
"./plugin": "./src/plugin.ts"
".": "./src/mcp-server.ts"
},

@@ -22,3 +21,4 @@ "files": [

"scripts": {
"install-extension": "node bin/cli.js install"
"install-extension": "node bin/cli.js install",
"serve": "bun run src/mcp-server.ts"
},

@@ -30,3 +30,4 @@ "keywords": [

"chrome",
"plugin"
"mcp",
"model-context-protocol"
],

@@ -43,9 +44,9 @@ "author": "Benjamin Shafii",

"homepage": "https://github.com/different-ai/opencode-browser#readme",
"peerDependencies": {
"@opencode-ai/plugin": "*"
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.2",
"zod": "^4.3.5"
},
"devDependencies": {
"@opencode-ai/plugin": "*",
"bun-types": "*"
}
}
+50
-43
# OpenCode Browser
Browser automation plugin for [OpenCode](https://github.com/opencode-ai/opencode).
Browser automation MCP server for [OpenCode](https://github.com/opencode-ai/opencode).

@@ -11,3 +11,3 @@ Control your real Chrome browser with existing logins, cookies, and bookmarks. No DevTools Protocol, no security prompts.

OpenCode Browser uses a simple WebSocket connection between an OpenCode plugin and a Chrome extension. Your automation works with your existing browser session - no prompts, no separate profiles.
OpenCode Browser uses a simple WebSocket connection between an MCP server and a Chrome extension. Your automation works with your existing browser session - no prompts, no separate profiles.

@@ -23,3 +23,3 @@ ## Installation

2. Guide you to load the extension in Chrome
3. Update your `opencode.json` to use the plugin
3. Update your `opencode.json` with MCP server config

@@ -32,3 +32,8 @@ ## Configuration

{
"plugin": ["@different-ai/opencode-browser"]
"mcp": {
"browser": {
"type": "local",
"command": ["bunx", "@different-ai/opencode-browser", "serve"]
}
}
}

@@ -46,11 +51,8 @@ ```

|------|-------------|
| `browser_status` | Check if browser is available or locked |
| `browser_kill_session` | Request other session release + take over (no kill) |
| `browser_release` | Release lock and stop server |
| `browser_force_kill_session` | (Last resort) kill other OpenCode process |
| `browser_status` | Check if browser extension is connected |
| `browser_navigate` | Navigate to a URL |
| `browser_click` | Click an element by CSS selector |
| `browser_type` | Type text into an input field |
| `browser_screenshot` | Capture the visible page |
| `browser_snapshot` | Get accessibility tree with selectors |
| `browser_screenshot` | Capture the page (returns base64, optionally saves to file) |
| `browser_snapshot` | Get accessibility tree with selectors + all page links |
| `browser_get_tabs` | List all open tabs |

@@ -61,45 +63,50 @@ | `browser_scroll` | Scroll page or element into view |

## Multi-Session Support
### Screenshot Tool
Only one OpenCode session can use the browser at a time. This prevents conflicts when you have multiple terminals open.
The `browser_screenshot` tool returns base64 image data by default, allowing AI to view images directly:
- `browser_status` - Check who has the lock
- `browser_kill_session` - Request the other session to release (no kill)
- `browser_release` - Release lock/server for this session
- `browser_force_kill_session` - (Last resort) kill the other OpenCode process and take over
```javascript
// Returns base64 image (AI can view it)
browser_screenshot()
In your prompts, you can say:
- "If browser is locked, kill the session and proceed"
- "If browser is locked, skip this task"
// Save to current working directory
browser_screenshot({ save: true })
// Save to specific path
browser_screenshot({ path: "my-screenshot.png" })
```
## Architecture
```
OpenCode Plugin ◄──WebSocket:19222──► Chrome Extension
│ │
└── Lock file └── chrome.tabs, chrome.scripting
OpenCode <──STDIO──> MCP Server <──WebSocket:19222──> Chrome Extension
│ │
└── @modelcontextprotocol/sdk └── chrome.tabs, chrome.scripting
```
**Two components:**
1. OpenCode plugin (runs WebSocket server, defines tools)
2. Chrome extension (connects to plugin, executes commands)
1. MCP Server (runs as separate process, manages WebSocket server)
2. Chrome extension (connects to server, executes browser commands)
**No daemon. No MCP server. No native messaging host.**
**Benefits of MCP architecture:**
- No session conflicts between OpenCode instances
- Server runs independently of OpenCode process
- Clean separation of concerns
- Standard MCP protocol
## Upgrading from v1.x
## Upgrading from v2.x (Plugin)
v2.0 is a complete rewrite with a simpler architecture:
v3.0 migrates from plugin to MCP architecture:
1. Run `npx @different-ai/opencode-browser install` (cleans up old daemon automatically)
2. Replace MCP config with plugin config in `opencode.json`:
1. Run `npx @different-ai/opencode-browser install`
2. Replace plugin config with MCP config in `opencode.json`:
```diff
- "mcp": {
- "browser": {
- "type": "local",
- "command": ["npx", "@different-ai/opencode-browser", "start"],
- "enabled": true
- }
- }
+ "plugin": ["@different-ai/opencode-browser"]
- "plugin": ["@different-ai/opencode-browser"]
+ "mcp": {
+ "browser": {
+ "type": "local",
+ "command": ["bunx", "@different-ai/opencode-browser", "serve"]
+ }
+ }
```

@@ -116,10 +123,10 @@

**"Browser locked by another session"**
- Use `browser_kill_session` to take over
- Or close the other OpenCode session
**"Failed to start WebSocket server"**
- Port 19222 may be in use
- Check if another OpenCode session is running
- Run `lsof -i :19222` to check what's using it
**"browser_execute fails on some sites"**
- Sites with strict CSP block JavaScript execution
- Use `browser_snapshot` to get page data instead
## Uninstall

@@ -136,3 +143,3 @@

- macOS ✓
- Linux ✓
- Linux ✓
- Windows (not yet supported)

@@ -139,0 +146,0 @@

/**
* OpenCode Browser Plugin
*
* OpenCode Plugin (this) <--WebSocket:19222--> Chrome Extension
*
* Notes
* - Uses a lock file so only one OpenCode session owns the browser.
* - Supports a *soft takeover* (SIGUSR1) so we don't have to kill OpenCode.
*/
import type { Plugin } from "@opencode-ai/plugin";
import { tool } from "@opencode-ai/plugin";
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";
const WS_PORT = 19222;
const BASE_DIR = join(homedir(), ".opencode-browser");
const LOCK_FILE = join(BASE_DIR, "lock.json");
const SCREENSHOTS_DIR = join(BASE_DIR, "screenshots");
// If a session hasn't used the browser in this long, allow soft takeover by default.
const LOCK_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
mkdirSync(BASE_DIR, { recursive: true });
mkdirSync(SCREENSHOTS_DIR, { recursive: true });
// Session state
const sessionId = Math.random().toString(36).slice(2);
const pid = process.pid;
let ws: WebSocket | null = null;
let isConnected = false;
let server: ReturnType<typeof Bun.serve> | null = null;
let pendingRequests = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
let requestId = 0;
interface LockInfo {
pid: number;
sessionId: string;
startedAt: string;
lastUsedAt: string;
cwd: string;
}
function nowIso(): string {
return new Date().toISOString();
}
function readLock(): LockInfo | null {
try {
if (!existsSync(LOCK_FILE)) return null;
return JSON.parse(readFileSync(LOCK_FILE, "utf-8"));
} catch {
return null;
}
}
function writeLock(): void {
writeFileSync(
LOCK_FILE,
JSON.stringify(
{
pid,
sessionId,
startedAt: nowIso(),
lastUsedAt: nowIso(),
cwd: process.cwd(),
} satisfies LockInfo,
null,
2
) + "\n"
);
}
function touchLock(): void {
const lock = readLock();
if (!lock) return;
if (lock.sessionId !== sessionId) return;
try {
writeFileSync(
LOCK_FILE,
JSON.stringify(
{
...lock,
lastUsedAt: nowIso(),
} satisfies LockInfo,
null,
2
) + "\n"
);
} catch {
// Ignore
}
}
function releaseLock(): void {
try {
const lock = readLock();
if (lock && lock.sessionId === sessionId) {
unlinkSync(LOCK_FILE);
}
} catch {
// Ignore
}
}
function isProcessAlive(targetPid: number): boolean {
try {
process.kill(targetPid, 0);
return true;
} catch {
return false;
}
}
function lockAgeMs(lock: LockInfo): number {
const ts = lock.lastUsedAt || lock.startedAt;
const n = Date.parse(ts);
if (Number.isNaN(n)) return Number.POSITIVE_INFINITY;
return Date.now() - n;
}
function isLockExpired(lock: LockInfo): boolean {
return lockAgeMs(lock) > LOCK_TTL_MS;
}
function isPortFree(port: number): boolean {
try {
// If we can connect, something is already listening.
const testSocket = Bun.connect({ port, timeout: 300 });
testSocket.end();
return false;
} catch (e) {
if ((e as any).code === "ECONNREFUSED") return true;
return false;
}
}
function stopBrowserServer(): void {
try {
(ws as any)?.close?.();
} catch {
// Ignore
}
ws = null;
isConnected = false;
try {
server?.stop();
} catch {
// Ignore
}
server = null;
}
function startServer(): boolean {
if (server) return true;
if (!isPortFree(WS_PORT)) return false;
try {
server = Bun.serve({
port: WS_PORT,
fetch(req, server) {
if (server.upgrade(req)) return;
return new Response("OpenCode Browser Plugin", { status: 200 });
},
websocket: {
open(wsClient) {
console.error(`[browser-plugin] Chrome extension connected`);
ws = wsClient as unknown as WebSocket;
isConnected = true;
},
close() {
console.error(`[browser-plugin] Chrome extension disconnected`);
ws = null;
isConnected = false;
},
message(_wsClient, data) {
try {
const message = JSON.parse(data.toString());
handleMessage(message);
} catch (e) {
console.error(`[browser-plugin] Parse error:`, e);
}
},
},
});
console.error(`[browser-plugin] WebSocket server listening on port ${WS_PORT}`);
return true;
} catch (e) {
console.error(`[browser-plugin] Failed to start server:`, e);
return false;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForExtensionConnection(timeoutMs: number): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (isConnected) return true;
await sleep(100);
}
return isConnected;
}
async function requestSessionRelease(targetPid: number, opts?: { timeoutMs?: number }): Promise<{ success: boolean; error?: string }> {
const timeoutMs = opts?.timeoutMs ?? 3000;
try {
// SIGUSR1 is treated as "release browser lock + stop server".
// This does NOT terminate OpenCode.
process.kill(targetPid, "SIGUSR1");
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const lock = readLock();
const lockCleared = !lock || lock.pid !== targetPid;
const portCleared = isPortFree(WS_PORT);
if (lockCleared && portCleared) return { success: true };
await sleep(100);
}
return {
success: false,
error: `Timed out waiting for PID ${targetPid} to release browser`,
};
}
async function forceKillSession(targetPid: number): Promise<{ success: boolean; error?: string }> {
try {
process.kill(targetPid, "SIGTERM");
let attempts = 0;
while (isProcessAlive(targetPid) && attempts < 20) {
await sleep(100);
attempts++;
}
if (isProcessAlive(targetPid)) {
process.kill(targetPid, "SIGKILL");
}
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}
function handleMessage(message: { type: string; id?: number; result?: any; error?: any }): void {
if (message.type === "tool_response" && message.id !== undefined) {
const pending = pendingRequests.get(message.id);
if (!pending) return;
pendingRequests.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.content || String(message.error)));
} else {
pending.resolve(message.result?.content);
}
}
}
function sendToChrome(message: any): boolean {
if (ws && isConnected) {
(ws as any).send(JSON.stringify(message));
return true;
}
return false;
}
async function performTakeover(): Promise<string> {
const lock = readLock();
if (!lock) {
writeLock();
} else if (lock.sessionId === sessionId) {
// Already ours.
} else if (!isProcessAlive(lock.pid)) {
// Dead PID -> stale.
console.error(`[browser-plugin] Cleaning stale lock from dead PID ${lock.pid}`);
writeLock();
} else {
const ageMinutes = Math.round(lockAgeMs(lock) / 60000);
console.error(
`[browser-plugin] Requesting release from PID ${lock.pid} (last used ${ageMinutes}m ago)...`
);
const released = await requestSessionRelease(lock.pid, { timeoutMs: 4000 });
if (!released.success) {
throw new Error(
`Failed to takeover without killing OpenCode: ${released.error}. ` +
`Try again, or use browser_force_kill_session as last resort.`
);
}
console.error(`[browser-plugin] Previous session released gracefully.`);
writeLock();
}
touchLock();
if (!server) {
if (!startServer()) {
throw new Error("Failed to start WebSocket server after takeover.");
}
}
const ok = await waitForExtensionConnection(3000);
if (!ok) {
throw new Error("Took over lock but Chrome extension did not connect.");
}
return "Browser now connected to this session.";
}
async function ensureLockAndServer(): Promise<void> {
const existingLock = readLock();
if (!existingLock) {
writeLock();
} else if (existingLock.sessionId === sessionId) {
// Already ours.
} else if (!isProcessAlive(existingLock.pid)) {
// Stale lock (dead PID).
console.error(`[browser-plugin] Cleaning stale lock from dead PID ${existingLock.pid}`);
writeLock();
} else {
// Another session holds the lock - attempt automatic soft takeover
const ageMinutes = Math.round(lockAgeMs(existingLock) / 60000);
console.error(
`[browser-plugin] Browser locked by PID ${existingLock.pid} (last used ${ageMinutes}m ago). Attempting auto-takeover...`
);
const released = await requestSessionRelease(existingLock.pid, { timeoutMs: 4000 });
if (released.success) {
console.error(`[browser-plugin] Auto-takeover succeeded. Previous session released gracefully.`);
writeLock();
} else {
// Soft takeover failed - provide helpful error
const expired = isLockExpired(existingLock);
const why = expired ? "expired" : "active";
throw new Error(
`Browser locked by another session (PID ${existingLock.pid}, ${why}). ` +
`Auto-takeover failed: ${released.error}. ` +
`Use browser_force_kill_session as last resort, or browser_status for details.`
);
}
}
touchLock();
if (!server) {
if (!startServer()) {
throw new Error("Failed to start WebSocket server. Port may be in use.");
}
}
if (!isConnected) {
const ok = await waitForExtensionConnection(3000);
if (!ok) {
throw new Error(
"Chrome extension not connected. Make sure Chrome is running with the OpenCode Browser extension enabled."
);
}
}
}
async function executeCommand(toolName: string, args: Record<string, any>): Promise<any> {
await ensureLockAndServer();
const id = ++requestId;
touchLock();
return new Promise((resolve, reject) => {
pendingRequests.set(id, { resolve, reject });
sendToChrome({
type: "tool_request",
id,
tool: toolName,
args,
});
setTimeout(() => {
if (!pendingRequests.has(id)) return;
pendingRequests.delete(id);
reject(new Error("Tool execution timed out after 60 seconds"));
}, 60000);
});
}
// ============================================================================
// Cleanup / Signals
// ============================================================================
// Soft release: do NOT exit the OpenCode process.
process.on("SIGUSR1", () => {
console.error(`[browser-plugin] SIGUSR1: releasing lock + stopping server`);
releaseLock();
stopBrowserServer();
});
process.on("SIGTERM", () => {
releaseLock();
stopBrowserServer();
process.exit(0);
});
process.on("SIGINT", () => {
releaseLock();
stopBrowserServer();
process.exit(0);
});
process.on("exit", () => {
releaseLock();
});
// ============================================================================
// Plugin Export
// ============================================================================
export const BrowserPlugin: Plugin = async (_ctx) => {
console.error(`[browser-plugin] Initializing (session ${sessionId})`);
return {
tool: {
browser_status: tool({
description:
"Check if browser is available or locked by another session. Returns connection status and lock info.",
args: {},
async execute() {
const lock = readLock();
if (!lock) {
return "Browser available (no active session)";
}
if (lock.sessionId === sessionId) {
return (
`Browser connected (this session)\n` +
`PID: ${pid}\n` +
`Started: ${lock.startedAt}\n` +
`Last used: ${lock.lastUsedAt}\n` +
`Extension: ${isConnected ? "connected" : "not connected"}`
);
}
const alive = isProcessAlive(lock.pid);
const ageMinutes = Math.round(lockAgeMs(lock) / 60000);
const expired = isLockExpired(lock);
if (!alive) {
return `Browser available (stale lock from dead PID ${lock.pid} will be auto-cleaned on next command)`;
}
return (
`Browser locked by another session\n` +
`PID: ${lock.pid}\n` +
`Session: ${lock.sessionId}\n` +
`Started: ${lock.startedAt}\n` +
`Last used: ${lock.lastUsedAt} (~${ageMinutes}m ago)${expired ? " [expired]" : ""}\n` +
`Working directory: ${lock.cwd}\n\n` +
`Use browser_takeover to request release (no kill), or browser_force_kill_session as last resort.`
);
},
}),
browser_release: tool({
description: "Release browser lock and stop the server for this session.",
args: {},
async execute() {
const lock = readLock();
if (lock && lock.sessionId !== sessionId) {
throw new Error("This session does not own the browser lock.");
}
releaseLock();
stopBrowserServer();
return "Released browser lock for this session.";
},
}),
browser_takeover: tool({
description:
"Request the session holding the browser lock to release it (no process kill), then take over.",
args: {},
async execute() {
return await performTakeover();
},
}),
browser_kill_session: tool({
description:
"(Deprecated name) Soft takeover without killing OpenCode. Prefer browser_takeover.",
args: {},
async execute() {
// Keep backward compatibility: old callers use this.
return await performTakeover();
},
}),
browser_force_kill_session: tool({
description: "Force kill the session holding the browser lock (last resort).",
args: {},
async execute() {
const lock = readLock();
if (!lock) {
writeLock();
return "No active session. Browser now connected to this session.";
}
if (lock.sessionId === sessionId) {
return "This session already owns the browser.";
}
if (!isProcessAlive(lock.pid)) {
writeLock();
return `Cleaned stale lock (PID ${lock.pid} was dead). Browser now connected to this session.`;
}
const result = await forceKillSession(lock.pid);
if (!result.success) {
throw new Error(`Failed to force kill session: ${result.error}`);
}
// Best-effort cleanup; then take lock.
try {
unlinkSync(LOCK_FILE);
} catch {
// Ignore
}
writeLock();
if (!server) {
if (!startServer()) {
throw new Error("Failed to start WebSocket server after force kill.");
}
}
const ok = await waitForExtensionConnection(3000);
if (!ok) {
throw new Error("Force-killed lock holder but Chrome extension did not connect.");
}
return `Force-killed session ${lock.sessionId} (PID ${lock.pid}). Browser now connected to this session.`;
},
}),
browser_navigate: tool({
description: "Navigate to a URL in browser",
args: {
url: tool.schema.string({ description: "The URL to navigate to" }),
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
},
async execute(args) {
return await executeCommand("navigate", args);
},
}),
browser_click: tool({
description: "Click an element on page using a CSS selector",
args: {
selector: tool.schema.string({ description: "CSS selector for element to click" }),
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
},
async execute(args) {
return await executeCommand("click", args);
},
}),
browser_type: tool({
description: "Type text into an input element",
args: {
selector: tool.schema.string({ description: "CSS selector for input element" }),
text: tool.schema.string({ description: "Text to type" }),
clear: tool.schema.optional(tool.schema.boolean({ description: "Clear field before typing" })),
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
},
async execute(args) {
return await executeCommand("type", args);
},
}),
browser_screenshot: tool({
description: "Take a screenshot of the current page. Saves to ~/.opencode-browser/screenshots/",
args: {
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
name: tool.schema.optional(
tool.schema.string({ description: "Optional name for screenshot file (without extension)" })
),
},
async execute(args: { tabId?: number; name?: string }) {
const result = await executeCommand("screenshot", args);
if (result && typeof result === "string" && result.startsWith("data:image")) {
const base64Data = result.replace(/^data:image\/\w+;base64,/, "");
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = args.name ? `${args.name}.png` : `screenshot-${timestamp}.png`;
const filepath = join(SCREENSHOTS_DIR, filename);
writeFileSync(filepath, Buffer.from(base64Data, "base64"));
return `Screenshot saved: ${filepath}`;
}
return result;
},
}),
browser_snapshot: tool({
description:
"Get an accessibility tree snapshot of the page. Returns interactive elements with selectors for clicking.",
args: {
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
},
async execute(args) {
return await executeCommand("snapshot", args);
},
}),
browser_get_tabs: tool({
description: "List all open browser tabs",
args: {},
async execute() {
return await executeCommand("get_tabs", {});
},
}),
browser_scroll: tool({
description: "Scroll the page or scroll an element into view",
args: {
selector: tool.schema.optional(tool.schema.string({ description: "CSS selector to scroll into view" })),
x: tool.schema.optional(tool.schema.number({ description: "Horizontal scroll amount in pixels" })),
y: tool.schema.optional(tool.schema.number({ description: "Vertical scroll amount in pixels" })),
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
},
async execute(args) {
return await executeCommand("scroll", args);
},
}),
browser_wait: tool({
description: "Wait for a specified duration",
args: {
ms: tool.schema.optional(tool.schema.number({ description: "Milliseconds to wait (default: 1000)" })),
},
async execute(args) {
return await executeCommand("wait", args);
},
}),
browser_execute: tool({
description: "Execute JavaScript code in the page context and return the result",
args: {
code: tool.schema.string({ description: "JavaScript code to execute" }),
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
},
async execute(args) {
return await executeCommand("execute_script", args);
},
}),
},
};
};
export default BrowserPlugin;