@different-ai/opencode-browser 1.0.5 → 2.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 +78 -52
- package/bin/cli.js +151 -251
- package/extension/background.js +3 -3
- package/extension/manifest.json +2 -3
- package/package.json +20 -9
- package/src/plugin.ts +450 -0
- package/src/daemon.js +0 -207
- package/src/host.js +0 -282
- package/src/server.js +0 -379
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Browser Plugin
|
|
3
|
+
*
|
|
4
|
+
* A simple plugin that provides browser automation tools.
|
|
5
|
+
* Connects to Chrome extension via WebSocket.
|
|
6
|
+
*
|
|
7
|
+
* Architecture:
|
|
8
|
+
* OpenCode Plugin (this) <--WebSocket:19222--> Chrome Extension
|
|
9
|
+
*
|
|
10
|
+
* Lock file ensures only one OpenCode session uses browser at a time.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
14
|
+
import { tool } from "@opencode-ai/plugin";
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
|
|
16
|
+
import { homedir } from "os";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
|
|
19
|
+
const WS_PORT = 19222;
|
|
20
|
+
const BASE_DIR = join(homedir(), ".opencode-browser");
|
|
21
|
+
const LOCK_FILE = join(BASE_DIR, "lock.json");
|
|
22
|
+
|
|
23
|
+
// Ensure base dir exists
|
|
24
|
+
mkdirSync(BASE_DIR, { recursive: true });
|
|
25
|
+
|
|
26
|
+
// Session state
|
|
27
|
+
const sessionId = Math.random().toString(36).slice(2);
|
|
28
|
+
const pid = process.pid;
|
|
29
|
+
let ws: WebSocket | null = null;
|
|
30
|
+
let isConnected = false;
|
|
31
|
+
let server: ReturnType<typeof Bun.serve> | null = null;
|
|
32
|
+
let pendingRequests = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
|
|
33
|
+
let requestId = 0;
|
|
34
|
+
let hasLock = false;
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Lock File Management
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
interface LockInfo {
|
|
41
|
+
pid: number;
|
|
42
|
+
sessionId: string;
|
|
43
|
+
startedAt: string;
|
|
44
|
+
cwd: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readLock(): LockInfo | null {
|
|
48
|
+
try {
|
|
49
|
+
if (!existsSync(LOCK_FILE)) return null;
|
|
50
|
+
return JSON.parse(readFileSync(LOCK_FILE, "utf-8"));
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function writeLock(): void {
|
|
57
|
+
writeFileSync(
|
|
58
|
+
LOCK_FILE,
|
|
59
|
+
JSON.stringify({
|
|
60
|
+
pid,
|
|
61
|
+
sessionId,
|
|
62
|
+
startedAt: new Date().toISOString(),
|
|
63
|
+
cwd: process.cwd(),
|
|
64
|
+
} satisfies LockInfo)
|
|
65
|
+
);
|
|
66
|
+
hasLock = true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function releaseLock(): void {
|
|
70
|
+
try {
|
|
71
|
+
const lock = readLock();
|
|
72
|
+
if (lock && lock.sessionId === sessionId) {
|
|
73
|
+
unlinkSync(LOCK_FILE);
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
76
|
+
hasLock = false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isProcessAlive(targetPid: number): boolean {
|
|
80
|
+
try {
|
|
81
|
+
process.kill(targetPid, 0);
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function tryAcquireLock(): { success: boolean; error?: string; lock?: LockInfo } {
|
|
89
|
+
const existingLock = readLock();
|
|
90
|
+
|
|
91
|
+
if (!existingLock) {
|
|
92
|
+
writeLock();
|
|
93
|
+
return { success: true };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (existingLock.sessionId === sessionId) {
|
|
97
|
+
return { success: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!isProcessAlive(existingLock.pid)) {
|
|
101
|
+
// Stale lock, take it
|
|
102
|
+
writeLock();
|
|
103
|
+
return { success: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
success: false,
|
|
108
|
+
error: `Browser locked by another session (PID ${existingLock.pid})`,
|
|
109
|
+
lock: existingLock,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function sleep(ms: number): Promise<void> {
|
|
114
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function killSession(targetPid: number): Promise<{ success: boolean; error?: string }> {
|
|
118
|
+
try {
|
|
119
|
+
process.kill(targetPid, "SIGTERM");
|
|
120
|
+
// Wait a bit for process to die
|
|
121
|
+
let attempts = 0;
|
|
122
|
+
while (isProcessAlive(targetPid) && attempts < 10) {
|
|
123
|
+
await sleep(100);
|
|
124
|
+
attempts++;
|
|
125
|
+
}
|
|
126
|
+
if (isProcessAlive(targetPid)) {
|
|
127
|
+
process.kill(targetPid, "SIGKILL");
|
|
128
|
+
}
|
|
129
|
+
// Remove lock and acquire
|
|
130
|
+
try { unlinkSync(LOCK_FILE); } catch {}
|
|
131
|
+
writeLock();
|
|
132
|
+
return { success: true };
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ============================================================================
|
|
139
|
+
// WebSocket Server
|
|
140
|
+
// ============================================================================
|
|
141
|
+
|
|
142
|
+
function startServer(): boolean {
|
|
143
|
+
try {
|
|
144
|
+
server = Bun.serve({
|
|
145
|
+
port: WS_PORT,
|
|
146
|
+
fetch(req, server) {
|
|
147
|
+
if (server.upgrade(req)) return;
|
|
148
|
+
return new Response("OpenCode Browser Plugin", { status: 200 });
|
|
149
|
+
},
|
|
150
|
+
websocket: {
|
|
151
|
+
open(wsClient) {
|
|
152
|
+
console.error(`[browser-plugin] Chrome extension connected`);
|
|
153
|
+
ws = wsClient as unknown as WebSocket;
|
|
154
|
+
isConnected = true;
|
|
155
|
+
},
|
|
156
|
+
close() {
|
|
157
|
+
console.error(`[browser-plugin] Chrome extension disconnected`);
|
|
158
|
+
ws = null;
|
|
159
|
+
isConnected = false;
|
|
160
|
+
},
|
|
161
|
+
message(wsClient, data) {
|
|
162
|
+
try {
|
|
163
|
+
const message = JSON.parse(data.toString());
|
|
164
|
+
handleMessage(message);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
console.error(`[browser-plugin] Parse error:`, e);
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
console.error(`[browser-plugin] WebSocket server listening on port ${WS_PORT}`);
|
|
172
|
+
return true;
|
|
173
|
+
} catch (e) {
|
|
174
|
+
console.error(`[browser-plugin] Failed to start server:`, e);
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function handleMessage(message: { type: string; id?: number; result?: any; error?: any }) {
|
|
180
|
+
if (message.type === "tool_response" && message.id !== undefined) {
|
|
181
|
+
const pending = pendingRequests.get(message.id);
|
|
182
|
+
if (pending) {
|
|
183
|
+
pendingRequests.delete(message.id);
|
|
184
|
+
if (message.error) {
|
|
185
|
+
pending.reject(new Error(message.error.content || String(message.error)));
|
|
186
|
+
} else {
|
|
187
|
+
pending.resolve(message.result?.content);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} else if (message.type === "pong") {
|
|
191
|
+
// Heartbeat response, ignore
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function sendToChrome(message: any): boolean {
|
|
196
|
+
if (ws && isConnected) {
|
|
197
|
+
(ws as any).send(JSON.stringify(message));
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function executeCommand(tool: string, args: Record<string, any>): Promise<any> {
|
|
204
|
+
// Check lock first
|
|
205
|
+
const lockResult = tryAcquireLock();
|
|
206
|
+
if (!lockResult.success) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`${lockResult.error}. Use browser_kill_session to take over, or browser_status to see details.`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Start server if not running
|
|
213
|
+
if (!server) {
|
|
214
|
+
if (!startServer()) {
|
|
215
|
+
throw new Error("Failed to start WebSocket server. Port may be in use.");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (!isConnected) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
"Chrome extension not connected. Make sure Chrome is running with the OpenCode Browser extension enabled."
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const id = ++requestId;
|
|
226
|
+
|
|
227
|
+
return new Promise((resolve, reject) => {
|
|
228
|
+
pendingRequests.set(id, { resolve, reject });
|
|
229
|
+
|
|
230
|
+
sendToChrome({
|
|
231
|
+
type: "tool_request",
|
|
232
|
+
id,
|
|
233
|
+
tool,
|
|
234
|
+
args,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Timeout after 60 seconds
|
|
238
|
+
setTimeout(() => {
|
|
239
|
+
if (pendingRequests.has(id)) {
|
|
240
|
+
pendingRequests.delete(id);
|
|
241
|
+
reject(new Error("Tool execution timed out after 60 seconds"));
|
|
242
|
+
}
|
|
243
|
+
}, 60000);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ============================================================================
|
|
248
|
+
// Cleanup on exit
|
|
249
|
+
// ============================================================================
|
|
250
|
+
|
|
251
|
+
process.on("SIGTERM", () => {
|
|
252
|
+
releaseLock();
|
|
253
|
+
server?.stop();
|
|
254
|
+
process.exit(0);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
process.on("SIGINT", () => {
|
|
258
|
+
releaseLock();
|
|
259
|
+
server?.stop();
|
|
260
|
+
process.exit(0);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
process.on("exit", () => {
|
|
264
|
+
releaseLock();
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ============================================================================
|
|
268
|
+
// Plugin Export
|
|
269
|
+
// ============================================================================
|
|
270
|
+
|
|
271
|
+
export const BrowserPlugin: Plugin = async (ctx) => {
|
|
272
|
+
console.error(`[browser-plugin] Initializing (session ${sessionId})`);
|
|
273
|
+
|
|
274
|
+
// Try to acquire lock and start server on load
|
|
275
|
+
const lockResult = tryAcquireLock();
|
|
276
|
+
if (lockResult.success) {
|
|
277
|
+
startServer();
|
|
278
|
+
} else {
|
|
279
|
+
console.error(`[browser-plugin] Lock held by PID ${lockResult.lock?.pid}, tools will fail until lock is released`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
tool: {
|
|
284
|
+
browser_status: tool({
|
|
285
|
+
description:
|
|
286
|
+
"Check if browser is available or locked by another session. Returns connection status and lock info.",
|
|
287
|
+
args: {},
|
|
288
|
+
async execute() {
|
|
289
|
+
const lock = readLock();
|
|
290
|
+
|
|
291
|
+
if (!lock) {
|
|
292
|
+
return "Browser available (no active session)";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (lock.sessionId === sessionId) {
|
|
296
|
+
return `Browser connected (this session)\nPID: ${pid}\nStarted: ${lock.startedAt}\nExtension: ${isConnected ? "connected" : "not connected"}`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!isProcessAlive(lock.pid)) {
|
|
300
|
+
return `Browser available (stale lock from dead PID ${lock.pid} will be auto-cleaned)`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
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.`;
|
|
304
|
+
},
|
|
305
|
+
}),
|
|
306
|
+
|
|
307
|
+
browser_kill_session: tool({
|
|
308
|
+
description:
|
|
309
|
+
"Kill the session that currently holds the browser lock and take over. Use when browser_status shows another session has the lock.",
|
|
310
|
+
args: {},
|
|
311
|
+
async execute() {
|
|
312
|
+
const lock = readLock();
|
|
313
|
+
|
|
314
|
+
if (!lock) {
|
|
315
|
+
// No lock, just acquire
|
|
316
|
+
writeLock();
|
|
317
|
+
if (!server) startServer();
|
|
318
|
+
return "No active session. Browser now connected to this session.";
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (lock.sessionId === sessionId) {
|
|
322
|
+
return "This session already owns the browser.";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!isProcessAlive(lock.pid)) {
|
|
326
|
+
// Stale lock
|
|
327
|
+
writeLock();
|
|
328
|
+
if (!server) startServer();
|
|
329
|
+
return `Cleaned stale lock (PID ${lock.pid} was dead). Browser now connected to this session.`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Kill the other session
|
|
333
|
+
const result = await killSession(lock.pid);
|
|
334
|
+
if (result.success) {
|
|
335
|
+
if (!server) startServer();
|
|
336
|
+
return `Killed session ${lock.sessionId} (PID ${lock.pid}). Browser now connected to this session.`;
|
|
337
|
+
} else {
|
|
338
|
+
throw new Error(`Failed to kill session: ${result.error}`);
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
}),
|
|
342
|
+
|
|
343
|
+
browser_navigate: tool({
|
|
344
|
+
description: "Navigate to a URL in the browser",
|
|
345
|
+
args: {
|
|
346
|
+
url: tool.schema.string({ description: "The URL to navigate to" }),
|
|
347
|
+
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
|
|
348
|
+
},
|
|
349
|
+
async execute(args) {
|
|
350
|
+
return await executeCommand("navigate", args);
|
|
351
|
+
},
|
|
352
|
+
}),
|
|
353
|
+
|
|
354
|
+
browser_click: tool({
|
|
355
|
+
description: "Click an element on the page using a CSS selector",
|
|
356
|
+
args: {
|
|
357
|
+
selector: tool.schema.string({ description: "CSS selector for the element to click" }),
|
|
358
|
+
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
|
|
359
|
+
},
|
|
360
|
+
async execute(args) {
|
|
361
|
+
return await executeCommand("click", args);
|
|
362
|
+
},
|
|
363
|
+
}),
|
|
364
|
+
|
|
365
|
+
browser_type: tool({
|
|
366
|
+
description: "Type text into an input element",
|
|
367
|
+
args: {
|
|
368
|
+
selector: tool.schema.string({ description: "CSS selector for the input element" }),
|
|
369
|
+
text: tool.schema.string({ description: "Text to type" }),
|
|
370
|
+
clear: tool.schema.optional(tool.schema.boolean({ description: "Clear field before typing" })),
|
|
371
|
+
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
|
|
372
|
+
},
|
|
373
|
+
async execute(args) {
|
|
374
|
+
return await executeCommand("type", args);
|
|
375
|
+
},
|
|
376
|
+
}),
|
|
377
|
+
|
|
378
|
+
browser_screenshot: tool({
|
|
379
|
+
description: "Take a screenshot of the current page",
|
|
380
|
+
args: {
|
|
381
|
+
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
|
|
382
|
+
},
|
|
383
|
+
async execute(args) {
|
|
384
|
+
const result = await executeCommand("screenshot", args);
|
|
385
|
+
// Return as base64 image
|
|
386
|
+
if (result && result.startsWith("data:image")) {
|
|
387
|
+
const base64Data = result.replace(/^data:image\/\w+;base64,/, "");
|
|
388
|
+
return { type: "image", data: base64Data, mimeType: "image/png" };
|
|
389
|
+
}
|
|
390
|
+
return result;
|
|
391
|
+
},
|
|
392
|
+
}),
|
|
393
|
+
|
|
394
|
+
browser_snapshot: tool({
|
|
395
|
+
description:
|
|
396
|
+
"Get an accessibility tree snapshot of the page. Returns interactive elements with selectors for clicking.",
|
|
397
|
+
args: {
|
|
398
|
+
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
|
|
399
|
+
},
|
|
400
|
+
async execute(args) {
|
|
401
|
+
return await executeCommand("snapshot", args);
|
|
402
|
+
},
|
|
403
|
+
}),
|
|
404
|
+
|
|
405
|
+
browser_get_tabs: tool({
|
|
406
|
+
description: "List all open browser tabs",
|
|
407
|
+
args: {},
|
|
408
|
+
async execute() {
|
|
409
|
+
return await executeCommand("get_tabs", {});
|
|
410
|
+
},
|
|
411
|
+
}),
|
|
412
|
+
|
|
413
|
+
browser_scroll: tool({
|
|
414
|
+
description: "Scroll the page or scroll an element into view",
|
|
415
|
+
args: {
|
|
416
|
+
selector: tool.schema.optional(tool.schema.string({ description: "CSS selector to scroll into view" })),
|
|
417
|
+
x: tool.schema.optional(tool.schema.number({ description: "Horizontal scroll amount in pixels" })),
|
|
418
|
+
y: tool.schema.optional(tool.schema.number({ description: "Vertical scroll amount in pixels" })),
|
|
419
|
+
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
|
|
420
|
+
},
|
|
421
|
+
async execute(args) {
|
|
422
|
+
return await executeCommand("scroll", args);
|
|
423
|
+
},
|
|
424
|
+
}),
|
|
425
|
+
|
|
426
|
+
browser_wait: tool({
|
|
427
|
+
description: "Wait for a specified duration",
|
|
428
|
+
args: {
|
|
429
|
+
ms: tool.schema.optional(tool.schema.number({ description: "Milliseconds to wait (default: 1000)" })),
|
|
430
|
+
},
|
|
431
|
+
async execute(args) {
|
|
432
|
+
return await executeCommand("wait", args);
|
|
433
|
+
},
|
|
434
|
+
}),
|
|
435
|
+
|
|
436
|
+
browser_execute: tool({
|
|
437
|
+
description: "Execute JavaScript code in the page context and return the result",
|
|
438
|
+
args: {
|
|
439
|
+
code: tool.schema.string({ description: "JavaScript code to execute" }),
|
|
440
|
+
tabId: tool.schema.optional(tool.schema.number({ description: "Optional tab ID" })),
|
|
441
|
+
},
|
|
442
|
+
async execute(args) {
|
|
443
|
+
return await executeCommand("execute_script", args);
|
|
444
|
+
},
|
|
445
|
+
}),
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
export default BrowserPlugin;
|
package/src/daemon.js
DELETED
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Persistent Browser Bridge Daemon
|
|
4
|
-
*
|
|
5
|
-
* Runs as a background service and bridges:
|
|
6
|
-
* - Chrome extension (via WebSocket on localhost)
|
|
7
|
-
* - MCP server (via Unix socket)
|
|
8
|
-
*
|
|
9
|
-
* This allows scheduled jobs to use browser tools even if
|
|
10
|
-
* the OpenCode session that created the job isn't running.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { createServer as createNetServer } from "net";
|
|
14
|
-
import { WebSocketServer } from "ws";
|
|
15
|
-
import { existsSync, mkdirSync, unlinkSync, appendFileSync } from "fs";
|
|
16
|
-
import { homedir } from "os";
|
|
17
|
-
import { join } from "path";
|
|
18
|
-
|
|
19
|
-
const BASE_DIR = join(homedir(), ".opencode-browser");
|
|
20
|
-
const LOG_DIR = join(BASE_DIR, "logs");
|
|
21
|
-
const SOCKET_PATH = join(BASE_DIR, "browser.sock");
|
|
22
|
-
const WS_PORT = 19222;
|
|
23
|
-
|
|
24
|
-
if (!existsSync(LOG_DIR)) mkdirSync(LOG_DIR, { recursive: true });
|
|
25
|
-
const LOG_FILE = join(LOG_DIR, "daemon.log");
|
|
26
|
-
|
|
27
|
-
function log(...args) {
|
|
28
|
-
const timestamp = new Date().toISOString();
|
|
29
|
-
const message = `[${timestamp}] ${args.join(" ")}\n`;
|
|
30
|
-
appendFileSync(LOG_FILE, message);
|
|
31
|
-
console.error(message.trim());
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
log("Daemon starting...");
|
|
35
|
-
|
|
36
|
-
// State
|
|
37
|
-
let chromeConnection = null;
|
|
38
|
-
let mcpConnections = new Set();
|
|
39
|
-
let pendingRequests = new Map();
|
|
40
|
-
let requestId = 0;
|
|
41
|
-
|
|
42
|
-
// ============================================================================
|
|
43
|
-
// WebSocket Server for Chrome Extension
|
|
44
|
-
// ============================================================================
|
|
45
|
-
|
|
46
|
-
const wss = new WebSocketServer({ port: WS_PORT });
|
|
47
|
-
|
|
48
|
-
wss.on("connection", (ws) => {
|
|
49
|
-
log("Chrome extension connected via WebSocket");
|
|
50
|
-
chromeConnection = ws;
|
|
51
|
-
|
|
52
|
-
ws.on("message", (data) => {
|
|
53
|
-
try {
|
|
54
|
-
const message = JSON.parse(data.toString());
|
|
55
|
-
handleChromeMessage(message);
|
|
56
|
-
} catch (e) {
|
|
57
|
-
log("Failed to parse Chrome message:", e.message);
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
ws.on("close", () => {
|
|
62
|
-
log("Chrome extension disconnected");
|
|
63
|
-
chromeConnection = null;
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
ws.on("error", (err) => {
|
|
67
|
-
log("Chrome WebSocket error:", err.message);
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
wss.on("listening", () => {
|
|
72
|
-
log(`WebSocket server listening on port ${WS_PORT}`);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
function sendToChrome(message) {
|
|
76
|
-
if (chromeConnection && chromeConnection.readyState === 1) {
|
|
77
|
-
chromeConnection.send(JSON.stringify(message));
|
|
78
|
-
return true;
|
|
79
|
-
}
|
|
80
|
-
return false;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function handleChromeMessage(message) {
|
|
84
|
-
log("From Chrome:", message.type);
|
|
85
|
-
|
|
86
|
-
if (message.type === "tool_response") {
|
|
87
|
-
const pending = pendingRequests.get(message.id);
|
|
88
|
-
if (pending) {
|
|
89
|
-
pendingRequests.delete(message.id);
|
|
90
|
-
sendToMcp(pending.socket, {
|
|
91
|
-
type: "tool_response",
|
|
92
|
-
id: pending.mcpId,
|
|
93
|
-
result: message.result,
|
|
94
|
-
error: message.error
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
} else if (message.type === "pong") {
|
|
98
|
-
log("Chrome ping OK");
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// ============================================================================
|
|
103
|
-
// Unix Socket Server for MCP
|
|
104
|
-
// ============================================================================
|
|
105
|
-
|
|
106
|
-
try {
|
|
107
|
-
if (existsSync(SOCKET_PATH)) {
|
|
108
|
-
unlinkSync(SOCKET_PATH);
|
|
109
|
-
}
|
|
110
|
-
} catch {}
|
|
111
|
-
|
|
112
|
-
const unixServer = createNetServer((socket) => {
|
|
113
|
-
log("MCP server connected");
|
|
114
|
-
mcpConnections.add(socket);
|
|
115
|
-
|
|
116
|
-
let buffer = "";
|
|
117
|
-
|
|
118
|
-
socket.on("data", (data) => {
|
|
119
|
-
buffer += data.toString();
|
|
120
|
-
|
|
121
|
-
const lines = buffer.split("\n");
|
|
122
|
-
buffer = lines.pop() || "";
|
|
123
|
-
|
|
124
|
-
for (const line of lines) {
|
|
125
|
-
if (line.trim()) {
|
|
126
|
-
try {
|
|
127
|
-
const message = JSON.parse(line);
|
|
128
|
-
handleMcpMessage(socket, message);
|
|
129
|
-
} catch (e) {
|
|
130
|
-
log("Failed to parse MCP message:", e.message);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
socket.on("close", () => {
|
|
137
|
-
log("MCP server disconnected");
|
|
138
|
-
mcpConnections.delete(socket);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
socket.on("error", (err) => {
|
|
142
|
-
log("MCP socket error:", err.message);
|
|
143
|
-
mcpConnections.delete(socket);
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
unixServer.listen(SOCKET_PATH, () => {
|
|
148
|
-
log(`Unix socket listening at ${SOCKET_PATH}`);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
function sendToMcp(socket, message) {
|
|
152
|
-
if (socket && !socket.destroyed) {
|
|
153
|
-
socket.write(JSON.stringify(message) + "\n");
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function handleMcpMessage(socket, message) {
|
|
158
|
-
log("From MCP:", message.type, message.tool || "");
|
|
159
|
-
|
|
160
|
-
if (message.type === "tool_request") {
|
|
161
|
-
if (!chromeConnection) {
|
|
162
|
-
sendToMcp(socket, {
|
|
163
|
-
type: "tool_response",
|
|
164
|
-
id: message.id,
|
|
165
|
-
error: { content: "Chrome extension not connected. Open Chrome and ensure the OpenCode extension is enabled." }
|
|
166
|
-
});
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const id = ++requestId;
|
|
171
|
-
pendingRequests.set(id, { socket, mcpId: message.id });
|
|
172
|
-
|
|
173
|
-
sendToChrome({
|
|
174
|
-
type: "tool_request",
|
|
175
|
-
id,
|
|
176
|
-
tool: message.tool,
|
|
177
|
-
args: message.args
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// ============================================================================
|
|
183
|
-
// Health Check
|
|
184
|
-
// ============================================================================
|
|
185
|
-
|
|
186
|
-
setInterval(() => {
|
|
187
|
-
if (chromeConnection) {
|
|
188
|
-
sendToChrome({ type: "ping" });
|
|
189
|
-
}
|
|
190
|
-
}, 30000);
|
|
191
|
-
|
|
192
|
-
// ============================================================================
|
|
193
|
-
// Graceful Shutdown
|
|
194
|
-
// ============================================================================
|
|
195
|
-
|
|
196
|
-
function shutdown() {
|
|
197
|
-
log("Shutting down...");
|
|
198
|
-
wss.close();
|
|
199
|
-
unixServer.close();
|
|
200
|
-
try { unlinkSync(SOCKET_PATH); } catch {}
|
|
201
|
-
process.exit(0);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
process.on("SIGTERM", shutdown);
|
|
205
|
-
process.on("SIGINT", shutdown);
|
|
206
|
-
|
|
207
|
-
log("Daemon started successfully");
|