@different-ai/opencode-browser 4.0.7 → 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
@@ -12529,10 +12529,11 @@ var plugin = async (ctx) => {
12529
12529
  description: "Click an element on the page using a CSS selector",
12530
12530
  args: {
12531
12531
  selector: schema.string(),
12532
+ index: schema.number().optional(),
12532
12533
  tabId: schema.number().optional()
12533
12534
  },
12534
- async execute({ selector, tabId }, ctx2) {
12535
- const data = await brokerRequest("tool", { tool: "click", args: { selector, tabId } });
12535
+ async execute({ selector, index, tabId }, ctx2) {
12536
+ const data = await brokerRequest("tool", { tool: "click", args: { selector, index, tabId } });
12536
12537
  return toolResultText(data, `Clicked ${selector}`);
12537
12538
  }
12538
12539
  }),
@@ -12542,10 +12543,11 @@ var plugin = async (ctx) => {
12542
12543
  selector: schema.string(),
12543
12544
  text: schema.string(),
12544
12545
  clear: schema.boolean().optional(),
12546
+ index: schema.number().optional(),
12545
12547
  tabId: schema.number().optional()
12546
12548
  },
12547
- async execute({ selector, text, clear, tabId }, ctx2) {
12548
- const data = await brokerRequest("tool", { tool: "type", args: { selector, text, clear, tabId } });
12549
+ async execute({ selector, text, clear, index, tabId }, ctx2) {
12550
+ const data = await brokerRequest("tool", { tool: "type", args: { selector, text, clear, index, tabId } });
12549
12551
  return toolResultText(data, `Typed "${text}" into ${selector}`);
12550
12552
  }
12551
12553
  }),
@@ -12594,7 +12596,7 @@ var plugin = async (ctx) => {
12594
12596
  }
12595
12597
  }),
12596
12598
  browser_execute: tool({
12597
- description: "Execute JavaScript code in the page context and return the result.",
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.",
12598
12600
  args: {
12599
12601
  code: schema.string(),
12600
12602
  tabId: schema.number().optional()
@@ -12604,6 +12606,58 @@ var plugin = async (ctx) => {
12604
12606
  return toolResultText(data, "Execute failed");
12605
12607
  }
12606
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
+ }),
12607
12661
  browser_claim_tab: tool({
12608
12662
  description: "Claim a tab for this OpenCode session (per-tab ownership).",
12609
12663
  args: {
@@ -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.7",
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": {