@different-ai/opencode-browser 1.0.0 → 1.0.1
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/bin/cli.js +90 -5
- package/extension/background.js +130 -301
- package/extension/manifest.json +2 -1
- package/package.json +3 -2
- package/src/daemon.js +207 -0
package/bin/cli.js
CHANGED
|
@@ -85,14 +85,25 @@ ${color("cyan", color("bright", "╚══════════════
|
|
|
85
85
|
await install();
|
|
86
86
|
} else if (command === "uninstall") {
|
|
87
87
|
await uninstall();
|
|
88
|
+
} else if (command === "daemon") {
|
|
89
|
+
await startDaemon();
|
|
90
|
+
} else if (command === "daemon-install") {
|
|
91
|
+
await installDaemon();
|
|
92
|
+
} else if (command === "start") {
|
|
93
|
+
rl.close();
|
|
94
|
+
await import("../src/server.js");
|
|
95
|
+
return;
|
|
88
96
|
} else {
|
|
89
97
|
log(`
|
|
90
98
|
${color("bright", "Usage:")}
|
|
91
|
-
npx opencode-browser install
|
|
92
|
-
npx opencode-browser
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
npx @different-ai/opencode-browser install Install extension
|
|
100
|
+
npx @different-ai/opencode-browser daemon-install Install background daemon
|
|
101
|
+
npx @different-ai/opencode-browser daemon Run daemon (foreground)
|
|
102
|
+
npx @different-ai/opencode-browser start Run MCP server
|
|
103
|
+
npx @different-ai/opencode-browser uninstall Remove installation
|
|
104
|
+
|
|
105
|
+
${color("bright", "For scheduled jobs:")}
|
|
106
|
+
Run 'daemon-install' to enable browser tools in background jobs.
|
|
96
107
|
`);
|
|
97
108
|
}
|
|
98
109
|
|
|
@@ -283,6 +294,80 @@ ${color("bright", "Logs:")} ~/.opencode-browser/logs/
|
|
|
283
294
|
`);
|
|
284
295
|
}
|
|
285
296
|
|
|
297
|
+
async function startDaemon() {
|
|
298
|
+
const { spawn } = await import("child_process");
|
|
299
|
+
const daemonPath = join(PACKAGE_ROOT, "src", "daemon.js");
|
|
300
|
+
log("Starting daemon...");
|
|
301
|
+
const child = spawn(process.execPath, [daemonPath], { stdio: "inherit" });
|
|
302
|
+
child.on("exit", (code) => process.exit(code || 0));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function installDaemon() {
|
|
306
|
+
header("Installing Background Daemon");
|
|
307
|
+
|
|
308
|
+
const os = platform();
|
|
309
|
+
if (os !== "darwin") {
|
|
310
|
+
error("Daemon auto-install currently supports macOS only");
|
|
311
|
+
log("On Linux, create a systemd service manually.");
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const nodePath = process.execPath;
|
|
316
|
+
const daemonPath = join(PACKAGE_ROOT, "src", "daemon.js");
|
|
317
|
+
const logsDir = join(homedir(), ".opencode-browser", "logs");
|
|
318
|
+
|
|
319
|
+
mkdirSync(logsDir, { recursive: true });
|
|
320
|
+
|
|
321
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
322
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
323
|
+
<plist version="1.0">
|
|
324
|
+
<dict>
|
|
325
|
+
<key>Label</key>
|
|
326
|
+
<string>com.opencode.browser-daemon</string>
|
|
327
|
+
<key>ProgramArguments</key>
|
|
328
|
+
<array>
|
|
329
|
+
<string>${nodePath}</string>
|
|
330
|
+
<string>${daemonPath}</string>
|
|
331
|
+
</array>
|
|
332
|
+
<key>RunAtLoad</key>
|
|
333
|
+
<true/>
|
|
334
|
+
<key>KeepAlive</key>
|
|
335
|
+
<true/>
|
|
336
|
+
<key>StandardOutPath</key>
|
|
337
|
+
<string>${logsDir}/daemon.log</string>
|
|
338
|
+
<key>StandardErrorPath</key>
|
|
339
|
+
<string>${logsDir}/daemon.log</string>
|
|
340
|
+
</dict>
|
|
341
|
+
</plist>`;
|
|
342
|
+
|
|
343
|
+
const plistPath = join(homedir(), "Library", "LaunchAgents", "com.opencode.browser-daemon.plist");
|
|
344
|
+
writeFileSync(plistPath, plist);
|
|
345
|
+
success(`Created launchd plist: ${plistPath}`);
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
execSync(`launchctl unload "${plistPath}" 2>/dev/null || true`);
|
|
349
|
+
execSync(`launchctl load "${plistPath}"`);
|
|
350
|
+
success("Daemon started");
|
|
351
|
+
} catch (e) {
|
|
352
|
+
error(`Failed to load daemon: ${e.message}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
log(`
|
|
356
|
+
${color("green", "✓")} Daemon installed and running
|
|
357
|
+
|
|
358
|
+
The daemon bridges Chrome extension ↔ MCP server.
|
|
359
|
+
It runs automatically on login and enables browser
|
|
360
|
+
tools in scheduled OpenCode jobs.
|
|
361
|
+
|
|
362
|
+
${color("bright", "Logs:")} ${logsDir}/daemon.log
|
|
363
|
+
|
|
364
|
+
${color("bright", "Control:")}
|
|
365
|
+
launchctl stop com.opencode.browser-daemon
|
|
366
|
+
launchctl start com.opencode.browser-daemon
|
|
367
|
+
launchctl unload ~/Library/LaunchAgents/com.opencode.browser-daemon.plist
|
|
368
|
+
`);
|
|
369
|
+
}
|
|
370
|
+
|
|
286
371
|
async function uninstall() {
|
|
287
372
|
header("Uninstalling OpenCode Browser");
|
|
288
373
|
|
package/extension/background.js
CHANGED
|
@@ -1,156 +1,114 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
const DAEMON_URL = "ws://localhost:19222";
|
|
2
|
+
const KEEPALIVE_ALARM = "keepalive";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
let nativePort = null;
|
|
4
|
+
let ws = null;
|
|
7
5
|
let isConnected = false;
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
// Native Messaging Connection
|
|
11
|
-
// ============================================================================
|
|
7
|
+
chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.25 });
|
|
12
8
|
|
|
13
|
-
|
|
14
|
-
if (
|
|
15
|
-
|
|
9
|
+
chrome.alarms.onAlarm.addListener((alarm) => {
|
|
10
|
+
if (alarm.name === KEEPALIVE_ALARM) {
|
|
11
|
+
if (!isConnected) {
|
|
12
|
+
console.log("[OpenCode] Alarm triggered reconnect");
|
|
13
|
+
connect();
|
|
14
|
+
}
|
|
16
15
|
}
|
|
16
|
+
});
|
|
17
17
|
|
|
18
|
+
function connect() {
|
|
19
|
+
if (ws && ws.readyState === WebSocket.OPEN) return;
|
|
20
|
+
if (ws) {
|
|
21
|
+
try { ws.close(); } catch {}
|
|
22
|
+
ws = null;
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
try {
|
|
19
|
-
|
|
26
|
+
ws = new WebSocket(DAEMON_URL);
|
|
20
27
|
|
|
21
|
-
|
|
28
|
+
ws.onopen = () => {
|
|
29
|
+
console.log("[OpenCode] Connected to daemon");
|
|
30
|
+
isConnected = true;
|
|
31
|
+
updateBadge(true);
|
|
32
|
+
};
|
|
22
33
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
ws.onmessage = async (event) => {
|
|
35
|
+
try {
|
|
36
|
+
const message = JSON.parse(event.data);
|
|
37
|
+
await handleMessage(message);
|
|
38
|
+
} catch (e) {
|
|
39
|
+
console.error("[OpenCode] Parse error:", e);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
ws.onclose = () => {
|
|
44
|
+
console.log("[OpenCode] Disconnected");
|
|
27
45
|
isConnected = false;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
nativePort.onMessage.addListener(pingHandler);
|
|
43
|
-
nativePort.postMessage({ type: "ping" });
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
if (connected) {
|
|
47
|
-
isConnected = true;
|
|
48
|
-
console.log("[OpenCode] Connected to native host");
|
|
49
|
-
return true;
|
|
50
|
-
} else {
|
|
51
|
-
nativePort.disconnect();
|
|
52
|
-
nativePort = null;
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
} catch (error) {
|
|
56
|
-
console.error("[OpenCode] Failed to connect:", error);
|
|
57
|
-
nativePort = null;
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function disconnectNativeHost() {
|
|
63
|
-
if (nativePort) {
|
|
64
|
-
nativePort.disconnect();
|
|
65
|
-
nativePort = null;
|
|
46
|
+
ws = null;
|
|
47
|
+
updateBadge(false);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
ws.onerror = (err) => {
|
|
51
|
+
console.error("[OpenCode] WebSocket error");
|
|
52
|
+
isConnected = false;
|
|
53
|
+
updateBadge(false);
|
|
54
|
+
};
|
|
55
|
+
} catch (e) {
|
|
56
|
+
console.error("[OpenCode] Connect failed:", e);
|
|
66
57
|
isConnected = false;
|
|
58
|
+
updateBadge(false);
|
|
67
59
|
}
|
|
68
60
|
}
|
|
69
61
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
async function handleNativeMessage(message) {
|
|
75
|
-
console.log("[OpenCode] Received from native:", message.type);
|
|
62
|
+
function updateBadge(connected) {
|
|
63
|
+
chrome.action.setBadgeText({ text: connected ? "ON" : "" });
|
|
64
|
+
chrome.action.setBadgeBackgroundColor({ color: connected ? "#22c55e" : "#ef4444" });
|
|
65
|
+
}
|
|
76
66
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
case "ping":
|
|
82
|
-
sendToNative({ type: "pong" });
|
|
83
|
-
break;
|
|
84
|
-
case "get_status":
|
|
85
|
-
sendToNative({
|
|
86
|
-
type: "status_response",
|
|
87
|
-
connected: isConnected,
|
|
88
|
-
version: chrome.runtime.getManifest().version
|
|
89
|
-
});
|
|
90
|
-
break;
|
|
67
|
+
function send(message) {
|
|
68
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
69
|
+
ws.send(JSON.stringify(message));
|
|
70
|
+
return true;
|
|
91
71
|
}
|
|
72
|
+
return false;
|
|
92
73
|
}
|
|
93
74
|
|
|
94
|
-
function
|
|
95
|
-
if (
|
|
96
|
-
|
|
97
|
-
} else {
|
|
98
|
-
|
|
75
|
+
async function handleMessage(message) {
|
|
76
|
+
if (message.type === "tool_request") {
|
|
77
|
+
await handleToolRequest(message);
|
|
78
|
+
} else if (message.type === "ping") {
|
|
79
|
+
send({ type: "pong" });
|
|
99
80
|
}
|
|
100
81
|
}
|
|
101
82
|
|
|
102
|
-
// ============================================================================
|
|
103
|
-
// Tool Execution
|
|
104
|
-
// ============================================================================
|
|
105
|
-
|
|
106
83
|
async function handleToolRequest(request) {
|
|
107
84
|
const { id, tool, args } = request;
|
|
108
85
|
|
|
109
86
|
try {
|
|
110
87
|
const result = await executeTool(tool, args || {});
|
|
111
|
-
|
|
112
|
-
type: "tool_response",
|
|
113
|
-
id,
|
|
114
|
-
result: { content: result }
|
|
115
|
-
});
|
|
88
|
+
send({ type: "tool_response", id, result: { content: result } });
|
|
116
89
|
} catch (error) {
|
|
117
|
-
|
|
118
|
-
type: "tool_response",
|
|
119
|
-
id,
|
|
120
|
-
error: { content: error.message || String(error) }
|
|
121
|
-
});
|
|
90
|
+
send({ type: "tool_response", id, error: { content: error.message || String(error) } });
|
|
122
91
|
}
|
|
123
92
|
}
|
|
124
93
|
|
|
125
94
|
async function executeTool(toolName, args) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
case "scroll":
|
|
142
|
-
return await toolScroll(args);
|
|
143
|
-
case "wait":
|
|
144
|
-
return await toolWait(args);
|
|
145
|
-
default:
|
|
146
|
-
throw new Error(`Unknown tool: ${toolName}`);
|
|
147
|
-
}
|
|
95
|
+
const tools = {
|
|
96
|
+
navigate: toolNavigate,
|
|
97
|
+
click: toolClick,
|
|
98
|
+
type: toolType,
|
|
99
|
+
screenshot: toolScreenshot,
|
|
100
|
+
snapshot: toolSnapshot,
|
|
101
|
+
get_tabs: toolGetTabs,
|
|
102
|
+
execute_script: toolExecuteScript,
|
|
103
|
+
scroll: toolScroll,
|
|
104
|
+
wait: toolWait
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const fn = tools[toolName];
|
|
108
|
+
if (!fn) throw new Error(`Unknown tool: ${toolName}`);
|
|
109
|
+
return await fn(args);
|
|
148
110
|
}
|
|
149
111
|
|
|
150
|
-
// ============================================================================
|
|
151
|
-
// Tool Implementations
|
|
152
|
-
// ============================================================================
|
|
153
|
-
|
|
154
112
|
async function getActiveTab() {
|
|
155
113
|
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
156
114
|
if (!tab?.id) throw new Error("No active tab found");
|
|
@@ -158,19 +116,14 @@ async function getActiveTab() {
|
|
|
158
116
|
}
|
|
159
117
|
|
|
160
118
|
async function getTabById(tabId) {
|
|
161
|
-
|
|
162
|
-
return await chrome.tabs.get(tabId);
|
|
163
|
-
}
|
|
164
|
-
return await getActiveTab();
|
|
119
|
+
return tabId ? await chrome.tabs.get(tabId) : await getActiveTab();
|
|
165
120
|
}
|
|
166
121
|
|
|
167
122
|
async function toolNavigate({ url, tabId }) {
|
|
168
123
|
if (!url) throw new Error("URL is required");
|
|
169
|
-
|
|
170
124
|
const tab = await getTabById(tabId);
|
|
171
125
|
await chrome.tabs.update(tab.id, { url });
|
|
172
126
|
|
|
173
|
-
// Wait for page to load
|
|
174
127
|
await new Promise((resolve) => {
|
|
175
128
|
const listener = (updatedTabId, info) => {
|
|
176
129
|
if (updatedTabId === tab.id && info.status === "complete") {
|
|
@@ -179,11 +132,7 @@ async function toolNavigate({ url, tabId }) {
|
|
|
179
132
|
}
|
|
180
133
|
};
|
|
181
134
|
chrome.tabs.onUpdated.addListener(listener);
|
|
182
|
-
|
|
183
|
-
setTimeout(() => {
|
|
184
|
-
chrome.tabs.onUpdated.removeListener(listener);
|
|
185
|
-
resolve();
|
|
186
|
-
}, 30000);
|
|
135
|
+
setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, 30000);
|
|
187
136
|
});
|
|
188
137
|
|
|
189
138
|
return `Navigated to ${url}`;
|
|
@@ -191,74 +140,54 @@ async function toolNavigate({ url, tabId }) {
|
|
|
191
140
|
|
|
192
141
|
async function toolClick({ selector, tabId }) {
|
|
193
142
|
if (!selector) throw new Error("Selector is required");
|
|
194
|
-
|
|
195
143
|
const tab = await getTabById(tabId);
|
|
196
144
|
|
|
197
145
|
const result = await chrome.scripting.executeScript({
|
|
198
146
|
target: { tabId: tab.id },
|
|
199
147
|
func: (sel) => {
|
|
200
|
-
const
|
|
201
|
-
if (!
|
|
202
|
-
|
|
148
|
+
const el = document.querySelector(sel);
|
|
149
|
+
if (!el) return { success: false, error: `Element not found: ${sel}` };
|
|
150
|
+
el.click();
|
|
203
151
|
return { success: true };
|
|
204
152
|
},
|
|
205
153
|
args: [selector]
|
|
206
154
|
});
|
|
207
155
|
|
|
208
|
-
if (!result[0]?.result?.success)
|
|
209
|
-
throw new Error(result[0]?.result?.error || "Click failed");
|
|
210
|
-
}
|
|
211
|
-
|
|
156
|
+
if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Click failed");
|
|
212
157
|
return `Clicked ${selector}`;
|
|
213
158
|
}
|
|
214
159
|
|
|
215
160
|
async function toolType({ selector, text, tabId, clear = false }) {
|
|
216
161
|
if (!selector) throw new Error("Selector is required");
|
|
217
162
|
if (text === undefined) throw new Error("Text is required");
|
|
218
|
-
|
|
219
163
|
const tab = await getTabById(tabId);
|
|
220
164
|
|
|
221
165
|
const result = await chrome.scripting.executeScript({
|
|
222
166
|
target: { tabId: tab.id },
|
|
223
167
|
func: (sel, txt, shouldClear) => {
|
|
224
|
-
const
|
|
225
|
-
if (!
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
if (
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (element.tagName === "INPUT" || element.tagName === "TEXTAREA") {
|
|
234
|
-
element.value = element.value + txt;
|
|
235
|
-
element.dispatchEvent(new Event("input", { bubbles: true }));
|
|
236
|
-
element.dispatchEvent(new Event("change", { bubbles: true }));
|
|
237
|
-
} else if (element.isContentEditable) {
|
|
168
|
+
const el = document.querySelector(sel);
|
|
169
|
+
if (!el) return { success: false, error: `Element not found: ${sel}` };
|
|
170
|
+
el.focus();
|
|
171
|
+
if (shouldClear) el.value = "";
|
|
172
|
+
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
|
|
173
|
+
el.value = el.value + txt;
|
|
174
|
+
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
175
|
+
el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
176
|
+
} else if (el.isContentEditable) {
|
|
238
177
|
document.execCommand("insertText", false, txt);
|
|
239
178
|
}
|
|
240
|
-
|
|
241
179
|
return { success: true };
|
|
242
180
|
},
|
|
243
181
|
args: [selector, text, clear]
|
|
244
182
|
});
|
|
245
183
|
|
|
246
|
-
if (!result[0]?.result?.success)
|
|
247
|
-
throw new Error(result[0]?.result?.error || "Type failed");
|
|
248
|
-
}
|
|
249
|
-
|
|
184
|
+
if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Type failed");
|
|
250
185
|
return `Typed "${text}" into ${selector}`;
|
|
251
186
|
}
|
|
252
187
|
|
|
253
|
-
async function toolScreenshot({ tabId
|
|
188
|
+
async function toolScreenshot({ tabId }) {
|
|
254
189
|
const tab = await getTabById(tabId);
|
|
255
|
-
|
|
256
|
-
// Capture visible area
|
|
257
|
-
const dataUrl = await chrome.tabs.captureVisibleTab(tab.windowId, {
|
|
258
|
-
format: "png"
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
return dataUrl;
|
|
190
|
+
return await chrome.tabs.captureVisibleTab(tab.windowId, { format: "png" });
|
|
262
191
|
}
|
|
263
192
|
|
|
264
193
|
async function toolSnapshot({ tabId }) {
|
|
@@ -267,89 +196,45 @@ async function toolSnapshot({ tabId }) {
|
|
|
267
196
|
const result = await chrome.scripting.executeScript({
|
|
268
197
|
target: { tabId: tab.id },
|
|
269
198
|
func: () => {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
element.getAttribute("title") ||
|
|
275
|
-
element.getAttribute("placeholder") ||
|
|
276
|
-
element.innerText?.slice(0, 100) ||
|
|
277
|
-
"";
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function getRole(element) {
|
|
281
|
-
return element.getAttribute("role") ||
|
|
282
|
-
element.tagName.toLowerCase();
|
|
199
|
+
function getName(el) {
|
|
200
|
+
return el.getAttribute("aria-label") || el.getAttribute("alt") ||
|
|
201
|
+
el.getAttribute("title") || el.getAttribute("placeholder") ||
|
|
202
|
+
el.innerText?.slice(0, 100) || "";
|
|
283
203
|
}
|
|
284
204
|
|
|
285
|
-
function
|
|
205
|
+
function build(el, depth = 0, uid = 0) {
|
|
286
206
|
if (depth > 10) return { nodes: [], nextUid: uid };
|
|
287
|
-
|
|
288
207
|
const nodes = [];
|
|
289
|
-
const style = window.getComputedStyle(
|
|
208
|
+
const style = window.getComputedStyle(el);
|
|
209
|
+
if (style.display === "none" || style.visibility === "hidden") return { nodes: [], nextUid: uid };
|
|
290
210
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
211
|
+
const isInteractive = ["A", "BUTTON", "INPUT", "TEXTAREA", "SELECT"].includes(el.tagName) ||
|
|
212
|
+
el.getAttribute("onclick") || el.getAttribute("role") === "button" || el.isContentEditable;
|
|
213
|
+
const rect = el.getBoundingClientRect();
|
|
295
214
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const rect = element.getBoundingClientRect();
|
|
307
|
-
const isVisible = rect.width > 0 && rect.height > 0;
|
|
308
|
-
|
|
309
|
-
if (isVisible && (isInteractive || element.innerText?.trim())) {
|
|
310
|
-
const node = {
|
|
311
|
-
uid: `e${uid}`,
|
|
312
|
-
role: getRole(element),
|
|
313
|
-
name: getAccessibleName(element).slice(0, 200),
|
|
314
|
-
tag: element.tagName.toLowerCase()
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
if (element.tagName === "A" && element.href) {
|
|
318
|
-
node.href = element.href;
|
|
319
|
-
}
|
|
320
|
-
if (element.tagName === "INPUT") {
|
|
321
|
-
node.type = element.type;
|
|
322
|
-
node.value = element.value;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Generate a selector
|
|
326
|
-
if (element.id) {
|
|
327
|
-
node.selector = `#${element.id}`;
|
|
328
|
-
} else if (element.className && typeof element.className === "string") {
|
|
329
|
-
const classes = element.className.trim().split(/\s+/).slice(0, 2).join(".");
|
|
330
|
-
if (classes) node.selector = `${element.tagName.toLowerCase()}.${classes}`;
|
|
215
|
+
if (rect.width > 0 && rect.height > 0 && (isInteractive || el.innerText?.trim())) {
|
|
216
|
+
const node = { uid: `e${uid}`, role: el.getAttribute("role") || el.tagName.toLowerCase(),
|
|
217
|
+
name: getName(el).slice(0, 200), tag: el.tagName.toLowerCase() };
|
|
218
|
+
if (el.tagName === "A" && el.href) node.href = el.href;
|
|
219
|
+
if (el.tagName === "INPUT") { node.type = el.type; node.value = el.value; }
|
|
220
|
+
if (el.id) node.selector = `#${el.id}`;
|
|
221
|
+
else if (el.className && typeof el.className === "string") {
|
|
222
|
+
const cls = el.className.trim().split(/\s+/).slice(0, 2).join(".");
|
|
223
|
+
if (cls) node.selector = `${el.tagName.toLowerCase()}.${cls}`;
|
|
331
224
|
}
|
|
332
|
-
|
|
333
225
|
nodes.push(node);
|
|
334
226
|
uid++;
|
|
335
227
|
}
|
|
336
228
|
|
|
337
|
-
for (const child of
|
|
338
|
-
const
|
|
339
|
-
nodes.push(...
|
|
340
|
-
uid =
|
|
229
|
+
for (const child of el.children) {
|
|
230
|
+
const r = build(child, depth + 1, uid);
|
|
231
|
+
nodes.push(...r.nodes);
|
|
232
|
+
uid = r.nextUid;
|
|
341
233
|
}
|
|
342
|
-
|
|
343
234
|
return { nodes, nextUid: uid };
|
|
344
235
|
}
|
|
345
236
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
return {
|
|
349
|
-
url: window.location.href,
|
|
350
|
-
title: document.title,
|
|
351
|
-
nodes: nodes.slice(0, 500) // Limit to 500 nodes
|
|
352
|
-
};
|
|
237
|
+
return { url: location.href, title: document.title, nodes: build(document.body).nodes.slice(0, 500) };
|
|
353
238
|
}
|
|
354
239
|
});
|
|
355
240
|
|
|
@@ -358,44 +243,24 @@ async function toolSnapshot({ tabId }) {
|
|
|
358
243
|
|
|
359
244
|
async function toolGetTabs() {
|
|
360
245
|
const tabs = await chrome.tabs.query({});
|
|
361
|
-
return JSON.stringify(tabs.map(t => ({
|
|
362
|
-
id: t.id,
|
|
363
|
-
url: t.url,
|
|
364
|
-
title: t.title,
|
|
365
|
-
active: t.active,
|
|
366
|
-
windowId: t.windowId
|
|
367
|
-
})), null, 2);
|
|
246
|
+
return JSON.stringify(tabs.map(t => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId })), null, 2);
|
|
368
247
|
}
|
|
369
248
|
|
|
370
249
|
async function toolExecuteScript({ code, tabId }) {
|
|
371
250
|
if (!code) throw new Error("Code is required");
|
|
372
|
-
|
|
373
251
|
const tab = await getTabById(tabId);
|
|
374
|
-
|
|
375
|
-
const result = await chrome.scripting.executeScript({
|
|
376
|
-
target: { tabId: tab.id },
|
|
377
|
-
func: new Function(code)
|
|
378
|
-
});
|
|
379
|
-
|
|
252
|
+
const result = await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: new Function(code) });
|
|
380
253
|
return JSON.stringify(result[0]?.result);
|
|
381
254
|
}
|
|
382
255
|
|
|
383
256
|
async function toolScroll({ x = 0, y = 0, selector, tabId }) {
|
|
384
257
|
const tab = await getTabById(tabId);
|
|
385
|
-
|
|
386
|
-
// Ensure selector is null (not undefined) for proper serialization
|
|
387
258
|
const sel = selector || null;
|
|
388
259
|
|
|
389
260
|
await chrome.scripting.executeScript({
|
|
390
261
|
target: { tabId: tab.id },
|
|
391
262
|
func: (scrollX, scrollY, sel) => {
|
|
392
|
-
if (sel) {
|
|
393
|
-
const element = document.querySelector(sel);
|
|
394
|
-
if (element) {
|
|
395
|
-
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
263
|
+
if (sel) { const el = document.querySelector(sel); if (el) { el.scrollIntoView({ behavior: "smooth", block: "center" }); return; } }
|
|
399
264
|
window.scrollBy(scrollX, scrollY);
|
|
400
265
|
},
|
|
401
266
|
args: [x, y, sel]
|
|
@@ -409,48 +274,12 @@ async function toolWait({ ms = 1000 }) {
|
|
|
409
274
|
return `Waited ${ms}ms`;
|
|
410
275
|
}
|
|
411
276
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
chrome.
|
|
417
|
-
|
|
418
|
-
await connectToNativeHost();
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
chrome.runtime.onStartup.addListener(async () => {
|
|
422
|
-
console.log("[OpenCode] Extension started");
|
|
423
|
-
await connectToNativeHost();
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
// Auto-reconnect on action click
|
|
427
|
-
chrome.action.onClicked.addListener(async () => {
|
|
428
|
-
if (!isConnected) {
|
|
429
|
-
const connected = await connectToNativeHost();
|
|
430
|
-
if (connected) {
|
|
431
|
-
chrome.notifications.create({
|
|
432
|
-
type: "basic",
|
|
433
|
-
iconUrl: "icons/icon128.png",
|
|
434
|
-
title: "OpenCode Browser",
|
|
435
|
-
message: "Connected to native host"
|
|
436
|
-
});
|
|
437
|
-
} else {
|
|
438
|
-
chrome.notifications.create({
|
|
439
|
-
type: "basic",
|
|
440
|
-
iconUrl: "icons/icon128.png",
|
|
441
|
-
title: "OpenCode Browser",
|
|
442
|
-
message: "Failed to connect. Is the native host installed?"
|
|
443
|
-
});
|
|
444
|
-
}
|
|
445
|
-
} else {
|
|
446
|
-
chrome.notifications.create({
|
|
447
|
-
type: "basic",
|
|
448
|
-
iconUrl: "icons/icon128.png",
|
|
449
|
-
title: "OpenCode Browser",
|
|
450
|
-
message: "Already connected"
|
|
451
|
-
});
|
|
452
|
-
}
|
|
277
|
+
chrome.runtime.onInstalled.addListener(() => connect());
|
|
278
|
+
chrome.runtime.onStartup.addListener(() => connect());
|
|
279
|
+
chrome.action.onClicked.addListener(() => {
|
|
280
|
+
connect();
|
|
281
|
+
chrome.notifications.create({ type: "basic", iconUrl: "icons/icon128.png", title: "OpenCode Browser",
|
|
282
|
+
message: isConnected ? "Connected" : "Reconnecting..." });
|
|
453
283
|
});
|
|
454
284
|
|
|
455
|
-
|
|
456
|
-
connectToNativeHost();
|
|
285
|
+
connect();
|
package/extension/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@different-ai/opencode-browser",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "Browser automation for OpenCode via Chrome extension + Native Messaging. Inspired by Claude in Chrome.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"homepage": "https://github.com/different-ai/opencode-browser#readme",
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
33
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
34
|
+
"ws": "^8.18.3"
|
|
34
35
|
}
|
|
35
36
|
}
|
package/src/daemon.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
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");
|