@different-ai/opencode-browser 4.0.6 → 4.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 CHANGED
@@ -69,7 +69,10 @@ Tools:
69
69
  - `browser_snapshot`
70
70
  - `browser_scroll`
71
71
  - `browser_wait`
72
- - `browser_execute`
72
+ - `browser_execute` (deprecated, CSP-limited)
73
+ - `browser_query`
74
+ - `browser_wait_for`
75
+ - `browser_extract`
73
76
 
74
77
  ## Troubleshooting
75
78
 
package/dist/plugin.js CHANGED
@@ -12354,6 +12354,7 @@ function getPackageVersion() {
12354
12354
  cachedVersion = "unknown";
12355
12355
  return cachedVersion;
12356
12356
  }
12357
+ var { schema } = tool;
12357
12358
  var BASE_DIR = join(homedir(), ".opencode-browser");
12358
12359
  var SOCKET_PATH = join(BASE_DIR, "broker.sock");
12359
12360
  mkdirSync(BASE_DIR, { recursive: true });
@@ -12467,91 +12468,227 @@ function toolResultText(data, fallback) {
12467
12468
  return JSON.stringify(data.content);
12468
12469
  return fallback;
12469
12470
  }
12470
- var plugin = {
12471
- name: "opencode-browser",
12472
- tools: [
12473
- tool("browser_debug", "Debug plugin loading and connection status.", {}, async () => {
12474
- console.log("[opencode-browser] browser_debug called", { sessionId, pid: process.pid });
12475
- return JSON.stringify({
12476
- loaded: true,
12477
- sessionId,
12478
- pid: process.pid,
12479
- pluginVersion: getPackageVersion(),
12480
- tools: plugin.tools.map((t) => ({ name: t.name, description: t.description })),
12481
- timestamp: new Date().toISOString()
12482
- });
12483
- }),
12484
- tool("browser_version", "Return the installed @different-ai/opencode-browser plugin version.", {}, async () => {
12485
- return JSON.stringify({
12486
- name: "@different-ai/opencode-browser",
12487
- version: getPackageVersion(),
12488
- sessionId,
12489
- pid: process.pid
12490
- });
12491
- }),
12492
- tool("browser_status", "Check broker/native-host connection status and current tab claims.", {}, async () => {
12493
- const data = await brokerRequest("status", {});
12494
- return JSON.stringify(data);
12495
- }),
12496
- tool("browser_get_tabs", "List all open browser tabs", {}, async () => {
12497
- const data = await brokerRequest("tool", { tool: "get_tabs", args: {} });
12498
- return toolResultText(data, "ok");
12499
- }),
12500
- tool("browser_navigate", "Navigate to a URL in the browser", { url: { type: "string" }, tabId: { type: "number", optional: true } }, async ({ url: url2, tabId }) => {
12501
- const data = await brokerRequest("tool", { tool: "navigate", args: { url: url2, tabId } });
12502
- return toolResultText(data, `Navigated to ${url2}`);
12503
- }),
12504
- tool("browser_click", "Click an element on the page using a CSS selector", { selector: { type: "string" }, tabId: { type: "number", optional: true } }, async ({ selector, tabId }) => {
12505
- const data = await brokerRequest("tool", { tool: "click", args: { selector, tabId } });
12506
- return toolResultText(data, `Clicked ${selector}`);
12507
- }),
12508
- tool("browser_type", "Type text into an input element", {
12509
- selector: { type: "string" },
12510
- text: { type: "string" },
12511
- clear: { type: "boolean", optional: true },
12512
- tabId: { type: "number", optional: true }
12513
- }, async ({ selector, text, clear, tabId }) => {
12514
- const data = await brokerRequest("tool", { tool: "type", args: { selector, text, clear, tabId } });
12515
- return toolResultText(data, `Typed "${text}" into ${selector}`);
12516
- }),
12517
- tool("browser_screenshot", "Take a screenshot of the current page. Returns base64 image data URL.", { tabId: { type: "number", optional: true } }, async ({ tabId }) => {
12518
- const data = await brokerRequest("tool", { tool: "screenshot", args: { tabId } });
12519
- return toolResultText(data, "Screenshot failed");
12520
- }),
12521
- tool("browser_snapshot", "Get an accessibility tree snapshot of the page.", { tabId: { type: "number", optional: true } }, async ({ tabId }) => {
12522
- const data = await brokerRequest("tool", { tool: "snapshot", args: { tabId } });
12523
- return toolResultText(data, "Snapshot failed");
12524
- }),
12525
- tool("browser_scroll", "Scroll the page or scroll an element into view", {
12526
- selector: { type: "string", optional: true },
12527
- x: { type: "number", optional: true },
12528
- y: { type: "number", optional: true },
12529
- tabId: { type: "number", optional: true }
12530
- }, async ({ selector, x, y, tabId }) => {
12531
- const data = await brokerRequest("tool", { tool: "scroll", args: { selector, x, y, tabId } });
12532
- return toolResultText(data, "Scrolled");
12533
- }),
12534
- tool("browser_wait", "Wait for a specified duration", { ms: { type: "number", optional: true }, tabId: { type: "number", optional: true } }, async ({ ms, tabId }) => {
12535
- const data = await brokerRequest("tool", { tool: "wait", args: { ms, tabId } });
12536
- return toolResultText(data, "Waited");
12537
- }),
12538
- tool("browser_execute", "Execute JavaScript code in the page context and return the result.", { code: { type: "string" }, tabId: { type: "number", optional: true } }, async ({ code, tabId }) => {
12539
- const data = await brokerRequest("tool", { tool: "execute_script", args: { code, tabId } });
12540
- return toolResultText(data, "Execute failed");
12541
- }),
12542
- tool("browser_claim_tab", "Claim a tab for this OpenCode session (per-tab ownership).", { tabId: { type: "number" }, force: { type: "boolean", optional: true } }, async ({ tabId, force }) => {
12543
- const data = await brokerRequest("claim_tab", { tabId, force });
12544
- return JSON.stringify(data);
12545
- }),
12546
- tool("browser_release_tab", "Release a previously claimed tab.", { tabId: { type: "number" } }, async ({ tabId }) => {
12547
- const data = await brokerRequest("release_tab", { tabId });
12548
- return JSON.stringify(data);
12549
- }),
12550
- tool("browser_list_claims", "List current tab ownership claims.", {}, async () => {
12551
- const data = await brokerRequest("list_claims", {});
12552
- return JSON.stringify(data);
12553
- })
12554
- ]
12471
+ var plugin = async (ctx) => {
12472
+ console.log("[opencode-browser] Plugin loading...", { pid: process.pid });
12473
+ return {
12474
+ tool: {
12475
+ browser_debug: tool({
12476
+ description: "Debug plugin loading and connection status.",
12477
+ args: {},
12478
+ async execute(args, ctx2) {
12479
+ console.log("[opencode-browser] browser_debug called", { sessionId, pid: process.pid });
12480
+ return JSON.stringify({
12481
+ loaded: true,
12482
+ sessionId,
12483
+ pid: process.pid,
12484
+ pluginVersion: getPackageVersion(),
12485
+ timestamp: new Date().toISOString()
12486
+ });
12487
+ }
12488
+ }),
12489
+ browser_version: tool({
12490
+ description: "Return the installed @different-ai/opencode-browser plugin version.",
12491
+ args: {},
12492
+ async execute(args, ctx2) {
12493
+ return JSON.stringify({
12494
+ name: "@different-ai/opencode-browser",
12495
+ version: getPackageVersion(),
12496
+ sessionId,
12497
+ pid: process.pid
12498
+ });
12499
+ }
12500
+ }),
12501
+ browser_status: tool({
12502
+ description: "Check broker/native-host connection status and current tab claims.",
12503
+ args: {},
12504
+ async execute(args, ctx2) {
12505
+ const data = await brokerRequest("status", {});
12506
+ return JSON.stringify(data);
12507
+ }
12508
+ }),
12509
+ browser_get_tabs: tool({
12510
+ description: "List all open browser tabs",
12511
+ args: {},
12512
+ async execute(args, ctx2) {
12513
+ const data = await brokerRequest("tool", { tool: "get_tabs", args: {} });
12514
+ return toolResultText(data, "ok");
12515
+ }
12516
+ }),
12517
+ browser_navigate: tool({
12518
+ description: "Navigate to a URL in the browser",
12519
+ args: {
12520
+ url: schema.string(),
12521
+ tabId: schema.number().optional()
12522
+ },
12523
+ async execute({ url: url2, tabId }, ctx2) {
12524
+ const data = await brokerRequest("tool", { tool: "navigate", args: { url: url2, tabId } });
12525
+ return toolResultText(data, `Navigated to ${url2}`);
12526
+ }
12527
+ }),
12528
+ browser_click: tool({
12529
+ description: "Click an element on the page using a CSS selector",
12530
+ args: {
12531
+ selector: schema.string(),
12532
+ index: schema.number().optional(),
12533
+ tabId: schema.number().optional()
12534
+ },
12535
+ async execute({ selector, index, tabId }, ctx2) {
12536
+ const data = await brokerRequest("tool", { tool: "click", args: { selector, index, tabId } });
12537
+ return toolResultText(data, `Clicked ${selector}`);
12538
+ }
12539
+ }),
12540
+ browser_type: tool({
12541
+ description: "Type text into an input element",
12542
+ args: {
12543
+ selector: schema.string(),
12544
+ text: schema.string(),
12545
+ clear: schema.boolean().optional(),
12546
+ index: schema.number().optional(),
12547
+ tabId: schema.number().optional()
12548
+ },
12549
+ async execute({ selector, text, clear, index, tabId }, ctx2) {
12550
+ const data = await brokerRequest("tool", { tool: "type", args: { selector, text, clear, index, tabId } });
12551
+ return toolResultText(data, `Typed "${text}" into ${selector}`);
12552
+ }
12553
+ }),
12554
+ browser_screenshot: tool({
12555
+ description: "Take a screenshot of the current page. Returns base64 image data URL.",
12556
+ args: {
12557
+ tabId: schema.number().optional()
12558
+ },
12559
+ async execute({ tabId }, ctx2) {
12560
+ const data = await brokerRequest("tool", { tool: "screenshot", args: { tabId } });
12561
+ return toolResultText(data, "Screenshot failed");
12562
+ }
12563
+ }),
12564
+ browser_snapshot: tool({
12565
+ description: "Get an accessibility tree snapshot of the page.",
12566
+ args: {
12567
+ tabId: schema.number().optional()
12568
+ },
12569
+ async execute({ tabId }, ctx2) {
12570
+ const data = await brokerRequest("tool", { tool: "snapshot", args: { tabId } });
12571
+ return toolResultText(data, "Snapshot failed");
12572
+ }
12573
+ }),
12574
+ browser_scroll: tool({
12575
+ description: "Scroll the page or scroll an element into view",
12576
+ args: {
12577
+ selector: schema.string().optional(),
12578
+ x: schema.number().optional(),
12579
+ y: schema.number().optional(),
12580
+ tabId: schema.number().optional()
12581
+ },
12582
+ async execute({ selector, x, y, tabId }, ctx2) {
12583
+ const data = await brokerRequest("tool", { tool: "scroll", args: { selector, x, y, tabId } });
12584
+ return toolResultText(data, "Scrolled");
12585
+ }
12586
+ }),
12587
+ browser_wait: tool({
12588
+ description: "Wait for a specified duration",
12589
+ args: {
12590
+ ms: schema.number().optional(),
12591
+ tabId: schema.number().optional()
12592
+ },
12593
+ async execute({ ms, tabId }, ctx2) {
12594
+ const data = await brokerRequest("tool", { tool: "wait", args: { ms, tabId } });
12595
+ return toolResultText(data, "Waited");
12596
+ }
12597
+ }),
12598
+ browser_execute: tool({
12599
+ description: "(Deprecated) Execute arbitrary JavaScript in-page. Blocked on many sites by MV3 CSP/unsafe-eval. This tool now accepts JSON commands; prefer browser_query/browser_extract/browser_wait_for.",
12600
+ args: {
12601
+ code: schema.string(),
12602
+ tabId: schema.number().optional()
12603
+ },
12604
+ async execute({ code, tabId }, ctx2) {
12605
+ const data = await brokerRequest("tool", { tool: "execute_script", args: { code, tabId } });
12606
+ return toolResultText(data, "Execute failed");
12607
+ }
12608
+ }),
12609
+ browser_query: tool({
12610
+ description: "Read data from the page using selectors (supports shadow DOM + same-origin iframes).",
12611
+ args: {
12612
+ selector: schema.string(),
12613
+ mode: schema.string().optional(),
12614
+ attribute: schema.string().optional(),
12615
+ property: schema.string().optional(),
12616
+ index: schema.number().optional(),
12617
+ limit: schema.number().optional(),
12618
+ tabId: schema.number().optional()
12619
+ },
12620
+ async execute({ selector, mode, attribute, property, index, limit, tabId }, ctx2) {
12621
+ const data = await brokerRequest("tool", {
12622
+ tool: "query",
12623
+ args: { selector, mode, attribute, property, index, limit, tabId }
12624
+ });
12625
+ return toolResultText(data, "Query failed");
12626
+ }
12627
+ }),
12628
+ browser_wait_for: tool({
12629
+ description: "Wait until a selector appears (supports shadow DOM + same-origin iframes).",
12630
+ args: {
12631
+ selector: schema.string(),
12632
+ timeoutMs: schema.number().optional(),
12633
+ pollMs: schema.number().optional(),
12634
+ tabId: schema.number().optional()
12635
+ },
12636
+ async execute({ selector, timeoutMs, pollMs, tabId }, ctx2) {
12637
+ const data = await brokerRequest("tool", {
12638
+ tool: "wait_for",
12639
+ args: { selector, timeoutMs, pollMs, tabId }
12640
+ });
12641
+ return toolResultText(data, "Wait-for failed");
12642
+ }
12643
+ }),
12644
+ browser_extract: tool({
12645
+ description: "Extract readable text from the page (optionally regex match). Useful when content isn't in the accessibility tree.",
12646
+ args: {
12647
+ mode: schema.string().optional(),
12648
+ pattern: schema.string().optional(),
12649
+ flags: schema.string().optional(),
12650
+ limit: schema.number().optional(),
12651
+ tabId: schema.number().optional()
12652
+ },
12653
+ async execute({ mode, pattern, flags, limit, tabId }, ctx2) {
12654
+ const data = await brokerRequest("tool", {
12655
+ tool: "extract",
12656
+ args: { mode, pattern, flags, limit, tabId }
12657
+ });
12658
+ return toolResultText(data, "Extract failed");
12659
+ }
12660
+ }),
12661
+ browser_claim_tab: tool({
12662
+ description: "Claim a tab for this OpenCode session (per-tab ownership).",
12663
+ args: {
12664
+ tabId: schema.number(),
12665
+ force: schema.boolean().optional()
12666
+ },
12667
+ async execute({ tabId, force }, ctx2) {
12668
+ const data = await brokerRequest("claim_tab", { tabId, force });
12669
+ return JSON.stringify(data);
12670
+ }
12671
+ }),
12672
+ browser_release_tab: tool({
12673
+ description: "Release a previously claimed tab.",
12674
+ args: {
12675
+ tabId: schema.number()
12676
+ },
12677
+ async execute({ tabId }, ctx2) {
12678
+ const data = await brokerRequest("release_tab", { tabId });
12679
+ return JSON.stringify(data);
12680
+ }
12681
+ }),
12682
+ browser_list_claims: tool({
12683
+ description: "List current tab ownership claims.",
12684
+ args: {},
12685
+ async execute(args, ctx2) {
12686
+ const data = await brokerRequest("list_claims", {});
12687
+ return JSON.stringify(data);
12688
+ }
12689
+ })
12690
+ }
12691
+ };
12555
12692
  };
12556
12693
  var plugin_default = plugin;
12557
12694
  export {
@@ -15,7 +15,9 @@ chrome.alarms.onAlarm.addListener((alarm) => {
15
15
 
16
16
  function connect() {
17
17
  if (port) {
18
- try { port.disconnect() } catch {}
18
+ try {
19
+ port.disconnect()
20
+ } catch {}
19
21
  port = null
20
22
  }
21
23
 
@@ -35,7 +37,6 @@ function connect() {
35
37
 
36
38
  const err = chrome.runtime.lastError
37
39
  if (err?.message) {
38
- // Usually means native host not installed or crashed
39
40
  connectionAttempts++
40
41
  if (connectionAttempts === 1) {
41
42
  console.log("[OpenCode] Native host not available. Run: npx @different-ai/opencode-browser install")
@@ -104,6 +105,9 @@ async function executeTool(toolName, args) {
104
105
  type: toolType,
105
106
  screenshot: toolScreenshot,
106
107
  snapshot: toolSnapshot,
108
+ extract: toolExtract,
109
+ query: toolQuery,
110
+ wait_for: toolWaitFor,
107
111
  execute_script: toolExecuteScript,
108
112
  scroll: toolScroll,
109
113
  wait: toolWait,
@@ -151,52 +155,250 @@ async function toolNavigate({ url, tabId }) {
151
155
  return { tabId: tab.id, content: `Navigated to ${url}` }
152
156
  }
153
157
 
154
- async function toolClick({ selector, tabId }) {
158
+ function normalizeSelectorList(selector) {
159
+ if (typeof selector !== "string") return []
160
+ const parts = selector
161
+ .split(",")
162
+ .map((s) => s.trim())
163
+ .filter(Boolean)
164
+ return parts.length ? parts : [selector.trim()].filter(Boolean)
165
+ }
166
+
167
+ async function toolClick({ selector, tabId, index = 0 }) {
155
168
  if (!selector) throw new Error("Selector is required")
156
169
  const tab = await getTabById(tabId)
157
170
 
171
+ const selectorList = normalizeSelectorList(selector)
172
+
158
173
  const result = await chrome.scripting.executeScript({
159
174
  target: { tabId: tab.id },
160
- func: (sel) => {
161
- const el = document.querySelector(sel)
162
- if (!el) return { success: false, error: `Element not found: ${sel}` }
163
- el.click()
164
- return { success: true }
175
+ func: (selectors, index) => {
176
+ function safeString(v) {
177
+ return typeof v === "string" ? v : ""
178
+ }
179
+
180
+ function isVisible(el) {
181
+ if (!el) return false
182
+ const rect = el.getBoundingClientRect()
183
+ if (rect.width <= 0 || rect.height <= 0) return false
184
+ const style = window.getComputedStyle(el)
185
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false
186
+ return true
187
+ }
188
+
189
+ function deepQuerySelectorAll(sel, rootDoc) {
190
+ const out = []
191
+ const seen = new Set()
192
+
193
+ function addAll(nodeList) {
194
+ for (const el of nodeList) {
195
+ if (!el || seen.has(el)) continue
196
+ seen.add(el)
197
+ out.push(el)
198
+ }
199
+ }
200
+
201
+ function walkRoot(root, depth) {
202
+ if (!root || depth > 6) return
203
+
204
+ try {
205
+ addAll(root.querySelectorAll(sel))
206
+ } catch {
207
+ // Invalid selector
208
+ return
209
+ }
210
+
211
+ const tree = root.querySelectorAll ? root.querySelectorAll("*") : []
212
+ for (const el of tree) {
213
+ if (el.shadowRoot) {
214
+ walkRoot(el.shadowRoot, depth + 1)
215
+ }
216
+ }
217
+
218
+ // Same-origin iframes only
219
+ const frames = root.querySelectorAll ? root.querySelectorAll("iframe") : []
220
+ for (const frame of frames) {
221
+ try {
222
+ const doc = frame.contentDocument
223
+ if (doc) walkRoot(doc, depth + 1)
224
+ } catch {
225
+ // cross-origin
226
+ }
227
+ }
228
+ }
229
+
230
+ walkRoot(rootDoc || document, 0)
231
+ return out
232
+ }
233
+
234
+ function tryClick(el) {
235
+ try {
236
+ el.scrollIntoView({ block: "center", inline: "center" })
237
+ } catch {}
238
+
239
+ const rect = el.getBoundingClientRect()
240
+ const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1)
241
+ const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1)
242
+
243
+ const opts = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }
244
+ try {
245
+ el.dispatchEvent(new MouseEvent("mouseover", opts))
246
+ el.dispatchEvent(new MouseEvent("mousemove", opts))
247
+ el.dispatchEvent(new MouseEvent("mousedown", opts))
248
+ el.dispatchEvent(new MouseEvent("mouseup", opts))
249
+ el.dispatchEvent(new MouseEvent("click", opts))
250
+ } catch {}
251
+
252
+ try {
253
+ el.click()
254
+ } catch {}
255
+ }
256
+
257
+ for (const sel of selectors) {
258
+ const s = safeString(sel)
259
+ if (!s) continue
260
+
261
+ const matches = deepQuerySelectorAll(s, document)
262
+ const visible = matches.filter(isVisible)
263
+ const chosen = visible[index] || matches[index]
264
+ if (chosen) {
265
+ tryClick(chosen)
266
+ return { success: true, selectorUsed: s }
267
+ }
268
+ }
269
+
270
+ return { success: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
165
271
  },
166
- args: [selector],
272
+ args: [selectorList, index],
273
+ world: "ISOLATED",
167
274
  })
168
275
 
169
276
  if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Click failed")
170
- return { tabId: tab.id, content: `Clicked ${selector}` }
277
+ const used = result[0]?.result?.selectorUsed || selector
278
+ return { tabId: tab.id, content: `Clicked ${used}` }
171
279
  }
172
280
 
173
- async function toolType({ selector, text, tabId, clear = false }) {
281
+ async function toolType({ selector, text, tabId, clear = false, index = 0 }) {
174
282
  if (!selector) throw new Error("Selector is required")
175
283
  if (text === undefined) throw new Error("Text is required")
176
284
  const tab = await getTabById(tabId)
177
285
 
286
+ const selectorList = normalizeSelectorList(selector)
287
+
178
288
  const result = await chrome.scripting.executeScript({
179
289
  target: { tabId: tab.id },
180
- func: (sel, txt, shouldClear) => {
181
- const el = document.querySelector(sel)
182
- if (!el) return { success: false, error: `Element not found: ${sel}` }
183
- el.focus()
184
- if (shouldClear && (el.tagName === "INPUT" || el.tagName === "TEXTAREA")) el.value = ""
185
-
186
- if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
187
- el.value = el.value + txt
188
- el.dispatchEvent(new Event("input", { bubbles: true }))
189
- el.dispatchEvent(new Event("change", { bubbles: true }))
190
- } else if (el.isContentEditable) {
191
- document.execCommand("insertText", false, txt)
192
- }
193
- return { success: true }
290
+ func: (selectors, txt, shouldClear, index) => {
291
+ function isVisible(el) {
292
+ if (!el) return false
293
+ const rect = el.getBoundingClientRect()
294
+ if (rect.width <= 0 || rect.height <= 0) return false
295
+ const style = window.getComputedStyle(el)
296
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false
297
+ return true
298
+ }
299
+
300
+ function deepQuerySelectorAll(sel, rootDoc) {
301
+ const out = []
302
+ const seen = new Set()
303
+
304
+ function addAll(nodeList) {
305
+ for (const el of nodeList) {
306
+ if (!el || seen.has(el)) continue
307
+ seen.add(el)
308
+ out.push(el)
309
+ }
310
+ }
311
+
312
+ function walkRoot(root, depth) {
313
+ if (!root || depth > 6) return
314
+
315
+ try {
316
+ addAll(root.querySelectorAll(sel))
317
+ } catch {
318
+ return
319
+ }
320
+
321
+ const tree = root.querySelectorAll ? root.querySelectorAll("*") : []
322
+ for (const el of tree) {
323
+ if (el.shadowRoot) {
324
+ walkRoot(el.shadowRoot, depth + 1)
325
+ }
326
+ }
327
+
328
+ const frames = root.querySelectorAll ? root.querySelectorAll("iframe") : []
329
+ for (const frame of frames) {
330
+ try {
331
+ const doc = frame.contentDocument
332
+ if (doc) walkRoot(doc, depth + 1)
333
+ } catch {}
334
+ }
335
+ }
336
+
337
+ walkRoot(rootDoc || document, 0)
338
+ return out
339
+ }
340
+
341
+ function setNativeValue(el, value) {
342
+ const tag = el.tagName
343
+ if (tag === "INPUT" || tag === "TEXTAREA") {
344
+ const proto = tag === "INPUT" ? window.HTMLInputElement.prototype : window.HTMLTextAreaElement.prototype
345
+ const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set
346
+ if (setter) setter.call(el, value)
347
+ else el.value = value
348
+ return true
349
+ }
350
+ return false
351
+ }
352
+
353
+ for (const sel of selectors) {
354
+ if (!sel) continue
355
+ const matches = deepQuerySelectorAll(sel, document)
356
+ const visible = matches.filter(isVisible)
357
+ const el = visible[index] || matches[index]
358
+ if (!el) continue
359
+
360
+ try {
361
+ el.scrollIntoView({ block: "center", inline: "center" })
362
+ } catch {}
363
+
364
+ try {
365
+ el.focus()
366
+ } catch {}
367
+
368
+ const tag = el.tagName
369
+ const isTextInput = tag === "INPUT" || tag === "TEXTAREA"
370
+
371
+ if (isTextInput) {
372
+ if (shouldClear) setNativeValue(el, "")
373
+ setNativeValue(el, (el.value || "") + txt)
374
+ el.dispatchEvent(new Event("input", { bubbles: true }))
375
+ el.dispatchEvent(new Event("change", { bubbles: true }))
376
+ return { success: true, selectorUsed: sel }
377
+ }
378
+
379
+ if (el.isContentEditable) {
380
+ if (shouldClear) el.textContent = ""
381
+ try {
382
+ document.execCommand("insertText", false, txt)
383
+ } catch {
384
+ el.textContent = (el.textContent || "") + txt
385
+ }
386
+ el.dispatchEvent(new Event("input", { bubbles: true }))
387
+ return { success: true, selectorUsed: sel }
388
+ }
389
+
390
+ return { success: false, error: `Element is not typable: ${sel} (${tag.toLowerCase()})` }
391
+ }
392
+
393
+ return { success: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
194
394
  },
195
- args: [selector, text, clear],
395
+ args: [selectorList, text, !!clear, index],
396
+ world: "ISOLATED",
196
397
  })
197
398
 
198
399
  if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Type failed")
199
- return { tabId: tab.id, content: `Typed "${text}" into ${selector}` }
400
+ const used = result[0]?.result?.selectorUsed || selector
401
+ return { tabId: tab.id, content: `Typed "${text}" into ${used}` }
200
402
  }
201
403
 
202
404
  async function toolScreenshot({ tabId }) {
@@ -211,56 +413,109 @@ async function toolSnapshot({ tabId }) {
211
413
  const result = await chrome.scripting.executeScript({
212
414
  target: { tabId: tab.id },
213
415
  func: () => {
416
+ function safeText(s) {
417
+ return typeof s === "string" ? s : ""
418
+ }
419
+
420
+ function isVisible(el) {
421
+ if (!el) return false
422
+ const rect = el.getBoundingClientRect()
423
+ if (rect.width <= 0 || rect.height <= 0) return false
424
+ const style = window.getComputedStyle(el)
425
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false
426
+ return true
427
+ }
428
+
429
+ function pseudoText(el) {
430
+ try {
431
+ const before = window.getComputedStyle(el, "::before").content
432
+ const after = window.getComputedStyle(el, "::after").content
433
+ const norm = (v) => {
434
+ const s = safeText(v)
435
+ if (!s || s === "none") return ""
436
+ return s.replace(/^"|"$/g, "")
437
+ }
438
+ return { before: norm(before), after: norm(after) }
439
+ } catch {
440
+ return { before: "", after: "" }
441
+ }
442
+ }
443
+
214
444
  function getName(el) {
215
- return (
216
- el.getAttribute("aria-label") ||
217
- el.getAttribute("alt") ||
218
- el.getAttribute("title") ||
219
- el.getAttribute("placeholder") ||
220
- el.innerText?.slice(0, 100) ||
221
- ""
222
- )
445
+ const aria = el.getAttribute("aria-label")
446
+ if (aria) return aria
447
+ const alt = el.getAttribute("alt")
448
+ if (alt) return alt
449
+ const title = el.getAttribute("title")
450
+ if (title) return title
451
+ const placeholder = el.getAttribute("placeholder")
452
+ if (placeholder) return placeholder
453
+ const txt = safeText(el.innerText)
454
+ if (txt.trim()) return txt.slice(0, 200)
455
+ const pt = pseudoText(el)
456
+ const combo = `${pt.before} ${pt.after}`.trim()
457
+ if (combo) return combo.slice(0, 200)
458
+ return ""
223
459
  }
224
460
 
225
461
  function build(el, depth = 0, uid = 0) {
226
- if (depth > 10) return { nodes: [], nextUid: uid }
462
+ if (!el || depth > 12) return { nodes: [], nextUid: uid }
227
463
  const nodes = []
228
- const style = window.getComputedStyle(el)
229
- if (style.display === "none" || style.visibility === "hidden") return { nodes: [], nextUid: uid }
464
+
465
+ if (!isVisible(el)) return { nodes: [], nextUid: uid }
230
466
 
231
467
  const isInteractive =
232
468
  ["A", "BUTTON", "INPUT", "TEXTAREA", "SELECT"].includes(el.tagName) ||
233
469
  el.getAttribute("onclick") ||
234
470
  el.getAttribute("role") === "button" ||
235
471
  el.isContentEditable
236
- const rect = el.getBoundingClientRect()
237
472
 
238
- if (rect.width > 0 && rect.height > 0 && (isInteractive || el.innerText?.trim())) {
473
+ const name = getName(el)
474
+ const pt = pseudoText(el)
475
+
476
+ const shouldInclude = isInteractive || name.trim() || pt.before || pt.after
477
+
478
+ if (shouldInclude) {
239
479
  const node = {
240
480
  uid: `e${uid}`,
241
481
  role: el.getAttribute("role") || el.tagName.toLowerCase(),
242
- name: getName(el).slice(0, 200),
482
+ name: name,
243
483
  tag: el.tagName.toLowerCase(),
244
484
  }
485
+
486
+ if (pt.before) node.before = pt.before
487
+ if (pt.after) node.after = pt.after
488
+
245
489
  if (el.href) node.href = el.href
246
- if (el.tagName === "INPUT") {
490
+
491
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
247
492
  node.type = el.type
248
493
  node.value = el.value
494
+ if (el.readOnly) node.readOnly = true
495
+ if (el.disabled) node.disabled = true
249
496
  }
497
+
250
498
  if (el.id) node.selector = `#${el.id}`
251
499
  else if (el.className && typeof el.className === "string") {
252
500
  const cls = el.className.trim().split(/\s+/).slice(0, 2).join(".")
253
501
  if (cls) node.selector = `${el.tagName.toLowerCase()}.${cls}`
254
502
  }
503
+
255
504
  nodes.push(node)
256
505
  uid++
257
506
  }
258
507
 
508
+ if (el.shadowRoot) {
509
+ const r = build(el.shadowRoot.host, depth + 1, uid)
510
+ uid = r.nextUid
511
+ }
512
+
259
513
  for (const child of el.children) {
260
514
  const r = build(child, depth + 1, uid)
261
515
  nodes.push(...r.nodes)
262
516
  uid = r.nextUid
263
517
  }
518
+
264
519
  return { nodes, nextUid: uid }
265
520
  }
266
521
 
@@ -275,16 +530,114 @@ async function toolSnapshot({ tabId }) {
275
530
  links.push({ href, text })
276
531
  }
277
532
  })
278
- return links.slice(0, 100)
533
+ return links.slice(0, 200)
279
534
  }
280
535
 
536
+ let pageText = ""
537
+ try {
538
+ pageText = safeText(document.body?.innerText || "").slice(0, 20000)
539
+ } catch {}
540
+
541
+ const built = build(document.body).nodes.slice(0, 800)
542
+
281
543
  return {
282
544
  url: location.href,
283
545
  title: document.title,
284
- nodes: build(document.body).nodes.slice(0, 500),
546
+ text: pageText,
547
+ nodes: built,
285
548
  links: getAllLinks(),
286
549
  }
287
550
  },
551
+ world: "ISOLATED",
552
+ })
553
+
554
+ return { tabId: tab.id, content: JSON.stringify(result[0]?.result, null, 2) }
555
+ }
556
+
557
+ async function toolExtract({ tabId, mode = "combined", pattern, flags = "i", limit = 20000 }) {
558
+ const tab = await getTabById(tabId)
559
+
560
+ const result = await chrome.scripting.executeScript({
561
+ target: { tabId: tab.id },
562
+ func: (mode, pattern, flags, limit) => {
563
+ const cap = (s) => String(s ?? "").slice(0, Math.max(0, limit || 0))
564
+
565
+ const getPseudoText = () => {
566
+ const out = []
567
+ const pushContent = (content) => {
568
+ if (!content) return
569
+ const c = String(content)
570
+ if (!c || c === "none" || c === "normal") return
571
+ const unquoted = c.replace(/^"|"$/g, "").replace(/^'|'$/g, "")
572
+ if (unquoted && unquoted !== "none" && unquoted !== "normal") out.push(unquoted)
573
+ }
574
+
575
+ const elements = Array.from(document.querySelectorAll("*"))
576
+ for (let i = 0; i < elements.length && out.length < 2000; i++) {
577
+ const el = elements[i]
578
+ try {
579
+ const style = window.getComputedStyle(el)
580
+ if (style.display === "none" || style.visibility === "hidden") continue
581
+ const before = window.getComputedStyle(el, "::before").content
582
+ const after = window.getComputedStyle(el, "::after").content
583
+ pushContent(before)
584
+ pushContent(after)
585
+ } catch {
586
+ // ignore
587
+ }
588
+ }
589
+ return out.join("\n")
590
+ }
591
+
592
+ const getInputValues = () => {
593
+ const out = []
594
+ const nodes = document.querySelectorAll("input, textarea")
595
+ nodes.forEach((el) => {
596
+ try {
597
+ const name = el.getAttribute("aria-label") || el.getAttribute("name") || el.id || el.className || el.tagName
598
+ const value = el.value
599
+ if (value != null && String(value).trim()) out.push(`${name}: ${value}`)
600
+ } catch {
601
+ // ignore
602
+ }
603
+ })
604
+ return out.join("\n")
605
+ }
606
+
607
+ const getText = () => {
608
+ try {
609
+ return document.body ? document.body.innerText || "" : ""
610
+ } catch {
611
+ return ""
612
+ }
613
+ }
614
+
615
+ const parts = []
616
+ if (mode === "text" || mode === "combined") parts.push(getText())
617
+ if (mode === "pseudo" || mode === "combined") parts.push(getPseudoText())
618
+ if (mode === "inputs" || mode === "combined") parts.push(getInputValues())
619
+
620
+ const text = cap(parts.filter(Boolean).join("\n\n"))
621
+
622
+ let matches = []
623
+ if (pattern) {
624
+ try {
625
+ const re = new RegExp(pattern, flags || "")
626
+ const found = []
627
+ let m
628
+ while ((m = re.exec(text)) && found.length < 50) {
629
+ found.push(m[0])
630
+ if (!re.global) break
631
+ }
632
+ matches = found
633
+ } catch (e) {
634
+ matches = []
635
+ }
636
+ }
637
+
638
+ return { url: location.href, title: document.title, mode, text, matches }
639
+ },
640
+ args: [mode, pattern, flags, limit],
288
641
  })
289
642
 
290
643
  return { tabId: tab.id, content: JSON.stringify(result[0]?.result, null, 2) }
@@ -296,13 +649,258 @@ async function toolGetTabs() {
296
649
  return { content: JSON.stringify(out, null, 2) }
297
650
  }
298
651
 
652
+ async function toolQuery({ tabId, selector, mode = "text", attribute, property, limit = 50, index = 0 }) {
653
+ if (!selector) throw new Error("selector is required")
654
+ const tab = await getTabById(tabId)
655
+
656
+ const selectorList = normalizeSelectorList(selector)
657
+
658
+ const result = await chrome.scripting.executeScript({
659
+ target: { tabId: tab.id },
660
+ func: (selectors, mode, attribute, property, limit, index) => {
661
+ function isVisible(el) {
662
+ if (!el) return false
663
+ const rect = el.getBoundingClientRect()
664
+ if (rect.width <= 0 || rect.height <= 0) return false
665
+ const style = window.getComputedStyle(el)
666
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false
667
+ return true
668
+ }
669
+
670
+ function deepQuerySelectorAll(sel, rootDoc) {
671
+ const out = []
672
+ const seen = new Set()
673
+
674
+ function addAll(nodeList) {
675
+ for (const el of nodeList) {
676
+ if (!el || seen.has(el)) continue
677
+ seen.add(el)
678
+ out.push(el)
679
+ }
680
+ }
681
+
682
+ function walkRoot(root, depth) {
683
+ if (!root || depth > 6) return
684
+
685
+ try {
686
+ addAll(root.querySelectorAll(sel))
687
+ } catch {
688
+ return
689
+ }
690
+
691
+ const tree = root.querySelectorAll ? root.querySelectorAll("*") : []
692
+ for (const el of tree) {
693
+ if (el.shadowRoot) {
694
+ walkRoot(el.shadowRoot, depth + 1)
695
+ }
696
+ }
697
+
698
+ const frames = root.querySelectorAll ? root.querySelectorAll("iframe") : []
699
+ for (const frame of frames) {
700
+ try {
701
+ const doc = frame.contentDocument
702
+ if (doc) walkRoot(doc, depth + 1)
703
+ } catch {}
704
+ }
705
+ }
706
+
707
+ walkRoot(rootDoc || document, 0)
708
+ return out
709
+ }
710
+
711
+ for (const sel of selectors) {
712
+ const matches = deepQuerySelectorAll(sel, document)
713
+ if (!matches.length) continue
714
+
715
+ const visible = matches.filter(isVisible)
716
+ const chosen = visible[index] || matches[index]
717
+
718
+ if (mode === "exists") {
719
+ return { ok: true, selectorUsed: sel, exists: true, count: matches.length }
720
+ }
721
+
722
+ if (!chosen) return { ok: false, error: `No element at index ${index} for ${sel}`, selectorUsed: sel }
723
+
724
+ if (mode === "text") {
725
+ const text = (chosen.innerText || chosen.textContent || "").trim()
726
+ return { ok: true, selectorUsed: sel, value: text }
727
+ }
728
+
729
+ if (mode === "value") {
730
+ const v = chosen.value
731
+ return { ok: true, selectorUsed: sel, value: typeof v === "string" ? v : String(v ?? "") }
732
+ }
733
+
734
+ if (mode === "attribute") {
735
+ const a = attribute ? chosen.getAttribute(attribute) : null
736
+ return { ok: true, selectorUsed: sel, value: a }
737
+ }
738
+
739
+ if (mode === "property") {
740
+ if (!property) return { ok: false, error: "property is required", selectorUsed: sel }
741
+ const v = chosen[property]
742
+ return { ok: true, selectorUsed: sel, value: v }
743
+ }
744
+
745
+ if (mode === "html") {
746
+ return { ok: true, selectorUsed: sel, value: chosen.outerHTML }
747
+ }
748
+
749
+ if (mode === "list") {
750
+ const items = matches
751
+ .slice(0, Math.max(1, Math.min(200, limit)))
752
+ .map((el) => ({
753
+ text: (el.innerText || el.textContent || "").trim().slice(0, 200),
754
+ tag: (el.tagName || "").toLowerCase(),
755
+ ariaLabel: el.getAttribute ? el.getAttribute("aria-label") : null,
756
+ }))
757
+ return { ok: true, selectorUsed: sel, items, count: matches.length }
758
+ }
759
+
760
+ return { ok: false, error: `Unknown mode: ${mode}`, selectorUsed: sel }
761
+ }
762
+
763
+ return { ok: false, error: `No matches for selectors: ${selectors.join(", ")}` }
764
+ },
765
+ args: [selectorList, mode, attribute || null, property || null, limit, index],
766
+ world: "ISOLATED",
767
+ })
768
+
769
+ const r = result[0]?.result
770
+ if (!r?.ok) throw new Error(r?.error || "Query failed")
771
+
772
+ // Keep output predictable: JSON for list/property, string otherwise
773
+ if (mode === "list" || mode === "property") {
774
+ return { tabId: tab.id, content: JSON.stringify(r, null, 2) }
775
+ }
776
+
777
+ return { tabId: tab.id, content: typeof r.value === "string" ? r.value : JSON.stringify(r.value) }
778
+ }
779
+
780
+ async function toolWaitFor({ tabId, selector, timeoutMs = 10000, pollMs = 200 }) {
781
+ if (!selector) throw new Error("selector is required")
782
+ const tab = await getTabById(tabId)
783
+
784
+ const selectorList = normalizeSelectorList(selector)
785
+
786
+ const result = await chrome.scripting.executeScript({
787
+ target: { tabId: tab.id },
788
+ func: async (selectors, timeoutMs, pollMs) => {
789
+ function isVisible(el) {
790
+ if (!el) return false
791
+ const rect = el.getBoundingClientRect()
792
+ if (rect.width <= 0 || rect.height <= 0) return false
793
+ const style = window.getComputedStyle(el)
794
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false
795
+ return true
796
+ }
797
+
798
+ function deepQuerySelector(sel, rootDoc) {
799
+ function findInRoot(root, depth) {
800
+ if (!root || depth > 6) return null
801
+ try {
802
+ const found = root.querySelector(sel)
803
+ if (found) return found
804
+ } catch {
805
+ return null
806
+ }
807
+
808
+ const tree = root.querySelectorAll ? root.querySelectorAll("*") : []
809
+ for (const el of tree) {
810
+ if (el.shadowRoot) {
811
+ const f = findInRoot(el.shadowRoot, depth + 1)
812
+ if (f) return f
813
+ }
814
+ }
815
+
816
+ const frames = root.querySelectorAll ? root.querySelectorAll("iframe") : []
817
+ for (const frame of frames) {
818
+ try {
819
+ const doc = frame.contentDocument
820
+ if (doc) {
821
+ const f = findInRoot(doc, depth + 1)
822
+ if (f) return f
823
+ }
824
+ } catch {}
825
+ }
826
+
827
+ return null
828
+ }
829
+
830
+ return findInRoot(rootDoc || document, 0)
831
+ }
832
+
833
+ const start = Date.now()
834
+ while (Date.now() - start < timeoutMs) {
835
+ for (const sel of selectors) {
836
+ if (!sel) continue
837
+ const el = deepQuerySelector(sel, document)
838
+ if (el && isVisible(el)) return { ok: true, selectorUsed: sel }
839
+ }
840
+ await new Promise((r) => setTimeout(r, pollMs))
841
+ }
842
+
843
+ return { ok: false, error: `Timed out waiting for selectors: ${selectors.join(", ")}` }
844
+ },
845
+ args: [selectorList, timeoutMs, pollMs],
846
+ world: "ISOLATED",
847
+ })
848
+
849
+ const r = result[0]?.result
850
+ if (!r?.ok) throw new Error(r?.error || "wait_for failed")
851
+ return { tabId: tab.id, content: `Found ${r.selectorUsed}` }
852
+ }
853
+
854
+ // Legacy tool kept for compatibility.
855
+ // We intentionally do NOT evaluate arbitrary JS strings (unpredictable + CSP/unsafe-eval issues).
856
+ // Instead, accept a JSON payload string describing a query.
299
857
  async function toolExecuteScript({ code, tabId }) {
300
858
  if (!code) throw new Error("Code is required")
859
+
860
+ let command
861
+ try {
862
+ command = JSON.parse(code)
863
+ } catch {
864
+ throw new Error(
865
+ "browser_execute expects JSON (not raw JS) due to MV3 CSP. Try: {\"op\":\"query\",\"selector\":\"...\",\"return\":\"text\" } or use browser_extract."
866
+ )
867
+ }
868
+
301
869
  const tab = await getTabById(tabId)
302
870
  const result = await chrome.scripting.executeScript({
303
871
  target: { tabId: tab.id },
304
- func: new Function(code),
872
+ func: (cmd) => {
873
+ const getBySelector = (selector) => {
874
+ if (!selector) return null
875
+ try {
876
+ return document.querySelector(selector)
877
+ } catch {
878
+ return null
879
+ }
880
+ }
881
+
882
+ const op = cmd?.op
883
+ if (op === "query") {
884
+ const el = getBySelector(cmd.selector)
885
+ if (!el) return { ok: false, error: "not_found" }
886
+ const ret = cmd.return || "text"
887
+ if (ret === "text") return { ok: true, value: el.innerText ?? el.textContent ?? "" }
888
+ if (ret === "value") return { ok: true, value: el.value }
889
+ if (ret === "html") return { ok: true, value: el.innerHTML }
890
+ if (ret === "attr") return { ok: true, value: el.getAttribute(cmd.name) }
891
+ if (ret === "href") return { ok: true, value: el.href }
892
+ return { ok: false, error: `unknown_return:${ret}` }
893
+ }
894
+
895
+ if (op === "location") {
896
+ return { ok: true, value: { url: location.href, title: document.title } }
897
+ }
898
+
899
+ return { ok: false, error: `unknown_op:${String(op)}` }
900
+ },
901
+ args: [command],
305
902
  })
903
+
306
904
  return { tabId: tab.id, content: JSON.stringify(result[0]?.result) }
307
905
  }
308
906
 
@@ -323,15 +921,13 @@ async function toolScroll({ x = 0, y = 0, selector, tabId }) {
323
921
  window.scrollBy(scrollX, scrollY)
324
922
  },
325
923
  args: [x, y, sel],
924
+ world: "ISOLATED",
326
925
  })
327
926
 
328
927
  return { tabId: tab.id, content: `Scrolled ${sel ? `to ${sel}` : `by (${x}, ${y})`}` }
329
928
  }
330
929
 
331
930
  async function toolWait({ ms = 1000, tabId }) {
332
- if (typeof tabId === "number") {
333
- // keep tabId in response for ownership purposes
334
- }
335
931
  await new Promise((resolve) => setTimeout(resolve, ms))
336
932
  return { tabId, content: `Waited ${ms}ms` }
337
933
  }
@@ -348,4 +944,4 @@ chrome.action.onClicked.addListener(() => {
348
944
  })
349
945
  })
350
946
 
351
- connect()
947
+ connect()
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCode Browser Automation",
4
- "version": "4.0.0",
4
+ "version": "4.1.0",
5
5
  "description": "Browser automation for OpenCode",
6
6
  "permissions": [
7
7
  "tabs",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@different-ai/opencode-browser",
3
- "version": "4.0.6",
3
+ "version": "4.1.0",
4
4
  "description": "Browser automation plugin for OpenCode (native messaging + per-tab ownership).",
5
5
  "type": "module",
6
6
  "bin": {