@different-ai/opencode-browser
Advanced tools
+290
| #!/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); | ||
| }); | ||
| })(); |
+275
| 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); | ||
| }); |
+235
-190
@@ -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": [ |
+14
-12
| { | ||
| "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); | ||
| }); |
Install scripts
Supply chain riskInstall scripts are run when the package is installed or built. Malicious packages often use scripts that run automatically to execute payloads or fetch additional code.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Uses eval
Supply chain riskPackage uses dynamic code execution (e.g., eval()), which is a dangerous practice. This can prevent the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Found 1 instance in 1 package
Uses eval
Supply chain riskPackage uses dynamic code execution (e.g., eval()), which is a dangerous practice. This can prevent the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
46983
13.79%1
-50%11
22.22%1273
26.54%2
100%90
-38.78%1
Infinity%7
75%+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed