@different-ai/opencode-browser
Advanced tools
| #!/usr/bin/env node | ||
| /** | ||
| * OpenCode Browser MCP Server | ||
| * | ||
| * MCP Server <--STDIO--> OpenCode | ||
| * MCP Server <--WebSocket:19222--> Chrome Extension | ||
| * | ||
| * This is a standalone MCP server that manages browser automation. | ||
| * It runs as a separate process and communicates with OpenCode via STDIO. | ||
| */ | ||
| import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; | ||
| import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; | ||
| import { z } from "zod"; | ||
| import { existsSync, mkdirSync, writeFileSync } from "fs"; | ||
| import { homedir } from "os"; | ||
| import { join } from "path"; | ||
| const WS_PORT = 19222; | ||
| const BASE_DIR = join(homedir(), ".opencode-browser"); | ||
| mkdirSync(BASE_DIR, { recursive: true }); | ||
| // WebSocket state for Chrome extension connection | ||
| let ws: any = null; | ||
| let isConnected = false; | ||
| let server: ReturnType<typeof Bun.serve> | null = null; | ||
| let pendingRequests = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>(); | ||
| let requestId = 0; | ||
| // Create MCP server | ||
| const mcpServer = new McpServer({ | ||
| name: "opencode-browser", | ||
| version: "2.1.0", | ||
| }); | ||
| // ============================================================================ | ||
| // WebSocket Server for Chrome Extension | ||
| // ============================================================================ | ||
| function handleMessage(message: { type: string; id?: number; result?: any; error?: any }): void { | ||
| if (message.type === "tool_response" && message.id !== undefined) { | ||
| const pending = pendingRequests.get(message.id); | ||
| if (!pending) return; | ||
| pendingRequests.delete(message.id); | ||
| if (message.error) { | ||
| pending.reject(new Error(message.error.content || String(message.error))); | ||
| } else { | ||
| pending.resolve(message.result?.content); | ||
| } | ||
| } | ||
| } | ||
| function sendToChrome(message: any): boolean { | ||
| if (ws && isConnected) { | ||
| ws.send(JSON.stringify(message)); | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
| async function isPortFree(port: number): Promise<boolean> { | ||
| try { | ||
| const testSocket = await Bun.connect({ | ||
| hostname: "localhost", | ||
| port, | ||
| socket: { | ||
| data() {}, | ||
| open(socket) { | ||
| socket.end(); | ||
| }, | ||
| close() {}, | ||
| error() {}, | ||
| }, | ||
| }); | ||
| testSocket.end(); | ||
| return false; | ||
| } catch (e: any) { | ||
| if (e.code === "ECONNREFUSED" || e.message?.includes("ECONNREFUSED")) { | ||
| return true; | ||
| } | ||
| return true; | ||
| } | ||
| } | ||
| async function killProcessOnPort(port: number): Promise<boolean> { | ||
| try { | ||
| // Use lsof to find PID using the port | ||
| const proc = Bun.spawn(["lsof", "-t", `-i:${port}`], { | ||
| stdout: "pipe", | ||
| stderr: "pipe", | ||
| }); | ||
| const output = await new Response(proc.stdout).text(); | ||
| const pids = output.trim().split("\n").filter(Boolean); | ||
| if (pids.length === 0) { | ||
| return true; // No process found, port should be free | ||
| } | ||
| // Kill each PID found | ||
| for (const pid of pids) { | ||
| const pidNum = parseInt(pid, 10); | ||
| if (isNaN(pidNum)) continue; | ||
| console.error(`[browser-mcp] Killing existing process ${pidNum} on port ${port}`); | ||
| try { | ||
| process.kill(pidNum, "SIGTERM"); | ||
| } catch (e) { | ||
| // Process may have already exited | ||
| } | ||
| } | ||
| // Wait a bit for process to die | ||
| await sleep(500); | ||
| // Verify port is now free | ||
| return await isPortFree(port); | ||
| } catch (e) { | ||
| console.error(`[browser-mcp] Failed to kill process on port:`, e); | ||
| return false; | ||
| } | ||
| } | ||
| async function startWebSocketServer(): Promise<boolean> { | ||
| if (server) return true; | ||
| if (!(await isPortFree(WS_PORT))) { | ||
| console.error(`[browser-mcp] Port ${WS_PORT} is in use, attempting to take over...`); | ||
| const killed = await killProcessOnPort(WS_PORT); | ||
| if (!killed) { | ||
| console.error(`[browser-mcp] Failed to free port ${WS_PORT}`); | ||
| return false; | ||
| } | ||
| console.error(`[browser-mcp] Successfully freed port ${WS_PORT}`); | ||
| } | ||
| try { | ||
| server = Bun.serve({ | ||
| port: WS_PORT, | ||
| fetch(req, server) { | ||
| if (server.upgrade(req)) return; | ||
| return new Response("OpenCode Browser MCP Server", { status: 200 }); | ||
| }, | ||
| websocket: { | ||
| open(wsClient) { | ||
| console.error(`[browser-mcp] Chrome extension connected`); | ||
| ws = wsClient; | ||
| isConnected = true; | ||
| }, | ||
| close() { | ||
| console.error(`[browser-mcp] Chrome extension disconnected`); | ||
| ws = null; | ||
| isConnected = false; | ||
| }, | ||
| message(_wsClient, data) { | ||
| try { | ||
| const message = JSON.parse(data.toString()); | ||
| handleMessage(message); | ||
| } catch (e) { | ||
| console.error(`[browser-mcp] Parse error:`, e); | ||
| } | ||
| }, | ||
| }, | ||
| }); | ||
| console.error(`[browser-mcp] WebSocket server listening on port ${WS_PORT}`); | ||
| return true; | ||
| } catch (e) { | ||
| console.error(`[browser-mcp] Failed to start WebSocket server:`, e); | ||
| return false; | ||
| } | ||
| } | ||
| function sleep(ms: number): Promise<void> { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
| async function waitForExtensionConnection(timeoutMs: number): Promise<boolean> { | ||
| const start = Date.now(); | ||
| while (Date.now() - start < timeoutMs) { | ||
| if (isConnected) return true; | ||
| await sleep(100); | ||
| } | ||
| return isConnected; | ||
| } | ||
| async function ensureConnection(): Promise<void> { | ||
| if (!server) { | ||
| const started = await startWebSocketServer(); | ||
| if (!started) { | ||
| throw new Error("Failed to start WebSocket server. Port may be in use."); | ||
| } | ||
| } | ||
| if (!isConnected) { | ||
| const connected = await waitForExtensionConnection(5000); | ||
| if (!connected) { | ||
| throw new Error( | ||
| "Chrome extension not connected. Make sure Chrome is running with the OpenCode Browser extension enabled." | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| async function executeCommand(toolName: string, args: Record<string, any>): Promise<any> { | ||
| await ensureConnection(); | ||
| const id = ++requestId; | ||
| return new Promise((resolve, reject) => { | ||
| pendingRequests.set(id, { resolve, reject }); | ||
| sendToChrome({ | ||
| type: "tool_request", | ||
| id, | ||
| tool: toolName, | ||
| args, | ||
| }); | ||
| setTimeout(() => { | ||
| if (!pendingRequests.has(id)) return; | ||
| pendingRequests.delete(id); | ||
| reject(new Error("Tool execution timed out after 60 seconds")); | ||
| }, 60000); | ||
| }); | ||
| } | ||
| // ============================================================================ | ||
| // Register MCP Tools | ||
| // ============================================================================ | ||
| mcpServer.tool( | ||
| "browser_status", | ||
| "Check if browser extension is connected. Returns connection status.", | ||
| {}, | ||
| async () => { | ||
| const status = isConnected | ||
| ? "Browser extension connected and ready." | ||
| : "Browser extension not connected. Make sure Chrome is running with the OpenCode Browser extension enabled."; | ||
| return { | ||
| content: [{ type: "text", text: status }], | ||
| }; | ||
| } | ||
| ); | ||
| mcpServer.tool( | ||
| "browser_navigate", | ||
| "Navigate to a URL in the browser", | ||
| { | ||
| url: z.string().describe("The URL to navigate to"), | ||
| tabId: z.number().optional().describe("Optional tab ID to navigate in"), | ||
| }, | ||
| async ({ url, tabId }) => { | ||
| const result = await executeCommand("navigate", { url, tabId }); | ||
| return { | ||
| content: [{ type: "text", text: result || `Navigated to ${url}` }], | ||
| }; | ||
| } | ||
| ); | ||
| mcpServer.tool( | ||
| "browser_click", | ||
| "Click an element on the page using a CSS selector", | ||
| { | ||
| selector: z.string().describe("CSS selector for element to click"), | ||
| tabId: z.number().optional().describe("Optional tab ID"), | ||
| }, | ||
| async ({ selector, tabId }) => { | ||
| const result = await executeCommand("click", { selector, tabId }); | ||
| return { | ||
| content: [{ type: "text", text: result || `Clicked ${selector}` }], | ||
| }; | ||
| } | ||
| ); | ||
| mcpServer.tool( | ||
| "browser_type", | ||
| "Type text into an input element", | ||
| { | ||
| selector: z.string().describe("CSS selector for input element"), | ||
| text: z.string().describe("Text to type"), | ||
| clear: z.boolean().optional().describe("Clear field before typing"), | ||
| tabId: z.number().optional().describe("Optional tab ID"), | ||
| }, | ||
| async ({ selector, text, clear, tabId }) => { | ||
| const result = await executeCommand("type", { selector, text, clear, tabId }); | ||
| return { | ||
| content: [{ type: "text", text: result || `Typed "${text}" into ${selector}` }], | ||
| }; | ||
| } | ||
| ); | ||
| mcpServer.tool( | ||
| "browser_screenshot", | ||
| "Take a screenshot of the current page. Returns base64 image data that can be viewed directly. Optionally saves to a file.", | ||
| { | ||
| tabId: z.number().optional().describe("Optional tab ID"), | ||
| save: z.boolean().optional().describe("Save to file (default: false, just returns base64)"), | ||
| path: z.string().optional().describe("Custom file path to save screenshot (implies save=true). Defaults to cwd if just save=true"), | ||
| }, | ||
| async ({ tabId, save, path: savePath }) => { | ||
| const result = await executeCommand("screenshot", { tabId }); | ||
| if (result && typeof result === "string" && result.startsWith("data:image")) { | ||
| const base64Data = result.replace(/^data:image\/\w+;base64,/, ""); | ||
| const content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> = [ | ||
| { | ||
| type: "image", | ||
| data: base64Data, | ||
| mimeType: "image/png", | ||
| }, | ||
| ]; | ||
| // Optionally save to file | ||
| if (save || savePath) { | ||
| const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); | ||
| let filepath: string; | ||
| if (savePath) { | ||
| // Use provided path (add .png if no extension) | ||
| filepath = savePath.endsWith(".png") ? savePath : `${savePath}.png`; | ||
| // If relative path, resolve from cwd | ||
| if (!savePath.startsWith("/")) { | ||
| filepath = join(process.cwd(), filepath); | ||
| } | ||
| } else { | ||
| // Default to cwd with timestamp | ||
| filepath = join(process.cwd(), `screenshot-${timestamp}.png`); | ||
| } | ||
| writeFileSync(filepath, Buffer.from(base64Data, "base64")); | ||
| content.push({ type: "text", text: `Saved: ${filepath}` }); | ||
| } | ||
| return { content }; | ||
| } | ||
| return { | ||
| content: [{ type: "text", text: result || "Screenshot failed" }], | ||
| }; | ||
| } | ||
| ); | ||
| mcpServer.tool( | ||
| "browser_snapshot", | ||
| "Get an accessibility tree snapshot of the page. Returns interactive elements with selectors for clicking, plus all links on the page.", | ||
| { | ||
| tabId: z.number().optional().describe("Optional tab ID"), | ||
| }, | ||
| async ({ tabId }) => { | ||
| const result = await executeCommand("snapshot", { tabId }); | ||
| return { | ||
| content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }], | ||
| }; | ||
| } | ||
| ); | ||
| mcpServer.tool( | ||
| "browser_get_tabs", | ||
| "List all open browser tabs", | ||
| {}, | ||
| async () => { | ||
| const result = await executeCommand("get_tabs", {}); | ||
| return { | ||
| content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result, null, 2) }], | ||
| }; | ||
| } | ||
| ); | ||
| mcpServer.tool( | ||
| "browser_scroll", | ||
| "Scroll the page or scroll an element into view", | ||
| { | ||
| selector: z.string().optional().describe("CSS selector to scroll into view"), | ||
| x: z.number().optional().describe("Horizontal scroll amount in pixels"), | ||
| y: z.number().optional().describe("Vertical scroll amount in pixels"), | ||
| tabId: z.number().optional().describe("Optional tab ID"), | ||
| }, | ||
| async ({ selector, x, y, tabId }) => { | ||
| const result = await executeCommand("scroll", { selector, x, y, tabId }); | ||
| return { | ||
| content: [{ type: "text", text: result || `Scrolled ${selector ? `to ${selector}` : `by (${x || 0}, ${y || 0})`}` }], | ||
| }; | ||
| } | ||
| ); | ||
| mcpServer.tool( | ||
| "browser_wait", | ||
| "Wait for a specified duration", | ||
| { | ||
| ms: z.number().optional().describe("Milliseconds to wait (default: 1000)"), | ||
| }, | ||
| async ({ ms }) => { | ||
| const waitMs = ms || 1000; | ||
| const result = await executeCommand("wait", { ms: waitMs }); | ||
| return { | ||
| content: [{ type: "text", text: result || `Waited ${waitMs}ms` }], | ||
| }; | ||
| } | ||
| ); | ||
| mcpServer.tool( | ||
| "browser_execute", | ||
| "Execute JavaScript code in the page context and return the result. Note: May fail on pages with strict CSP.", | ||
| { | ||
| code: z.string().describe("JavaScript code to execute"), | ||
| tabId: z.number().optional().describe("Optional tab ID"), | ||
| }, | ||
| async ({ code, tabId }) => { | ||
| const result = await executeCommand("execute_script", { code, tabId }); | ||
| return { | ||
| content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }], | ||
| }; | ||
| } | ||
| ); | ||
| // ============================================================================ | ||
| // Main | ||
| // ============================================================================ | ||
| async function main() { | ||
| console.error("[browser-mcp] Starting OpenCode Browser MCP Server..."); | ||
| // Start WebSocket server for Chrome extension | ||
| await startWebSocketServer(); | ||
| // Connect MCP server to STDIO transport | ||
| const transport = new StdioServerTransport(); | ||
| await mcpServer.connect(transport); | ||
| console.error("[browser-mcp] MCP Server running on STDIO"); | ||
| } | ||
| main().catch((error) => { | ||
| console.error("[browser-mcp] Fatal error:", error); | ||
| process.exit(1); | ||
| }); |
+126
-80
| #!/usr/bin/env node | ||
| /** | ||
| * OpenCode Browser - CLI Installer | ||
| * OpenCode Browser - CLI | ||
| * | ||
| * Installs the Chrome extension for browser automation. | ||
| * v2.0: Plugin-based architecture (no daemon, no MCP server) | ||
| * Commands: | ||
| * install - Install Chrome extension | ||
| * serve - Run MCP server (used by OpenCode) | ||
| * status - Check connection status | ||
| */ | ||
@@ -13,3 +15,3 @@ | ||
| import { fileURLToPath } from "url"; | ||
| import { execSync } from "child_process"; | ||
| import { execSync, spawn } from "child_process"; | ||
| import { createInterface } from "readline"; | ||
@@ -75,16 +77,21 @@ | ||
| async function main() { | ||
| console.log(` | ||
| ${color("cyan", color("bright", "OpenCode Browser v2.0"))} | ||
| ${color("cyan", "Browser automation for OpenCode")} | ||
| `); | ||
| const command = process.argv[2]; | ||
| if (command === "install") { | ||
| if (command === "serve") { | ||
| // Run MCP server - this is called by OpenCode | ||
| await serve(); | ||
| } else if (command === "install") { | ||
| await showHeader(); | ||
| await install(); | ||
| rl.close(); | ||
| } else if (command === "uninstall") { | ||
| await showHeader(); | ||
| await uninstall(); | ||
| rl.close(); | ||
| } else if (command === "status") { | ||
| await showHeader(); | ||
| await status(); | ||
| rl.close(); | ||
| } else { | ||
| await showHeader(); | ||
| log(` | ||
@@ -94,13 +101,46 @@ ${color("bright", "Usage:")} | ||
| npx @different-ai/opencode-browser uninstall Remove installation | ||
| npx @different-ai/opencode-browser status Check lock status | ||
| npx @different-ai/opencode-browser status Check status | ||
| npx @different-ai/opencode-browser serve Run MCP server (internal) | ||
| ${color("bright", "v2.0 Changes:")} | ||
| - Plugin-based architecture (no daemon needed) | ||
| - Add plugin to opencode.json, load extension in Chrome, done | ||
| ${color("bright", "Quick Start:")} | ||
| 1. Run: npx @different-ai/opencode-browser install | ||
| 2. Add to your opencode.json: | ||
| ${color("cyan", `"mcp": { "browser": { "type": "local", "command": ["bunx", "@different-ai/opencode-browser", "serve"] } }`)} | ||
| 3. Restart OpenCode | ||
| `); | ||
| rl.close(); | ||
| } | ||
| } | ||
| rl.close(); | ||
| async function showHeader() { | ||
| console.log(` | ||
| ${color("cyan", color("bright", "OpenCode Browser v2.1"))} | ||
| ${color("cyan", "Browser automation MCP server for OpenCode")} | ||
| `); | ||
| } | ||
| async function serve() { | ||
| // Launch the MCP server | ||
| const serverPath = join(PACKAGE_ROOT, "src", "mcp-server.ts"); | ||
| // Use bun to run the TypeScript server | ||
| const child = spawn("bun", ["run", serverPath], { | ||
| stdio: "inherit", | ||
| env: process.env, | ||
| }); | ||
| child.on("error", (err) => { | ||
| console.error("[browser-mcp] Failed to start server:", err); | ||
| process.exit(1); | ||
| }); | ||
| child.on("exit", (code) => { | ||
| process.exit(code || 0); | ||
| }); | ||
| // Forward signals to child | ||
| process.on("SIGINT", () => child.kill("SIGINT")); | ||
| process.on("SIGTERM", () => child.kill("SIGTERM")); | ||
| } | ||
| async function install() { | ||
@@ -163,14 +203,16 @@ header("Step 1: Check Platform"); | ||
| const pluginConfig = `{ | ||
| "$schema": "https://opencode.ai/config.json", | ||
| "plugin": ["@different-ai/opencode-browser"] | ||
| }`; | ||
| const mcpConfig = { | ||
| browser: { | ||
| type: "local", | ||
| command: ["bunx", "@different-ai/opencode-browser", "serve"], | ||
| }, | ||
| }; | ||
| log(` | ||
| Add the plugin to your ${color("cyan", "opencode.json")}: | ||
| Add the MCP server to your ${color("cyan", "opencode.json")}: | ||
| ${color("bright", pluginConfig)} | ||
| ${color("bright", JSON.stringify({ $schema: "https://opencode.ai/config.json", mcp: mcpConfig }, null, 2))} | ||
| Or if you already have an opencode.json, just add to the "plugin" array: | ||
| ${color("bright", '"plugin": ["@different-ai/opencode-browser"]')} | ||
| Or if you already have an opencode.json, add to the "mcp" object: | ||
| ${color("bright", JSON.stringify({ mcp: mcpConfig }, null, 2))} | ||
| `); | ||
@@ -181,3 +223,3 @@ | ||
| if (existsSync(opencodeJsonPath)) { | ||
| const shouldUpdate = await confirm(`Found opencode.json. Add plugin automatically?`); | ||
| const shouldUpdate = await confirm(`Found opencode.json. Add MCP server automatically?`); | ||
@@ -187,19 +229,22 @@ if (shouldUpdate) { | ||
| const config = JSON.parse(readFileSync(opencodeJsonPath, "utf-8")); | ||
| config.plugin = config.plugin || []; | ||
| if (!config.plugin.includes("@different-ai/opencode-browser")) { | ||
| config.plugin.push("@different-ai/opencode-browser"); | ||
| } | ||
| // Remove old MCP config if present | ||
| if (config.mcp?.browser) { | ||
| delete config.mcp.browser; | ||
| if (Object.keys(config.mcp).length === 0) { | ||
| delete config.mcp; | ||
| config.mcp = config.mcp || {}; | ||
| config.mcp.browser = mcpConfig.browser; | ||
| // Remove old plugin config if present | ||
| if (config.plugin && Array.isArray(config.plugin)) { | ||
| const idx = config.plugin.indexOf("@different-ai/opencode-browser"); | ||
| if (idx !== -1) { | ||
| config.plugin.splice(idx, 1); | ||
| warn("Removed old plugin entry (replaced by MCP)"); | ||
| } | ||
| warn("Removed old MCP browser config (replaced by plugin)"); | ||
| if (config.plugin.length === 0) { | ||
| delete config.plugin; | ||
| } | ||
| } | ||
| writeFileSync(opencodeJsonPath, JSON.stringify(config, null, 2) + "\n"); | ||
| success("Updated opencode.json with plugin"); | ||
| success("Updated opencode.json with MCP server"); | ||
| } catch (e) { | ||
| error(`Failed to update opencode.json: ${e.message}`); | ||
| log("Please add the plugin manually."); | ||
| log("Please add the MCP config manually."); | ||
| } | ||
@@ -214,6 +259,6 @@ } | ||
| $schema: "https://opencode.ai/config.json", | ||
| plugin: ["@different-ai/opencode-browser"], | ||
| mcp: mcpConfig, | ||
| }; | ||
| writeFileSync(opencodeJsonPath, JSON.stringify(config, null, 2) + "\n"); | ||
| success("Created opencode.json with plugin"); | ||
| success("Created opencode.json with MCP server"); | ||
| } catch (e) { | ||
@@ -225,4 +270,4 @@ error(`Failed to create opencode.json: ${e.message}`); | ||
| // Clean up old daemon if present | ||
| header("Step 5: Cleanup (v1.x migration)"); | ||
| // Clean up old daemon/plugin if present | ||
| header("Step 5: Cleanup (migration)"); | ||
@@ -234,10 +279,19 @@ const oldDaemonPlist = join(homedir(), "Library", "LaunchAgents", "com.opencode.browser-daemon.plist"); | ||
| unlinkSync(oldDaemonPlist); | ||
| success("Removed old daemon (no longer needed in v2.0)"); | ||
| success("Removed old daemon (no longer needed)"); | ||
| } catch { | ||
| warn("Could not remove old daemon plist. Remove manually if needed."); | ||
| } | ||
| } else { | ||
| success("No old daemon to clean up"); | ||
| } | ||
| // Remove old lock file | ||
| const oldLockFile = join(homedir(), ".opencode-browser", "lock.json"); | ||
| if (existsSync(oldLockFile)) { | ||
| try { | ||
| unlinkSync(oldLockFile); | ||
| success("Removed old lock file (not needed with MCP)"); | ||
| } catch {} | ||
| } | ||
| success("Cleanup complete"); | ||
| header("Installation Complete!"); | ||
@@ -247,13 +301,12 @@ | ||
| ${color("green", "")} Extension: ${extensionDir} | ||
| ${color("green", "")} Plugin: @different-ai/opencode-browser | ||
| ${color("green", "")} MCP Server: @different-ai/opencode-browser | ||
| ${color("bright", "How it works:")} | ||
| 1. OpenCode loads the plugin on startup | ||
| 2. Plugin starts WebSocket server on port 19222 | ||
| 1. OpenCode spawns MCP server on demand | ||
| 2. MCP server starts WebSocket server on port 19222 | ||
| 3. Chrome extension connects automatically | ||
| 4. Browser tools are available! | ||
| 4. Browser tools are available to any OpenCode session! | ||
| ${color("bright", "Available tools:")} | ||
| browser_status - Check if browser is available | ||
| browser_kill_session - Take over from another session | ||
| browser_status - Check if browser is connected | ||
| browser_navigate - Go to a URL | ||
@@ -263,3 +316,3 @@ browser_click - Click an element | ||
| browser_screenshot - Capture the page | ||
| browser_snapshot - Get accessibility tree | ||
| browser_snapshot - Get accessibility tree + all links | ||
| browser_get_tabs - List open tabs | ||
@@ -270,5 +323,6 @@ browser_scroll - Scroll the page | ||
| ${color("bright", "Multi-session:")} | ||
| Only one OpenCode session can use browser at a time. | ||
| Use browser_status to check, browser_kill_session to take over. | ||
| ${color("bright", "Benefits of MCP architecture:")} | ||
| - No session conflicts between OpenCode instances | ||
| - Server runs independently of OpenCode process | ||
| - Clean separation of concerns | ||
@@ -281,32 +335,24 @@ ${color("bright", "Test it:")} | ||
| async function status() { | ||
| header("Browser Lock Status"); | ||
| header("Browser Status"); | ||
| const lockFile = join(homedir(), ".opencode-browser", "lock.json"); | ||
| if (!existsSync(lockFile)) { | ||
| success("Browser available (no lock file)"); | ||
| return; | ||
| } | ||
| // Check if port 19222 is in use | ||
| try { | ||
| const lock = JSON.parse(readFileSync(lockFile, "utf-8")); | ||
| log(` | ||
| Lock file: ${lockFile} | ||
| PID: ${lock.pid} | ||
| Session: ${lock.sessionId} | ||
| Started: ${lock.startedAt} | ||
| Working directory: ${lock.cwd} | ||
| `); | ||
| // Check if process is alive | ||
| try { | ||
| process.kill(lock.pid, 0); | ||
| warn(`Process ${lock.pid} is running. Browser is locked.`); | ||
| } catch { | ||
| success(`Process ${lock.pid} is dead. Lock is stale and will be auto-cleaned.`); | ||
| const result = execSync("lsof -i :19222 2>/dev/null || true", { encoding: "utf-8" }); | ||
| if (result.trim()) { | ||
| success("WebSocket server is running on port 19222"); | ||
| log(result); | ||
| } else { | ||
| warn("WebSocket server not running (starts on demand via MCP)"); | ||
| } | ||
| } catch (e) { | ||
| error(`Could not read lock file: ${e.message}`); | ||
| } catch { | ||
| warn("Could not check port status"); | ||
| } | ||
| // Check extension directory | ||
| const extensionDir = join(homedir(), ".opencode-browser", "extension"); | ||
| if (existsSync(extensionDir)) { | ||
| success(`Extension installed at: ${extensionDir}`); | ||
| } else { | ||
| warn("Extension not installed. Run: npx @different-ai/opencode-browser install"); | ||
| } | ||
| } | ||
@@ -354,3 +400,3 @@ | ||
| Also remove "@different-ai/opencode-browser" from your opencode.json plugin array. | ||
| Also remove the "browser" entry from your opencode.json mcp section. | ||
| `); | ||
@@ -357,0 +403,0 @@ } |
@@ -218,3 +218,4 @@ const PLUGIN_URL = "ws://localhost:19222"; | ||
| name: getName(el).slice(0, 200), tag: el.tagName.toLowerCase() }; | ||
| if (el.tagName === "A" && el.href) node.href = el.href; | ||
| // Capture href for any element that has one (links, area, base, etc.) | ||
| if (el.href) node.href = el.href; | ||
| if (el.tagName === "INPUT") { node.type = el.type; node.value = el.value; } | ||
@@ -238,3 +239,23 @@ if (el.id) node.selector = `#${el.id}`; | ||
| return { url: location.href, title: document.title, nodes: build(document.body).nodes.slice(0, 500) }; | ||
| // Collect all links on the page separately for easy access | ||
| function getAllLinks() { | ||
| const links = []; | ||
| const seen = new Set(); | ||
| document.querySelectorAll("a[href]").forEach(a => { | ||
| const href = a.href; | ||
| if (href && !seen.has(href) && !href.startsWith("javascript:")) { | ||
| seen.add(href); | ||
| const text = a.innerText?.trim().slice(0, 100) || a.getAttribute("aria-label") || ""; | ||
| links.push({ href, text }); | ||
| } | ||
| }); | ||
| return links.slice(0, 100); // Limit to 100 links | ||
| } | ||
| return { | ||
| url: location.href, | ||
| title: document.title, | ||
| nodes: build(document.body).nodes.slice(0, 500), | ||
| links: getAllLinks() | ||
| }; | ||
| } | ||
@@ -241,0 +262,0 @@ }); |
+11
-10
| { | ||
| "name": "@different-ai/opencode-browser", | ||
| "version": "2.1.0", | ||
| "description": "Browser automation plugin for OpenCode. Control your real Chrome browser with existing logins and cookies.", | ||
| "version": "3.0.0", | ||
| "description": "Browser automation MCP server for OpenCode. Control your real Chrome browser with existing logins and cookies.", | ||
| "type": "module", | ||
@@ -9,6 +9,5 @@ "bin": { | ||
| }, | ||
| "main": "./src/plugin.ts", | ||
| "main": "./src/mcp-server.ts", | ||
| "exports": { | ||
| ".": "./src/plugin.ts", | ||
| "./plugin": "./src/plugin.ts" | ||
| ".": "./src/mcp-server.ts" | ||
| }, | ||
@@ -22,3 +21,4 @@ "files": [ | ||
| "scripts": { | ||
| "install-extension": "node bin/cli.js install" | ||
| "install-extension": "node bin/cli.js install", | ||
| "serve": "bun run src/mcp-server.ts" | ||
| }, | ||
@@ -30,3 +30,4 @@ "keywords": [ | ||
| "chrome", | ||
| "plugin" | ||
| "mcp", | ||
| "model-context-protocol" | ||
| ], | ||
@@ -43,9 +44,9 @@ "author": "Benjamin Shafii", | ||
| "homepage": "https://github.com/different-ai/opencode-browser#readme", | ||
| "peerDependencies": { | ||
| "@opencode-ai/plugin": "*" | ||
| "dependencies": { | ||
| "@modelcontextprotocol/sdk": "^1.25.2", | ||
| "zod": "^4.3.5" | ||
| }, | ||
| "devDependencies": { | ||
| "@opencode-ai/plugin": "*", | ||
| "bun-types": "*" | ||
| } | ||
| } |
+50
-43
| # OpenCode Browser | ||
| Browser automation plugin for [OpenCode](https://github.com/opencode-ai/opencode). | ||
| Browser automation MCP server for [OpenCode](https://github.com/opencode-ai/opencode). | ||
@@ -11,3 +11,3 @@ Control your real Chrome browser with existing logins, cookies, and bookmarks. No DevTools Protocol, no security prompts. | ||
| OpenCode Browser uses a simple WebSocket connection between an OpenCode plugin and a Chrome extension. Your automation works with your existing browser session - no prompts, no separate profiles. | ||
| OpenCode Browser uses a simple WebSocket connection between an MCP server and a Chrome extension. Your automation works with your existing browser session - no prompts, no separate profiles. | ||
@@ -23,3 +23,3 @@ ## Installation | ||
| 2. Guide you to load the extension in Chrome | ||
| 3. Update your `opencode.json` to use the plugin | ||
| 3. Update your `opencode.json` with MCP server config | ||
@@ -32,3 +32,8 @@ ## Configuration | ||
| { | ||
| "plugin": ["@different-ai/opencode-browser"] | ||
| "mcp": { | ||
| "browser": { | ||
| "type": "local", | ||
| "command": ["bunx", "@different-ai/opencode-browser", "serve"] | ||
| } | ||
| } | ||
| } | ||
@@ -46,11 +51,8 @@ ``` | ||
| |------|-------------| | ||
| | `browser_status` | Check if browser is available or locked | | ||
| | `browser_kill_session` | Request other session release + take over (no kill) | | ||
| | `browser_release` | Release lock and stop server | | ||
| | `browser_force_kill_session` | (Last resort) kill other OpenCode process | | ||
| | `browser_status` | Check if browser extension is connected | | ||
| | `browser_navigate` | Navigate to a URL | | ||
| | `browser_click` | Click an element by CSS selector | | ||
| | `browser_type` | Type text into an input field | | ||
| | `browser_screenshot` | Capture the visible page | | ||
| | `browser_snapshot` | Get accessibility tree with selectors | | ||
| | `browser_screenshot` | Capture the page (returns base64, optionally saves to file) | | ||
| | `browser_snapshot` | Get accessibility tree with selectors + all page links | | ||
| | `browser_get_tabs` | List all open tabs | | ||
@@ -61,45 +63,50 @@ | `browser_scroll` | Scroll page or element into view | | ||
| ## Multi-Session Support | ||
| ### Screenshot Tool | ||
| Only one OpenCode session can use the browser at a time. This prevents conflicts when you have multiple terminals open. | ||
| The `browser_screenshot` tool returns base64 image data by default, allowing AI to view images directly: | ||
| - `browser_status` - Check who has the lock | ||
| - `browser_kill_session` - Request the other session to release (no kill) | ||
| - `browser_release` - Release lock/server for this session | ||
| - `browser_force_kill_session` - (Last resort) kill the other OpenCode process and take over | ||
| ```javascript | ||
| // Returns base64 image (AI can view it) | ||
| browser_screenshot() | ||
| In your prompts, you can say: | ||
| - "If browser is locked, kill the session and proceed" | ||
| - "If browser is locked, skip this task" | ||
| // Save to current working directory | ||
| browser_screenshot({ save: true }) | ||
| // Save to specific path | ||
| browser_screenshot({ path: "my-screenshot.png" }) | ||
| ``` | ||
| ## Architecture | ||
| ``` | ||
| OpenCode Plugin ◄──WebSocket:19222──► Chrome Extension | ||
| │ │ | ||
| └── Lock file └── chrome.tabs, chrome.scripting | ||
| OpenCode <──STDIO──> MCP Server <──WebSocket:19222──> Chrome Extension | ||
| │ │ | ||
| └── @modelcontextprotocol/sdk └── chrome.tabs, chrome.scripting | ||
| ``` | ||
| **Two components:** | ||
| 1. OpenCode plugin (runs WebSocket server, defines tools) | ||
| 2. Chrome extension (connects to plugin, executes commands) | ||
| 1. MCP Server (runs as separate process, manages WebSocket server) | ||
| 2. Chrome extension (connects to server, executes browser commands) | ||
| **No daemon. No MCP server. No native messaging host.** | ||
| **Benefits of MCP architecture:** | ||
| - No session conflicts between OpenCode instances | ||
| - Server runs independently of OpenCode process | ||
| - Clean separation of concerns | ||
| - Standard MCP protocol | ||
| ## Upgrading from v1.x | ||
| ## Upgrading from v2.x (Plugin) | ||
| v2.0 is a complete rewrite with a simpler architecture: | ||
| v3.0 migrates from plugin to MCP architecture: | ||
| 1. Run `npx @different-ai/opencode-browser install` (cleans up old daemon automatically) | ||
| 2. Replace MCP config with plugin config in `opencode.json`: | ||
| 1. Run `npx @different-ai/opencode-browser install` | ||
| 2. Replace plugin config with MCP config in `opencode.json`: | ||
| ```diff | ||
| - "mcp": { | ||
| - "browser": { | ||
| - "type": "local", | ||
| - "command": ["npx", "@different-ai/opencode-browser", "start"], | ||
| - "enabled": true | ||
| - } | ||
| - } | ||
| + "plugin": ["@different-ai/opencode-browser"] | ||
| - "plugin": ["@different-ai/opencode-browser"] | ||
| + "mcp": { | ||
| + "browser": { | ||
| + "type": "local", | ||
| + "command": ["bunx", "@different-ai/opencode-browser", "serve"] | ||
| + } | ||
| + } | ||
| ``` | ||
@@ -116,10 +123,10 @@ | ||
| **"Browser locked by another session"** | ||
| - Use `browser_kill_session` to take over | ||
| - Or close the other OpenCode session | ||
| **"Failed to start WebSocket server"** | ||
| - Port 19222 may be in use | ||
| - Check if another OpenCode session is running | ||
| - Run `lsof -i :19222` to check what's using it | ||
| **"browser_execute fails on some sites"** | ||
| - Sites with strict CSP block JavaScript execution | ||
| - Use `browser_snapshot` to get page data instead | ||
| ## Uninstall | ||
@@ -136,3 +143,3 @@ | ||
| - macOS ✓ | ||
| - Linux ✓ | ||
| - Linux ✓ | ||
| - Windows (not yet supported) | ||
@@ -139,0 +146,0 @@ |
-672
| /** | ||
| * OpenCode Browser Plugin | ||
| * | ||
| * OpenCode Plugin (this) <--WebSocket:19222--> Chrome Extension | ||
| * | ||
| * Notes | ||
| * - Uses a lock file so only one OpenCode session owns the browser. | ||
| * - Supports a *soft takeover* (SIGUSR1) so we don't have to kill OpenCode. | ||
| */ | ||
| import type { Plugin } from "@opencode-ai/plugin"; | ||
| import { tool } from "@opencode-ai/plugin"; | ||
| import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs"; | ||
| import { homedir } from "os"; | ||
| import { join } from "path"; | ||
| const WS_PORT = 19222; | ||
| const BASE_DIR = join(homedir(), ".opencode-browser"); | ||
| const LOCK_FILE = join(BASE_DIR, "lock.json"); | ||
| const SCREENSHOTS_DIR = join(BASE_DIR, "screenshots"); | ||
| // If a session hasn't used the browser in this long, allow soft takeover by default. | ||
| const LOCK_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours | ||
| mkdirSync(BASE_DIR, { recursive: true }); | ||
| mkdirSync(SCREENSHOTS_DIR, { recursive: true }); | ||
| // Session state | ||
| const sessionId = Math.random().toString(36).slice(2); | ||
| const pid = process.pid; | ||
| let ws: WebSocket | null = null; | ||
| let isConnected = false; | ||
| let server: ReturnType<typeof Bun.serve> | null = null; | ||
| let pendingRequests = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>(); | ||
| let requestId = 0; | ||
| interface LockInfo { | ||
| pid: number; | ||
| sessionId: string; | ||
| startedAt: string; | ||
| lastUsedAt: string; | ||
| cwd: string; | ||
| } | ||
| function nowIso(): string { | ||
| return new Date().toISOString(); | ||
| } | ||
| function readLock(): LockInfo | null { | ||
| try { | ||
| if (!existsSync(LOCK_FILE)) return null; | ||
| return JSON.parse(readFileSync(LOCK_FILE, "utf-8")); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function writeLock(): void { | ||
| writeFileSync( | ||
| LOCK_FILE, | ||
| JSON.stringify( | ||
| { | ||
| pid, | ||
| sessionId, | ||
| startedAt: nowIso(), | ||
| lastUsedAt: nowIso(), | ||
| cwd: process.cwd(), | ||
| } satisfies LockInfo, | ||
| null, | ||
| 2 | ||
| ) + "\n" | ||
| ); | ||
| } | ||
| function touchLock(): void { | ||
| const lock = readLock(); | ||
| if (!lock) return; | ||
| if (lock.sessionId !== sessionId) return; | ||
| try { | ||
| writeFileSync( | ||
| LOCK_FILE, | ||
| JSON.stringify( | ||
| { | ||
| ...lock, | ||
| lastUsedAt: nowIso(), | ||
| } satisfies LockInfo, | ||
| null, | ||
| 2 | ||
| ) + "\n" | ||
| ); | ||
| } catch { | ||
| // Ignore | ||
| } | ||
| } | ||
| function releaseLock(): void { | ||
| try { | ||
| const lock = readLock(); | ||
| if (lock && lock.sessionId === sessionId) { | ||
| unlinkSync(LOCK_FILE); | ||
| } | ||
| } catch { | ||
| // Ignore | ||
| } | ||
| } | ||
| function isProcessAlive(targetPid: number): boolean { | ||
| try { | ||
| process.kill(targetPid, 0); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function lockAgeMs(lock: LockInfo): number { | ||
| const ts = lock.lastUsedAt || lock.startedAt; | ||
| const n = Date.parse(ts); | ||
| if (Number.isNaN(n)) return Number.POSITIVE_INFINITY; | ||
| return Date.now() - n; | ||
| } | ||
| function isLockExpired(lock: LockInfo): boolean { | ||
| return lockAgeMs(lock) > LOCK_TTL_MS; | ||
| } | ||
| function isPortFree(port: number): boolean { | ||
| try { | ||
| // If we can connect, something is already listening. | ||
| const testSocket = Bun.connect({ port, timeout: 300 }); | ||
| testSocket.end(); | ||
| return false; | ||
| } catch (e) { | ||
| if ((e as any).code === "ECONNREFUSED") return true; | ||
| return false; | ||
| } | ||
| } | ||
| function stopBrowserServer(): void { | ||
| try { | ||
| (ws as any)?.close?.(); | ||
| } catch { | ||
| // Ignore | ||
| } | ||
| ws = null; | ||
| isConnected = false; | ||
| try { | ||
| server?.stop(); | ||
| } catch { | ||
| // Ignore | ||
| } | ||
| server = null; | ||
| } | ||
| function startServer(): boolean { | ||
| if (server) return true; | ||
| if (!isPortFree(WS_PORT)) return false; | ||
| try { | ||
| server = Bun.serve({ | ||
| port: WS_PORT, | ||
| fetch(req, server) { | ||
| if (server.upgrade(req)) return; | ||
| return new Response("OpenCode Browser Plugin", { status: 200 }); | ||
| }, | ||
| websocket: { | ||
| open(wsClient) { | ||
| console.error(`[browser-plugin] Chrome extension connected`); | ||
| ws = wsClient as unknown as WebSocket; | ||
| isConnected = true; | ||
| }, | ||
| close() { | ||
| console.error(`[browser-plugin] Chrome extension disconnected`); | ||
| ws = null; | ||
| isConnected = false; | ||
| }, | ||
| message(_wsClient, data) { | ||
| try { | ||
| const message = JSON.parse(data.toString()); | ||
| handleMessage(message); | ||
| } catch (e) { | ||
| console.error(`[browser-plugin] Parse error:`, e); | ||
| } | ||
| }, | ||
| }, | ||
| }); | ||
| console.error(`[browser-plugin] WebSocket server listening on port ${WS_PORT}`); | ||
| return true; | ||
| } catch (e) { | ||
| console.error(`[browser-plugin] Failed to start server:`, e); | ||
| return false; | ||
| } | ||
| } | ||
| function sleep(ms: number): Promise<void> { | ||
| return new Promise((resolve) => setTimeout(resolve, ms)); | ||
| } | ||
| async function waitForExtensionConnection(timeoutMs: number): Promise<boolean> { | ||
| const start = Date.now(); | ||
| while (Date.now() - start < timeoutMs) { | ||
| if (isConnected) return true; | ||
| await sleep(100); | ||
| } | ||
| return isConnected; | ||
| } | ||
| async function requestSessionRelease(targetPid: number, opts?: { timeoutMs?: number }): Promise<{ success: boolean; error?: string }> { | ||
| const timeoutMs = opts?.timeoutMs ?? 3000; | ||
| try { | ||
| // SIGUSR1 is treated as "release browser lock + stop server". | ||
| // This does NOT terminate OpenCode. | ||
| process.kill(targetPid, "SIGUSR1"); | ||
| } catch (e) { | ||
| return { success: false, error: e instanceof Error ? e.message : String(e) }; | ||
| } | ||
| const start = Date.now(); | ||
| while (Date.now() - start < timeoutMs) { | ||
| const lock = readLock(); | ||
| const lockCleared = !lock || lock.pid !== targetPid; | ||
| const portCleared = isPortFree(WS_PORT); | ||
| if (lockCleared && portCleared) return { success: true }; | ||
| await sleep(100); | ||
| } | ||
| return { | ||
| success: false, | ||
| error: `Timed out waiting for PID ${targetPid} to release browser`, | ||
| }; | ||
| } | ||
| async function forceKillSession(targetPid: number): Promise<{ success: boolean; error?: string }> { | ||
| try { | ||
| process.kill(targetPid, "SIGTERM"); | ||
| let attempts = 0; | ||
| while (isProcessAlive(targetPid) && attempts < 20) { | ||
| await sleep(100); | ||
| attempts++; | ||
| } | ||
| if (isProcessAlive(targetPid)) { | ||
| process.kill(targetPid, "SIGKILL"); | ||
| } | ||
| return { success: true }; | ||
| } catch (e) { | ||
| return { success: false, error: e instanceof Error ? e.message : String(e) }; | ||
| } | ||
| } | ||
| function handleMessage(message: { type: string; id?: number; result?: any; error?: any }): void { | ||
| if (message.type === "tool_response" && message.id !== undefined) { | ||
| const pending = pendingRequests.get(message.id); | ||
| if (!pending) return; | ||
| pendingRequests.delete(message.id); | ||
| if (message.error) { | ||
| pending.reject(new Error(message.error.content || String(message.error))); | ||
| } else { | ||
| pending.resolve(message.result?.content); | ||
| } | ||
| } | ||
| } | ||
| function sendToChrome(message: any): boolean { | ||
| if (ws && isConnected) { | ||
| (ws as any).send(JSON.stringify(message)); | ||
| return true; | ||
| } | ||
| return false; | ||
| } | ||
| async function performTakeover(): Promise<string> { | ||
| const lock = readLock(); | ||
| if (!lock) { | ||
| writeLock(); | ||
| } else if (lock.sessionId === sessionId) { | ||
| // Already ours. | ||
| } else if (!isProcessAlive(lock.pid)) { | ||
| // Dead PID -> stale. | ||
| console.error(`[browser-plugin] Cleaning stale lock from dead PID ${lock.pid}`); | ||
| writeLock(); | ||
| } else { | ||
| const ageMinutes = Math.round(lockAgeMs(lock) / 60000); | ||
| console.error( | ||
| `[browser-plugin] Requesting release from PID ${lock.pid} (last used ${ageMinutes}m ago)...` | ||
| ); | ||
| const released = await requestSessionRelease(lock.pid, { timeoutMs: 4000 }); | ||
| if (!released.success) { | ||
| throw new Error( | ||
| `Failed to takeover without killing OpenCode: ${released.error}. ` + | ||
| `Try again, or use browser_force_kill_session as last resort.` | ||
| ); | ||
| } | ||
| console.error(`[browser-plugin] Previous session released gracefully.`); | ||
| writeLock(); | ||
| } | ||
| touchLock(); | ||
| if (!server) { | ||
| if (!startServer()) { | ||
| throw new Error("Failed to start WebSocket server after takeover."); | ||
| } | ||
| } | ||
| const ok = await waitForExtensionConnection(3000); | ||
| if (!ok) { | ||
| throw new Error("Took over lock but Chrome extension did not connect."); | ||
| } | ||
| return "Browser now connected to this session."; | ||
| } | ||
| async function ensureLockAndServer(): Promise<void> { | ||
| const existingLock = readLock(); | ||
| if (!existingLock) { | ||
| writeLock(); | ||
| } else if (existingLock.sessionId === sessionId) { | ||
| // Already ours. | ||
| } else if (!isProcessAlive(existingLock.pid)) { | ||
| // Stale lock (dead PID). | ||
| console.error(`[browser-plugin] Cleaning stale lock from dead PID ${existingLock.pid}`); | ||
| writeLock(); | ||
| } else { | ||
| // Another session holds the lock - attempt automatic soft takeover | ||
| const ageMinutes = Math.round(lockAgeMs(existingLock) / 60000); | ||
| console.error( | ||
| `[browser-plugin] Browser locked by PID ${existingLock.pid} (last used ${ageMinutes}m ago). Attempting auto-takeover...` | ||
| ); | ||
| const released = await requestSessionRelease(existingLock.pid, { timeoutMs: 4000 }); | ||
| if (released.success) { | ||
| console.error(`[browser-plugin] Auto-takeover succeeded. Previous session released gracefully.`); | ||
| writeLock(); | ||
| } else { | ||
| // Soft takeover failed - provide helpful error | ||
| const expired = isLockExpired(existingLock); | ||
| const why = expired ? "expired" : "active"; | ||
| throw new Error( | ||
| `Browser locked by another session (PID ${existingLock.pid}, ${why}). ` + | ||
| `Auto-takeover failed: ${released.error}. ` + | ||
| `Use browser_force_kill_session as last resort, or browser_status for details.` | ||
| ); | ||
| } | ||
| } | ||
| touchLock(); | ||
| if (!server) { | ||
| if (!startServer()) { | ||
| throw new Error("Failed to start WebSocket server. Port may be in use."); | ||
| } | ||
| } | ||
| if (!isConnected) { | ||
| const ok = await waitForExtensionConnection(3000); | ||
| if (!ok) { | ||
| throw new Error( | ||
| "Chrome extension not connected. Make sure Chrome is running with the OpenCode Browser extension enabled." | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| async function executeCommand(toolName: string, args: Record<string, any>): Promise<any> { | ||
| await ensureLockAndServer(); | ||
| const id = ++requestId; | ||
| touchLock(); | ||
| return new Promise((resolve, reject) => { | ||
| pendingRequests.set(id, { resolve, reject }); | ||
| sendToChrome({ | ||
| type: "tool_request", | ||
| id, | ||
| tool: toolName, | ||
| args, | ||
| }); | ||
| setTimeout(() => { | ||
| if (!pendingRequests.has(id)) return; | ||
| pendingRequests.delete(id); | ||
| reject(new Error("Tool execution timed out after 60 seconds")); | ||
| }, 60000); | ||
| }); | ||
| } | ||
| // ============================================================================ | ||
| // Cleanup / Signals | ||
| // ============================================================================ | ||
| // Soft release: do NOT exit the OpenCode process. | ||
| process.on("SIGUSR1", () => { | ||
| console.error(`[browser-plugin] SIGUSR1: releasing lock + stopping server`); | ||
| releaseLock(); | ||
| stopBrowserServer(); | ||
| }); | ||
| process.on("SIGTERM", () => { | ||
| releaseLock(); | ||
| stopBrowserServer(); | ||
| process.exit(0); | ||
| }); | ||
| process.on("SIGINT", () => { | ||
| releaseLock(); | ||
| stopBrowserServer(); | ||
| process.exit(0); | ||
| }); | ||
| process.on("exit", () => { | ||
| releaseLock(); | ||
| }); | ||
| // ============================================================================ | ||
| // Plugin Export | ||
| // ============================================================================ | ||
| export const BrowserPlugin: Plugin = async (_ctx) => { | ||
| console.error(`[browser-plugin] Initializing (session ${sessionId})`); | ||
| return { | ||
| tool: { | ||
| browser_status: tool({ | ||
| description: | ||
| "Check if browser is available or locked by another session. Returns connection status and lock info.", | ||
| args: {}, | ||
| async execute() { | ||
| const lock = readLock(); | ||
| if (!lock) { | ||
| return "Browser available (no active session)"; | ||
| } | ||
| if (lock.sessionId === sessionId) { | ||
| return ( | ||
| `Browser connected (this session)\n` + | ||
| `PID: ${pid}\n` + | ||
| `Started: ${lock.startedAt}\n` + | ||
| `Last used: ${lock.lastUsedAt}\n` + | ||
| `Extension: ${isConnected ? "connected" : "not connected"}` | ||
| ); | ||
| } | ||
| const alive = isProcessAlive(lock.pid); | ||
| const ageMinutes = Math.round(lockAgeMs(lock) / 60000); | ||
| const expired = isLockExpired(lock); | ||
| if (!alive) { | ||
| return `Browser available (stale lock from dead PID ${lock.pid} will be auto-cleaned on next command)`; | ||
| } | ||
| return ( | ||
| `Browser locked by another session\n` + | ||
| `PID: ${lock.pid}\n` + | ||
| `Session: ${lock.sessionId}\n` + | ||
| `Started: ${lock.startedAt}\n` + | ||
| `Last used: ${lock.lastUsedAt} (~${ageMinutes}m ago)${expired ? " [expired]" : ""}\n` + | ||
| `Working directory: ${lock.cwd}\n\n` + | ||
| `Use browser_takeover to request release (no kill), or browser_force_kill_session as last resort.` | ||
| ); | ||
| }, | ||
| }), | ||
| browser_release: tool({ | ||
| description: "Release browser lock and stop the server for this session.", | ||
| args: {}, | ||
| async execute() { | ||
| const lock = readLock(); | ||
| if (lock && lock.sessionId !== sessionId) { | ||
| throw new Error("This session does not own the browser lock."); | ||
| } | ||
| releaseLock(); | ||
| stopBrowserServer(); | ||
| return "Released browser lock for this session."; | ||
| }, | ||
| }), | ||
| browser_takeover: tool({ | ||
| description: | ||
| "Request the session holding the browser lock to release it (no process kill), then take over.", | ||
| args: {}, | ||
| async execute() { | ||
| return await performTakeover(); | ||
| }, | ||
| }), | ||
| browser_kill_session: tool({ | ||
| description: | ||
| "(Deprecated name) Soft takeover without killing OpenCode. Prefer browser_takeover.", | ||
| args: {}, | ||
| async execute() { | ||
| // Keep backward compatibility: old callers use this. | ||
| return await performTakeover(); | ||
| }, | ||
| }), | ||
| browser_force_kill_session: tool({ | ||
| description: "Force kill the session holding the browser lock (last resort).", | ||
| args: {}, | ||
| async execute() { | ||
| const lock = readLock(); | ||
| if (!lock) { | ||
| writeLock(); | ||
| return "No active session. Browser now connected to this session."; | ||
| } | ||
| if (lock.sessionId === sessionId) { | ||
| return "This session already owns the browser."; | ||
| } | ||
| if (!isProcessAlive(lock.pid)) { | ||
| writeLock(); | ||
| return `Cleaned stale lock (PID ${lock.pid} was dead). Browser now connected to this session.`; | ||
| } | ||
| const result = await forceKillSession(lock.pid); | ||
| if (!result.success) { | ||
| throw new Error(`Failed to force kill session: ${result.error}`); | ||
| } | ||
| // Best-effort cleanup; then take lock. | ||
| try { | ||
| unlinkSync(LOCK_FILE); | ||
| } catch { | ||
| // Ignore | ||
| } | ||
| writeLock(); | ||
| if (!server) { | ||
| if (!startServer()) { | ||
| throw new Error("Failed to start WebSocket server after force kill."); | ||
| } | ||
| } | ||
| const ok = await waitForExtensionConnection(3000); | ||
| if (!ok) { | ||
| throw new Error("Force-killed lock holder but Chrome extension did not connect."); | ||
| } | ||
| return `Force-killed session ${lock.sessionId} (PID ${lock.pid}). Browser now connected to this session.`; | ||
| }, | ||
| }), | ||
| browser_navigate: tool({ | ||
| description: "Navigate to a URL in browser", | ||
| args: { | ||
| url: tool.schema.string({ description: "The URL to navigate to" }), | ||
| tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })), | ||
| }, | ||
| async execute(args) { | ||
| return await executeCommand("navigate", args); | ||
| }, | ||
| }), | ||
| browser_click: tool({ | ||
| description: "Click an element on page using a CSS selector", | ||
| args: { | ||
| selector: tool.schema.string({ description: "CSS selector for element to click" }), | ||
| tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })), | ||
| }, | ||
| async execute(args) { | ||
| return await executeCommand("click", args); | ||
| }, | ||
| }), | ||
| browser_type: tool({ | ||
| description: "Type text into an input element", | ||
| args: { | ||
| selector: tool.schema.string({ description: "CSS selector for input element" }), | ||
| text: tool.schema.string({ description: "Text to type" }), | ||
| clear: tool.schema.optional(tool.schema.boolean({ description: "Clear field before typing" })), | ||
| tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })), | ||
| }, | ||
| async execute(args) { | ||
| return await executeCommand("type", args); | ||
| }, | ||
| }), | ||
| browser_screenshot: tool({ | ||
| description: "Take a screenshot of the current page. Saves to ~/.opencode-browser/screenshots/", | ||
| args: { | ||
| tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })), | ||
| name: tool.schema.optional( | ||
| tool.schema.string({ description: "Optional name for screenshot file (without extension)" }) | ||
| ), | ||
| }, | ||
| async execute(args: { tabId?: number; name?: string }) { | ||
| const result = await executeCommand("screenshot", args); | ||
| if (result && typeof result === "string" && result.startsWith("data:image")) { | ||
| const base64Data = result.replace(/^data:image\/\w+;base64,/, ""); | ||
| const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); | ||
| const filename = args.name ? `${args.name}.png` : `screenshot-${timestamp}.png`; | ||
| const filepath = join(SCREENSHOTS_DIR, filename); | ||
| writeFileSync(filepath, Buffer.from(base64Data, "base64")); | ||
| return `Screenshot saved: ${filepath}`; | ||
| } | ||
| return result; | ||
| }, | ||
| }), | ||
| browser_snapshot: tool({ | ||
| description: | ||
| "Get an accessibility tree snapshot of the page. Returns interactive elements with selectors for clicking.", | ||
| args: { | ||
| tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })), | ||
| }, | ||
| async execute(args) { | ||
| return await executeCommand("snapshot", args); | ||
| }, | ||
| }), | ||
| browser_get_tabs: tool({ | ||
| description: "List all open browser tabs", | ||
| args: {}, | ||
| async execute() { | ||
| return await executeCommand("get_tabs", {}); | ||
| }, | ||
| }), | ||
| browser_scroll: tool({ | ||
| description: "Scroll the page or scroll an element into view", | ||
| args: { | ||
| selector: tool.schema.optional(tool.schema.string({ description: "CSS selector to scroll into view" })), | ||
| x: tool.schema.optional(tool.schema.number({ description: "Horizontal scroll amount in pixels" })), | ||
| y: tool.schema.optional(tool.schema.number({ description: "Vertical scroll amount in pixels" })), | ||
| tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })), | ||
| }, | ||
| async execute(args) { | ||
| return await executeCommand("scroll", args); | ||
| }, | ||
| }), | ||
| browser_wait: tool({ | ||
| description: "Wait for a specified duration", | ||
| args: { | ||
| ms: tool.schema.optional(tool.schema.number({ description: "Milliseconds to wait (default: 1000)" })), | ||
| }, | ||
| async execute(args) { | ||
| return await executeCommand("wait", args); | ||
| }, | ||
| }), | ||
| browser_execute: tool({ | ||
| description: "Execute JavaScript code in the page context and return the result", | ||
| args: { | ||
| code: tool.schema.string({ description: "JavaScript code to execute" }), | ||
| tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })), | ||
| }, | ||
| async execute(args) { | ||
| return await executeCommand("execute_script", args); | ||
| }, | ||
| }), | ||
| }, | ||
| }; | ||
| }; | ||
| export default BrowserPlugin; |
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
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
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
1
-50%147
5%41289
-10.51%2
100%1006
-11.91%6
20%4
33.33%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed