@different-ai/opencode-browser 2.0.2 → 3.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.
package/src/plugin.ts DELETED
@@ -1,515 +0,0 @@
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
- const SCREENSHOTS_DIR = join(BASE_DIR, "screenshots");
23
-
24
- // Ensure directories exist
25
- mkdirSync(BASE_DIR, { recursive: true });
26
- mkdirSync(SCREENSHOTS_DIR, { recursive: true });
27
-
28
- // Session state
29
- const sessionId = Math.random().toString(36).slice(2);
30
- const pid = process.pid;
31
- let ws: WebSocket | null = null;
32
- let isConnected = false;
33
- let server: ReturnType<typeof Bun.serve> | null = null;
34
- let pendingRequests = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
35
- let requestId = 0;
36
- let hasLock = false;
37
- let serverFailed = false;
38
-
39
- // ============================================================================
40
- // Lock File Management
41
- // ============================================================================
42
-
43
- interface LockInfo {
44
- pid: number;
45
- sessionId: string;
46
- startedAt: string;
47
- cwd: string;
48
- }
49
-
50
- function readLock(): LockInfo | null {
51
- try {
52
- if (!existsSync(LOCK_FILE)) return null;
53
- return JSON.parse(readFileSync(LOCK_FILE, "utf-8"));
54
- } catch {
55
- return null;
56
- }
57
- }
58
-
59
- function writeLock(): void {
60
- writeFileSync(
61
- LOCK_FILE,
62
- JSON.stringify({
63
- pid,
64
- sessionId,
65
- startedAt: new Date().toISOString(),
66
- cwd: process.cwd(),
67
- } satisfies LockInfo)
68
- );
69
- hasLock = true;
70
- }
71
-
72
- function releaseLock(): void {
73
- try {
74
- const lock = readLock();
75
- if (lock && lock.sessionId === sessionId) {
76
- unlinkSync(LOCK_FILE);
77
- }
78
- } catch {}
79
- hasLock = false;
80
- }
81
-
82
- function isProcessAlive(targetPid: number): boolean {
83
- try {
84
- process.kill(targetPid, 0);
85
- return true;
86
- } catch {
87
- return false;
88
- }
89
- }
90
-
91
- function tryAcquireLock(): { success: boolean; error?: string; lock?: LockInfo } {
92
- const existingLock = readLock();
93
-
94
- if (!existingLock) {
95
- writeLock();
96
- return { success: true };
97
- }
98
-
99
- if (existingLock.sessionId === sessionId) {
100
- return { success: true };
101
- }
102
-
103
- if (!isProcessAlive(existingLock.pid)) {
104
- // Stale lock, take it
105
- writeLock();
106
- return { success: true };
107
- }
108
-
109
- return {
110
- success: false,
111
- error: `Browser locked by another session (PID ${existingLock.pid})`,
112
- lock: existingLock,
113
- };
114
- }
115
-
116
- function sleep(ms: number): Promise<void> {
117
- return new Promise((resolve) => setTimeout(resolve, ms));
118
- }
119
-
120
- async function killSession(targetPid: number): Promise<{ success: boolean; error?: string }> {
121
- try {
122
- process.kill(targetPid, "SIGTERM");
123
- // Wait for process to die
124
- let attempts = 0;
125
- while (isProcessAlive(targetPid) && attempts < 10) {
126
- await sleep(100);
127
- attempts++;
128
- }
129
- if (isProcessAlive(targetPid)) {
130
- process.kill(targetPid, "SIGKILL");
131
- }
132
- // Remove lock and acquire
133
- try { unlinkSync(LOCK_FILE); } catch {}
134
- writeLock();
135
- return { success: true };
136
- } catch (e) {
137
- return { success: false, error: e instanceof Error ? e.message : String(e) };
138
- }
139
- }
140
-
141
- // ============================================================================
142
- // WebSocket Server
143
- // ============================================================================
144
-
145
- function checkPortAvailable(): boolean {
146
- try {
147
- const testSocket = Bun.connect({ port: WS_PORT, timeout: 1000 });
148
- testSocket.end();
149
- return true;
150
- } catch (e) {
151
- if ((e as any).code === "ECONNREFUSED") {
152
- return false;
153
- }
154
- return true;
155
- }
156
- }
157
-
158
- function startServer(): boolean {
159
- if (server) {
160
- console.error(`[browser-plugin] Server already running`);
161
- return true;
162
- }
163
-
164
- try {
165
- server = Bun.serve({
166
- port: WS_PORT,
167
- fetch(req, server) {
168
- if (server.upgrade(req)) return;
169
- return new Response("OpenCode Browser Plugin", { status: 200 });
170
- },
171
- websocket: {
172
- open(wsClient) {
173
- console.error(`[browser-plugin] Chrome extension connected`);
174
- ws = wsClient as unknown as WebSocket;
175
- isConnected = true;
176
- },
177
- close() {
178
- console.error(`[browser-plugin] Chrome extension disconnected`);
179
- ws = null;
180
- isConnected = false;
181
- },
182
- message(wsClient, data) {
183
- try {
184
- const message = JSON.parse(data.toString());
185
- handleMessage(message);
186
- } catch (e) {
187
- console.error(`[browser-plugin] Parse error:`, e);
188
- }
189
- },
190
- },
191
- });
192
- console.error(`[browser-plugin] WebSocket server listening on port ${WS_PORT}`);
193
- serverFailed = false;
194
- return true;
195
- } catch (e) {
196
- console.error(`[browser-plugin] Failed to start server:`, e);
197
- return false;
198
- }
199
- }
200
-
201
- function handleMessage(message: { type: string; id?: number; result?: any; error?: any }): void {
202
- if (message.type === "tool_response" && message.id !== undefined) {
203
- const pending = pendingRequests.get(message.id);
204
- if (pending) {
205
- pendingRequests.delete(message.id);
206
- if (message.error) {
207
- pending.reject(new Error(message.error.content || String(message.error)));
208
- } else {
209
- pending.resolve(message.result?.content);
210
- }
211
- }
212
- } else if (message.type === "pong") {
213
- // Heartbeat response, ignore
214
- }
215
- }
216
-
217
- function sendToChrome(message: any): boolean {
218
- if (ws && isConnected) {
219
- (ws as any).send(JSON.stringify(message));
220
- return true;
221
- }
222
- return false;
223
- }
224
-
225
- async function executeCommand(tool: string, args: Record<string, any>): Promise<any> {
226
- // Check lock and start server if needed
227
- const lockResult = tryAcquireLock();
228
- if (!lockResult.success) {
229
- throw new Error(
230
- `${lockResult.error}. Use browser_kill_session to take over, or browser_status to see details.`
231
- );
232
- }
233
-
234
- if (!server) {
235
- if (!startServer()) {
236
- throw new Error("Failed to start WebSocket server. Port may be in use.");
237
- }
238
- }
239
-
240
- if (!isConnected) {
241
- throw new Error(
242
- "Chrome extension not connected. Make sure Chrome is running with the OpenCode Browser extension enabled."
243
- );
244
- }
245
-
246
- const id = ++requestId;
247
-
248
- return new Promise((resolve, reject) => {
249
- pendingRequests.set(id, { resolve, reject });
250
-
251
- sendToChrome({
252
- type: "tool_request",
253
- id,
254
- tool,
255
- args,
256
- });
257
-
258
- // Timeout after 60 seconds
259
- setTimeout(() => {
260
- if (pendingRequests.has(id)) {
261
- pendingRequests.delete(id);
262
- reject(new Error("Tool execution timed out after 60 seconds"));
263
- }
264
- }, 60000);
265
- });
266
- }
267
-
268
- // ============================================================================
269
- // Cleanup on exit
270
- // ============================================================================
271
-
272
- process.on("SIGTERM", () => {
273
- releaseLock();
274
- server?.stop();
275
- process.exit(0);
276
- });
277
-
278
- process.on("SIGINT", () => {
279
- releaseLock();
280
- server?.stop();
281
- process.exit(0);
282
- });
283
-
284
- process.on("exit", () => {
285
- releaseLock();
286
- });
287
-
288
- // ============================================================================
289
- // Plugin Export
290
- // ============================================================================
291
-
292
- export const BrowserPlugin: Plugin = async (ctx) => {
293
- console.error(`[browser-plugin] Initializing (session ${sessionId})`);
294
-
295
- // Check port availability on load, don't try to acquire lock yet
296
- checkPortAvailable();
297
-
298
- // Check lock status and set appropriate state
299
- const lock = readLock();
300
- if (!lock) {
301
- // No lock - just check if we can start server
302
- console.error(`[browser-plugin] No lock file, checking port...`);
303
- if (!startServer()) {
304
- serverFailed = true;
305
- }
306
- } else if (lock.sessionId === sessionId) {
307
- // We own the lock - start server
308
- console.error(`[browser-plugin] Already have lock, starting server...`);
309
- if (!startServer()) {
310
- serverFailed = true;
311
- }
312
- } else if (!isProcessAlive(lock.pid)) {
313
- // Stale lock - take it and start server
314
- console.error(`[browser-plugin] Stale lock from dead PID ${lock.pid}, taking over...`);
315
- writeLock();
316
- if (!startServer()) {
317
- serverFailed = true;
318
- }
319
- } else {
320
- // Another session has the lock
321
- console.error(`[browser-plugin] Lock held by PID ${lock.pid}, tools will fail until lock is released`);
322
- }
323
-
324
- return {
325
- tool: {
326
- browser_status: tool({
327
- description:
328
- "Check if browser is available or locked by another session. Returns connection status and lock info.",
329
- args: {},
330
- async execute() {
331
- const lock = readLock();
332
-
333
- if (!lock) {
334
- return "Browser available (no active session)";
335
- }
336
-
337
- if (lock.sessionId === sessionId) {
338
- return `Browser connected (this session)\nPID: ${pid}\nStarted: ${lock.startedAt}\nExtension: ${isConnected ? "connected" : "not connected"}`;
339
- }
340
-
341
- if (!isProcessAlive(lock.pid)) {
342
- return `Browser available (stale lock from dead PID ${lock.pid} will be auto-cleaned)`;
343
- }
344
-
345
- 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.`;
346
- },
347
- }),
348
-
349
- browser_kill_session: tool({
350
- description:
351
- "Kill the session that currently holds the browser lock and take over. Use when browser_status shows another session has the lock.",
352
- args: {},
353
- async execute() {
354
- const lock = readLock();
355
-
356
- if (!lock) {
357
- // No lock, just acquire
358
- writeLock();
359
- // Start server if needed
360
- if (!server) {
361
- if (!startServer()) {
362
- throw new Error("Failed to start WebSocket server after acquiring lock.");
363
- }
364
- }
365
- return "No active session. Browser now connected to this session.";
366
- }
367
-
368
- if (lock.sessionId === sessionId) {
369
- return "This session already owns the browser.";
370
- }
371
-
372
- if (!isProcessAlive(lock.pid)) {
373
- // Stale lock
374
- writeLock();
375
- // Start server if needed
376
- if (!server) {
377
- if (!startServer()) {
378
- throw new Error("Failed to start WebSocket server after cleaning stale lock.");
379
- }
380
- }
381
- return `Cleaned stale lock (PID ${lock.pid} was dead). Browser now connected to this session.`;
382
- }
383
-
384
- // Kill other session and wait for port to be free
385
- const result = await killSession(lock.pid);
386
- if (result.success) {
387
- if (!server) {
388
- if (!startServer()) {
389
- throw new Error("Failed to start WebSocket server after killing other session.");
390
- }
391
- }
392
- return `Killed session ${lock.sessionId} (PID ${lock.pid}). Browser now connected to this session.`;
393
- } else {
394
- throw new Error(`Failed to kill session: ${result.error}`);
395
- }
396
- },
397
- }),
398
-
399
- browser_navigate: tool({
400
- description: "Navigate to a URL in browser",
401
- args: {
402
- url: tool.schema.string({ description: "The URL to navigate to" }),
403
- tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
404
- },
405
- async execute(args) {
406
- return await executeCommand("navigate", args);
407
- },
408
- }),
409
-
410
- browser_click: tool({
411
- description: "Click an element on page using a CSS selector",
412
- args: {
413
- selector: tool.schema.string({ description: "CSS selector for element to click" }),
414
- tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
415
- },
416
- async execute(args) {
417
- return await executeCommand("click", args);
418
- },
419
- }),
420
-
421
- browser_type: tool({
422
- description: "Type text into an input element",
423
- args: {
424
- selector: tool.schema.string({ description: "CSS selector for input element" }),
425
- text: tool.schema.string({ description: "Text to type" }),
426
- clear: tool.schema.optional(tool.schema.boolean({ description: "Clear field before typing" })),
427
- tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
428
- },
429
- async execute(args) {
430
- return await executeCommand("type", args);
431
- },
432
- }),
433
-
434
- browser_screenshot: tool({
435
- description: "Take a screenshot of the current page. Saves to ~/.opencode-browser/screenshots/",
436
- args: {
437
- tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
438
- name: tool.schema.optional(
439
- tool.schema.string({ description: "Optional name for screenshot file (without extension)" })
440
- ),
441
- },
442
- async execute(args) {
443
- const result = await executeCommand("screenshot", args);
444
-
445
- if (result && result.startsWith("data:image")) {
446
- const base64Data = result.replace(/^data:image\/\w+;base64,/, "");
447
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
448
- const filename = args.name ? `${args.name}.png` : `screenshot-${timestamp}.png`;
449
- const filepath = join(SCREENSHOTS_DIR, filename);
450
-
451
- writeFileSync(filepath, Buffer.from(base64Data, "base64"));
452
- return `Screenshot saved: ${filepath}`;
453
- }
454
-
455
- return result;
456
- },
457
- }),
458
-
459
- browser_snapshot: tool({
460
- description:
461
- "Get an accessibility tree snapshot of the page. Returns interactive elements with selectors for clicking.",
462
- args: {
463
- tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
464
- },
465
- async execute(args) {
466
- return await executeCommand("snapshot", args);
467
- },
468
- }),
469
-
470
- browser_get_tabs: tool({
471
- description: "List all open browser tabs",
472
- args: {},
473
- async execute() {
474
- return await executeCommand("get_tabs", {});
475
- },
476
- }),
477
-
478
- browser_scroll: tool({
479
- description: "Scroll the page or scroll an element into view",
480
- args: {
481
- selector: tool.schema.optional(tool.schema.string({ description: "CSS selector to scroll into view" })),
482
- x: tool.schema.optional(tool.schema.number({ description: "Horizontal scroll amount in pixels" })),
483
- y: tool.schema.optional(tool.schema.number({ description: "Vertical scroll amount in pixels" })),
484
- tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
485
- },
486
- async execute(args) {
487
- return await executeCommand("scroll", args);
488
- },
489
- }),
490
-
491
- browser_wait: tool({
492
- description: "Wait for a specified duration",
493
- args: {
494
- ms: tool.schema.optional(tool.schema.number({ description: "Milliseconds to wait (default: 1000)" })),
495
- },
496
- async execute(args) {
497
- return await executeCommand("wait", args);
498
- },
499
- }),
500
-
501
- browser_execute: tool({
502
- description: "Execute JavaScript code in the page context and return the result",
503
- args: {
504
- code: tool.schema.string({ description: "JavaScript code to execute" }),
505
- tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
506
- },
507
- async execute(args) {
508
- return await executeCommand("execute_script", args);
509
- },
510
- }),
511
- },
512
- };
513
- };
514
-
515
- export default BrowserPlugin;