@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 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 Install extension and native host
92
- npx opencode-browser uninstall Remove native host registration
93
-
94
- ${color("bright", "After installation:")}
95
- The MCP server starts automatically when OpenCode connects.
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
 
@@ -1,156 +1,114 @@
1
- // OpenCode Browser Automation - Background Service Worker
2
- // Native Messaging Host: com.opencode.browser_automation
1
+ const DAEMON_URL = "ws://localhost:19222";
2
+ const KEEPALIVE_ALARM = "keepalive";
3
3
 
4
- const NATIVE_HOST_NAME = "com.opencode.browser_automation";
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
- async function connectToNativeHost() {
14
- if (nativePort) {
15
- return true;
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
- nativePort = chrome.runtime.connectNative(NATIVE_HOST_NAME);
26
+ ws = new WebSocket(DAEMON_URL);
20
27
 
21
- nativePort.onMessage.addListener(handleNativeMessage);
28
+ ws.onopen = () => {
29
+ console.log("[OpenCode] Connected to daemon");
30
+ isConnected = true;
31
+ updateBadge(true);
32
+ };
22
33
 
23
- nativePort.onDisconnect.addListener(() => {
24
- const error = chrome.runtime.lastError?.message;
25
- console.log("[OpenCode] Native host disconnected:", error);
26
- nativePort = null;
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
- // Ping to verify connection
31
- const connected = await new Promise((resolve) => {
32
- const timeout = setTimeout(() => resolve(false), 5000);
33
-
34
- const pingHandler = (msg) => {
35
- if (msg.type === "pong") {
36
- clearTimeout(timeout);
37
- nativePort.onMessage.removeListener(pingHandler);
38
- resolve(true);
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
- // Message Handling from Native Host
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
- switch (message.type) {
78
- case "tool_request":
79
- await handleToolRequest(message);
80
- break;
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 sendToNative(message) {
95
- if (nativePort) {
96
- nativePort.postMessage(message);
97
- } else {
98
- console.error("[OpenCode] Cannot send - not connected");
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
- sendToNative({
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
- sendToNative({
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
- switch (toolName) {
127
- case "navigate":
128
- return await toolNavigate(args);
129
- case "click":
130
- return await toolClick(args);
131
- case "type":
132
- return await toolType(args);
133
- case "screenshot":
134
- return await toolScreenshot(args);
135
- case "snapshot":
136
- return await toolSnapshot(args);
137
- case "get_tabs":
138
- return await toolGetTabs(args);
139
- case "execute_script":
140
- return await toolExecuteScript(args);
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
- if (tabId) {
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
- // Timeout after 30 seconds
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 element = document.querySelector(sel);
201
- if (!element) return { success: false, error: `Element not found: ${sel}` };
202
- element.click();
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 element = document.querySelector(sel);
225
- if (!element) return { success: false, error: `Element not found: ${sel}` };
226
-
227
- element.focus();
228
- if (shouldClear) {
229
- element.value = "";
230
- }
231
-
232
- // For input/textarea, set value directly
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, fullPage = false }) {
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
- // Build accessibility tree snapshot
271
- function getAccessibleName(element) {
272
- return element.getAttribute("aria-label") ||
273
- element.getAttribute("alt") ||
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 buildSnapshot(element, depth = 0, uid = 0) {
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(element);
208
+ const style = window.getComputedStyle(el);
209
+ if (style.display === "none" || style.visibility === "hidden") return { nodes: [], nextUid: uid };
290
210
 
291
- // Skip hidden elements
292
- if (style.display === "none" || style.visibility === "hidden") {
293
- return { nodes: [], nextUid: uid };
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
- const isInteractive =
297
- element.tagName === "A" ||
298
- element.tagName === "BUTTON" ||
299
- element.tagName === "INPUT" ||
300
- element.tagName === "TEXTAREA" ||
301
- element.tagName === "SELECT" ||
302
- element.getAttribute("onclick") ||
303
- element.getAttribute("role") === "button" ||
304
- element.isContentEditable;
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 element.children) {
338
- const childResult = buildSnapshot(child, depth + 1, uid);
339
- nodes.push(...childResult.nodes);
340
- uid = childResult.nextUid;
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
- const { nodes } = buildSnapshot(document.body);
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
- // Extension Lifecycle
414
- // ============================================================================
415
-
416
- chrome.runtime.onInstalled.addListener(async () => {
417
- console.log("[OpenCode] Extension installed");
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
- // Try to connect on load
456
- connectToNativeHost();
285
+ connect();
@@ -9,7 +9,8 @@
9
9
  "scripting",
10
10
  "nativeMessaging",
11
11
  "storage",
12
- "notifications"
12
+ "notifications",
13
+ "alarms"
13
14
  ],
14
15
  "host_permissions": [
15
16
  "<all_urls>"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@different-ai/opencode-browser",
3
- "version": "1.0.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");