@different-ai/opencode-browser 2.0.1 → 2.1.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.
Files changed (3) hide show
  1. package/README.md +6 -2
  2. package/package.json +1 -1
  3. package/src/plugin.ts +345 -134
package/README.md CHANGED
@@ -41,7 +41,9 @@ Then load the extension in Chrome:
41
41
  | Tool | Description |
42
42
  |------|-------------|
43
43
  | `browser_status` | Check if browser is available or locked |
44
- | `browser_kill_session` | Take over from another OpenCode session |
44
+ | `browser_kill_session` | Request other session release + take over (no kill) |
45
+ | `browser_release` | Release lock and stop server |
46
+ | `browser_force_kill_session` | (Last resort) kill other OpenCode process |
45
47
  | `browser_navigate` | Navigate to a URL |
46
48
  | `browser_click` | Click an element by CSS selector |
47
49
  | `browser_type` | Type text into an input field |
@@ -57,7 +59,9 @@ Then load the extension in Chrome:
57
59
  Only one OpenCode session can use the browser at a time. This prevents conflicts when you have multiple terminals open.
58
60
 
59
61
  - `browser_status` - Check who has the lock
60
- - `browser_kill_session` - Kill the other session and take over
62
+ - `browser_kill_session` - Request the other session to release (no kill)
63
+ - `browser_release` - Release lock/server for this session
64
+ - `browser_force_kill_session` - (Last resort) kill the other OpenCode process and take over
61
65
 
62
66
  In your prompts, you can say:
63
67
  - "If browser is locked, kill the session and proceed"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@different-ai/opencode-browser",
3
- "version": "2.0.1",
3
+ "version": "2.1.0",
4
4
  "description": "Browser automation plugin for OpenCode. Control your real Chrome browser with existing logins and cookies.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/plugin.ts CHANGED
@@ -1,18 +1,16 @@
1
1
  /**
2
2
  * OpenCode Browser Plugin
3
3
  *
4
- * A simple plugin that provides browser automation tools.
5
- * Connects to Chrome extension via WebSocket.
4
+ * OpenCode Plugin (this) <--WebSocket:19222--> Chrome Extension
6
5
  *
7
- * Architecture:
8
- * OpenCode Plugin (this) <--WebSocket:19222--> Chrome Extension
9
- *
10
- * Lock file ensures only one OpenCode session uses browser at a time.
6
+ * Notes
7
+ * - Uses a lock file so only one OpenCode session owns the browser.
8
+ * - Supports a *soft takeover* (SIGUSR1) so we don't have to kill OpenCode.
11
9
  */
12
10
 
13
11
  import type { Plugin } from "@opencode-ai/plugin";
14
12
  import { tool } from "@opencode-ai/plugin";
15
- import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
13
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
16
14
  import { homedir } from "os";
17
15
  import { join } from "path";
18
16
 
@@ -21,7 +19,9 @@ const BASE_DIR = join(homedir(), ".opencode-browser");
21
19
  const LOCK_FILE = join(BASE_DIR, "lock.json");
22
20
  const SCREENSHOTS_DIR = join(BASE_DIR, "screenshots");
23
21
 
24
- // Ensure directories exist
22
+ // If a session hasn't used the browser in this long, allow soft takeover by default.
23
+ const LOCK_TTL_MS = 2 * 60 * 60 * 1000; // 2 hours
24
+
25
25
  mkdirSync(BASE_DIR, { recursive: true });
26
26
  mkdirSync(SCREENSHOTS_DIR, { recursive: true });
27
27
 
@@ -33,19 +33,19 @@ let isConnected = false;
33
33
  let server: ReturnType<typeof Bun.serve> | null = null;
34
34
  let pendingRequests = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
35
35
  let requestId = 0;
36
- let hasLock = false;
37
-
38
- // ============================================================================
39
- // Lock File Management
40
- // ============================================================================
41
36
 
42
37
  interface LockInfo {
43
38
  pid: number;
44
39
  sessionId: string;
45
40
  startedAt: string;
41
+ lastUsedAt: string;
46
42
  cwd: string;
47
43
  }
48
44
 
45
+ function nowIso(): string {
46
+ return new Date().toISOString();
47
+ }
48
+
49
49
  function readLock(): LockInfo | null {
50
50
  try {
51
51
  if (!existsSync(LOCK_FILE)) return null;
@@ -58,14 +58,40 @@ function readLock(): LockInfo | null {
58
58
  function writeLock(): void {
59
59
  writeFileSync(
60
60
  LOCK_FILE,
61
- JSON.stringify({
62
- pid,
63
- sessionId,
64
- startedAt: new Date().toISOString(),
65
- cwd: process.cwd(),
66
- } satisfies LockInfo)
61
+ JSON.stringify(
62
+ {
63
+ pid,
64
+ sessionId,
65
+ startedAt: nowIso(),
66
+ lastUsedAt: nowIso(),
67
+ cwd: process.cwd(),
68
+ } satisfies LockInfo,
69
+ null,
70
+ 2
71
+ ) + "\n"
67
72
  );
68
- hasLock = true;
73
+ }
74
+
75
+ function touchLock(): void {
76
+ const lock = readLock();
77
+ if (!lock) return;
78
+ if (lock.sessionId !== sessionId) return;
79
+
80
+ try {
81
+ writeFileSync(
82
+ LOCK_FILE,
83
+ JSON.stringify(
84
+ {
85
+ ...lock,
86
+ lastUsedAt: nowIso(),
87
+ } satisfies LockInfo,
88
+ null,
89
+ 2
90
+ ) + "\n"
91
+ );
92
+ } catch {
93
+ // Ignore
94
+ }
69
95
  }
70
96
 
71
97
  function releaseLock(): void {
@@ -74,8 +100,9 @@ function releaseLock(): void {
74
100
  if (lock && lock.sessionId === sessionId) {
75
101
  unlinkSync(LOCK_FILE);
76
102
  }
77
- } catch {}
78
- hasLock = false;
103
+ } catch {
104
+ // Ignore
105
+ }
79
106
  }
80
107
 
81
108
  function isProcessAlive(targetPid: number): boolean {
@@ -87,61 +114,50 @@ function isProcessAlive(targetPid: number): boolean {
87
114
  }
88
115
  }
89
116
 
90
- function tryAcquireLock(): { success: boolean; error?: string; lock?: LockInfo } {
91
- const existingLock = readLock();
92
-
93
- if (!existingLock) {
94
- writeLock();
95
- return { success: true };
96
- }
97
-
98
- if (existingLock.sessionId === sessionId) {
99
- return { success: true };
100
- }
101
-
102
- if (!isProcessAlive(existingLock.pid)) {
103
- // Stale lock, take it
104
- writeLock();
105
- return { success: true };
106
- }
107
-
108
- return {
109
- success: false,
110
- error: `Browser locked by another session (PID ${existingLock.pid})`,
111
- lock: existingLock,
112
- };
117
+ function lockAgeMs(lock: LockInfo): number {
118
+ const ts = lock.lastUsedAt || lock.startedAt;
119
+ const n = Date.parse(ts);
120
+ if (Number.isNaN(n)) return Number.POSITIVE_INFINITY;
121
+ return Date.now() - n;
113
122
  }
114
123
 
115
- function sleep(ms: number): Promise<void> {
116
- return new Promise((resolve) => setTimeout(resolve, ms));
124
+ function isLockExpired(lock: LockInfo): boolean {
125
+ return lockAgeMs(lock) > LOCK_TTL_MS;
117
126
  }
118
127
 
119
- async function killSession(targetPid: number): Promise<{ success: boolean; error?: string }> {
128
+ function isPortFree(port: number): boolean {
120
129
  try {
121
- process.kill(targetPid, "SIGTERM");
122
- // Wait a bit for process to die
123
- let attempts = 0;
124
- while (isProcessAlive(targetPid) && attempts < 10) {
125
- await sleep(100);
126
- attempts++;
127
- }
128
- if (isProcessAlive(targetPid)) {
129
- process.kill(targetPid, "SIGKILL");
130
- }
131
- // Remove lock and acquire
132
- try { unlinkSync(LOCK_FILE); } catch {}
133
- writeLock();
134
- return { success: true };
130
+ // If we can connect, something is already listening.
131
+ const testSocket = Bun.connect({ port, timeout: 300 });
132
+ testSocket.end();
133
+ return false;
135
134
  } catch (e) {
136
- return { success: false, error: e instanceof Error ? e.message : String(e) };
135
+ if ((e as any).code === "ECONNREFUSED") return true;
136
+ return false;
137
137
  }
138
138
  }
139
139
 
140
- // ============================================================================
141
- // WebSocket Server
142
- // ============================================================================
140
+ function stopBrowserServer(): void {
141
+ try {
142
+ (ws as any)?.close?.();
143
+ } catch {
144
+ // Ignore
145
+ }
146
+ ws = null;
147
+ isConnected = false;
148
+
149
+ try {
150
+ server?.stop();
151
+ } catch {
152
+ // Ignore
153
+ }
154
+ server = null;
155
+ }
143
156
 
144
157
  function startServer(): boolean {
158
+ if (server) return true;
159
+ if (!isPortFree(WS_PORT)) return false;
160
+
145
161
  try {
146
162
  server = Bun.serve({
147
163
  port: WS_PORT,
@@ -160,7 +176,7 @@ function startServer(): boolean {
160
176
  ws = null;
161
177
  isConnected = false;
162
178
  },
163
- message(wsClient, data) {
179
+ message(_wsClient, data) {
164
180
  try {
165
181
  const message = JSON.parse(data.toString());
166
182
  handleMessage(message);
@@ -170,6 +186,7 @@ function startServer(): boolean {
170
186
  },
171
187
  },
172
188
  });
189
+
173
190
  console.error(`[browser-plugin] WebSocket server listening on port ${WS_PORT}`);
174
191
  return true;
175
192
  } catch (e) {
@@ -178,19 +195,74 @@ function startServer(): boolean {
178
195
  }
179
196
  }
180
197
 
181
- function handleMessage(message: { type: string; id?: number; result?: any; error?: any }) {
198
+ function sleep(ms: number): Promise<void> {
199
+ return new Promise((resolve) => setTimeout(resolve, ms));
200
+ }
201
+
202
+ async function waitForExtensionConnection(timeoutMs: number): Promise<boolean> {
203
+ const start = Date.now();
204
+ while (Date.now() - start < timeoutMs) {
205
+ if (isConnected) return true;
206
+ await sleep(100);
207
+ }
208
+ return isConnected;
209
+ }
210
+
211
+ async function requestSessionRelease(targetPid: number, opts?: { timeoutMs?: number }): Promise<{ success: boolean; error?: string }> {
212
+ const timeoutMs = opts?.timeoutMs ?? 3000;
213
+
214
+ try {
215
+ // SIGUSR1 is treated as "release browser lock + stop server".
216
+ // This does NOT terminate OpenCode.
217
+ process.kill(targetPid, "SIGUSR1");
218
+ } catch (e) {
219
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
220
+ }
221
+
222
+ const start = Date.now();
223
+ while (Date.now() - start < timeoutMs) {
224
+ const lock = readLock();
225
+ const lockCleared = !lock || lock.pid !== targetPid;
226
+ const portCleared = isPortFree(WS_PORT);
227
+
228
+ if (lockCleared && portCleared) return { success: true };
229
+ await sleep(100);
230
+ }
231
+
232
+ return {
233
+ success: false,
234
+ error: `Timed out waiting for PID ${targetPid} to release browser`,
235
+ };
236
+ }
237
+
238
+ async function forceKillSession(targetPid: number): Promise<{ success: boolean; error?: string }> {
239
+ try {
240
+ process.kill(targetPid, "SIGTERM");
241
+ let attempts = 0;
242
+ while (isProcessAlive(targetPid) && attempts < 20) {
243
+ await sleep(100);
244
+ attempts++;
245
+ }
246
+ if (isProcessAlive(targetPid)) {
247
+ process.kill(targetPid, "SIGKILL");
248
+ }
249
+ return { success: true };
250
+ } catch (e) {
251
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
252
+ }
253
+ }
254
+
255
+ function handleMessage(message: { type: string; id?: number; result?: any; error?: any }): void {
182
256
  if (message.type === "tool_response" && message.id !== undefined) {
183
257
  const pending = pendingRequests.get(message.id);
184
- if (pending) {
185
- pendingRequests.delete(message.id);
186
- if (message.error) {
187
- pending.reject(new Error(message.error.content || String(message.error)));
188
- } else {
189
- pending.resolve(message.result?.content);
190
- }
258
+ if (!pending) return;
259
+
260
+ pendingRequests.delete(message.id);
261
+ if (message.error) {
262
+ pending.reject(new Error(message.error.content || String(message.error)));
263
+ } else {
264
+ pending.resolve(message.result?.content);
191
265
  }
192
- } else if (message.type === "pong") {
193
- // Heartbeat response, ignore
194
266
  }
195
267
  }
196
268
 
@@ -202,16 +274,85 @@ function sendToChrome(message: any): boolean {
202
274
  return false;
203
275
  }
204
276
 
205
- async function executeCommand(tool: string, args: Record<string, any>): Promise<any> {
206
- // Check lock first
207
- const lockResult = tryAcquireLock();
208
- if (!lockResult.success) {
209
- throw new Error(
210
- `${lockResult.error}. Use browser_kill_session to take over, or browser_status to see details.`
277
+ async function performTakeover(): Promise<string> {
278
+ const lock = readLock();
279
+
280
+ if (!lock) {
281
+ writeLock();
282
+ } else if (lock.sessionId === sessionId) {
283
+ // Already ours.
284
+ } else if (!isProcessAlive(lock.pid)) {
285
+ // Dead PID -> stale.
286
+ console.error(`[browser-plugin] Cleaning stale lock from dead PID ${lock.pid}`);
287
+ writeLock();
288
+ } else {
289
+ const ageMinutes = Math.round(lockAgeMs(lock) / 60000);
290
+ console.error(
291
+ `[browser-plugin] Requesting release from PID ${lock.pid} (last used ${ageMinutes}m ago)...`
211
292
  );
293
+ const released = await requestSessionRelease(lock.pid, { timeoutMs: 4000 });
294
+ if (!released.success) {
295
+ throw new Error(
296
+ `Failed to takeover without killing OpenCode: ${released.error}. ` +
297
+ `Try again, or use browser_force_kill_session as last resort.`
298
+ );
299
+ }
300
+ console.error(`[browser-plugin] Previous session released gracefully.`);
301
+ writeLock();
212
302
  }
213
303
 
214
- // Start server if not running
304
+ touchLock();
305
+
306
+ if (!server) {
307
+ if (!startServer()) {
308
+ throw new Error("Failed to start WebSocket server after takeover.");
309
+ }
310
+ }
311
+
312
+ const ok = await waitForExtensionConnection(3000);
313
+ if (!ok) {
314
+ throw new Error("Took over lock but Chrome extension did not connect.");
315
+ }
316
+
317
+ return "Browser now connected to this session.";
318
+ }
319
+
320
+ async function ensureLockAndServer(): Promise<void> {
321
+ const existingLock = readLock();
322
+
323
+ if (!existingLock) {
324
+ writeLock();
325
+ } else if (existingLock.sessionId === sessionId) {
326
+ // Already ours.
327
+ } else if (!isProcessAlive(existingLock.pid)) {
328
+ // Stale lock (dead PID).
329
+ console.error(`[browser-plugin] Cleaning stale lock from dead PID ${existingLock.pid}`);
330
+ writeLock();
331
+ } else {
332
+ // Another session holds the lock - attempt automatic soft takeover
333
+ const ageMinutes = Math.round(lockAgeMs(existingLock) / 60000);
334
+ console.error(
335
+ `[browser-plugin] Browser locked by PID ${existingLock.pid} (last used ${ageMinutes}m ago). Attempting auto-takeover...`
336
+ );
337
+
338
+ const released = await requestSessionRelease(existingLock.pid, { timeoutMs: 4000 });
339
+ if (released.success) {
340
+ console.error(`[browser-plugin] Auto-takeover succeeded. Previous session released gracefully.`);
341
+ writeLock();
342
+ } else {
343
+ // Soft takeover failed - provide helpful error
344
+ const expired = isLockExpired(existingLock);
345
+ const why = expired ? "expired" : "active";
346
+ throw new Error(
347
+ `Browser locked by another session (PID ${existingLock.pid}, ${why}). ` +
348
+ `Auto-takeover failed: ${released.error}. ` +
349
+ `Use browser_force_kill_session as last resort, or browser_status for details.`
350
+ );
351
+ }
352
+ }
353
+
354
+ touchLock();
355
+
215
356
  if (!server) {
216
357
  if (!startServer()) {
217
358
  throw new Error("Failed to start WebSocket server. Port may be in use.");
@@ -219,12 +360,20 @@ async function executeCommand(tool: string, args: Record<string, any>): Promise<
219
360
  }
220
361
 
221
362
  if (!isConnected) {
222
- throw new Error(
223
- "Chrome extension not connected. Make sure Chrome is running with the OpenCode Browser extension enabled."
224
- );
363
+ const ok = await waitForExtensionConnection(3000);
364
+ if (!ok) {
365
+ throw new Error(
366
+ "Chrome extension not connected. Make sure Chrome is running with the OpenCode Browser extension enabled."
367
+ );
368
+ }
225
369
  }
370
+ }
371
+
372
+ async function executeCommand(toolName: string, args: Record<string, any>): Promise<any> {
373
+ await ensureLockAndServer();
226
374
 
227
375
  const id = ++requestId;
376
+ touchLock();
228
377
 
229
378
  return new Promise((resolve, reject) => {
230
379
  pendingRequests.set(id, { resolve, reject });
@@ -232,33 +381,38 @@ async function executeCommand(tool: string, args: Record<string, any>): Promise<
232
381
  sendToChrome({
233
382
  type: "tool_request",
234
383
  id,
235
- tool,
384
+ tool: toolName,
236
385
  args,
237
386
  });
238
387
 
239
- // Timeout after 60 seconds
240
388
  setTimeout(() => {
241
- if (pendingRequests.has(id)) {
242
- pendingRequests.delete(id);
243
- reject(new Error("Tool execution timed out after 60 seconds"));
244
- }
389
+ if (!pendingRequests.has(id)) return;
390
+ pendingRequests.delete(id);
391
+ reject(new Error("Tool execution timed out after 60 seconds"));
245
392
  }, 60000);
246
393
  });
247
394
  }
248
395
 
249
396
  // ============================================================================
250
- // Cleanup on exit
397
+ // Cleanup / Signals
251
398
  // ============================================================================
252
399
 
400
+ // Soft release: do NOT exit the OpenCode process.
401
+ process.on("SIGUSR1", () => {
402
+ console.error(`[browser-plugin] SIGUSR1: releasing lock + stopping server`);
403
+ releaseLock();
404
+ stopBrowserServer();
405
+ });
406
+
253
407
  process.on("SIGTERM", () => {
254
408
  releaseLock();
255
- server?.stop();
409
+ stopBrowserServer();
256
410
  process.exit(0);
257
411
  });
258
412
 
259
413
  process.on("SIGINT", () => {
260
414
  releaseLock();
261
- server?.stop();
415
+ stopBrowserServer();
262
416
  process.exit(0);
263
417
  });
264
418
 
@@ -270,17 +424,9 @@ process.on("exit", () => {
270
424
  // Plugin Export
271
425
  // ============================================================================
272
426
 
273
- export const BrowserPlugin: Plugin = async (ctx) => {
427
+ export const BrowserPlugin: Plugin = async (_ctx) => {
274
428
  console.error(`[browser-plugin] Initializing (session ${sessionId})`);
275
429
 
276
- // Try to acquire lock and start server on load
277
- const lockResult = tryAcquireLock();
278
- if (lockResult.success) {
279
- startServer();
280
- } else {
281
- console.error(`[browser-plugin] Lock held by PID ${lockResult.lock?.pid}, tools will fail until lock is released`);
282
- }
283
-
284
430
  return {
285
431
  tool: {
286
432
  browser_status: tool({
@@ -295,28 +441,77 @@ export const BrowserPlugin: Plugin = async (ctx) => {
295
441
  }
296
442
 
297
443
  if (lock.sessionId === sessionId) {
298
- return `Browser connected (this session)\nPID: ${pid}\nStarted: ${lock.startedAt}\nExtension: ${isConnected ? "connected" : "not connected"}`;
444
+ return (
445
+ `Browser connected (this session)\n` +
446
+ `PID: ${pid}\n` +
447
+ `Started: ${lock.startedAt}\n` +
448
+ `Last used: ${lock.lastUsedAt}\n` +
449
+ `Extension: ${isConnected ? "connected" : "not connected"}`
450
+ );
299
451
  }
300
452
 
301
- if (!isProcessAlive(lock.pid)) {
302
- return `Browser available (stale lock from dead PID ${lock.pid} will be auto-cleaned)`;
453
+ const alive = isProcessAlive(lock.pid);
454
+ const ageMinutes = Math.round(lockAgeMs(lock) / 60000);
455
+ const expired = isLockExpired(lock);
456
+
457
+ if (!alive) {
458
+ return `Browser available (stale lock from dead PID ${lock.pid} will be auto-cleaned on next command)`;
303
459
  }
304
460
 
305
- 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.`;
461
+ return (
462
+ `Browser locked by another session\n` +
463
+ `PID: ${lock.pid}\n` +
464
+ `Session: ${lock.sessionId}\n` +
465
+ `Started: ${lock.startedAt}\n` +
466
+ `Last used: ${lock.lastUsedAt} (~${ageMinutes}m ago)${expired ? " [expired]" : ""}\n` +
467
+ `Working directory: ${lock.cwd}\n\n` +
468
+ `Use browser_takeover to request release (no kill), or browser_force_kill_session as last resort.`
469
+ );
470
+ },
471
+ }),
472
+
473
+ browser_release: tool({
474
+ description: "Release browser lock and stop the server for this session.",
475
+ args: {},
476
+ async execute() {
477
+ const lock = readLock();
478
+ if (lock && lock.sessionId !== sessionId) {
479
+ throw new Error("This session does not own the browser lock.");
480
+ }
481
+
482
+ releaseLock();
483
+ stopBrowserServer();
484
+ return "Released browser lock for this session.";
485
+ },
486
+ }),
487
+
488
+ browser_takeover: tool({
489
+ description:
490
+ "Request the session holding the browser lock to release it (no process kill), then take over.",
491
+ args: {},
492
+ async execute() {
493
+ return await performTakeover();
306
494
  },
307
495
  }),
308
496
 
309
497
  browser_kill_session: tool({
310
498
  description:
311
- "Kill the session that currently holds the browser lock and take over. Use when browser_status shows another session has the lock.",
499
+ "(Deprecated name) Soft takeover without killing OpenCode. Prefer browser_takeover.",
500
+ args: {},
501
+ async execute() {
502
+ // Keep backward compatibility: old callers use this.
503
+ return await performTakeover();
504
+ },
505
+ }),
506
+
507
+ browser_force_kill_session: tool({
508
+ description: "Force kill the session holding the browser lock (last resort).",
312
509
  args: {},
313
510
  async execute() {
314
511
  const lock = readLock();
315
512
 
316
513
  if (!lock) {
317
- // No lock, just acquire
318
514
  writeLock();
319
- if (!server) startServer();
320
515
  return "No active session. Browser now connected to this session.";
321
516
  }
322
517
 
@@ -325,25 +520,41 @@ export const BrowserPlugin: Plugin = async (ctx) => {
325
520
  }
326
521
 
327
522
  if (!isProcessAlive(lock.pid)) {
328
- // Stale lock
329
523
  writeLock();
330
- if (!server) startServer();
331
524
  return `Cleaned stale lock (PID ${lock.pid} was dead). Browser now connected to this session.`;
332
525
  }
333
526
 
334
- // Kill the other session
335
- const result = await killSession(lock.pid);
336
- if (result.success) {
337
- if (!server) startServer();
338
- return `Killed session ${lock.sessionId} (PID ${lock.pid}). Browser now connected to this session.`;
339
- } else {
340
- throw new Error(`Failed to kill session: ${result.error}`);
527
+ const result = await forceKillSession(lock.pid);
528
+ if (!result.success) {
529
+ throw new Error(`Failed to force kill session: ${result.error}`);
530
+ }
531
+
532
+ // Best-effort cleanup; then take lock.
533
+ try {
534
+ unlinkSync(LOCK_FILE);
535
+ } catch {
536
+ // Ignore
341
537
  }
538
+
539
+ writeLock();
540
+
541
+ if (!server) {
542
+ if (!startServer()) {
543
+ throw new Error("Failed to start WebSocket server after force kill.");
544
+ }
545
+ }
546
+
547
+ const ok = await waitForExtensionConnection(3000);
548
+ if (!ok) {
549
+ throw new Error("Force-killed lock holder but Chrome extension did not connect.");
550
+ }
551
+
552
+ return `Force-killed session ${lock.sessionId} (PID ${lock.pid}). Browser now connected to this session.`;
342
553
  },
343
554
  }),
344
555
 
345
556
  browser_navigate: tool({
346
- description: "Navigate to a URL in the browser",
557
+ description: "Navigate to a URL in browser",
347
558
  args: {
348
559
  url: tool.schema.string({ description: "The URL to navigate to" }),
349
560
  tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
@@ -354,9 +565,9 @@ export const BrowserPlugin: Plugin = async (ctx) => {
354
565
  }),
355
566
 
356
567
  browser_click: tool({
357
- description: "Click an element on the page using a CSS selector",
568
+ description: "Click an element on page using a CSS selector",
358
569
  args: {
359
- selector: tool.schema.string({ description: "CSS selector for the element to click" }),
570
+ selector: tool.schema.string({ description: "CSS selector for element to click" }),
360
571
  tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
361
572
  },
362
573
  async execute(args) {
@@ -367,7 +578,7 @@ export const BrowserPlugin: Plugin = async (ctx) => {
367
578
  browser_type: tool({
368
579
  description: "Type text into an input element",
369
580
  args: {
370
- selector: tool.schema.string({ description: "CSS selector for the input element" }),
581
+ selector: tool.schema.string({ description: "CSS selector for input element" }),
371
582
  text: tool.schema.string({ description: "Text to type" }),
372
583
  clear: tool.schema.optional(tool.schema.boolean({ description: "Clear field before typing" })),
373
584
  tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
@@ -378,26 +589,26 @@ export const BrowserPlugin: Plugin = async (ctx) => {
378
589
  }),
379
590
 
380
591
  browser_screenshot: tool({
381
- description: "Take a screenshot of the current page. Saves to ~/.opencode-browser/screenshots/ and returns the file path.",
592
+ description: "Take a screenshot of the current page. Saves to ~/.opencode-browser/screenshots/",
382
593
  args: {
383
594
  tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
384
- name: tool.schema.optional(tool.schema.string({ description: "Optional name for the screenshot file (without extension)" })),
595
+ name: tool.schema.optional(
596
+ tool.schema.string({ description: "Optional name for screenshot file (without extension)" })
597
+ ),
385
598
  },
386
- async execute(args) {
599
+ async execute(args: { tabId?: number; name?: string }) {
387
600
  const result = await executeCommand("screenshot", args);
388
-
389
- if (result && result.startsWith("data:image")) {
390
- // Extract base64 data and save to file
601
+
602
+ if (result && typeof result === "string" && result.startsWith("data:image")) {
391
603
  const base64Data = result.replace(/^data:image\/\w+;base64,/, "");
392
604
  const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
393
605
  const filename = args.name ? `${args.name}.png` : `screenshot-${timestamp}.png`;
394
606
  const filepath = join(SCREENSHOTS_DIR, filename);
395
-
607
+
396
608
  writeFileSync(filepath, Buffer.from(base64Data, "base64"));
397
-
398
609
  return `Screenshot saved: ${filepath}`;
399
610
  }
400
-
611
+
401
612
  return result;
402
613
  },
403
614
  }),