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
3.0.0
to
4.0.0
+290
bin/broker.cjs
#!/usr/bin/env node
"use strict";
const net = require("net");
const fs = require("fs");
const os = require("os");
const path = require("path");
const BASE_DIR = path.join(os.homedir(), ".opencode-browser");
const SOCKET_PATH = path.join(BASE_DIR, "broker.sock");
fs.mkdirSync(BASE_DIR, { recursive: true });
function nowIso() {
return new Date().toISOString();
}
function createJsonLineParser(onMessage) {
let buffer = "";
return (chunk) => {
buffer += chunk.toString("utf8");
while (true) {
const idx = buffer.indexOf("\n");
if (idx === -1) return;
const line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
if (!line.trim()) continue;
try {
onMessage(JSON.parse(line));
} catch {
// ignore
}
}
};
}
function writeJsonLine(socket, msg) {
socket.write(JSON.stringify(msg) + "\n");
}
function wantsTab(toolName) {
return !["get_tabs", "get_active_tab"].includes(toolName);
}
// --- State ---
let host = null; // { socket }
let nextExtId = 0;
const extPending = new Map(); // extId -> { pluginSocket, pluginRequestId, sessionId }
const clients = new Set();
// Tab ownership: tabId -> { sessionId, claimedAt }
const claims = new Map();
function listClaims() {
const out = [];
for (const [tabId, info] of claims.entries()) {
out.push({ tabId, ...info });
}
out.sort((a, b) => a.tabId - b.tabId);
return out;
}
function releaseClaimsForSession(sessionId) {
for (const [tabId, info] of claims.entries()) {
if (info.sessionId === sessionId) claims.delete(tabId);
}
}
function checkClaim(tabId, sessionId) {
const existing = claims.get(tabId);
if (!existing) return { ok: true };
if (existing.sessionId === sessionId) return { ok: true };
return { ok: false, error: `Tab ${tabId} is owned by another OpenCode session (${existing.sessionId})` };
}
function setClaim(tabId, sessionId) {
claims.set(tabId, { sessionId, claimedAt: nowIso() });
}
function ensureHost() {
if (host && host.socket && !host.socket.destroyed) return;
throw new Error("Chrome extension is not connected (native host offline)");
}
function callExtension(tool, args, sessionId) {
ensureHost();
const extId = ++nextExtId;
return new Promise((resolve, reject) => {
extPending.set(extId, { resolve, reject, sessionId });
writeJsonLine(host.socket, {
type: "to_extension",
message: { type: "tool_request", id: extId, tool, args },
});
const timeout = setTimeout(() => {
if (!extPending.has(extId)) return;
extPending.delete(extId);
reject(new Error("Timed out waiting for extension"));
}, 60000);
// attach timeout to resolver
const pending = extPending.get(extId);
if (pending) pending.timeout = timeout;
});
}
async function resolveActiveTab(sessionId) {
const res = await callExtension("get_active_tab", {}, sessionId);
const tabId = res && typeof res.tabId === "number" ? res.tabId : undefined;
if (!tabId) throw new Error("Could not determine active tab");
return tabId;
}
async function handleTool(pluginSocket, req) {
const { tool, args = {}, sessionId } = req;
if (!tool) throw new Error("Missing tool");
let tabId = args.tabId;
if (wantsTab(tool)) {
if (typeof tabId !== "number") {
tabId = await resolveActiveTab(sessionId);
}
const claimCheck = checkClaim(tabId, sessionId);
if (!claimCheck.ok) throw new Error(claimCheck.error);
}
const res = await callExtension(tool, { ...args, tabId }, sessionId);
const usedTabId =
res && typeof res.tabId === "number" ? res.tabId : typeof tabId === "number" ? tabId : undefined;
if (typeof usedTabId === "number") {
// Auto-claim on first touch
const existing = claims.get(usedTabId);
if (!existing) setClaim(usedTabId, sessionId);
}
return res;
}
function handleClientMessage(socket, client, msg) {
if (msg && msg.type === "hello") {
client.role = msg.role || "unknown";
client.sessionId = msg.sessionId;
if (client.role === "native-host") {
host = { socket };
// allow host to see current state
writeJsonLine(socket, { type: "host_ready", claims: listClaims() });
}
return;
}
if (msg && msg.type === "from_extension") {
const message = msg.message;
if (message && message.type === "tool_response" && typeof message.id === "number") {
const pending = extPending.get(message.id);
if (!pending) return;
extPending.delete(message.id);
if (pending.timeout) clearTimeout(pending.timeout);
if (message.error) {
pending.reject(new Error(message.error.content || String(message.error)));
} else {
// Forward full result payload so callers can read tabId
pending.resolve(message.result);
}
}
return;
}
if (msg && msg.type === "request" && typeof msg.id === "number") {
const requestId = msg.id;
const sessionId = msg.sessionId || client.sessionId;
const replyOk = (data) => writeJsonLine(socket, { type: "response", id: requestId, ok: true, data });
const replyErr = (err) =>
writeJsonLine(socket, { type: "response", id: requestId, ok: false, error: err.message || String(err) });
(async () => {
try {
if (msg.op === "status") {
replyOk({ broker: true, hostConnected: !!host && !!host.socket && !host.socket.destroyed, claims: listClaims() });
return;
}
if (msg.op === "list_claims") {
replyOk({ claims: listClaims() });
return;
}
if (msg.op === "claim_tab") {
const tabId = msg.tabId;
const force = !!msg.force;
if (typeof tabId !== "number") throw new Error("tabId is required");
const existing = claims.get(tabId);
if (existing && existing.sessionId !== sessionId && !force) {
throw new Error(`Tab ${tabId} is owned by another OpenCode session (${existing.sessionId})`);
}
setClaim(tabId, sessionId);
replyOk({ ok: true, tabId, sessionId });
return;
}
if (msg.op === "release_tab") {
const tabId = msg.tabId;
if (typeof tabId !== "number") throw new Error("tabId is required");
const existing = claims.get(tabId);
if (!existing) {
replyOk({ ok: true, tabId, released: false });
return;
}
if (existing.sessionId !== sessionId) {
throw new Error(`Tab ${tabId} is owned by another OpenCode session (${existing.sessionId})`);
}
claims.delete(tabId);
replyOk({ ok: true, tabId, released: true });
return;
}
if (msg.op === "tool") {
const result = await handleTool(socket, { tool: msg.tool, args: msg.args || {}, sessionId });
replyOk(result);
return;
}
throw new Error(`Unknown op: ${msg.op}`);
} catch (e) {
replyErr(e);
}
})();
return;
}
}
function start() {
try {
if (fs.existsSync(SOCKET_PATH)) fs.unlinkSync(SOCKET_PATH);
} catch {
// ignore
}
const server = net.createServer((socket) => {
socket.setNoDelay(true);
const client = { role: "unknown", sessionId: null };
clients.add(client);
socket.on(
"data",
createJsonLineParser((msg) => handleClientMessage(socket, client, msg))
);
socket.on("close", () => {
clients.delete(client);
if (client.role === "native-host" && host && host.socket === socket) {
host = null;
// fail pending extension requests
for (const [extId, pending] of extPending.entries()) {
extPending.delete(extId);
if (pending.timeout) clearTimeout(pending.timeout);
pending.reject(new Error("Native host disconnected"));
}
}
if (client.sessionId) releaseClaimsForSession(client.sessionId);
});
socket.on("error", () => {
// close handler will clean up
});
});
server.listen(SOCKET_PATH, () => {
// Make socket group-readable; ignore errors
try {
fs.chmodSync(SOCKET_PATH, 0o600);
} catch {}
console.error(`[browser-broker] listening on ${SOCKET_PATH}`);
});
server.on("error", (err) => {
console.error("[browser-broker] server error", err);
process.exit(1);
});
}
start();
#!/usr/bin/env node
"use strict";
// Chrome Native Messaging host for OpenCode Browser.
// Speaks length-prefixed JSON over stdin/stdout and forwards messages to the local broker over a unix socket.
const net = require("net");
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawn } = require("child_process");
const BASE_DIR = path.join(os.homedir(), ".opencode-browser");
const SOCKET_PATH = path.join(BASE_DIR, "broker.sock");
const BROKER_PATH = path.join(BASE_DIR, "broker.cjs");
fs.mkdirSync(BASE_DIR, { recursive: true });
function createJsonLineParser(onMessage) {
let buffer = "";
return (chunk) => {
buffer += chunk.toString("utf8");
while (true) {
const idx = buffer.indexOf("\n");
if (idx === -1) return;
const line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
if (!line.trim()) continue;
try {
onMessage(JSON.parse(line));
} catch {
// ignore
}
}
};
}
function writeJsonLine(socket, msg) {
socket.write(JSON.stringify(msg) + "\n");
}
function maybeStartBroker() {
try {
if (!fs.existsSync(BROKER_PATH)) return;
const child = spawn(process.execPath, [BROKER_PATH], { detached: true, stdio: "ignore" });
child.unref();
} catch {
// ignore
}
}
async function connectToBroker() {
return await new Promise((resolve, reject) => {
const socket = net.createConnection(SOCKET_PATH);
socket.once("connect", () => resolve(socket));
socket.once("error", (err) => reject(err));
});
}
async function ensureBroker() {
try {
return await connectToBroker();
} catch {
maybeStartBroker();
for (let i = 0; i < 50; i++) {
await new Promise((r) => setTimeout(r, 100));
try {
return await connectToBroker();
} catch {}
}
throw new Error("Could not connect to broker");
}
}
// --- Native messaging framing ---
let stdinBuffer = Buffer.alloc(0);
function writeNativeMessage(obj) {
try {
const payload = Buffer.from(JSON.stringify(obj), "utf8");
const header = Buffer.alloc(4);
header.writeUInt32LE(payload.length, 0);
process.stdout.write(Buffer.concat([header, payload]));
} catch (e) {
console.error("[native-host] write error", e);
}
}
function onStdinData(chunk, onMessage) {
stdinBuffer = Buffer.concat([stdinBuffer, chunk]);
while (stdinBuffer.length >= 4) {
const len = stdinBuffer.readUInt32LE(0);
if (stdinBuffer.length < 4 + len) return;
const body = stdinBuffer.slice(4, 4 + len);
stdinBuffer = stdinBuffer.slice(4 + len);
try {
onMessage(JSON.parse(body.toString("utf8")));
} catch {
// ignore
}
}
}
(async () => {
const broker = await ensureBroker();
broker.setNoDelay(true);
broker.on("data", createJsonLineParser((msg) => {
if (msg && msg.type === "to_extension" && msg.message) {
writeNativeMessage(msg.message);
}
}));
broker.on("close", () => {
process.exit(0);
});
broker.on("error", () => {
process.exit(1);
});
writeJsonLine(broker, { type: "hello", role: "native-host" });
process.stdin.on("data", (chunk) =>
onStdinData(chunk, (message) => {
// Forward extension-origin messages to broker.
writeJsonLine(broker, { type: "from_extension", message });
})
);
process.stdin.on("end", () => {
try {
broker.end();
} catch {}
process.exit(0);
});
})();
import type { Plugin } from "@opencode-ai/plugin";
import { tool } from "@opencode-ai/plugin";
import net from "net";
import { existsSync, mkdirSync } from "fs";
import { homedir } from "os";
import { join } from "path";
import { spawn } from "child_process";
const BASE_DIR = join(homedir(), ".opencode-browser");
const SOCKET_PATH = join(BASE_DIR, "broker.sock");
mkdirSync(BASE_DIR, { recursive: true });
type BrokerResponse =
| { type: "response"; id: number; ok: true; data: any }
| { type: "response"; id: number; ok: false; error: string };
function createJsonLineParser(onMessage: (msg: any) => void): (chunk: Buffer) => void {
let buffer = "";
return (chunk: Buffer) => {
buffer += chunk.toString("utf8");
while (true) {
const idx = buffer.indexOf("\n");
if (idx === -1) return;
const line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
if (!line.trim()) continue;
try {
onMessage(JSON.parse(line));
} catch {
// ignore
}
}
};
}
function writeJsonLine(socket: net.Socket, msg: any): void {
socket.write(JSON.stringify(msg) + "\n");
}
function maybeStartBroker(): void {
const brokerPath = join(BASE_DIR, "broker.cjs");
if (!existsSync(brokerPath)) return;
try {
const child = spawn(process.execPath, [brokerPath], { detached: true, stdio: "ignore" });
child.unref();
} catch {
// ignore
}
}
async function connectToBroker(): Promise<net.Socket> {
return await new Promise((resolve, reject) => {
const socket = net.createConnection(SOCKET_PATH);
socket.once("connect", () => resolve(socket));
socket.once("error", (err) => reject(err));
});
}
async function sleep(ms: number): Promise<void> {
return await new Promise((r) => setTimeout(r, ms));
}
let socket: net.Socket | null = null;
let sessionId = Math.random().toString(36).slice(2);
let reqId = 0;
const pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
async function ensureBrokerSocket(): Promise<net.Socket> {
if (socket && !socket.destroyed) return socket;
// Try to connect; if missing, try to start broker and retry.
try {
socket = await connectToBroker();
} catch {
maybeStartBroker();
for (let i = 0; i < 20; i++) {
await sleep(100);
try {
socket = await connectToBroker();
break;
} catch {}
}
}
if (!socket || socket.destroyed) {
throw new Error(
"Could not connect to local broker. Run `npx @different-ai/opencode-browser install` and ensure the extension is loaded."
);
}
socket.setNoDelay(true);
socket.on(
"data",
createJsonLineParser((msg) => {
if (msg?.type !== "response" || typeof msg.id !== "number") return;
const p = pending.get(msg.id);
if (!p) return;
pending.delete(msg.id);
const res = msg as BrokerResponse;
if (!res.ok) p.reject(new Error(res.error));
else p.resolve(res.data);
})
);
socket.on("close", () => {
socket = null;
});
socket.on("error", () => {
socket = null;
});
writeJsonLine(socket, { type: "hello", role: "plugin", sessionId, pid: process.pid });
return socket;
}
async function brokerRequest(op: string, payload: Record<string, any>): Promise<any> {
const s = await ensureBrokerSocket();
const id = ++reqId;
return await new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
writeJsonLine(s, { type: "request", id, op, ...payload });
setTimeout(() => {
if (!pending.has(id)) return;
pending.delete(id);
reject(new Error("Timed out waiting for broker response"));
}, 60000);
});
}
function toolResultText(data: any, fallback: string): string {
if (typeof data?.content === "string") return data.content;
if (typeof data === "string") return data;
if (data?.content != null) return JSON.stringify(data.content);
return fallback;
}
const plugin: Plugin = {
name: "opencode-browser",
tools: [
tool(
"browser_status",
"Check broker/native-host connection status and current tab claims.",
{},
async () => {
const data = await brokerRequest("status", {});
return JSON.stringify(data);
}
),
tool(
"browser_get_tabs",
"List all open browser tabs",
{},
async () => {
const data = await brokerRequest("tool", { tool: "get_tabs", args: {} });
return toolResultText(data, "ok");
}
),
tool(
"browser_navigate",
"Navigate to a URL in the browser",
{ url: { type: "string" }, tabId: { type: "number", optional: true } },
async ({ url, tabId }: any) => {
const data = await brokerRequest("tool", { tool: "navigate", args: { url, tabId } });
return toolResultText(data, `Navigated to ${url}`);
}
),
tool(
"browser_click",
"Click an element on the page using a CSS selector",
{ selector: { type: "string" }, tabId: { type: "number", optional: true } },
async ({ selector, tabId }: any) => {
const data = await brokerRequest("tool", { tool: "click", args: { selector, tabId } });
return toolResultText(data, `Clicked ${selector}`);
}
),
tool(
"browser_type",
"Type text into an input element",
{
selector: { type: "string" },
text: { type: "string" },
clear: { type: "boolean", optional: true },
tabId: { type: "number", optional: true },
},
async ({ selector, text, clear, tabId }: any) => {
const data = await brokerRequest("tool", { tool: "type", args: { selector, text, clear, tabId } });
return toolResultText(data, `Typed \"${text}\" into ${selector}`);
}
),
tool(
"browser_screenshot",
"Take a screenshot of the current page. Returns base64 image data URL.",
{ tabId: { type: "number", optional: true } },
async ({ tabId }: any) => {
const data = await brokerRequest("tool", { tool: "screenshot", args: { tabId } });
return toolResultText(data, "Screenshot failed");
}
),
tool(
"browser_snapshot",
"Get an accessibility tree snapshot of the page.",
{ tabId: { type: "number", optional: true } },
async ({ tabId }: any) => {
const data = await brokerRequest("tool", { tool: "snapshot", args: { tabId } });
return toolResultText(data, "Snapshot failed");
}
),
tool(
"browser_scroll",
"Scroll the page or scroll an element into view",
{
selector: { type: "string", optional: true },
x: { type: "number", optional: true },
y: { type: "number", optional: true },
tabId: { type: "number", optional: true },
},
async ({ selector, x, y, tabId }: any) => {
const data = await brokerRequest("tool", { tool: "scroll", args: { selector, x, y, tabId } });
return toolResultText(data, "Scrolled");
}
),
tool(
"browser_wait",
"Wait for a specified duration",
{ ms: { type: "number", optional: true }, tabId: { type: "number", optional: true } },
async ({ ms, tabId }: any) => {
const data = await brokerRequest("tool", { tool: "wait", args: { ms, tabId } });
return toolResultText(data, "Waited");
}
),
tool(
"browser_execute",
"Execute JavaScript code in the page context and return the result.",
{ code: { type: "string" }, tabId: { type: "number", optional: true } },
async ({ code, tabId }: any) => {
const data = await brokerRequest("tool", { tool: "execute_script", args: { code, tabId } });
return toolResultText(data, "Execute failed");
}
),
tool(
"browser_claim_tab",
"Claim a tab for this OpenCode session (per-tab ownership).",
{ tabId: { type: "number" }, force: { type: "boolean", optional: true } },
async ({ tabId, force }: any) => {
const data = await brokerRequest("claim_tab", { tabId, force });
return JSON.stringify(data);
}
),
tool(
"browser_release_tab",
"Release a previously claimed tab.",
{ tabId: { type: "number" } },
async ({ tabId }: any) => {
const data = await brokerRequest("release_tab", { tabId });
return JSON.stringify(data);
}
),
tool(
"browser_list_claims",
"List current tab ownership claims.",
{},
async () => {
const data = await brokerRequest("list_claims", {});
return JSON.stringify(data);
}
),
],
};
export default plugin;
+235
-236

@@ -5,13 +5,24 @@ #!/usr/bin/env node

*
* Architecture (v4):
* OpenCode Plugin <-> Local Broker (unix socket) <-> Native Messaging Host <-> Chrome Extension
*
* Commands:
* install - Install Chrome extension
* serve - Run MCP server (used by OpenCode)
* status - Check connection status
* install - Install extension + native host
* uninstall - Remove native host registration
* status - Show installation status
*/
import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, readdirSync, unlinkSync } from "fs";
import {
existsSync,
mkdirSync,
writeFileSync,
readFileSync,
copyFileSync,
readdirSync,
unlinkSync,
chmodSync,
} from "fs";
import { homedir, platform } from "os";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
import { execSync, spawn } from "child_process";
import { createInterface } from "readline";

@@ -23,2 +34,10 @@

const BASE_DIR = join(homedir(), ".opencode-browser");
const EXTENSION_DIR = join(BASE_DIR, "extension");
const BROKER_DST = join(BASE_DIR, "broker.cjs");
const NATIVE_HOST_DST = join(BASE_DIR, "native-host.cjs");
const CONFIG_DST = join(BASE_DIR, "config.json");
const NATIVE_HOST_NAME = "com.opencode.browser_automation";
const COLORS = {

@@ -30,3 +49,2 @@ reset: "\x1b[0m",

yellow: "\x1b[33m",
blue: "\x1b[34m",
cyan: "\x1b[36m",

@@ -67,5 +85,3 @@ };

return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer.trim());
});
rl.question(question, (answer) => resolve(answer.trim()));
});

@@ -79,246 +95,234 @@ }

function ensureDir(p) {
mkdirSync(p, { recursive: true });
}
function copyDirRecursive(srcDir, destDir) {
ensureDir(destDir);
const entries = readdirSync(srcDir, { recursive: true });
for (const entry of entries) {
const srcPath = join(srcDir, entry);
const destPath = join(destDir, entry);
try {
readdirSync(srcPath);
ensureDir(destPath);
} catch {
ensureDir(dirname(destPath));
copyFileSync(srcPath, destPath);
}
}
}
function getNativeHostDirs(osName) {
if (osName === "darwin") {
const base = join(homedir(), "Library", "Application Support");
return [
join(base, "Google", "Chrome", "NativeMessagingHosts"),
join(base, "Chromium", "NativeMessagingHosts"),
join(base, "BraveSoftware", "Brave-Browser", "NativeMessagingHosts"),
];
}
// linux
const base = join(homedir(), ".config");
return [
join(base, "google-chrome", "NativeMessagingHosts"),
join(base, "chromium", "NativeMessagingHosts"),
join(base, "BraveSoftware", "Brave-Browser", "NativeMessagingHosts"),
];
}
function nativeHostManifestPath(dir) {
return join(dir, `${NATIVE_HOST_NAME}.json`);
}
function writeNativeHostManifest(dir, extensionId) {
ensureDir(dir);
const manifest = {
name: NATIVE_HOST_NAME,
description: "OpenCode Browser native messaging host",
path: NATIVE_HOST_DST,
type: "stdio",
allowed_origins: [`chrome-extension://${extensionId}/`],
};
writeFileSync(nativeHostManifestPath(dir), JSON.stringify(manifest, null, 2) + "\n");
}
function loadConfig() {
try {
if (!existsSync(CONFIG_DST)) return null;
return JSON.parse(readFileSync(CONFIG_DST, "utf-8"));
} catch {
return null;
}
}
function saveConfig(config) {
ensureDir(BASE_DIR);
writeFileSync(CONFIG_DST, JSON.stringify(config, null, 2) + "\n");
}
async function main() {
const command = process.argv[2];
if (command === "serve") {
// Run MCP server - this is called by OpenCode
await serve();
} else if (command === "install") {
await showHeader();
console.log(`
${color("cyan", color("bright", "OpenCode Browser v4"))}
${color("cyan", "Browser automation plugin (native messaging + per-tab ownership)")}
`);
if (command === "install") {
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(`
${color("bright", "Usage:")}
npx @different-ai/opencode-browser install Install extension
npx @different-ai/opencode-browser uninstall Remove installation
npx @different-ai/opencode-browser status Check status
npx @different-ai/opencode-browser serve Run MCP server (internal)
npx @different-ai/opencode-browser install
npx @different-ai/opencode-browser status
npx @different-ai/opencode-browser uninstall
${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
2. Restart OpenCode
3. Use: browser_navigate / browser_click / browser_snapshot
`);
rl.close();
}
}
async function showHeader() {
console.log(`
${color("cyan", color("bright", "OpenCode Browser v2.1"))}
${color("cyan", "Browser automation MCP server for OpenCode")}
`);
rl.close();
}
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() {
header("Step 1: Check Platform");
const os = platform();
if (os !== "darwin" && os !== "linux") {
error(`Unsupported platform: ${os}`);
const osName = platform();
if (osName !== "darwin" && osName !== "linux") {
error(`Unsupported platform: ${osName}`);
error("OpenCode Browser currently supports macOS and Linux only.");
process.exit(1);
}
success(`Platform: ${os === "darwin" ? "macOS" : "Linux"}`);
success(`Platform: ${osName === "darwin" ? "macOS" : "Linux"}`);
header("Step 2: Copy Extension Files");
const extensionDir = join(homedir(), ".opencode-browser", "extension");
ensureDir(BASE_DIR);
const srcExtensionDir = join(PACKAGE_ROOT, "extension");
copyDirRecursive(srcExtensionDir, EXTENSION_DIR);
success(`Extension files copied to: ${EXTENSION_DIR}`);
mkdirSync(extensionDir, { recursive: true });
header("Step 3: Load & Pin Extension");
const files = readdirSync(srcExtensionDir, { recursive: true });
for (const file of files) {
const srcPath = join(srcExtensionDir, file);
const destPath = join(extensionDir, file);
log(`
To load the extension:
try {
const stat = readdirSync(srcPath);
mkdirSync(destPath, { recursive: true });
} catch {
mkdirSync(dirname(destPath), { recursive: true });
copyFileSync(srcPath, destPath);
}
}
1. Open ${color("cyan", "chrome://extensions")}
2. Enable ${color("bright", "Developer mode")}
3. Click ${color("bright", "Load unpacked")}
4. Select:
${color("cyan", EXTENSION_DIR)}
success(`Extension files copied to: ${extensionDir}`);
After loading, ${color("bright", "pin the extension")}: open the Extensions menu (puzzle icon) and click the pin.
`);
header("Step 3: Load Extension in Chrome");
await ask(color("bright", "Press Enter when you've loaded and pinned the extension..."));
header("Step 4: Get Extension ID");
log(`
Works with: ${color("cyan", "Chrome")}, ${color("cyan", "Brave")}, ${color("cyan", "Arc")}, ${color("cyan", "Edge")}, and other Chromium browsers.
We need the extension ID to register the native messaging host.
To load the extension:
Find it at ${color("cyan", "chrome://extensions")}:
- Locate ${color("bright", "OpenCode Browser Automation")}
- Click ${color("bright", "Details")}
- Copy the ${color("bright", "ID")}
`);
1. Open your browser and go to: ${color("cyan", "chrome://extensions")}
(or ${color("cyan", "brave://extensions")}, ${color("cyan", "arc://extensions")}, etc.)
const extensionId = await ask(color("bright", "Paste Extension ID: "));
if (!/^[a-p]{32}$/i.test(extensionId)) {
warn("That doesn't look like a Chrome extension ID (expected 32 chars a-p). Continuing anyway.");
}
2. Enable ${color("bright", "Developer mode")} (toggle in top right)
header("Step 5: Install Local Host + Broker");
3. Click ${color("bright", "Load unpacked")}
const brokerSrc = join(PACKAGE_ROOT, "bin", "broker.cjs");
const nativeHostSrc = join(PACKAGE_ROOT, "bin", "native-host.cjs");
4. Select this folder:
${color("cyan", extensionDir)}
${os === "darwin" ? color("yellow", "Tip: Press Cmd+Shift+G and paste the path above") : ""}
`);
copyFileSync(brokerSrc, BROKER_DST);
copyFileSync(nativeHostSrc, NATIVE_HOST_DST);
await ask(color("bright", "Press Enter when you've loaded the extension..."));
try {
chmodSync(BROKER_DST, 0o755);
} catch {}
try {
chmodSync(NATIVE_HOST_DST, 0o755);
} catch {}
header("Step 4: Configure OpenCode");
success(`Installed broker: ${BROKER_DST}`);
success(`Installed native host: ${NATIVE_HOST_DST}`);
const mcpConfig = {
browser: {
type: "local",
command: ["bunx", "@different-ai/opencode-browser", "serve"],
},
};
saveConfig({ extensionId, installedAt: new Date().toISOString() });
log(`
Add the MCP server to your ${color("cyan", "opencode.json")}:
header("Step 6: Register Native Messaging Host");
${color("bright", JSON.stringify({ $schema: "https://opencode.ai/config.json", mcp: mcpConfig }, null, 2))}
const hostDirs = getNativeHostDirs(osName);
for (const dir of hostDirs) {
try {
writeNativeHostManifest(dir, extensionId);
success(`Wrote native host manifest: ${nativeHostManifestPath(dir)}`);
} catch (e) {
warn(`Could not write native host manifest to: ${dir}`);
}
}
Or if you already have an opencode.json, add to the "mcp" object:
${color("bright", JSON.stringify({ mcp: mcpConfig }, null, 2))}
`);
header("Step 7: Configure OpenCode");
const opencodeJsonPath = join(process.cwd(), "opencode.json");
const desiredPlugin = "@different-ai/opencode-browser";
if (existsSync(opencodeJsonPath)) {
const shouldUpdate = await confirm(`Found opencode.json. Add MCP server automatically?`);
const shouldUpdate = await confirm("Found opencode.json. Add plugin automatically?");
if (shouldUpdate) {
try {
const config = JSON.parse(readFileSync(opencodeJsonPath, "utf-8"));
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)");
}
if (config.plugin.length === 0) {
delete config.plugin;
}
config.plugin = config.plugin || [];
if (!Array.isArray(config.plugin)) config.plugin = [];
if (!config.plugin.includes(desiredPlugin)) config.plugin.push(desiredPlugin);
// Remove MCP config if present
if (config.mcp?.browser) {
delete config.mcp.browser;
if (Object.keys(config.mcp).length === 0) delete config.mcp;
warn("Removed old MCP browser config (replaced by plugin)");
}
writeFileSync(opencodeJsonPath, JSON.stringify(config, null, 2) + "\n");
success("Updated opencode.json with MCP server");
success("Updated opencode.json with plugin");
} catch (e) {
error(`Failed to update opencode.json: ${e.message}`);
log("Please add the MCP config manually.");
}
}
} else {
const shouldCreate = await confirm(`No opencode.json found. Create one?`);
const shouldCreate = await confirm("No opencode.json found. Create one?");
if (shouldCreate) {
try {
const config = {
$schema: "https://opencode.ai/config.json",
mcp: mcpConfig,
};
writeFileSync(opencodeJsonPath, JSON.stringify(config, null, 2) + "\n");
success("Created opencode.json with MCP server");
} catch (e) {
error(`Failed to create opencode.json: ${e.message}`);
}
const config = { $schema: "https://opencode.ai/config.json", plugin: [desiredPlugin] };
writeFileSync(opencodeJsonPath, JSON.stringify(config, null, 2) + "\n");
success("Created opencode.json with plugin");
}
}
// Clean up old daemon/plugin if present
header("Step 5: Cleanup (migration)");
const oldDaemonPlist = join(homedir(), "Library", "LaunchAgents", "com.opencode.browser-daemon.plist");
if (existsSync(oldDaemonPlist)) {
try {
execSync(`launchctl unload "${oldDaemonPlist}" 2>/dev/null || true`, { stdio: "ignore" });
unlinkSync(oldDaemonPlist);
success("Removed old daemon (no longer needed)");
} catch {
warn("Could not remove old daemon plist. Remove manually if needed.");
}
}
// 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!");
log(`
${color("green", "")} Extension: ${extensionDir}
${color("green", "")} MCP Server: @different-ai/opencode-browser
${color("bright", "What happens now:")}
- The extension connects to the native host automatically.
- OpenCode loads the plugin, which talks to the broker.
- The broker enforces ${color("bright", "per-tab ownership")}. First touch auto-claims.
${color("bright", "How it works:")}
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 to any OpenCode session!
${color("bright", "Available tools:")}
browser_status - Check if browser is connected
browser_navigate - Go to a URL
browser_click - Click an element
browser_type - Type into an input
browser_screenshot - Capture the page
browser_snapshot - Get accessibility tree + all links
browser_get_tabs - List open tabs
browser_scroll - Scroll the page
browser_wait - Wait for duration
browser_execute - Run JavaScript
${color("bright", "Benefits of MCP architecture:")}
- No session conflicts between OpenCode instances
- Server runs independently of OpenCode process
- Clean separation of concerns
${color("bright", "Test it:")}
Restart OpenCode and try: ${color("cyan", '"Check browser status"')}
${color("bright", "Try it:")}
Restart OpenCode and run: ${color("cyan", "browser_get_tabs")}
`);

@@ -328,67 +332,62 @@ }

async function status() {
header("Browser Status");
header("Status");
// Check if port 19222 is in use
try {
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 {
warn("Could not check port status");
}
success(`Base dir: ${BASE_DIR}`);
success(`Extension dir present: ${existsSync(EXTENSION_DIR)}`);
success(`Broker installed: ${existsSync(BROKER_DST)}`);
success(`Native host installed: ${existsSync(NATIVE_HOST_DST)}`);
// Check extension directory
const extensionDir = join(homedir(), ".opencode-browser", "extension");
if (existsSync(extensionDir)) {
success(`Extension installed at: ${extensionDir}`);
const cfg = loadConfig();
if (cfg?.extensionId) {
success(`Configured extension ID: ${cfg.extensionId}`);
} else {
warn("Extension not installed. Run: npx @different-ai/opencode-browser install");
warn("No config.json found (run install)");
}
const osName = platform();
const hostDirs = getNativeHostDirs(osName);
let foundAny = false;
for (const dir of hostDirs) {
const p = nativeHostManifestPath(dir);
if (existsSync(p)) {
foundAny = true;
success(`Native host manifest: ${p}`);
}
}
if (!foundAny) {
warn("No native host manifest found. Run: npx @different-ai/opencode-browser install");
}
}
async function uninstall() {
header("Uninstalling OpenCode Browser");
header("Uninstall");
// Remove old daemon
const os = platform();
if (os === "darwin") {
const plistPath = join(homedir(), "Library", "LaunchAgents", "com.opencode.browser-daemon.plist");
if (existsSync(plistPath)) {
try {
execSync(`launchctl unload "${plistPath}" 2>/dev/null || true`, { stdio: "ignore" });
unlinkSync(plistPath);
success("Removed daemon plist");
} catch {}
const osName = platform();
const hostDirs = getNativeHostDirs(osName);
for (const dir of hostDirs) {
const p = nativeHostManifestPath(dir);
if (!existsSync(p)) continue;
try {
unlinkSync(p);
success(`Removed native host manifest: ${p}`);
} catch {
warn(`Could not remove: ${p}`);
}
}
// Remove native host registration (v1.x)
const nativeHostDir =
os === "darwin"
? join(homedir(), "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts")
: join(homedir(), ".config", "google-chrome", "NativeMessagingHosts");
const manifestPath = join(nativeHostDir, "com.opencode.browser_automation.json");
if (existsSync(manifestPath)) {
unlinkSync(manifestPath);
success("Removed native host registration");
for (const p of [BROKER_DST, NATIVE_HOST_DST, CONFIG_DST, join(BASE_DIR, "broker.sock")]) {
if (!existsSync(p)) continue;
try {
unlinkSync(p);
success(`Removed: ${p}`);
} catch {
// ignore
}
}
// Remove lock file
const lockFile = join(homedir(), ".opencode-browser", "lock.json");
if (existsSync(lockFile)) {
unlinkSync(lockFile);
success("Removed lock file");
}
log(`
${color("bright", "Note:")} Extension files at ~/.opencode-browser/ were not removed.
Remove manually if needed:
rm -rf ~/.opencode-browser/
Also remove the "browser" entry from your opencode.json mcp section.
${color("bright", "Note:")}
- The unpacked extension folder remains at: ${EXTENSION_DIR}
- Remove it manually in ${color("cyan", "chrome://extensions")}
- Remove ${color("bright", "@different-ai/opencode-browser")} from your opencode.json plugin list if desired.
`);

@@ -398,4 +397,4 @@ }

main().catch((e) => {
error(e.message);
error(e.message || String(e));
process.exit(1);
});

@@ -1,59 +0,55 @@

const PLUGIN_URL = "ws://localhost:19222";
const KEEPALIVE_ALARM = "keepalive";
const NATIVE_HOST_NAME = "com.opencode.browser_automation"
const KEEPALIVE_ALARM = "keepalive"
let ws = null;
let isConnected = false;
let port = null
let isConnected = false
let connectionAttempts = 0
chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.25 });
chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.25 })
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === KEEPALIVE_ALARM) {
if (!isConnected) {
console.log("[OpenCode] Alarm triggered reconnect");
connect();
}
if (!isConnected) connect()
}
});
})
function connect() {
if (ws && ws.readyState === WebSocket.OPEN) return;
if (ws) {
try { ws.close(); } catch {}
ws = null;
if (port) {
try { port.disconnect() } catch {}
port = null
}
try {
ws = new WebSocket(PLUGIN_URL);
ws.onopen = () => {
console.log("[OpenCode] Connected to plugin");
isConnected = true;
updateBadge(true);
};
ws.onmessage = async (event) => {
try {
const message = JSON.parse(event.data);
await handleMessage(message);
} catch (e) {
console.error("[OpenCode] Parse error:", e);
port = chrome.runtime.connectNative(NATIVE_HOST_NAME)
port.onMessage.addListener((message) => {
handleMessage(message).catch((e) => {
console.error("[OpenCode] Message handler error:", e)
})
})
port.onDisconnect.addListener(() => {
isConnected = false
port = null
updateBadge(false)
const err = chrome.runtime.lastError
if (err?.message) {
// Usually means native host not installed or crashed
connectionAttempts++
if (connectionAttempts === 1) {
console.log("[OpenCode] Native host not available. Run: npx @different-ai/opencode-browser install")
} else if (connectionAttempts % 20 === 0) {
console.log("[OpenCode] Still waiting for native host...")
}
}
};
ws.onclose = () => {
console.log("[OpenCode] Disconnected");
isConnected = false;
ws = null;
updateBadge(false);
};
ws.onerror = (err) => {
console.error("[OpenCode] WebSocket error");
isConnected = false;
updateBadge(false);
};
})
isConnected = true
connectionAttempts = 0
updateBadge(true)
} catch (e) {
console.error("[OpenCode] Connect failed:", e);
isConnected = false;
updateBadge(false);
isConnected = false
updateBadge(false)
console.error("[OpenCode] connectNative failed:", e)
}

@@ -63,19 +59,23 @@ }

function updateBadge(connected) {
chrome.action.setBadgeText({ text: connected ? "ON" : "" });
chrome.action.setBadgeBackgroundColor({ color: connected ? "#22c55e" : "#ef4444" });
chrome.action.setBadgeText({ text: connected ? "ON" : "" })
chrome.action.setBadgeBackgroundColor({ color: connected ? "#22c55e" : "#ef4444" })
}
function send(message) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
return true;
if (!port) return false
try {
port.postMessage(message)
return true
} catch {
return false
}
return false;
}
async function handleMessage(message) {
if (!message || typeof message !== "object") return
if (message.type === "tool_request") {
await handleToolRequest(message);
await handleToolRequest(message)
} else if (message.type === "ping") {
send({ type: "pong" });
send({ type: "pong" })
}

@@ -85,9 +85,13 @@ }

async function handleToolRequest(request) {
const { id, tool, args } = request;
const { id, tool, args } = request
try {
const result = await executeTool(tool, args || {});
send({ type: "tool_response", id, result: { content: result } });
const result = await executeTool(tool, args || {})
send({ type: "tool_response", id, result })
} catch (error) {
send({ type: "tool_response", id, error: { content: error.message || String(error) } });
send({
type: "tool_response",
id,
error: { content: error?.message || String(error) },
})
}

@@ -98,2 +102,4 @@ }

const tools = {
get_active_tab: toolGetActiveTab,
get_tabs: toolGetTabs,
navigate: toolNavigate,

@@ -104,97 +110,106 @@ click: toolClick,

snapshot: toolSnapshot,
get_tabs: toolGetTabs,
execute_script: toolExecuteScript,
scroll: toolScroll,
wait: toolWait
};
const fn = tools[toolName];
if (!fn) throw new Error(`Unknown tool: ${toolName}`);
return await fn(args);
wait: toolWait,
}
const fn = tools[toolName]
if (!fn) throw new Error(`Unknown tool: ${toolName}`)
return await fn(args)
}
async function getActiveTab() {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) throw new Error("No active tab found");
return tab;
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true })
if (!tab?.id) throw new Error("No active tab found")
return tab
}
async function getTabById(tabId) {
return tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
return tabId ? await chrome.tabs.get(tabId) : await getActiveTab()
}
async function toolGetActiveTab() {
const tab = await getActiveTab()
return { tabId: tab.id, content: { tabId: tab.id, url: tab.url, title: tab.title } }
}
async function toolNavigate({ url, tabId }) {
if (!url) throw new Error("URL is required");
const tab = await getTabById(tabId);
await chrome.tabs.update(tab.id, { url });
if (!url) throw new Error("URL is required")
const tab = await getTabById(tabId)
await chrome.tabs.update(tab.id, { url })
await new Promise((resolve) => {
const listener = (updatedTabId, info) => {
if (updatedTabId === tab.id && info.status === "complete") {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
chrome.tabs.onUpdated.removeListener(listener)
resolve()
}
};
chrome.tabs.onUpdated.addListener(listener);
setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30000);
});
return `Navigated to ${url}`;
}
chrome.tabs.onUpdated.addListener(listener)
setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener)
resolve()
}, 30000)
})
return { tabId: tab.id, content: `Navigated to ${url}` }
}
async function toolClick({ selector, tabId }) {
if (!selector) throw new Error("Selector is required");
const tab = await getTabById(tabId);
if (!selector) throw new Error("Selector is required")
const tab = await getTabById(tabId)
const result = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (sel) => {
const el = document.querySelector(sel);
if (!el) return { success: false, error: `Element not found: ${sel}` };
el.click();
return { success: true };
const el = document.querySelector(sel)
if (!el) return { success: false, error: `Element not found: ${sel}` }
el.click()
return { success: true }
},
args: [selector]
});
if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Click failed");
return `Clicked ${selector}`;
args: [selector],
})
if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Click failed")
return { tabId: tab.id, content: `Clicked ${selector}` }
}
async function toolType({ selector, text, tabId, clear = false }) {
if (!selector) throw new Error("Selector is required");
if (text === undefined) throw new Error("Text is required");
const tab = await getTabById(tabId);
if (!selector) throw new Error("Selector is required")
if (text === undefined) throw new Error("Text is required")
const tab = await getTabById(tabId)
const result = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (sel, txt, shouldClear) => {
const el = document.querySelector(sel);
if (!el) return { success: false, error: `Element not found: ${sel}` };
el.focus();
if (shouldClear) el.value = "";
const el = document.querySelector(sel)
if (!el) return { success: false, error: `Element not found: ${sel}` }
el.focus()
if (shouldClear && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) el.value = ""
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
el.value = el.value + txt;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
el.value = el.value + txt
el.dispatchEvent(new Event("input", { bubbles: true }))
el.dispatchEvent(new Event("change", { bubbles: true }))
} else if (el.isContentEditable) {
document.execCommand("insertText", false, txt);
document.execCommand("insertText", false, txt)
}
return { success: true };
return { success: true }
},
args: [selector, text, clear]
});
if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Type failed");
return `Typed "${text}" into ${selector}`;
args: [selector, text, clear],
})
if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Type failed")
return { tabId: tab.id, content: `Typed "${text}" into ${selector}` }
}
async function toolScreenshot({ tabId }) {
const tab = await getTabById(tabId);
return await chrome.tabs.captureVisibleTab(tab.windowId, { format: "png" });
const tab = await getTabById(tabId)
const png = await chrome.tabs.captureVisibleTab(tab.windowId, { format: "png" })
return { tabId: tab.id, content: png }
}
async function toolSnapshot({ tabId }) {
const tab = await getTabById(tabId);
const tab = await getTabById(tabId)
const result = await chrome.scripting.executeScript({

@@ -204,108 +219,138 @@ target: { tabId: tab.id },

function getName(el) {
return el.getAttribute("aria-label") || el.getAttribute("alt") ||
el.getAttribute("title") || el.getAttribute("placeholder") ||
el.innerText?.slice(0, 100) || "";
return (
el.getAttribute("aria-label") ||
el.getAttribute("alt") ||
el.getAttribute("title") ||
el.getAttribute("placeholder") ||
el.innerText?.slice(0, 100) ||
""
)
}
function build(el, depth = 0, uid = 0) {
if (depth > 10) return { nodes: [], nextUid: uid };
const nodes = [];
const style = window.getComputedStyle(el);
if (style.display === "none" || style.visibility === "hidden") return { nodes: [], nextUid: uid };
const isInteractive = ["A", "BUTTON", "INPUT", "TEXTAREA", "SELECT"].includes(el.tagName) ||
el.getAttribute("onclick") || el.getAttribute("role") === "button" || el.isContentEditable;
const rect = el.getBoundingClientRect();
if (depth > 10) return { nodes: [], nextUid: uid }
const nodes = []
const style = window.getComputedStyle(el)
if (style.display === "none" || style.visibility === "hidden") return { nodes: [], nextUid: uid }
const isInteractive =
["A", "BUTTON", "INPUT", "TEXTAREA", "SELECT"].includes(el.tagName) ||
el.getAttribute("onclick") ||
el.getAttribute("role") === "button" ||
el.isContentEditable
const rect = el.getBoundingClientRect()
if (rect.width > 0 && rect.height > 0 && (isInteractive || el.innerText?.trim())) {
const node = { uid: `e${uid}`, role: el.getAttribute("role") || el.tagName.toLowerCase(),
name: getName(el).slice(0, 200), tag: el.tagName.toLowerCase() };
// 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; }
if (el.id) node.selector = `#${el.id}`;
const node = {
uid: `e${uid}`,
role: el.getAttribute("role") || el.tagName.toLowerCase(),
name: getName(el).slice(0, 200),
tag: el.tagName.toLowerCase(),
}
if (el.href) node.href = el.href
if (el.tagName === "INPUT") {
node.type = el.type
node.value = el.value
}
if (el.id) node.selector = `#${el.id}`
else if (el.className && typeof el.className === "string") {
const cls = el.className.trim().split(/\s+/).slice(0, 2).join(".");
if (cls) node.selector = `${el.tagName.toLowerCase()}.${cls}`;
const cls = el.className.trim().split(/\s+/).slice(0, 2).join(".")
if (cls) node.selector = `${el.tagName.toLowerCase()}.${cls}`
}
nodes.push(node);
uid++;
nodes.push(node)
uid++
}
for (const child of el.children) {
const r = build(child, depth + 1, uid);
nodes.push(...r.nodes);
uid = r.nextUid;
const r = build(child, depth + 1, uid)
nodes.push(...r.nodes)
uid = r.nextUid
}
return { nodes, nextUid: uid };
return { nodes, nextUid: uid }
}
// 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;
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 });
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 links.slice(0, 100)
}
return {
url: location.href,
title: document.title,
return {
url: location.href,
title: document.title,
nodes: build(document.body).nodes.slice(0, 500),
links: getAllLinks()
};
}
});
return JSON.stringify(result[0]?.result, null, 2);
links: getAllLinks(),
}
},
})
return { tabId: tab.id, content: JSON.stringify(result[0]?.result, null, 2) }
}
async function toolGetTabs() {
const tabs = await chrome.tabs.query({});
return JSON.stringify(tabs.map(t => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId })), null, 2);
const tabs = await chrome.tabs.query({})
const out = tabs.map((t) => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId }))
return { content: JSON.stringify(out, null, 2) }
}
async function toolExecuteScript({ code, tabId }) {
if (!code) throw new Error("Code is required");
const tab = await getTabById(tabId);
const result = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: new Function(code) });
return JSON.stringify(result[0]?.result);
if (!code) throw new Error("Code is required")
const tab = await getTabById(tabId)
const result = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: new Function(code),
})
return { tabId: tab.id, content: JSON.stringify(result[0]?.result) }
}
async function toolScroll({ x = 0, y = 0, selector, tabId }) {
const tab = await getTabById(tabId);
const sel = selector || null;
const tab = await getTabById(tabId)
const sel = selector || null
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (scrollX, scrollY, sel) => {
if (sel) { const el = document.querySelector(sel); if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); return; } }
window.scrollBy(scrollX, scrollY);
if (sel) {
const el = document.querySelector(sel)
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" })
return
}
}
window.scrollBy(scrollX, scrollY)
},
args: [x, y, sel]
});
return `Scrolled ${sel ? `to ${sel}` : `by (${x}, ${y})`}`;
args: [x, y, sel],
})
return { tabId: tab.id, content: `Scrolled ${sel ? `to ${sel}` : `by (${x}, ${y})`}` }
}
async function toolWait({ ms = 1000 }) {
await new Promise(resolve => setTimeout(resolve, ms));
return `Waited ${ms}ms`;
async function toolWait({ ms = 1000, tabId }) {
if (typeof tabId === "number") {
// keep tabId in response for ownership purposes
}
await new Promise((resolve) => setTimeout(resolve, ms))
return { tabId, content: `Waited ${ms}ms` }
}
chrome.runtime.onInstalled.addListener(() => connect());
chrome.runtime.onStartup.addListener(() => connect());
chrome.runtime.onInstalled.addListener(() => connect())
chrome.runtime.onStartup.addListener(() => connect())
chrome.action.onClicked.addListener(() => {
connect();
chrome.notifications.create({ type: "basic", iconUrl: "icons/icon128.png", title: "OpenCode Browser",
message: isConnected ? "Connected" : "Reconnecting..." });
});
connect()
chrome.notifications.create({
type: "basic",
iconUrl: "icons/icon128.png",
title: "OpenCode Browser",
message: isConnected ? "Connected" : "Reconnecting...",
})
})
connect();
connect()
{
"manifest_version": 3,
"name": "OpenCode Browser Automation",
"version": "2.0.0",
"version": "4.0.0",
"description": "Browser automation for OpenCode",

@@ -12,3 +12,4 @@ "permissions": [

"notifications",
"alarms"
"alarms",
"nativeMessaging"
],

@@ -15,0 +16,0 @@ "host_permissions": [

{
"name": "@different-ai/opencode-browser",
"version": "3.0.0",
"description": "Browser automation MCP server for OpenCode. Control your real Chrome browser with existing logins and cookies.",
"version": "4.0.0",
"description": "Browser automation plugin for OpenCode (native messaging + per-tab ownership).",
"type": "module",

@@ -9,9 +9,10 @@ "bin": {

},
"main": "./src/mcp-server.ts",
"main": "./src/plugin.ts",
"exports": {
".": "./src/mcp-server.ts"
".": "./src/plugin.ts",
"./plugin": "./src/plugin.ts"
},
"files": [
"bin",
"src",
"src/plugin.ts",
"extension",

@@ -21,4 +22,5 @@ "README.md"

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

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

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

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

"homepage": "https://github.com/different-ai/opencode-browser#readme",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.2",
"zod": "^4.3.5"
"peerDependencies": {
"@opencode-ai/plugin": "*"
},
"devDependencies": {
"@opencode-ai/plugin": "*",
"bun-types": "*"
}
}
+45
-102
# OpenCode Browser
Browser automation MCP server for [OpenCode](https://github.com/opencode-ai/opencode).
Browser automation plugin for [OpenCode](https://github.com/opencode-ai/opencode).
Control your real Chrome browser with existing logins, cookies, and bookmarks. No DevTools Protocol, no security prompts.
Control your real Chromium browser (Chrome/Brave/Arc/Edge) using your existing profile (logins, cookies, bookmarks). No DevTools Protocol, no security prompts.
## Why?
## Why this architecture
Chrome 136+ blocks `--remote-debugging-port` on your default profile for security reasons. DevTools-based automation (like Playwright) triggers a security prompt every time.
This version is optimized for reliability and predictable multi-session behavior:
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.
- **No WebSocket port** → no port conflicts
- **Chrome Native Messaging** between extension and a local host process
- A local **broker** multiplexes multiple OpenCode plugin sessions and enforces **per-tab ownership**

@@ -20,108 +22,63 @@ ## Installation

The installer will:
1. Copy the extension to `~/.opencode-browser/extension/`
2. Guide you to load the extension in Chrome
3. Update your `opencode.json` with MCP server config
2. Walk you through loading + pinning it in `chrome://extensions`
3. Ask for the extension ID and install a **Native Messaging Host manifest**
4. Update your `opencode.json` to load the plugin
## Configuration
### Configure OpenCode
Add to your `opencode.json`:
Your `opencode.json` should contain:
```json
{
"mcp": {
"browser": {
"type": "local",
"command": ["bunx", "@different-ai/opencode-browser", "serve"]
}
}
"$schema": "https://opencode.ai/config.json",
"plugin": ["@different-ai/opencode-browser"]
}
```
Then load the extension in Chrome:
1. Go to `chrome://extensions`
2. Enable "Developer mode"
3. Click "Load unpacked" and select `~/.opencode-browser/extension/`
## How it works
## Available Tools
| Tool | Description |
|------|-------------|
| `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 page (returns base64, optionally saves to file) |
| `browser_snapshot` | Get accessibility tree with selectors + all page links |
| `browser_get_tabs` | List all open tabs |
| `browser_scroll` | Scroll page or element into view |
| `browser_wait` | Wait for a duration |
| `browser_execute` | Run JavaScript in page context |
### Screenshot Tool
The `browser_screenshot` tool returns base64 image data by default, allowing AI to view images directly:
```javascript
// Returns base64 image (AI can view it)
browser_screenshot()
// Save to current working directory
browser_screenshot({ save: true })
// Save to specific path
browser_screenshot({ path: "my-screenshot.png" })
```
## Architecture
OpenCode Plugin <-> Local Broker (unix socket) <-> Native Host <-> Chrome Extension
```
OpenCode <──STDIO──> MCP Server <──WebSocket:19222──> Chrome Extension
│ │
└── @modelcontextprotocol/sdk └── chrome.tabs, chrome.scripting
```
**Two components:**
1. MCP Server (runs as separate process, manages WebSocket server)
2. Chrome extension (connects to server, executes browser commands)
- The extension connects to the native host.
- The plugin talks to the broker over a local unix socket.
- The broker forwards tool requests to the extension and enforces tab ownership.
**Benefits of MCP architecture:**
- No session conflicts between OpenCode instances
- Server runs independently of OpenCode process
- Clean separation of concerns
- Standard MCP protocol
## Per-tab ownership
## Upgrading from v2.x (Plugin)
- First time a session touches a tab, the broker **auto-claims** it for that session.
- Other sessions attempting to use the same tab will get an error.
v3.0 migrates from plugin to MCP architecture:
Tools:
1. Run `npx @different-ai/opencode-browser install`
2. Replace plugin config with MCP config in `opencode.json`:
- `browser_claim_tab({ tabId })`
- `browser_release_tab({ tabId })`
- `browser_list_claims()`
```diff
- "plugin": ["@different-ai/opencode-browser"]
+ "mcp": {
+ "browser": {
+ "type": "local",
+ "command": ["bunx", "@different-ai/opencode-browser", "serve"]
+ }
+ }
```
## Available tools
3. Restart OpenCode
- `browser_status`
- `browser_get_tabs`
- `browser_navigate`
- `browser_click`
- `browser_type`
- `browser_screenshot`
- `browser_snapshot`
- `browser_scroll`
- `browser_wait`
- `browser_execute`
## Troubleshooting
**"Chrome extension not connected"**
- Make sure Chrome is running
- Check that the extension is loaded and enabled
- Click the extension icon to see connection status
**Extension says native host not available**
- Re-run `npx @different-ai/opencode-browser install`
- Confirm the extension ID you pasted matches the loaded extension in `chrome://extensions`
**"Failed to start WebSocket server"**
- Port 19222 may be in use
- Run `lsof -i :19222` to check what's using it
**Tab ownership errors**
- Use `browser_list_claims()` to see who owns a tab
- Use `browser_claim_tab({ tabId, force: true })` to take over intentionally
**"browser_execute fails on some sites"**
- Sites with strict CSP block JavaScript execution
- Use `browser_snapshot` to get page data instead
## Uninstall

@@ -133,16 +90,2 @@

Then remove the extension from Chrome and delete `~/.opencode-browser/` if desired.
## Platform Support
- macOS ✓
- Linux ✓
- Windows (not yet supported)
## License
MIT
## Credits
Inspired by [Claude in Chrome](https://www.anthropic.com/news/claude-in-chrome) by Anthropic.
Then remove the unpacked extension in `chrome://extensions` and remove the plugin from `opencode.json`.
#!/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);
});