@different-ai/opencode-browser 4.0.7 → 4.2.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
@@ -23,11 +23,11 @@ The installer will:
23
23
  1. Copy the extension to `~/.opencode-browser/extension/`
24
24
  2. Walk you through loading + pinning it in `chrome://extensions`
25
25
  3. Ask for the extension ID and install a **Native Messaging Host manifest**
26
- 4. Update your `.opencode.json` to load the plugin
26
+ 4. Update your `opencode.json` or `opencode.jsonc` to load the plugin
27
27
 
28
28
  ### Configure OpenCode
29
29
 
30
- Your `.opencode.json` should contain:
30
+ Your `opencode.json` or `opencode.jsonc` should contain:
31
31
 
32
32
  ```json
33
33
  {
@@ -50,26 +50,24 @@ OpenCode Plugin <-> Local Broker (unix socket) <-> Native Host <-> Chrome Extens
50
50
 
51
51
  - First time a session touches a tab, the broker **auto-claims** it for that session.
52
52
  - Other sessions attempting to use the same tab will get an error.
53
-
54
- Tools:
55
-
56
- - `browser_claim_tab({ tabId })`
57
- - `browser_release_tab({ tabId })`
58
- - `browser_list_claims()`
53
+ - Use `browser_status` to inspect claims if needed.
59
54
 
60
55
  ## Available tools
61
56
 
62
- - `browser_version`
57
+ Core primitives:
63
58
  - `browser_status`
64
59
  - `browser_get_tabs`
65
60
  - `browser_navigate`
61
+ - `browser_query` (modes: `text`, `value`, `list`, `exists`, `page_text`; optional `timeoutMs`/`pollMs`)
66
62
  - `browser_click`
67
63
  - `browser_type`
68
- - `browser_screenshot`
69
- - `browser_snapshot`
70
64
  - `browser_scroll`
71
65
  - `browser_wait`
72
- - `browser_execute`
66
+
67
+ Diagnostics:
68
+ - `browser_snapshot`
69
+ - `browser_screenshot`
70
+ - `browser_version`
73
71
 
74
72
  ## Troubleshooting
75
73
 
@@ -78,8 +76,8 @@ Tools:
78
76
  - Confirm the extension ID you pasted matches the loaded extension in `chrome://extensions`
79
77
 
80
78
  **Tab ownership errors**
81
- - Use `browser_list_claims()` to see who owns a tab
82
- - Use `browser_claim_tab({ tabId, force: true })` to take over intentionally
79
+ - Use `browser_status` to see current claims
80
+ - Close the other OpenCode session to release ownership
83
81
 
84
82
  ## Uninstall
85
83
 
@@ -87,4 +85,4 @@ Tools:
87
85
  npx @different-ai/opencode-browser uninstall
88
86
  ```
89
87
 
90
- Then remove the unpacked extension in `chrome://extensions` and remove the plugin from `.opencode.json`.
88
+ Then remove the unpacked extension in `chrome://extensions` and remove the plugin from `opencode.json` or `opencode.jsonc`.
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
  }),
@@ -12593,44 +12595,27 @@ var plugin = async (ctx) => {
12593
12595
  return toolResultText(data, "Waited");
12594
12596
  }
12595
12597
  }),
12596
- browser_execute: tool({
12597
- description: "Execute JavaScript code in the page context and return the result.",
12598
+ browser_query: tool({
12599
+ description: "Read data from the page using selectors, optional wait, or page_text extraction (shadow DOM + same-origin iframes).",
12598
12600
  args: {
12599
- code: schema.string(),
12601
+ selector: schema.string().optional(),
12602
+ mode: schema.string().optional(),
12603
+ attribute: schema.string().optional(),
12604
+ property: schema.string().optional(),
12605
+ index: schema.number().optional(),
12606
+ limit: schema.number().optional(),
12607
+ timeoutMs: schema.number().optional(),
12608
+ pollMs: schema.number().optional(),
12609
+ pattern: schema.string().optional(),
12610
+ flags: schema.string().optional(),
12600
12611
  tabId: schema.number().optional()
12601
12612
  },
12602
- async execute({ code, tabId }, ctx2) {
12603
- const data = await brokerRequest("tool", { tool: "execute_script", args: { code, tabId } });
12604
- return toolResultText(data, "Execute failed");
12605
- }
12606
- }),
12607
- browser_claim_tab: tool({
12608
- description: "Claim a tab for this OpenCode session (per-tab ownership).",
12609
- args: {
12610
- tabId: schema.number(),
12611
- force: schema.boolean().optional()
12612
- },
12613
- async execute({ tabId, force }, ctx2) {
12614
- const data = await brokerRequest("claim_tab", { tabId, force });
12615
- return JSON.stringify(data);
12616
- }
12617
- }),
12618
- browser_release_tab: tool({
12619
- description: "Release a previously claimed tab.",
12620
- args: {
12621
- tabId: schema.number()
12622
- },
12623
- async execute({ tabId }, ctx2) {
12624
- const data = await brokerRequest("release_tab", { tabId });
12625
- return JSON.stringify(data);
12626
- }
12627
- }),
12628
- browser_list_claims: tool({
12629
- description: "List current tab ownership claims.",
12630
- args: {},
12631
- async execute(args, ctx2) {
12632
- const data = await brokerRequest("list_claims", {});
12633
- return JSON.stringify(data);
12613
+ async execute({ selector, mode, attribute, property, index, limit, timeoutMs, pollMs, pattern, flags, tabId }, ctx2) {
12614
+ const data = await brokerRequest("tool", {
12615
+ tool: "query",
12616
+ args: { selector, mode, attribute, property, index, limit, timeoutMs, pollMs, pattern, flags, tabId }
12617
+ });
12618
+ return toolResultText(data, "Query failed");
12634
12619
  }
12635
12620
  })
12636
12621
  }
@@ -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,7 +105,7 @@ async function executeTool(toolName, args) {
104
105
  type: toolType,
105
106
  screenshot: toolScreenshot,
106
107
  snapshot: toolSnapshot,
107
- execute_script: toolExecuteScript,
108
+ query: toolQuery,
108
109
  scroll: toolScroll,
109
110
  wait: toolWait,
110
111
  }
@@ -124,6 +125,363 @@ async function getTabById(tabId) {
124
125
  return tabId ? await chrome.tabs.get(tabId) : await getActiveTab()
125
126
  }
126
127
 
128
+ async function runInPage(tabId, command, args) {
129
+ const result = await chrome.scripting.executeScript({
130
+ target: { tabId },
131
+ func: pageOps,
132
+ args: [command, args || {}],
133
+ world: "ISOLATED",
134
+ })
135
+ return result[0]?.result
136
+ }
137
+
138
+ async function pageOps(command, args) {
139
+ const options = args || {}
140
+ const MAX_DEPTH = 6
141
+
142
+ function safeString(value) {
143
+ return typeof value === "string" ? value : ""
144
+ }
145
+
146
+ function normalizeSelectorList(selector) {
147
+ if (Array.isArray(selector)) {
148
+ return selector.map((s) => safeString(s).trim()).filter(Boolean)
149
+ }
150
+ if (typeof selector !== "string") return []
151
+ const parts = selector
152
+ .split(",")
153
+ .map((s) => s.trim())
154
+ .filter(Boolean)
155
+ return parts.length ? parts : [selector.trim()].filter(Boolean)
156
+ }
157
+
158
+ function isVisible(el) {
159
+ if (!el) return false
160
+ const rect = el.getBoundingClientRect()
161
+ if (rect.width <= 0 || rect.height <= 0) return false
162
+ const style = window.getComputedStyle(el)
163
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false
164
+ return true
165
+ }
166
+
167
+ function deepQuerySelectorAll(sel, rootDoc) {
168
+ const out = []
169
+ const seen = new Set()
170
+
171
+ function addAll(nodeList) {
172
+ for (const el of nodeList) {
173
+ if (!el || seen.has(el)) continue
174
+ seen.add(el)
175
+ out.push(el)
176
+ }
177
+ }
178
+
179
+ function walkRoot(root, depth) {
180
+ if (!root || depth > MAX_DEPTH) return
181
+ try {
182
+ addAll(root.querySelectorAll(sel))
183
+ } catch {
184
+ return
185
+ }
186
+
187
+ const tree = root.querySelectorAll ? root.querySelectorAll("*") : []
188
+ for (const el of tree) {
189
+ if (el.shadowRoot) {
190
+ walkRoot(el.shadowRoot, depth + 1)
191
+ }
192
+ }
193
+
194
+ const frames = root.querySelectorAll ? root.querySelectorAll("iframe") : []
195
+ for (const frame of frames) {
196
+ try {
197
+ const doc = frame.contentDocument
198
+ if (doc) walkRoot(doc, depth + 1)
199
+ } catch {}
200
+ }
201
+ }
202
+
203
+ walkRoot(rootDoc || document, 0)
204
+ return out
205
+ }
206
+
207
+ function resolveMatches(selectors, index) {
208
+ for (const sel of selectors) {
209
+ const s = safeString(sel)
210
+ if (!s) continue
211
+ const matches = deepQuerySelectorAll(s, document)
212
+ if (!matches.length) continue
213
+ const visible = matches.filter(isVisible)
214
+ const chosen = visible[index] || matches[index]
215
+ return { selectorUsed: s, matches, chosen }
216
+ }
217
+ return { selectorUsed: selectors[0] || "", matches: [], chosen: null }
218
+ }
219
+
220
+ function clickElement(el) {
221
+ try {
222
+ el.scrollIntoView({ block: "center", inline: "center" })
223
+ } catch {}
224
+
225
+ const rect = el.getBoundingClientRect()
226
+ const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1)
227
+ const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1)
228
+ const opts = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }
229
+
230
+ try {
231
+ el.dispatchEvent(new MouseEvent("mouseover", opts))
232
+ el.dispatchEvent(new MouseEvent("mousemove", opts))
233
+ el.dispatchEvent(new MouseEvent("mousedown", opts))
234
+ el.dispatchEvent(new MouseEvent("mouseup", opts))
235
+ el.dispatchEvent(new MouseEvent("click", opts))
236
+ } catch {}
237
+
238
+ try {
239
+ el.click()
240
+ } catch {}
241
+ }
242
+
243
+ function setNativeValue(el, value) {
244
+ const tag = el.tagName
245
+ if (tag === "INPUT" || tag === "TEXTAREA") {
246
+ const proto = tag === "INPUT" ? window.HTMLInputElement.prototype : window.HTMLTextAreaElement.prototype
247
+ const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set
248
+ if (setter) setter.call(el, value)
249
+ else el.value = value
250
+ return true
251
+ }
252
+ return false
253
+ }
254
+
255
+ function getInputValues() {
256
+ const out = []
257
+ const nodes = document.querySelectorAll("input, textarea")
258
+ nodes.forEach((el) => {
259
+ try {
260
+ const name = el.getAttribute("aria-label") || el.getAttribute("name") || el.id || el.className || el.tagName
261
+ const value = el.value
262
+ if (value != null && String(value).trim()) out.push(`${name}: ${value}`)
263
+ } catch {}
264
+ })
265
+ return out.join("\n")
266
+ }
267
+
268
+ function getPseudoText() {
269
+ const out = []
270
+ const elements = Array.from(document.querySelectorAll("*"))
271
+ for (let i = 0; i < elements.length && out.length < 2000; i++) {
272
+ const el = elements[i]
273
+ try {
274
+ const style = window.getComputedStyle(el)
275
+ if (style.display === "none" || style.visibility === "hidden") continue
276
+ const before = window.getComputedStyle(el, "::before").content
277
+ const after = window.getComputedStyle(el, "::after").content
278
+ const pushContent = (content) => {
279
+ if (!content) return
280
+ const c = String(content)
281
+ if (!c || c === "none" || c === "normal") return
282
+ const unquoted = c.replace(/^"|"$/g, "").replace(/^'|'$/g, "")
283
+ if (unquoted && unquoted !== "none" && unquoted !== "normal") out.push(unquoted)
284
+ }
285
+ pushContent(before)
286
+ pushContent(after)
287
+ } catch {}
288
+ }
289
+ return out.join("\n")
290
+ }
291
+
292
+ function buildMatches(text, pattern, flags) {
293
+ if (!pattern) return []
294
+ try {
295
+ const re = new RegExp(pattern, flags || "")
296
+ const found = []
297
+ let m
298
+ while ((m = re.exec(text)) && found.length < 50) {
299
+ found.push(m[0])
300
+ if (!re.global) break
301
+ }
302
+ return found
303
+ } catch {
304
+ return []
305
+ }
306
+ }
307
+
308
+ function getPageText(limit, pattern, flags) {
309
+ const parts = []
310
+ const bodyText = safeString(document.body?.innerText || "")
311
+ if (bodyText.trim()) parts.push(bodyText)
312
+ const inputValues = getInputValues()
313
+ if (inputValues) parts.push(inputValues)
314
+ const pseudo = getPseudoText()
315
+ if (pseudo) parts.push(pseudo)
316
+ const text = parts.filter(Boolean).join("\n\n").slice(0, Math.max(0, limit))
317
+ return {
318
+ url: location.href,
319
+ title: document.title,
320
+ text,
321
+ matches: buildMatches(text, pattern, flags),
322
+ }
323
+ }
324
+
325
+ const mode = typeof options.mode === "string" && options.mode ? options.mode : "text"
326
+ const selectors = normalizeSelectorList(options.selector)
327
+ const index = Number.isFinite(options.index) ? options.index : 0
328
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : 0
329
+ const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : 200
330
+ const limit = Number.isFinite(options.limit) ? options.limit : mode === "page_text" ? 20000 : 50
331
+ const pattern = typeof options.pattern === "string" ? options.pattern : null
332
+ const flags = typeof options.flags === "string" ? options.flags : "i"
333
+
334
+ if (command === "click") {
335
+ const match = resolveMatches(selectors, index)
336
+ if (!match.chosen) {
337
+ return { ok: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
338
+ }
339
+ clickElement(match.chosen)
340
+ return { ok: true, selectorUsed: match.selectorUsed }
341
+ }
342
+
343
+ if (command === "type") {
344
+ const text = options.text
345
+ const shouldClear = !!options.clear
346
+ const match = resolveMatches(selectors, index)
347
+ if (!match.chosen) {
348
+ return { ok: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
349
+ }
350
+
351
+ try {
352
+ match.chosen.scrollIntoView({ block: "center", inline: "center" })
353
+ } catch {}
354
+
355
+ try {
356
+ match.chosen.focus()
357
+ } catch {}
358
+
359
+ const tag = match.chosen.tagName
360
+ const isTextInput = tag === "INPUT" || tag === "TEXTAREA"
361
+
362
+ if (isTextInput) {
363
+ if (shouldClear) setNativeValue(match.chosen, "")
364
+ setNativeValue(match.chosen, (match.chosen.value || "") + text)
365
+ match.chosen.dispatchEvent(new Event("input", { bubbles: true }))
366
+ match.chosen.dispatchEvent(new Event("change", { bubbles: true }))
367
+ return { ok: true, selectorUsed: match.selectorUsed }
368
+ }
369
+
370
+ if (match.chosen.isContentEditable) {
371
+ if (shouldClear) match.chosen.textContent = ""
372
+ try {
373
+ document.execCommand("insertText", false, text)
374
+ } catch {
375
+ match.chosen.textContent = (match.chosen.textContent || "") + text
376
+ }
377
+ match.chosen.dispatchEvent(new Event("input", { bubbles: true }))
378
+ return { ok: true, selectorUsed: match.selectorUsed }
379
+ }
380
+
381
+ return { ok: false, error: `Element is not typable: ${match.selectorUsed} (${tag.toLowerCase()})` }
382
+ }
383
+
384
+ if (command === "scroll") {
385
+ const scrollX = Number.isFinite(options.x) ? options.x : 0
386
+ const scrollY = Number.isFinite(options.y) ? options.y : 0
387
+ if (selectors.length) {
388
+ const match = resolveMatches(selectors, index)
389
+ if (!match.chosen) {
390
+ return { ok: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
391
+ }
392
+ try {
393
+ match.chosen.scrollIntoView({ behavior: "smooth", block: "center" })
394
+ } catch {}
395
+ return { ok: true, selectorUsed: match.selectorUsed }
396
+ }
397
+ window.scrollBy(scrollX, scrollY)
398
+ return { ok: true }
399
+ }
400
+
401
+ if (command === "query") {
402
+ if (mode === "page_text") {
403
+ if (selectors.length && timeoutMs > 0) {
404
+ const start = Date.now()
405
+ while (Date.now() - start < timeoutMs) {
406
+ const match = resolveMatches(selectors, index)
407
+ if (match.matches.length) break
408
+ await new Promise((r) => setTimeout(r, pollMs))
409
+ }
410
+ }
411
+ return { ok: true, value: getPageText(limit, pattern, flags) }
412
+ }
413
+
414
+ if (!selectors.length) {
415
+ return { ok: false, error: "Selector is required" }
416
+ }
417
+
418
+ let match = resolveMatches(selectors, index)
419
+ if (timeoutMs > 0) {
420
+ const start = Date.now()
421
+ while (Date.now() - start < timeoutMs) {
422
+ match = resolveMatches(selectors, index)
423
+ if (mode === "exists" && match.matches.length) break
424
+ if (mode !== "exists" && match.chosen) break
425
+ await new Promise((r) => setTimeout(r, pollMs))
426
+ }
427
+ }
428
+
429
+ if (mode === "exists") {
430
+ return {
431
+ ok: true,
432
+ selectorUsed: match.selectorUsed,
433
+ value: { exists: match.matches.length > 0, count: match.matches.length },
434
+ }
435
+ }
436
+
437
+ if (!match.chosen) {
438
+ return { ok: false, error: `No matches for selectors: ${selectors.join(", ")}` }
439
+ }
440
+
441
+ if (mode === "text") {
442
+ const text = (match.chosen.innerText || match.chosen.textContent || "").trim()
443
+ return { ok: true, selectorUsed: match.selectorUsed, value: text }
444
+ }
445
+
446
+ if (mode === "value") {
447
+ const value = match.chosen.value
448
+ return { ok: true, selectorUsed: match.selectorUsed, value: typeof value === "string" ? value : String(value ?? "") }
449
+ }
450
+
451
+ if (mode === "attribute") {
452
+ const value = options.attribute ? match.chosen.getAttribute(options.attribute) : null
453
+ return { ok: true, selectorUsed: match.selectorUsed, value }
454
+ }
455
+
456
+ if (mode === "property") {
457
+ if (!options.property) return { ok: false, error: "property is required" }
458
+ return { ok: true, selectorUsed: match.selectorUsed, value: match.chosen[options.property] }
459
+ }
460
+
461
+ if (mode === "html") {
462
+ return { ok: true, selectorUsed: match.selectorUsed, value: match.chosen.outerHTML }
463
+ }
464
+
465
+ if (mode === "list") {
466
+ const maxItems = Math.min(Math.max(1, limit), 200)
467
+ const items = match.matches.slice(0, maxItems).map((el) => ({
468
+ text: (el.innerText || el.textContent || "").trim().slice(0, 200),
469
+ tag: (el.tagName || "").toLowerCase(),
470
+ ariaLabel: el.getAttribute ? el.getAttribute("aria-label") : null,
471
+ }))
472
+ return {
473
+ ok: true,
474
+ selectorUsed: match.selectorUsed,
475
+ value: { items, count: match.matches.length },
476
+ }
477
+ }
478
+
479
+ return { ok: false, error: `Unknown mode: ${mode}` }
480
+ }
481
+
482
+ return { ok: false, error: `Unknown command: ${String(command)}` }
483
+ }
484
+
127
485
  async function toolGetActiveTab() {
128
486
  const tab = await getActiveTab()
129
487
  return { tabId: tab.id, content: { tabId: tab.id, url: tab.url, title: tab.title } }
@@ -151,52 +509,25 @@ async function toolNavigate({ url, tabId }) {
151
509
  return { tabId: tab.id, content: `Navigated to ${url}` }
152
510
  }
153
511
 
154
- async function toolClick({ selector, tabId }) {
512
+ async function toolClick({ selector, tabId, index = 0 }) {
155
513
  if (!selector) throw new Error("Selector is required")
156
514
  const tab = await getTabById(tabId)
157
515
 
158
- const result = await chrome.scripting.executeScript({
159
- 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 }
165
- },
166
- args: [selector],
167
- })
168
-
169
- if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Click failed")
170
- return { tabId: tab.id, content: `Clicked ${selector}` }
516
+ const result = await runInPage(tab.id, "click", { selector, index })
517
+ if (!result?.ok) throw new Error(result?.error || "Click failed")
518
+ const used = result.selectorUsed || selector
519
+ return { tabId: tab.id, content: `Clicked ${used}` }
171
520
  }
172
521
 
173
- async function toolType({ selector, text, tabId, clear = false }) {
522
+ async function toolType({ selector, text, tabId, clear = false, index = 0 }) {
174
523
  if (!selector) throw new Error("Selector is required")
175
524
  if (text === undefined) throw new Error("Text is required")
176
525
  const tab = await getTabById(tabId)
177
526
 
178
- const result = await chrome.scripting.executeScript({
179
- 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 }
194
- },
195
- args: [selector, text, clear],
196
- })
197
-
198
- if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Type failed")
199
- return { tabId: tab.id, content: `Typed "${text}" into ${selector}` }
527
+ const result = await runInPage(tab.id, "type", { selector, text, clear, index })
528
+ if (!result?.ok) throw new Error(result?.error || "Type failed")
529
+ const used = result.selectorUsed || selector
530
+ return { tabId: tab.id, content: `Typed "${text}" into ${used}` }
200
531
  }
201
532
 
202
533
  async function toolScreenshot({ tabId }) {
@@ -211,56 +542,109 @@ async function toolSnapshot({ tabId }) {
211
542
  const result = await chrome.scripting.executeScript({
212
543
  target: { tabId: tab.id },
213
544
  func: () => {
545
+ function safeText(s) {
546
+ return typeof s === "string" ? s : ""
547
+ }
548
+
549
+ function isVisible(el) {
550
+ if (!el) return false
551
+ const rect = el.getBoundingClientRect()
552
+ if (rect.width <= 0 || rect.height <= 0) return false
553
+ const style = window.getComputedStyle(el)
554
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false
555
+ return true
556
+ }
557
+
558
+ function pseudoText(el) {
559
+ try {
560
+ const before = window.getComputedStyle(el, "::before").content
561
+ const after = window.getComputedStyle(el, "::after").content
562
+ const norm = (v) => {
563
+ const s = safeText(v)
564
+ if (!s || s === "none") return ""
565
+ return s.replace(/^"|"$/g, "")
566
+ }
567
+ return { before: norm(before), after: norm(after) }
568
+ } catch {
569
+ return { before: "", after: "" }
570
+ }
571
+ }
572
+
214
573
  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
- )
574
+ const aria = el.getAttribute("aria-label")
575
+ if (aria) return aria
576
+ const alt = el.getAttribute("alt")
577
+ if (alt) return alt
578
+ const title = el.getAttribute("title")
579
+ if (title) return title
580
+ const placeholder = el.getAttribute("placeholder")
581
+ if (placeholder) return placeholder
582
+ const txt = safeText(el.innerText)
583
+ if (txt.trim()) return txt.slice(0, 200)
584
+ const pt = pseudoText(el)
585
+ const combo = `${pt.before} ${pt.after}`.trim()
586
+ if (combo) return combo.slice(0, 200)
587
+ return ""
223
588
  }
224
589
 
225
590
  function build(el, depth = 0, uid = 0) {
226
- if (depth > 10) return { nodes: [], nextUid: uid }
591
+ if (!el || depth > 12) return { nodes: [], nextUid: uid }
227
592
  const nodes = []
228
- const style = window.getComputedStyle(el)
229
- if (style.display === "none" || style.visibility === "hidden") return { nodes: [], nextUid: uid }
593
+
594
+ if (!isVisible(el)) return { nodes: [], nextUid: uid }
230
595
 
231
596
  const isInteractive =
232
597
  ["A", "BUTTON", "INPUT", "TEXTAREA", "SELECT"].includes(el.tagName) ||
233
598
  el.getAttribute("onclick") ||
234
599
  el.getAttribute("role") === "button" ||
235
600
  el.isContentEditable
236
- const rect = el.getBoundingClientRect()
237
601
 
238
- if (rect.width > 0 && rect.height > 0 && (isInteractive || el.innerText?.trim())) {
602
+ const name = getName(el)
603
+ const pt = pseudoText(el)
604
+
605
+ const shouldInclude = isInteractive || name.trim() || pt.before || pt.after
606
+
607
+ if (shouldInclude) {
239
608
  const node = {
240
609
  uid: `e${uid}`,
241
610
  role: el.getAttribute("role") || el.tagName.toLowerCase(),
242
- name: getName(el).slice(0, 200),
611
+ name: name,
243
612
  tag: el.tagName.toLowerCase(),
244
613
  }
614
+
615
+ if (pt.before) node.before = pt.before
616
+ if (pt.after) node.after = pt.after
617
+
245
618
  if (el.href) node.href = el.href
246
- if (el.tagName === "INPUT") {
619
+
620
+ if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
247
621
  node.type = el.type
248
622
  node.value = el.value
623
+ if (el.readOnly) node.readOnly = true
624
+ if (el.disabled) node.disabled = true
249
625
  }
626
+
250
627
  if (el.id) node.selector = `#${el.id}`
251
628
  else if (el.className && typeof el.className === "string") {
252
629
  const cls = el.className.trim().split(/\s+/).slice(0, 2).join(".")
253
630
  if (cls) node.selector = `${el.tagName.toLowerCase()}.${cls}`
254
631
  }
632
+
255
633
  nodes.push(node)
256
634
  uid++
257
635
  }
258
636
 
637
+ if (el.shadowRoot) {
638
+ const r = build(el.shadowRoot.host, depth + 1, uid)
639
+ uid = r.nextUid
640
+ }
641
+
259
642
  for (const child of el.children) {
260
643
  const r = build(child, depth + 1, uid)
261
644
  nodes.push(...r.nodes)
262
645
  uid = r.nextUid
263
646
  }
647
+
264
648
  return { nodes, nextUid: uid }
265
649
  }
266
650
 
@@ -275,16 +659,25 @@ async function toolSnapshot({ tabId }) {
275
659
  links.push({ href, text })
276
660
  }
277
661
  })
278
- return links.slice(0, 100)
662
+ return links.slice(0, 200)
279
663
  }
280
664
 
665
+ let pageText = ""
666
+ try {
667
+ pageText = safeText(document.body?.innerText || "").slice(0, 20000)
668
+ } catch {}
669
+
670
+ const built = build(document.body).nodes.slice(0, 800)
671
+
281
672
  return {
282
673
  url: location.href,
283
674
  title: document.title,
284
- nodes: build(document.body).nodes.slice(0, 500),
675
+ text: pageText,
676
+ nodes: built,
285
677
  links: getAllLinks(),
286
678
  }
287
679
  },
680
+ world: "ISOLATED",
288
681
  })
289
682
 
290
683
  return { tabId: tab.id, content: JSON.stringify(result[0]?.result, null, 2) }
@@ -296,42 +689,54 @@ async function toolGetTabs() {
296
689
  return { content: JSON.stringify(out, null, 2) }
297
690
  }
298
691
 
299
- async function toolExecuteScript({ code, tabId }) {
300
- if (!code) throw new Error("Code is required")
692
+ async function toolQuery({
693
+ tabId,
694
+ selector,
695
+ mode = "text",
696
+ attribute,
697
+ property,
698
+ limit,
699
+ index = 0,
700
+ timeoutMs,
701
+ pollMs,
702
+ pattern,
703
+ flags,
704
+ }) {
705
+ if (!selector && mode !== "page_text") throw new Error("selector is required")
301
706
  const tab = await getTabById(tabId)
302
- const result = await chrome.scripting.executeScript({
303
- target: { tabId: tab.id },
304
- func: new Function(code),
707
+
708
+ const result = await runInPage(tab.id, "query", {
709
+ selector,
710
+ mode,
711
+ attribute,
712
+ property,
713
+ limit,
714
+ index,
715
+ timeoutMs,
716
+ pollMs,
717
+ pattern,
718
+ flags,
305
719
  })
306
- return { tabId: tab.id, content: JSON.stringify(result[0]?.result) }
720
+
721
+ if (!result?.ok) throw new Error(result?.error || "Query failed")
722
+
723
+ if (mode === "list" || mode === "property" || mode === "exists" || mode === "page_text") {
724
+ return { tabId: tab.id, content: JSON.stringify(result, null, 2) }
725
+ }
726
+
727
+ return { tabId: tab.id, content: typeof result.value === "string" ? result.value : JSON.stringify(result.value) }
307
728
  }
308
729
 
309
730
  async function toolScroll({ x = 0, y = 0, selector, tabId }) {
310
731
  const tab = await getTabById(tabId)
311
- const sel = selector || null
312
732
 
313
- await chrome.scripting.executeScript({
314
- target: { tabId: tab.id },
315
- func: (scrollX, scrollY, sel) => {
316
- if (sel) {
317
- const el = document.querySelector(sel)
318
- if (el) {
319
- el.scrollIntoView({ behavior: "smooth", block: "center" })
320
- return
321
- }
322
- }
323
- window.scrollBy(scrollX, scrollY)
324
- },
325
- args: [x, y, sel],
326
- })
327
-
328
- return { tabId: tab.id, content: `Scrolled ${sel ? `to ${sel}` : `by (${x}, ${y})`}` }
733
+ const result = await runInPage(tab.id, "scroll", { x, y, selector })
734
+ if (!result?.ok) throw new Error(result?.error || "Scroll failed")
735
+ const target = result.selectorUsed ? `to ${result.selectorUsed}` : `by (${x}, ${y})`
736
+ return { tabId: tab.id, content: `Scrolled ${target}` }
329
737
  }
330
738
 
331
739
  async function toolWait({ ms = 1000, tabId }) {
332
- if (typeof tabId === "number") {
333
- // keep tabId in response for ownership purposes
334
- }
335
740
  await new Promise((resolve) => setTimeout(resolve, ms))
336
741
  return { tabId, content: `Waited ${ms}ms` }
337
742
  }
@@ -348,4 +753,4 @@ chrome.action.onClicked.addListener(() => {
348
753
  })
349
754
  })
350
755
 
351
- connect()
756
+ 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.2.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.2.0",
4
4
  "description": "Browser automation plugin for OpenCode (native messaging + per-tab ownership).",
5
5
  "type": "module",
6
6
  "bin": {