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

@different-ai/opencode-browser

Package Overview
Dependencies
Maintainers
2
Versions
35
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

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

Comparing version
2.0.2
to
2.1.0
+1
-1
package.json
{
"name": "@different-ai/opencode-browser",
"version": "2.0.2",
"version": "2.1.0",
"description": "Browser automation plugin for OpenCode. Control your real Chrome browser with existing logins and cookies.",

@@ -5,0 +5,0 @@ "type": "module",

@@ -44,3 +44,5 @@ # OpenCode Browser

| `browser_status` | Check if browser is available or locked |
| `browser_kill_session` | Take over from another OpenCode session |
| `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_navigate` | Navigate to a URL |

@@ -61,3 +63,5 @@ | `browser_click` | Click an element by CSS selector |

- `browser_status` - Check who has the lock
- `browser_kill_session` - Kill the other session and take over
- `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

@@ -64,0 +68,0 @@ In your prompts, you can say:

/**
* OpenCode Browser Plugin
*
* A simple plugin that provides browser automation tools.
* Connects to Chrome extension via WebSocket.
* OpenCode Plugin (this) <--WebSocket:19222--> Chrome Extension
*
* Architecture:
* OpenCode Plugin (this) <--WebSocket:19222--> Chrome Extension
*
* Lock file ensures only one OpenCode session uses browser at a time.
* 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.
*/

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

import { tool } from "@opencode-ai/plugin";
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
import { homedir } from "os";

@@ -25,3 +23,5 @@ import { join } from "path";

// Ensure directories exist
// 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 });

@@ -38,9 +38,3 @@ mkdirSync(SCREENSHOTS_DIR, { recursive: true });

let requestId = 0;
let hasLock = false;
let serverFailed = false;
// ============================================================================
// Lock File Management
// ============================================================================
interface LockInfo {

@@ -50,5 +44,10 @@ pid: number;

startedAt: string;
lastUsedAt: string;
cwd: string;
}
function nowIso(): string {
return new Date().toISOString();
}
function readLock(): LockInfo | null {

@@ -66,12 +65,38 @@ try {

LOCK_FILE,
JSON.stringify({
pid,
sessionId,
startedAt: new Date().toISOString(),
cwd: process.cwd(),
} satisfies LockInfo)
JSON.stringify(
{
pid,
sessionId,
startedAt: nowIso(),
lastUsedAt: nowIso(),
cwd: process.cwd(),
} satisfies LockInfo,
null,
2
) + "\n"
);
hasLock = true;
}
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 {

@@ -83,4 +108,5 @@ try {

}
} catch {}
hasLock = false;
} catch {
// Ignore
}
}

@@ -97,74 +123,45 @@

function tryAcquireLock(): { success: boolean; error?: string; lock?: LockInfo } {
const existingLock = readLock();
if (!existingLock) {
writeLock();
return { success: true };
}
if (existingLock.sessionId === sessionId) {
return { success: true };
}
if (!isProcessAlive(existingLock.pid)) {
// Stale lock, take it
writeLock();
return { success: true };
}
return {
success: false,
error: `Browser locked by another session (PID ${existingLock.pid})`,
lock: existingLock,
};
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 sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
function isLockExpired(lock: LockInfo): boolean {
return lockAgeMs(lock) > LOCK_TTL_MS;
}
async function killSession(targetPid: number): Promise<{ success: boolean; error?: string }> {
function isPortFree(port: number): boolean {
try {
process.kill(targetPid, "SIGTERM");
// Wait for process to die
let attempts = 0;
while (isProcessAlive(targetPid) && attempts < 10) {
await sleep(100);
attempts++;
}
if (isProcessAlive(targetPid)) {
process.kill(targetPid, "SIGKILL");
}
// Remove lock and acquire
try { unlinkSync(LOCK_FILE); } catch {}
writeLock();
return { success: true };
// If we can connect, something is already listening.
const testSocket = Bun.connect({ port, timeout: 300 });
testSocket.end();
return false;
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : String(e) };
if ((e as any).code === "ECONNREFUSED") return true;
return false;
}
}
// ============================================================================
// WebSocket Server
// ============================================================================
function stopBrowserServer(): void {
try {
(ws as any)?.close?.();
} catch {
// Ignore
}
ws = null;
isConnected = false;
function checkPortAvailable(): boolean {
try {
const testSocket = Bun.connect({ port: WS_PORT, timeout: 1000 });
testSocket.end();
return true;
} catch (e) {
if ((e as any).code === "ECONNREFUSED") {
return false;
}
return true;
server?.stop();
} catch {
// Ignore
}
server = null;
}
function startServer(): boolean {
if (server) {
console.error(`[browser-plugin] Server already running`);
return true;
}
if (server) return true;
if (!isPortFree(WS_PORT)) return false;

@@ -189,3 +186,3 @@ try {

},
message(wsClient, data) {
message(_wsClient, data) {
try {

@@ -200,4 +197,4 @@ const message = JSON.parse(data.toString());

});
console.error(`[browser-plugin] WebSocket server listening on port ${WS_PORT}`);
serverFailed = false;
return true;

@@ -210,15 +207,70 @@ } catch (e) {

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) {
pendingRequests.delete(message.id);
if (message.error) {
pending.reject(new Error(message.error.content || String(message.error)));
} else {
pending.resolve(message.result?.content);
}
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);
}
} else if (message.type === "pong") {
// Heartbeat response, ignore
}

@@ -235,13 +287,83 @@ }

async function executeCommand(tool: string, args: Record<string, any>): Promise<any> {
// Check lock and start server if needed
const lockResult = tryAcquireLock();
if (!lockResult.success) {
throw new Error(
`${lockResult.error}. Use browser_kill_session to take over, or browser_status to see details.`
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.");

@@ -252,8 +374,16 @@ }

if (!isConnected) {
throw new Error(
"Chrome extension not connected. Make sure Chrome is running with the OpenCode Browser extension enabled."
);
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();

@@ -266,12 +396,10 @@ return new Promise((resolve, reject) => {

id,
tool,
tool: toolName,
args,
});
// Timeout after 60 seconds
setTimeout(() => {
if (pendingRequests.has(id)) {
pendingRequests.delete(id);
reject(new Error("Tool execution timed out after 60 seconds"));
}
if (!pendingRequests.has(id)) return;
pendingRequests.delete(id);
reject(new Error("Tool execution timed out after 60 seconds"));
}, 60000);

@@ -282,8 +410,15 @@ });

// ============================================================================
// Cleanup on exit
// 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();
server?.stop();
stopBrowserServer();
process.exit(0);

@@ -294,3 +429,3 @@ });

releaseLock();
server?.stop();
stopBrowserServer();
process.exit(0);

@@ -307,34 +442,5 @@ });

export const BrowserPlugin: Plugin = async (ctx) => {
export const BrowserPlugin: Plugin = async (_ctx) => {
console.error(`[browser-plugin] Initializing (session ${sessionId})`);
// Check port availability on load, don't try to acquire lock yet
checkPortAvailable();
// Check lock status and set appropriate state
const lock = readLock();
if (!lock) {
// No lock - just check if we can start server
console.error(`[browser-plugin] No lock file, checking port...`);
if (!startServer()) {
serverFailed = true;
}
} else if (lock.sessionId === sessionId) {
// We own the lock - start server
console.error(`[browser-plugin] Already have lock, starting server...`);
if (!startServer()) {
serverFailed = true;
}
} else if (!isProcessAlive(lock.pid)) {
// Stale lock - take it and start server
console.error(`[browser-plugin] Stale lock from dead PID ${lock.pid}, taking over...`);
writeLock();
if (!startServer()) {
serverFailed = true;
}
} else {
// Another session has the lock
console.error(`[browser-plugin] Lock held by PID ${lock.pid}, tools will fail until lock is released`);
}
return {

@@ -354,29 +460,73 @@ tool: {

if (lock.sessionId === sessionId) {
return `Browser connected (this session)\nPID: ${pid}\nStarted: ${lock.startedAt}\nExtension: ${isConnected ? "connected" : "not connected"}`;
return (
`Browser connected (this session)\n` +
`PID: ${pid}\n` +
`Started: ${lock.startedAt}\n` +
`Last used: ${lock.lastUsedAt}\n` +
`Extension: ${isConnected ? "connected" : "not connected"}`
);
}
if (!isProcessAlive(lock.pid)) {
return `Browser available (stale lock from dead PID ${lock.pid} will be auto-cleaned)`;
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\nPID: ${lock.pid}\nSession: ${lock.sessionId}\nStarted: ${lock.startedAt}\nWorking directory: ${lock.cwd}\n\nUse browser_kill_session to take over.`;
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:
"Kill the session that currently holds the browser lock and take over. Use when browser_status shows another session has the lock.",
"(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) {
// No lock, just acquire
writeLock();
// Start server if needed
if (!server) {
if (!startServer()) {
throw new Error("Failed to start WebSocket server after acquiring lock.");
}
}
return "No active session. Browser now connected to this session.";

@@ -390,25 +540,32 @@ }

if (!isProcessAlive(lock.pid)) {
// Stale lock
writeLock();
// Start server if needed
if (!server) {
if (!startServer()) {
throw new Error("Failed to start WebSocket server after cleaning stale lock.");
}
}
return `Cleaned stale lock (PID ${lock.pid} was dead). Browser now connected to this session.`;
}
// Kill other session and wait for port to be free
const result = await killSession(lock.pid);
if (result.success) {
if (!server) {
if (!startServer()) {
throw new Error("Failed to start WebSocket server after killing other 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.");
}
return `Killed session ${lock.sessionId} (PID ${lock.pid}). Browser now connected to this session.`;
} else {
throw new Error(`Failed to kill session: ${result.error}`);
}
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.`;
},

@@ -460,6 +617,6 @@ }),

},
async execute(args) {
async execute(args: { tabId?: number; name?: string }) {
const result = await executeCommand("screenshot", args);
if (result && result.startsWith("data:image")) {
if (result && typeof result === "string" && result.startsWith("data:image")) {
const base64Data = result.replace(/^data:image\/\w+;base64,/, "");

@@ -466,0 +623,0 @@ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");