@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.
- package/README.md +6 -2
- package/package.json +1 -1
- 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` |
|
|
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` -
|
|
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
package/src/plugin.ts
CHANGED
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenCode Browser Plugin
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Connects to Chrome extension via WebSocket.
|
|
4
|
+
* OpenCode Plugin (this) <--WebSocket:19222--> Chrome Extension
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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,
|
|
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
|
-
//
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
|
|
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
|
|
116
|
-
return
|
|
124
|
+
function isLockExpired(lock: LockInfo): boolean {
|
|
125
|
+
return lockAgeMs(lock) > LOCK_TTL_MS;
|
|
117
126
|
}
|
|
118
127
|
|
|
119
|
-
|
|
128
|
+
function isPortFree(port: number): boolean {
|
|
120
129
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
135
|
+
if ((e as any).code === "ECONNREFUSED") return true;
|
|
136
|
+
return false;
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
|
|
141
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if (!
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
|
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
|
-
|
|
409
|
+
stopBrowserServer();
|
|
256
410
|
process.exit(0);
|
|
257
411
|
});
|
|
258
412
|
|
|
259
413
|
process.on("SIGINT", () => {
|
|
260
414
|
releaseLock();
|
|
261
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
302
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
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
|
|
568
|
+
description: "Click an element on page using a CSS selector",
|
|
358
569
|
args: {
|
|
359
|
-
selector: tool.schema.string({ description: "CSS selector for
|
|
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
|
|
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/
|
|
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(
|
|
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
|
}),
|