@different-ai/opencode-browser 2.1.0 → 4.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/README.md +43 -93
- package/bin/broker.cjs +290 -0
- package/bin/cli.js +231 -186
- package/bin/native-host.cjs +136 -0
- package/extension/background.js +240 -174
- package/extension/manifest.json +3 -2
- package/package.json +8 -5
- package/src/plugin.ts +226 -623
package/src/plugin.ts
CHANGED
|
@@ -1,672 +1,275 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OpenCode Browser Plugin
|
|
3
|
-
*
|
|
4
|
-
* OpenCode Plugin (this) <--WebSocket:19222--> Chrome Extension
|
|
5
|
-
*
|
|
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.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
1
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
12
2
|
import { tool } from "@opencode-ai/plugin";
|
|
13
|
-
import
|
|
3
|
+
import net from "net";
|
|
4
|
+
import { existsSync, mkdirSync } from "fs";
|
|
14
5
|
import { homedir } from "os";
|
|
15
6
|
import { join } from "path";
|
|
7
|
+
import { spawn } from "child_process";
|
|
16
8
|
|
|
17
|
-
const WS_PORT = 19222;
|
|
18
9
|
const BASE_DIR = join(homedir(), ".opencode-browser");
|
|
19
|
-
const
|
|
20
|
-
const SCREENSHOTS_DIR = join(BASE_DIR, "screenshots");
|
|
21
|
-
|
|
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
|
|
10
|
+
const SOCKET_PATH = join(BASE_DIR, "broker.sock");
|
|
24
11
|
|
|
25
12
|
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
|
-
|
|
37
|
-
interface LockInfo {
|
|
38
|
-
pid: number;
|
|
39
|
-
sessionId: string;
|
|
40
|
-
startedAt: string;
|
|
41
|
-
lastUsedAt: string;
|
|
42
|
-
cwd: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function nowIso(): string {
|
|
46
|
-
return new Date().toISOString();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function readLock(): LockInfo | null {
|
|
50
|
-
try {
|
|
51
|
-
if (!existsSync(LOCK_FILE)) return null;
|
|
52
|
-
return JSON.parse(readFileSync(LOCK_FILE, "utf-8"));
|
|
53
|
-
} catch {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
13
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function releaseLock(): void {
|
|
98
|
-
try {
|
|
99
|
-
const lock = readLock();
|
|
100
|
-
if (lock && lock.sessionId === sessionId) {
|
|
101
|
-
unlinkSync(LOCK_FILE);
|
|
14
|
+
type BrokerResponse =
|
|
15
|
+
| { type: "response"; id: number; ok: true; data: any }
|
|
16
|
+
| { type: "response"; id: number; ok: false; error: string };
|
|
17
|
+
|
|
18
|
+
function createJsonLineParser(onMessage: (msg: any) => void): (chunk: Buffer) => void {
|
|
19
|
+
let buffer = "";
|
|
20
|
+
return (chunk: Buffer) => {
|
|
21
|
+
buffer += chunk.toString("utf8");
|
|
22
|
+
while (true) {
|
|
23
|
+
const idx = buffer.indexOf("\n");
|
|
24
|
+
if (idx === -1) return;
|
|
25
|
+
const line = buffer.slice(0, idx);
|
|
26
|
+
buffer = buffer.slice(idx + 1);
|
|
27
|
+
if (!line.trim()) continue;
|
|
28
|
+
try {
|
|
29
|
+
onMessage(JSON.parse(line));
|
|
30
|
+
} catch {
|
|
31
|
+
// ignore
|
|
32
|
+
}
|
|
102
33
|
}
|
|
103
|
-
}
|
|
104
|
-
// Ignore
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function isProcessAlive(targetPid: number): boolean {
|
|
109
|
-
try {
|
|
110
|
-
process.kill(targetPid, 0);
|
|
111
|
-
return true;
|
|
112
|
-
} catch {
|
|
113
|
-
return false;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
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;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function isLockExpired(lock: LockInfo): boolean {
|
|
125
|
-
return lockAgeMs(lock) > LOCK_TTL_MS;
|
|
34
|
+
};
|
|
126
35
|
}
|
|
127
36
|
|
|
128
|
-
function
|
|
129
|
-
|
|
130
|
-
// If we can connect, something is already listening.
|
|
131
|
-
const testSocket = Bun.connect({ port, timeout: 300 });
|
|
132
|
-
testSocket.end();
|
|
133
|
-
return false;
|
|
134
|
-
} catch (e) {
|
|
135
|
-
if ((e as any).code === "ECONNREFUSED") return true;
|
|
136
|
-
return false;
|
|
137
|
-
}
|
|
37
|
+
function writeJsonLine(socket: net.Socket, msg: any): void {
|
|
38
|
+
socket.write(JSON.stringify(msg) + "\n");
|
|
138
39
|
}
|
|
139
40
|
|
|
140
|
-
function
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
} catch {
|
|
144
|
-
// Ignore
|
|
145
|
-
}
|
|
146
|
-
ws = null;
|
|
147
|
-
isConnected = false;
|
|
41
|
+
function maybeStartBroker(): void {
|
|
42
|
+
const brokerPath = join(BASE_DIR, "broker.cjs");
|
|
43
|
+
if (!existsSync(brokerPath)) return;
|
|
148
44
|
|
|
149
45
|
try {
|
|
150
|
-
|
|
46
|
+
const child = spawn(process.execPath, [brokerPath], { detached: true, stdio: "ignore" });
|
|
47
|
+
child.unref();
|
|
151
48
|
} catch {
|
|
152
|
-
//
|
|
49
|
+
// ignore
|
|
153
50
|
}
|
|
154
|
-
server = null;
|
|
155
51
|
}
|
|
156
52
|
|
|
157
|
-
function
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
port: WS_PORT,
|
|
164
|
-
fetch(req, server) {
|
|
165
|
-
if (server.upgrade(req)) return;
|
|
166
|
-
return new Response("OpenCode Browser Plugin", { status: 200 });
|
|
167
|
-
},
|
|
168
|
-
websocket: {
|
|
169
|
-
open(wsClient) {
|
|
170
|
-
console.error(`[browser-plugin] Chrome extension connected`);
|
|
171
|
-
ws = wsClient as unknown as WebSocket;
|
|
172
|
-
isConnected = true;
|
|
173
|
-
},
|
|
174
|
-
close() {
|
|
175
|
-
console.error(`[browser-plugin] Chrome extension disconnected`);
|
|
176
|
-
ws = null;
|
|
177
|
-
isConnected = false;
|
|
178
|
-
},
|
|
179
|
-
message(_wsClient, data) {
|
|
180
|
-
try {
|
|
181
|
-
const message = JSON.parse(data.toString());
|
|
182
|
-
handleMessage(message);
|
|
183
|
-
} catch (e) {
|
|
184
|
-
console.error(`[browser-plugin] Parse error:`, e);
|
|
185
|
-
}
|
|
186
|
-
},
|
|
187
|
-
},
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
console.error(`[browser-plugin] WebSocket server listening on port ${WS_PORT}`);
|
|
191
|
-
return true;
|
|
192
|
-
} catch (e) {
|
|
193
|
-
console.error(`[browser-plugin] Failed to start server:`, e);
|
|
194
|
-
return false;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function sleep(ms: number): Promise<void> {
|
|
199
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
53
|
+
async function connectToBroker(): Promise<net.Socket> {
|
|
54
|
+
return await new Promise((resolve, reject) => {
|
|
55
|
+
const socket = net.createConnection(SOCKET_PATH);
|
|
56
|
+
socket.once("connect", () => resolve(socket));
|
|
57
|
+
socket.once("error", (err) => reject(err));
|
|
58
|
+
});
|
|
200
59
|
}
|
|
201
60
|
|
|
202
|
-
async function
|
|
203
|
-
|
|
204
|
-
while (Date.now() - start < timeoutMs) {
|
|
205
|
-
if (isConnected) return true;
|
|
206
|
-
await sleep(100);
|
|
207
|
-
}
|
|
208
|
-
return isConnected;
|
|
61
|
+
async function sleep(ms: number): Promise<void> {
|
|
62
|
+
return await new Promise((r) => setTimeout(r, ms));
|
|
209
63
|
}
|
|
210
64
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
}
|
|
65
|
+
let socket: net.Socket | null = null;
|
|
66
|
+
let sessionId = Math.random().toString(36).slice(2);
|
|
67
|
+
let reqId = 0;
|
|
68
|
+
const pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
|
221
69
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const lock = readLock();
|
|
225
|
-
const lockCleared = !lock || lock.pid !== targetPid;
|
|
226
|
-
const portCleared = isPortFree(WS_PORT);
|
|
70
|
+
async function ensureBrokerSocket(): Promise<net.Socket> {
|
|
71
|
+
if (socket && !socket.destroyed) return socket;
|
|
227
72
|
|
|
228
|
-
|
|
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 }> {
|
|
73
|
+
// Try to connect; if missing, try to start broker and retry.
|
|
239
74
|
try {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
75
|
+
socket = await connectToBroker();
|
|
76
|
+
} catch {
|
|
77
|
+
maybeStartBroker();
|
|
78
|
+
for (let i = 0; i < 20; i++) {
|
|
243
79
|
await sleep(100);
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
80
|
+
try {
|
|
81
|
+
socket = await connectToBroker();
|
|
82
|
+
break;
|
|
83
|
+
} catch {}
|
|
248
84
|
}
|
|
249
|
-
return { success: true };
|
|
250
|
-
} catch (e) {
|
|
251
|
-
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
252
85
|
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function handleMessage(message: { type: string; id?: number; result?: any; error?: any }): void {
|
|
256
|
-
if (message.type === "tool_response" && message.id !== undefined) {
|
|
257
|
-
const pending = pendingRequests.get(message.id);
|
|
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);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
86
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
return true;
|
|
273
|
-
}
|
|
274
|
-
return false;
|
|
275
|
-
}
|
|
276
|
-
|
|
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)...`
|
|
87
|
+
if (!socket || socket.destroyed) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
"Could not connect to local broker. Run `npx @different-ai/opencode-browser install` and ensure the extension is loaded."
|
|
292
90
|
);
|
|
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();
|
|
302
|
-
}
|
|
303
|
-
|
|
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
91
|
}
|
|
316
92
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
);
|
|
93
|
+
socket.setNoDelay(true);
|
|
94
|
+
socket.on(
|
|
95
|
+
"data",
|
|
96
|
+
createJsonLineParser((msg) => {
|
|
97
|
+
if (msg?.type !== "response" || typeof msg.id !== "number") return;
|
|
98
|
+
const p = pending.get(msg.id);
|
|
99
|
+
if (!p) return;
|
|
100
|
+
pending.delete(msg.id);
|
|
101
|
+
const res = msg as BrokerResponse;
|
|
102
|
+
if (!res.ok) p.reject(new Error(res.error));
|
|
103
|
+
else p.resolve(res.data);
|
|
104
|
+
})
|
|
105
|
+
);
|
|
337
106
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
}
|
|
107
|
+
socket.on("close", () => {
|
|
108
|
+
socket = null;
|
|
109
|
+
});
|
|
353
110
|
|
|
354
|
-
|
|
111
|
+
socket.on("error", () => {
|
|
112
|
+
socket = null;
|
|
113
|
+
});
|
|
355
114
|
|
|
356
|
-
|
|
357
|
-
if (!startServer()) {
|
|
358
|
-
throw new Error("Failed to start WebSocket server. Port may be in use.");
|
|
359
|
-
}
|
|
360
|
-
}
|
|
115
|
+
writeJsonLine(socket, { type: "hello", role: "plugin", sessionId, pid: process.pid });
|
|
361
116
|
|
|
362
|
-
|
|
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
|
-
}
|
|
369
|
-
}
|
|
117
|
+
return socket;
|
|
370
118
|
}
|
|
371
119
|
|
|
372
|
-
async function
|
|
373
|
-
await
|
|
374
|
-
|
|
375
|
-
const id = ++requestId;
|
|
376
|
-
touchLock();
|
|
377
|
-
|
|
378
|
-
return new Promise((resolve, reject) => {
|
|
379
|
-
pendingRequests.set(id, { resolve, reject });
|
|
380
|
-
|
|
381
|
-
sendToChrome({
|
|
382
|
-
type: "tool_request",
|
|
383
|
-
id,
|
|
384
|
-
tool: toolName,
|
|
385
|
-
args,
|
|
386
|
-
});
|
|
120
|
+
async function brokerRequest(op: string, payload: Record<string, any>): Promise<any> {
|
|
121
|
+
const s = await ensureBrokerSocket();
|
|
122
|
+
const id = ++reqId;
|
|
387
123
|
|
|
124
|
+
return await new Promise((resolve, reject) => {
|
|
125
|
+
pending.set(id, { resolve, reject });
|
|
126
|
+
writeJsonLine(s, { type: "request", id, op, ...payload });
|
|
388
127
|
setTimeout(() => {
|
|
389
|
-
if (!
|
|
390
|
-
|
|
391
|
-
reject(new Error("
|
|
128
|
+
if (!pending.has(id)) return;
|
|
129
|
+
pending.delete(id);
|
|
130
|
+
reject(new Error("Timed out waiting for broker response"));
|
|
392
131
|
}, 60000);
|
|
393
132
|
});
|
|
394
133
|
}
|
|
395
134
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
console.error(`[browser-plugin] SIGUSR1: releasing lock + stopping server`);
|
|
403
|
-
releaseLock();
|
|
404
|
-
stopBrowserServer();
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
process.on("SIGTERM", () => {
|
|
408
|
-
releaseLock();
|
|
409
|
-
stopBrowserServer();
|
|
410
|
-
process.exit(0);
|
|
411
|
-
});
|
|
412
|
-
|
|
413
|
-
process.on("SIGINT", () => {
|
|
414
|
-
releaseLock();
|
|
415
|
-
stopBrowserServer();
|
|
416
|
-
process.exit(0);
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
process.on("exit", () => {
|
|
420
|
-
releaseLock();
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
// ============================================================================
|
|
424
|
-
// Plugin Export
|
|
425
|
-
// ============================================================================
|
|
426
|
-
|
|
427
|
-
export const BrowserPlugin: Plugin = async (_ctx) => {
|
|
428
|
-
console.error(`[browser-plugin] Initializing (session ${sessionId})`);
|
|
429
|
-
|
|
430
|
-
return {
|
|
431
|
-
tool: {
|
|
432
|
-
browser_status: tool({
|
|
433
|
-
description:
|
|
434
|
-
"Check if browser is available or locked by another session. Returns connection status and lock info.",
|
|
435
|
-
args: {},
|
|
436
|
-
async execute() {
|
|
437
|
-
const lock = readLock();
|
|
438
|
-
|
|
439
|
-
if (!lock) {
|
|
440
|
-
return "Browser available (no active session)";
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
if (lock.sessionId === sessionId) {
|
|
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
|
-
);
|
|
451
|
-
}
|
|
452
|
-
|
|
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)`;
|
|
459
|
-
}
|
|
460
|
-
|
|
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();
|
|
494
|
-
},
|
|
495
|
-
}),
|
|
496
|
-
|
|
497
|
-
browser_kill_session: tool({
|
|
498
|
-
description:
|
|
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).",
|
|
509
|
-
args: {},
|
|
510
|
-
async execute() {
|
|
511
|
-
const lock = readLock();
|
|
512
|
-
|
|
513
|
-
if (!lock) {
|
|
514
|
-
writeLock();
|
|
515
|
-
return "No active session. Browser now connected to this session.";
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
if (lock.sessionId === sessionId) {
|
|
519
|
-
return "This session already owns the browser.";
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
if (!isProcessAlive(lock.pid)) {
|
|
523
|
-
writeLock();
|
|
524
|
-
return `Cleaned stale lock (PID ${lock.pid} was dead). Browser now connected to this session.`;
|
|
525
|
-
}
|
|
526
|
-
|
|
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
|
|
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.`;
|
|
553
|
-
},
|
|
554
|
-
}),
|
|
555
|
-
|
|
556
|
-
browser_navigate: tool({
|
|
557
|
-
description: "Navigate to a URL in browser",
|
|
558
|
-
args: {
|
|
559
|
-
url: tool.schema.string({ description: "The URL to navigate to" }),
|
|
560
|
-
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
|
|
561
|
-
},
|
|
562
|
-
async execute(args) {
|
|
563
|
-
return await executeCommand("navigate", args);
|
|
564
|
-
},
|
|
565
|
-
}),
|
|
566
|
-
|
|
567
|
-
browser_click: tool({
|
|
568
|
-
description: "Click an element on page using a CSS selector",
|
|
569
|
-
args: {
|
|
570
|
-
selector: tool.schema.string({ description: "CSS selector for element to click" }),
|
|
571
|
-
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
|
|
572
|
-
},
|
|
573
|
-
async execute(args) {
|
|
574
|
-
return await executeCommand("click", args);
|
|
575
|
-
},
|
|
576
|
-
}),
|
|
577
|
-
|
|
578
|
-
browser_type: tool({
|
|
579
|
-
description: "Type text into an input element",
|
|
580
|
-
args: {
|
|
581
|
-
selector: tool.schema.string({ description: "CSS selector for input element" }),
|
|
582
|
-
text: tool.schema.string({ description: "Text to type" }),
|
|
583
|
-
clear: tool.schema.optional(tool.schema.boolean({ description: "Clear field before typing" })),
|
|
584
|
-
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
|
|
585
|
-
},
|
|
586
|
-
async execute(args) {
|
|
587
|
-
return await executeCommand("type", args);
|
|
588
|
-
},
|
|
589
|
-
}),
|
|
590
|
-
|
|
591
|
-
browser_screenshot: tool({
|
|
592
|
-
description: "Take a screenshot of the current page. Saves to ~/.opencode-browser/screenshots/",
|
|
593
|
-
args: {
|
|
594
|
-
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
|
|
595
|
-
name: tool.schema.optional(
|
|
596
|
-
tool.schema.string({ description: "Optional name for screenshot file (without extension)" })
|
|
597
|
-
),
|
|
598
|
-
},
|
|
599
|
-
async execute(args: { tabId?: number; name?: string }) {
|
|
600
|
-
const result = await executeCommand("screenshot", args);
|
|
601
|
-
|
|
602
|
-
if (result && typeof result === "string" && result.startsWith("data:image")) {
|
|
603
|
-
const base64Data = result.replace(/^data:image\/\w+;base64,/, "");
|
|
604
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
605
|
-
const filename = args.name ? `${args.name}.png` : `screenshot-${timestamp}.png`;
|
|
606
|
-
const filepath = join(SCREENSHOTS_DIR, filename);
|
|
607
|
-
|
|
608
|
-
writeFileSync(filepath, Buffer.from(base64Data, "base64"));
|
|
609
|
-
return `Screenshot saved: ${filepath}`;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
return result;
|
|
613
|
-
},
|
|
614
|
-
}),
|
|
615
|
-
|
|
616
|
-
browser_snapshot: tool({
|
|
617
|
-
description:
|
|
618
|
-
"Get an accessibility tree snapshot of the page. Returns interactive elements with selectors for clicking.",
|
|
619
|
-
args: {
|
|
620
|
-
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
|
|
621
|
-
},
|
|
622
|
-
async execute(args) {
|
|
623
|
-
return await executeCommand("snapshot", args);
|
|
624
|
-
},
|
|
625
|
-
}),
|
|
626
|
-
|
|
627
|
-
browser_get_tabs: tool({
|
|
628
|
-
description: "List all open browser tabs",
|
|
629
|
-
args: {},
|
|
630
|
-
async execute() {
|
|
631
|
-
return await executeCommand("get_tabs", {});
|
|
632
|
-
},
|
|
633
|
-
}),
|
|
634
|
-
|
|
635
|
-
browser_scroll: tool({
|
|
636
|
-
description: "Scroll the page or scroll an element into view",
|
|
637
|
-
args: {
|
|
638
|
-
selector: tool.schema.optional(tool.schema.string({ description: "CSS selector to scroll into view" })),
|
|
639
|
-
x: tool.schema.optional(tool.schema.number({ description: "Horizontal scroll amount in pixels" })),
|
|
640
|
-
y: tool.schema.optional(tool.schema.number({ description: "Vertical scroll amount in pixels" })),
|
|
641
|
-
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
|
|
642
|
-
},
|
|
643
|
-
async execute(args) {
|
|
644
|
-
return await executeCommand("scroll", args);
|
|
645
|
-
},
|
|
646
|
-
}),
|
|
647
|
-
|
|
648
|
-
browser_wait: tool({
|
|
649
|
-
description: "Wait for a specified duration",
|
|
650
|
-
args: {
|
|
651
|
-
ms: tool.schema.optional(tool.schema.number({ description: "Milliseconds to wait (default: 1000)" })),
|
|
652
|
-
},
|
|
653
|
-
async execute(args) {
|
|
654
|
-
return await executeCommand("wait", args);
|
|
655
|
-
},
|
|
656
|
-
}),
|
|
135
|
+
function toolResultText(data: any, fallback: string): string {
|
|
136
|
+
if (typeof data?.content === "string") return data.content;
|
|
137
|
+
if (typeof data === "string") return data;
|
|
138
|
+
if (data?.content != null) return JSON.stringify(data.content);
|
|
139
|
+
return fallback;
|
|
140
|
+
}
|
|
657
141
|
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
142
|
+
const plugin: Plugin = {
|
|
143
|
+
name: "opencode-browser",
|
|
144
|
+
tools: [
|
|
145
|
+
tool(
|
|
146
|
+
"browser_status",
|
|
147
|
+
"Check broker/native-host connection status and current tab claims.",
|
|
148
|
+
{},
|
|
149
|
+
async () => {
|
|
150
|
+
const data = await brokerRequest("status", {});
|
|
151
|
+
return JSON.stringify(data);
|
|
152
|
+
}
|
|
153
|
+
),
|
|
154
|
+
tool(
|
|
155
|
+
"browser_get_tabs",
|
|
156
|
+
"List all open browser tabs",
|
|
157
|
+
{},
|
|
158
|
+
async () => {
|
|
159
|
+
const data = await brokerRequest("tool", { tool: "get_tabs", args: {} });
|
|
160
|
+
return toolResultText(data, "ok");
|
|
161
|
+
}
|
|
162
|
+
),
|
|
163
|
+
tool(
|
|
164
|
+
"browser_navigate",
|
|
165
|
+
"Navigate to a URL in the browser",
|
|
166
|
+
{ url: { type: "string" }, tabId: { type: "number", optional: true } },
|
|
167
|
+
async ({ url, tabId }: any) => {
|
|
168
|
+
const data = await brokerRequest("tool", { tool: "navigate", args: { url, tabId } });
|
|
169
|
+
return toolResultText(data, `Navigated to ${url}`);
|
|
170
|
+
}
|
|
171
|
+
),
|
|
172
|
+
tool(
|
|
173
|
+
"browser_click",
|
|
174
|
+
"Click an element on the page using a CSS selector",
|
|
175
|
+
{ selector: { type: "string" }, tabId: { type: "number", optional: true } },
|
|
176
|
+
async ({ selector, tabId }: any) => {
|
|
177
|
+
const data = await brokerRequest("tool", { tool: "click", args: { selector, tabId } });
|
|
178
|
+
return toolResultText(data, `Clicked ${selector}`);
|
|
179
|
+
}
|
|
180
|
+
),
|
|
181
|
+
tool(
|
|
182
|
+
"browser_type",
|
|
183
|
+
"Type text into an input element",
|
|
184
|
+
{
|
|
185
|
+
selector: { type: "string" },
|
|
186
|
+
text: { type: "string" },
|
|
187
|
+
clear: { type: "boolean", optional: true },
|
|
188
|
+
tabId: { type: "number", optional: true },
|
|
189
|
+
},
|
|
190
|
+
async ({ selector, text, clear, tabId }: any) => {
|
|
191
|
+
const data = await brokerRequest("tool", { tool: "type", args: { selector, text, clear, tabId } });
|
|
192
|
+
return toolResultText(data, `Typed \"${text}\" into ${selector}`);
|
|
193
|
+
}
|
|
194
|
+
),
|
|
195
|
+
tool(
|
|
196
|
+
"browser_screenshot",
|
|
197
|
+
"Take a screenshot of the current page. Returns base64 image data URL.",
|
|
198
|
+
{ tabId: { type: "number", optional: true } },
|
|
199
|
+
async ({ tabId }: any) => {
|
|
200
|
+
const data = await brokerRequest("tool", { tool: "screenshot", args: { tabId } });
|
|
201
|
+
return toolResultText(data, "Screenshot failed");
|
|
202
|
+
}
|
|
203
|
+
),
|
|
204
|
+
tool(
|
|
205
|
+
"browser_snapshot",
|
|
206
|
+
"Get an accessibility tree snapshot of the page.",
|
|
207
|
+
{ tabId: { type: "number", optional: true } },
|
|
208
|
+
async ({ tabId }: any) => {
|
|
209
|
+
const data = await brokerRequest("tool", { tool: "snapshot", args: { tabId } });
|
|
210
|
+
return toolResultText(data, "Snapshot failed");
|
|
211
|
+
}
|
|
212
|
+
),
|
|
213
|
+
tool(
|
|
214
|
+
"browser_scroll",
|
|
215
|
+
"Scroll the page or scroll an element into view",
|
|
216
|
+
{
|
|
217
|
+
selector: { type: "string", optional: true },
|
|
218
|
+
x: { type: "number", optional: true },
|
|
219
|
+
y: { type: "number", optional: true },
|
|
220
|
+
tabId: { type: "number", optional: true },
|
|
221
|
+
},
|
|
222
|
+
async ({ selector, x, y, tabId }: any) => {
|
|
223
|
+
const data = await brokerRequest("tool", { tool: "scroll", args: { selector, x, y, tabId } });
|
|
224
|
+
return toolResultText(data, "Scrolled");
|
|
225
|
+
}
|
|
226
|
+
),
|
|
227
|
+
tool(
|
|
228
|
+
"browser_wait",
|
|
229
|
+
"Wait for a specified duration",
|
|
230
|
+
{ ms: { type: "number", optional: true }, tabId: { type: "number", optional: true } },
|
|
231
|
+
async ({ ms, tabId }: any) => {
|
|
232
|
+
const data = await brokerRequest("tool", { tool: "wait", args: { ms, tabId } });
|
|
233
|
+
return toolResultText(data, "Waited");
|
|
234
|
+
}
|
|
235
|
+
),
|
|
236
|
+
tool(
|
|
237
|
+
"browser_execute",
|
|
238
|
+
"Execute JavaScript code in the page context and return the result.",
|
|
239
|
+
{ code: { type: "string" }, tabId: { type: "number", optional: true } },
|
|
240
|
+
async ({ code, tabId }: any) => {
|
|
241
|
+
const data = await brokerRequest("tool", { tool: "execute_script", args: { code, tabId } });
|
|
242
|
+
return toolResultText(data, "Execute failed");
|
|
243
|
+
}
|
|
244
|
+
),
|
|
245
|
+
tool(
|
|
246
|
+
"browser_claim_tab",
|
|
247
|
+
"Claim a tab for this OpenCode session (per-tab ownership).",
|
|
248
|
+
{ tabId: { type: "number" }, force: { type: "boolean", optional: true } },
|
|
249
|
+
async ({ tabId, force }: any) => {
|
|
250
|
+
const data = await brokerRequest("claim_tab", { tabId, force });
|
|
251
|
+
return JSON.stringify(data);
|
|
252
|
+
}
|
|
253
|
+
),
|
|
254
|
+
tool(
|
|
255
|
+
"browser_release_tab",
|
|
256
|
+
"Release a previously claimed tab.",
|
|
257
|
+
{ tabId: { type: "number" } },
|
|
258
|
+
async ({ tabId }: any) => {
|
|
259
|
+
const data = await brokerRequest("release_tab", { tabId });
|
|
260
|
+
return JSON.stringify(data);
|
|
261
|
+
}
|
|
262
|
+
),
|
|
263
|
+
tool(
|
|
264
|
+
"browser_list_claims",
|
|
265
|
+
"List current tab ownership claims.",
|
|
266
|
+
{},
|
|
267
|
+
async () => {
|
|
268
|
+
const data = await brokerRequest("list_claims", {});
|
|
269
|
+
return JSON.stringify(data);
|
|
270
|
+
}
|
|
271
|
+
),
|
|
272
|
+
],
|
|
670
273
|
};
|
|
671
274
|
|
|
672
|
-
export default
|
|
275
|
+
export default plugin;
|