@different-ai/opencode-browser 1.0.4 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- const DAEMON_URL = "ws://localhost:19222";
1
+ const PLUGIN_URL = "ws://localhost:19222";
2
2
  const KEEPALIVE_ALARM = "keepalive";
3
3
 
4
4
  let ws = null;
@@ -23,10 +23,10 @@ function connect() {
23
23
  }
24
24
 
25
25
  try {
26
- ws = new WebSocket(DAEMON_URL);
26
+ ws = new WebSocket(PLUGIN_URL);
27
27
 
28
28
  ws.onopen = () => {
29
- console.log("[OpenCode] Connected to daemon");
29
+ console.log("[OpenCode] Connected to plugin");
30
30
  isConnected = true;
31
31
  updateBadge(true);
32
32
  };
@@ -1,13 +1,12 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCode Browser Automation",
4
- "version": "1.0.0",
5
- "description": "Browser automation for OpenCode via Native Messaging",
4
+ "version": "2.0.0",
5
+ "description": "Browser automation for OpenCode",
6
6
  "permissions": [
7
7
  "tabs",
8
8
  "activeTab",
9
9
  "scripting",
10
- "nativeMessaging",
11
10
  "storage",
12
11
  "notifications",
13
12
  "alarms"
package/package.json CHANGED
@@ -1,14 +1,23 @@
1
1
  {
2
2
  "name": "@different-ai/opencode-browser",
3
- "version": "1.0.4",
4
- "description": "Browser automation for OpenCode via Chrome extension + Native Messaging. Inspired by Claude in Chrome.",
3
+ "version": "2.0.0",
4
+ "description": "Browser automation plugin for OpenCode. Control your real Chrome browser with existing logins and cookies.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "opencode-browser": "./bin/cli.js"
8
8
  },
9
- "main": "./src/server.js",
9
+ "main": "./src/plugin.ts",
10
+ "exports": {
11
+ ".": "./src/plugin.ts",
12
+ "./plugin": "./src/plugin.ts"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "src",
17
+ "extension",
18
+ "README.md"
19
+ ],
10
20
  "scripts": {
11
- "start": "node src/server.js",
12
21
  "install-extension": "node bin/cli.js install"
13
22
  },
14
23
  "keywords": [
@@ -16,8 +25,7 @@
16
25
  "browser",
17
26
  "automation",
18
27
  "chrome",
19
- "mcp",
20
- "claude"
28
+ "plugin"
21
29
  ],
22
30
  "author": "Benjamin Shafii",
23
31
  "license": "MIT",
@@ -29,8 +37,11 @@
29
37
  "url": "https://github.com/different-ai/opencode-browser/issues"
30
38
  },
31
39
  "homepage": "https://github.com/different-ai/opencode-browser#readme",
32
- "dependencies": {
33
- "@modelcontextprotocol/sdk": "^1.0.0",
34
- "ws": "^8.18.3"
40
+ "peerDependencies": {
41
+ "@opencode-ai/plugin": "*"
42
+ },
43
+ "devDependencies": {
44
+ "@opencode-ai/plugin": "*",
45
+ "bun-types": "*"
35
46
  }
36
47
  }
package/src/plugin.ts ADDED
@@ -0,0 +1,450 @@
1
+ /**
2
+ * OpenCode Browser Plugin
3
+ *
4
+ * A simple plugin that provides browser automation tools.
5
+ * Connects to Chrome extension via WebSocket.
6
+ *
7
+ * Architecture:
8
+ * OpenCode Plugin (this) <--WebSocket:19222--> Chrome Extension
9
+ *
10
+ * Lock file ensures only one OpenCode session uses browser at a time.
11
+ */
12
+
13
+ import type { Plugin } from "@opencode-ai/plugin";
14
+ import { tool } from "@opencode-ai/plugin";
15
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
16
+ import { homedir } from "os";
17
+ import { join } from "path";
18
+
19
+ const WS_PORT = 19222;
20
+ const BASE_DIR = join(homedir(), ".opencode-browser");
21
+ const LOCK_FILE = join(BASE_DIR, "lock.json");
22
+
23
+ // Ensure base dir exists
24
+ mkdirSync(BASE_DIR, { recursive: true });
25
+
26
+ // Session state
27
+ const sessionId = Math.random().toString(36).slice(2);
28
+ const pid = process.pid;
29
+ let ws: WebSocket | null = null;
30
+ let isConnected = false;
31
+ let server: ReturnType<typeof Bun.serve> | null = null;
32
+ let pendingRequests = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
33
+ let requestId = 0;
34
+ let hasLock = false;
35
+
36
+ // ============================================================================
37
+ // Lock File Management
38
+ // ============================================================================
39
+
40
+ interface LockInfo {
41
+ pid: number;
42
+ sessionId: string;
43
+ startedAt: string;
44
+ cwd: string;
45
+ }
46
+
47
+ function readLock(): LockInfo | null {
48
+ try {
49
+ if (!existsSync(LOCK_FILE)) return null;
50
+ return JSON.parse(readFileSync(LOCK_FILE, "utf-8"));
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function writeLock(): void {
57
+ writeFileSync(
58
+ LOCK_FILE,
59
+ JSON.stringify({
60
+ pid,
61
+ sessionId,
62
+ startedAt: new Date().toISOString(),
63
+ cwd: process.cwd(),
64
+ } satisfies LockInfo)
65
+ );
66
+ hasLock = true;
67
+ }
68
+
69
+ function releaseLock(): void {
70
+ try {
71
+ const lock = readLock();
72
+ if (lock && lock.sessionId === sessionId) {
73
+ unlinkSync(LOCK_FILE);
74
+ }
75
+ } catch {}
76
+ hasLock = false;
77
+ }
78
+
79
+ function isProcessAlive(targetPid: number): boolean {
80
+ try {
81
+ process.kill(targetPid, 0);
82
+ return true;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ function tryAcquireLock(): { success: boolean; error?: string; lock?: LockInfo } {
89
+ const existingLock = readLock();
90
+
91
+ if (!existingLock) {
92
+ writeLock();
93
+ return { success: true };
94
+ }
95
+
96
+ if (existingLock.sessionId === sessionId) {
97
+ return { success: true };
98
+ }
99
+
100
+ if (!isProcessAlive(existingLock.pid)) {
101
+ // Stale lock, take it
102
+ writeLock();
103
+ return { success: true };
104
+ }
105
+
106
+ return {
107
+ success: false,
108
+ error: `Browser locked by another session (PID ${existingLock.pid})`,
109
+ lock: existingLock,
110
+ };
111
+ }
112
+
113
+ function sleep(ms: number): Promise<void> {
114
+ return new Promise((resolve) => setTimeout(resolve, ms));
115
+ }
116
+
117
+ async function killSession(targetPid: number): Promise<{ success: boolean; error?: string }> {
118
+ try {
119
+ process.kill(targetPid, "SIGTERM");
120
+ // Wait a bit for process to die
121
+ let attempts = 0;
122
+ while (isProcessAlive(targetPid) && attempts < 10) {
123
+ await sleep(100);
124
+ attempts++;
125
+ }
126
+ if (isProcessAlive(targetPid)) {
127
+ process.kill(targetPid, "SIGKILL");
128
+ }
129
+ // Remove lock and acquire
130
+ try { unlinkSync(LOCK_FILE); } catch {}
131
+ writeLock();
132
+ return { success: true };
133
+ } catch (e) {
134
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
135
+ }
136
+ }
137
+
138
+ // ============================================================================
139
+ // WebSocket Server
140
+ // ============================================================================
141
+
142
+ function startServer(): boolean {
143
+ try {
144
+ server = Bun.serve({
145
+ port: WS_PORT,
146
+ fetch(req, server) {
147
+ if (server.upgrade(req)) return;
148
+ return new Response("OpenCode Browser Plugin", { status: 200 });
149
+ },
150
+ websocket: {
151
+ open(wsClient) {
152
+ console.error(`[browser-plugin] Chrome extension connected`);
153
+ ws = wsClient as unknown as WebSocket;
154
+ isConnected = true;
155
+ },
156
+ close() {
157
+ console.error(`[browser-plugin] Chrome extension disconnected`);
158
+ ws = null;
159
+ isConnected = false;
160
+ },
161
+ message(wsClient, data) {
162
+ try {
163
+ const message = JSON.parse(data.toString());
164
+ handleMessage(message);
165
+ } catch (e) {
166
+ console.error(`[browser-plugin] Parse error:`, e);
167
+ }
168
+ },
169
+ },
170
+ });
171
+ console.error(`[browser-plugin] WebSocket server listening on port ${WS_PORT}`);
172
+ return true;
173
+ } catch (e) {
174
+ console.error(`[browser-plugin] Failed to start server:`, e);
175
+ return false;
176
+ }
177
+ }
178
+
179
+ function handleMessage(message: { type: string; id?: number; result?: any; error?: any }) {
180
+ if (message.type === "tool_response" && message.id !== undefined) {
181
+ const pending = pendingRequests.get(message.id);
182
+ if (pending) {
183
+ pendingRequests.delete(message.id);
184
+ if (message.error) {
185
+ pending.reject(new Error(message.error.content || String(message.error)));
186
+ } else {
187
+ pending.resolve(message.result?.content);
188
+ }
189
+ }
190
+ } else if (message.type === "pong") {
191
+ // Heartbeat response, ignore
192
+ }
193
+ }
194
+
195
+ function sendToChrome(message: any): boolean {
196
+ if (ws && isConnected) {
197
+ (ws as any).send(JSON.stringify(message));
198
+ return true;
199
+ }
200
+ return false;
201
+ }
202
+
203
+ async function executeCommand(tool: string, args: Record<string, any>): Promise<any> {
204
+ // Check lock first
205
+ const lockResult = tryAcquireLock();
206
+ if (!lockResult.success) {
207
+ throw new Error(
208
+ `${lockResult.error}. Use browser_kill_session to take over, or browser_status to see details.`
209
+ );
210
+ }
211
+
212
+ // Start server if not running
213
+ if (!server) {
214
+ if (!startServer()) {
215
+ throw new Error("Failed to start WebSocket server. Port may be in use.");
216
+ }
217
+ }
218
+
219
+ if (!isConnected) {
220
+ throw new Error(
221
+ "Chrome extension not connected. Make sure Chrome is running with the OpenCode Browser extension enabled."
222
+ );
223
+ }
224
+
225
+ const id = ++requestId;
226
+
227
+ return new Promise((resolve, reject) => {
228
+ pendingRequests.set(id, { resolve, reject });
229
+
230
+ sendToChrome({
231
+ type: "tool_request",
232
+ id,
233
+ tool,
234
+ args,
235
+ });
236
+
237
+ // Timeout after 60 seconds
238
+ setTimeout(() => {
239
+ if (pendingRequests.has(id)) {
240
+ pendingRequests.delete(id);
241
+ reject(new Error("Tool execution timed out after 60 seconds"));
242
+ }
243
+ }, 60000);
244
+ });
245
+ }
246
+
247
+ // ============================================================================
248
+ // Cleanup on exit
249
+ // ============================================================================
250
+
251
+ process.on("SIGTERM", () => {
252
+ releaseLock();
253
+ server?.stop();
254
+ process.exit(0);
255
+ });
256
+
257
+ process.on("SIGINT", () => {
258
+ releaseLock();
259
+ server?.stop();
260
+ process.exit(0);
261
+ });
262
+
263
+ process.on("exit", () => {
264
+ releaseLock();
265
+ });
266
+
267
+ // ============================================================================
268
+ // Plugin Export
269
+ // ============================================================================
270
+
271
+ export const BrowserPlugin: Plugin = async (ctx) => {
272
+ console.error(`[browser-plugin] Initializing (session ${sessionId})`);
273
+
274
+ // Try to acquire lock and start server on load
275
+ const lockResult = tryAcquireLock();
276
+ if (lockResult.success) {
277
+ startServer();
278
+ } else {
279
+ console.error(`[browser-plugin] Lock held by PID ${lockResult.lock?.pid}, tools will fail until lock is released`);
280
+ }
281
+
282
+ return {
283
+ tool: {
284
+ browser_status: tool({
285
+ description:
286
+ "Check if browser is available or locked by another session. Returns connection status and lock info.",
287
+ args: {},
288
+ async execute() {
289
+ const lock = readLock();
290
+
291
+ if (!lock) {
292
+ return "Browser available (no active session)";
293
+ }
294
+
295
+ if (lock.sessionId === sessionId) {
296
+ return `Browser connected (this session)\nPID: ${pid}\nStarted: ${lock.startedAt}\nExtension: ${isConnected ? "connected" : "not connected"}`;
297
+ }
298
+
299
+ if (!isProcessAlive(lock.pid)) {
300
+ return `Browser available (stale lock from dead PID ${lock.pid} will be auto-cleaned)`;
301
+ }
302
+
303
+ 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.`;
304
+ },
305
+ }),
306
+
307
+ browser_kill_session: tool({
308
+ description:
309
+ "Kill the session that currently holds the browser lock and take over. Use when browser_status shows another session has the lock.",
310
+ args: {},
311
+ async execute() {
312
+ const lock = readLock();
313
+
314
+ if (!lock) {
315
+ // No lock, just acquire
316
+ writeLock();
317
+ if (!server) startServer();
318
+ return "No active session. Browser now connected to this session.";
319
+ }
320
+
321
+ if (lock.sessionId === sessionId) {
322
+ return "This session already owns the browser.";
323
+ }
324
+
325
+ if (!isProcessAlive(lock.pid)) {
326
+ // Stale lock
327
+ writeLock();
328
+ if (!server) startServer();
329
+ return `Cleaned stale lock (PID ${lock.pid} was dead). Browser now connected to this session.`;
330
+ }
331
+
332
+ // Kill the other session
333
+ const result = await killSession(lock.pid);
334
+ if (result.success) {
335
+ if (!server) startServer();
336
+ return `Killed session ${lock.sessionId} (PID ${lock.pid}). Browser now connected to this session.`;
337
+ } else {
338
+ throw new Error(`Failed to kill session: ${result.error}`);
339
+ }
340
+ },
341
+ }),
342
+
343
+ browser_navigate: tool({
344
+ description: "Navigate to a URL in the browser",
345
+ args: {
346
+ url: tool.schema.string({ description: "The URL to navigate to" }),
347
+ tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
348
+ },
349
+ async execute(args) {
350
+ return await executeCommand("navigate", args);
351
+ },
352
+ }),
353
+
354
+ browser_click: tool({
355
+ description: "Click an element on the page using a CSS selector",
356
+ args: {
357
+ selector: tool.schema.string({ description: "CSS selector for the element to click" }),
358
+ tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
359
+ },
360
+ async execute(args) {
361
+ return await executeCommand("click", args);
362
+ },
363
+ }),
364
+
365
+ browser_type: tool({
366
+ description: "Type text into an input element",
367
+ args: {
368
+ selector: tool.schema.string({ description: "CSS selector for the input element" }),
369
+ text: tool.schema.string({ description: "Text to type" }),
370
+ clear: tool.schema.optional(tool.schema.boolean({ description: "Clear field before typing" })),
371
+ tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
372
+ },
373
+ async execute(args) {
374
+ return await executeCommand("type", args);
375
+ },
376
+ }),
377
+
378
+ browser_screenshot: tool({
379
+ description: "Take a screenshot of the current page",
380
+ args: {
381
+ tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
382
+ },
383
+ async execute(args) {
384
+ const result = await executeCommand("screenshot", args);
385
+ // Return as base64 image
386
+ if (result && result.startsWith("data:image")) {
387
+ const base64Data = result.replace(/^data:image\/\w+;base64,/, "");
388
+ return { type: "image", data: base64Data, mimeType: "image/png" };
389
+ }
390
+ return result;
391
+ },
392
+ }),
393
+
394
+ browser_snapshot: tool({
395
+ description:
396
+ "Get an accessibility tree snapshot of the page. Returns interactive elements with selectors for clicking.",
397
+ args: {
398
+ tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
399
+ },
400
+ async execute(args) {
401
+ return await executeCommand("snapshot", args);
402
+ },
403
+ }),
404
+
405
+ browser_get_tabs: tool({
406
+ description: "List all open browser tabs",
407
+ args: {},
408
+ async execute() {
409
+ return await executeCommand("get_tabs", {});
410
+ },
411
+ }),
412
+
413
+ browser_scroll: tool({
414
+ description: "Scroll the page or scroll an element into view",
415
+ args: {
416
+ selector: tool.schema.optional(tool.schema.string({ description: "CSS selector to scroll into view" })),
417
+ x: tool.schema.optional(tool.schema.number({ description: "Horizontal scroll amount in pixels" })),
418
+ y: tool.schema.optional(tool.schema.number({ description: "Vertical scroll amount in pixels" })),
419
+ tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
420
+ },
421
+ async execute(args) {
422
+ return await executeCommand("scroll", args);
423
+ },
424
+ }),
425
+
426
+ browser_wait: tool({
427
+ description: "Wait for a specified duration",
428
+ args: {
429
+ ms: tool.schema.optional(tool.schema.number({ description: "Milliseconds to wait (default: 1000)" })),
430
+ },
431
+ async execute(args) {
432
+ return await executeCommand("wait", args);
433
+ },
434
+ }),
435
+
436
+ browser_execute: tool({
437
+ description: "Execute JavaScript code in the page context and return the result",
438
+ args: {
439
+ code: tool.schema.string({ description: "JavaScript code to execute" }),
440
+ tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
441
+ },
442
+ async execute(args) {
443
+ return await executeCommand("execute_script", args);
444
+ },
445
+ }),
446
+ },
447
+ };
448
+ };
449
+
450
+ export default BrowserPlugin;