@different-ai/opencode-browser 2.0.2 → 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 +327 -170
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,20 +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
|
-
let serverFailed = false;
|
|
38
|
-
|
|
39
|
-
// ============================================================================
|
|
40
|
-
// Lock File Management
|
|
41
|
-
// ============================================================================
|
|
42
36
|
|
|
43
37
|
interface LockInfo {
|
|
44
38
|
pid: number;
|
|
45
39
|
sessionId: string;
|
|
46
40
|
startedAt: string;
|
|
41
|
+
lastUsedAt: string;
|
|
47
42
|
cwd: string;
|
|
48
43
|
}
|
|
49
44
|
|
|
45
|
+
function nowIso(): string {
|
|
46
|
+
return new Date().toISOString();
|
|
47
|
+
}
|
|
48
|
+
|
|
50
49
|
function readLock(): LockInfo | null {
|
|
51
50
|
try {
|
|
52
51
|
if (!existsSync(LOCK_FILE)) return null;
|
|
@@ -59,14 +58,40 @@ function readLock(): LockInfo | null {
|
|
|
59
58
|
function writeLock(): void {
|
|
60
59
|
writeFileSync(
|
|
61
60
|
LOCK_FILE,
|
|
62
|
-
JSON.stringify(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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"
|
|
68
72
|
);
|
|
69
|
-
|
|
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
|
+
}
|
|
70
95
|
}
|
|
71
96
|
|
|
72
97
|
function releaseLock(): void {
|
|
@@ -75,8 +100,9 @@ function releaseLock(): void {
|
|
|
75
100
|
if (lock && lock.sessionId === sessionId) {
|
|
76
101
|
unlinkSync(LOCK_FILE);
|
|
77
102
|
}
|
|
78
|
-
} catch {
|
|
79
|
-
|
|
103
|
+
} catch {
|
|
104
|
+
// Ignore
|
|
105
|
+
}
|
|
80
106
|
}
|
|
81
107
|
|
|
82
108
|
function isProcessAlive(targetPid: number): boolean {
|
|
@@ -88,78 +114,49 @@ function isProcessAlive(targetPid: number): boolean {
|
|
|
88
114
|
}
|
|
89
115
|
}
|
|
90
116
|
|
|
91
|
-
function
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
|
|
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
|
-
};
|
|
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;
|
|
114
122
|
}
|
|
115
123
|
|
|
116
|
-
function
|
|
117
|
-
return
|
|
124
|
+
function isLockExpired(lock: LockInfo): boolean {
|
|
125
|
+
return lockAgeMs(lock) > LOCK_TTL_MS;
|
|
118
126
|
}
|
|
119
127
|
|
|
120
|
-
|
|
128
|
+
function isPortFree(port: number): boolean {
|
|
121
129
|
try {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 };
|
|
130
|
+
// If we can connect, something is already listening.
|
|
131
|
+
const testSocket = Bun.connect({ port, timeout: 300 });
|
|
132
|
+
testSocket.end();
|
|
133
|
+
return false;
|
|
136
134
|
} catch (e) {
|
|
137
|
-
|
|
135
|
+
if ((e as any).code === "ECONNREFUSED") return true;
|
|
136
|
+
return false;
|
|
138
137
|
}
|
|
139
138
|
}
|
|
140
139
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
function stopBrowserServer(): void {
|
|
141
|
+
try {
|
|
142
|
+
(ws as any)?.close?.();
|
|
143
|
+
} catch {
|
|
144
|
+
// Ignore
|
|
145
|
+
}
|
|
146
|
+
ws = null;
|
|
147
|
+
isConnected = false;
|
|
144
148
|
|
|
145
|
-
function checkPortAvailable(): boolean {
|
|
146
149
|
try {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
} catch (e) {
|
|
151
|
-
if ((e as any).code === "ECONNREFUSED") {
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
154
|
-
return true;
|
|
150
|
+
server?.stop();
|
|
151
|
+
} catch {
|
|
152
|
+
// Ignore
|
|
155
153
|
}
|
|
154
|
+
server = null;
|
|
156
155
|
}
|
|
157
156
|
|
|
158
157
|
function startServer(): boolean {
|
|
159
|
-
if (server)
|
|
160
|
-
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
158
|
+
if (server) return true;
|
|
159
|
+
if (!isPortFree(WS_PORT)) return false;
|
|
163
160
|
|
|
164
161
|
try {
|
|
165
162
|
server = Bun.serve({
|
|
@@ -179,7 +176,7 @@ function startServer(): boolean {
|
|
|
179
176
|
ws = null;
|
|
180
177
|
isConnected = false;
|
|
181
178
|
},
|
|
182
|
-
message(
|
|
179
|
+
message(_wsClient, data) {
|
|
183
180
|
try {
|
|
184
181
|
const message = JSON.parse(data.toString());
|
|
185
182
|
handleMessage(message);
|
|
@@ -189,8 +186,8 @@ function startServer(): boolean {
|
|
|
189
186
|
},
|
|
190
187
|
},
|
|
191
188
|
});
|
|
189
|
+
|
|
192
190
|
console.error(`[browser-plugin] WebSocket server listening on port ${WS_PORT}`);
|
|
193
|
-
serverFailed = false;
|
|
194
191
|
return true;
|
|
195
192
|
} catch (e) {
|
|
196
193
|
console.error(`[browser-plugin] Failed to start server:`, e);
|
|
@@ -198,19 +195,74 @@ function startServer(): boolean {
|
|
|
198
195
|
}
|
|
199
196
|
}
|
|
200
197
|
|
|
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
|
+
|
|
201
255
|
function handleMessage(message: { type: string; id?: number; result?: any; error?: any }): void {
|
|
202
256
|
if (message.type === "tool_response" && message.id !== undefined) {
|
|
203
257
|
const pending = pendingRequests.get(message.id);
|
|
204
|
-
if (pending)
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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);
|
|
211
265
|
}
|
|
212
|
-
} else if (message.type === "pong") {
|
|
213
|
-
// Heartbeat response, ignore
|
|
214
266
|
}
|
|
215
267
|
}
|
|
216
268
|
|
|
@@ -222,15 +274,85 @@ function sendToChrome(message: any): boolean {
|
|
|
222
274
|
return false;
|
|
223
275
|
}
|
|
224
276
|
|
|
225
|
-
async function
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (!
|
|
229
|
-
|
|
230
|
-
|
|
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)...`
|
|
231
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();
|
|
232
302
|
}
|
|
233
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
|
+
}
|
|
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
|
+
|
|
234
356
|
if (!server) {
|
|
235
357
|
if (!startServer()) {
|
|
236
358
|
throw new Error("Failed to start WebSocket server. Port may be in use.");
|
|
@@ -238,12 +360,20 @@ async function executeCommand(tool: string, args: Record<string, any>): Promise<
|
|
|
238
360
|
}
|
|
239
361
|
|
|
240
362
|
if (!isConnected) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
+
}
|
|
244
369
|
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function executeCommand(toolName: string, args: Record<string, any>): Promise<any> {
|
|
373
|
+
await ensureLockAndServer();
|
|
245
374
|
|
|
246
375
|
const id = ++requestId;
|
|
376
|
+
touchLock();
|
|
247
377
|
|
|
248
378
|
return new Promise((resolve, reject) => {
|
|
249
379
|
pendingRequests.set(id, { resolve, reject });
|
|
@@ -251,33 +381,38 @@ async function executeCommand(tool: string, args: Record<string, any>): Promise<
|
|
|
251
381
|
sendToChrome({
|
|
252
382
|
type: "tool_request",
|
|
253
383
|
id,
|
|
254
|
-
tool,
|
|
384
|
+
tool: toolName,
|
|
255
385
|
args,
|
|
256
386
|
});
|
|
257
387
|
|
|
258
|
-
// Timeout after 60 seconds
|
|
259
388
|
setTimeout(() => {
|
|
260
|
-
if (pendingRequests.has(id))
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
389
|
+
if (!pendingRequests.has(id)) return;
|
|
390
|
+
pendingRequests.delete(id);
|
|
391
|
+
reject(new Error("Tool execution timed out after 60 seconds"));
|
|
264
392
|
}, 60000);
|
|
265
393
|
});
|
|
266
394
|
}
|
|
267
395
|
|
|
268
396
|
// ============================================================================
|
|
269
|
-
// Cleanup
|
|
397
|
+
// Cleanup / Signals
|
|
270
398
|
// ============================================================================
|
|
271
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
|
+
|
|
272
407
|
process.on("SIGTERM", () => {
|
|
273
408
|
releaseLock();
|
|
274
|
-
|
|
409
|
+
stopBrowserServer();
|
|
275
410
|
process.exit(0);
|
|
276
411
|
});
|
|
277
412
|
|
|
278
413
|
process.on("SIGINT", () => {
|
|
279
414
|
releaseLock();
|
|
280
|
-
|
|
415
|
+
stopBrowserServer();
|
|
281
416
|
process.exit(0);
|
|
282
417
|
});
|
|
283
418
|
|
|
@@ -289,38 +424,9 @@ process.on("exit", () => {
|
|
|
289
424
|
// Plugin Export
|
|
290
425
|
// ============================================================================
|
|
291
426
|
|
|
292
|
-
export const BrowserPlugin: Plugin = async (
|
|
427
|
+
export const BrowserPlugin: Plugin = async (_ctx) => {
|
|
293
428
|
console.error(`[browser-plugin] Initializing (session ${sessionId})`);
|
|
294
429
|
|
|
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
430
|
return {
|
|
325
431
|
tool: {
|
|
326
432
|
browser_status: tool({
|
|
@@ -335,33 +441,77 @@ export const BrowserPlugin: Plugin = async (ctx) => {
|
|
|
335
441
|
}
|
|
336
442
|
|
|
337
443
|
if (lock.sessionId === sessionId) {
|
|
338
|
-
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
|
+
);
|
|
339
451
|
}
|
|
340
452
|
|
|
341
|
-
|
|
342
|
-
|
|
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.");
|
|
343
480
|
}
|
|
344
481
|
|
|
345
|
-
|
|
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();
|
|
346
494
|
},
|
|
347
495
|
}),
|
|
348
496
|
|
|
349
497
|
browser_kill_session: tool({
|
|
350
498
|
description:
|
|
351
|
-
"
|
|
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).",
|
|
352
509
|
args: {},
|
|
353
510
|
async execute() {
|
|
354
511
|
const lock = readLock();
|
|
355
512
|
|
|
356
513
|
if (!lock) {
|
|
357
|
-
// No lock, just acquire
|
|
358
514
|
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
515
|
return "No active session. Browser now connected to this session.";
|
|
366
516
|
}
|
|
367
517
|
|
|
@@ -370,29 +520,36 @@ export const BrowserPlugin: Plugin = async (ctx) => {
|
|
|
370
520
|
}
|
|
371
521
|
|
|
372
522
|
if (!isProcessAlive(lock.pid)) {
|
|
373
|
-
// Stale lock
|
|
374
523
|
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
524
|
return `Cleaned stale lock (PID ${lock.pid} was dead). Browser now connected to this session.`;
|
|
382
525
|
}
|
|
383
526
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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.");
|
|
391
544
|
}
|
|
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
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.`;
|
|
396
553
|
},
|
|
397
554
|
}),
|
|
398
555
|
|
|
@@ -439,10 +596,10 @@ export const BrowserPlugin: Plugin = async (ctx) => {
|
|
|
439
596
|
tool.schema.string({ description: "Optional name for screenshot file (without extension)" })
|
|
440
597
|
),
|
|
441
598
|
},
|
|
442
|
-
async execute(args) {
|
|
599
|
+
async execute(args: { tabId?: number; name?: string }) {
|
|
443
600
|
const result = await executeCommand("screenshot", args);
|
|
444
601
|
|
|
445
|
-
if (result && result.startsWith("data:image")) {
|
|
602
|
+
if (result && typeof result === "string" && result.startsWith("data:image")) {
|
|
446
603
|
const base64Data = result.replace(/^data:image\/\w+;base64,/, "");
|
|
447
604
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
448
605
|
const filename = args.name ? `${args.name}.png` : `screenshot-${timestamp}.png`;
|