@different-ai/opencode-browser 4.1.0 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,29 +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` (deprecated, CSP-limited)
73
- - `browser_query`
74
- - `browser_wait_for`
75
- - `browser_extract`
66
+
67
+ Diagnostics:
68
+ - `browser_snapshot`
69
+ - `browser_screenshot`
70
+ - `browser_version`
76
71
 
77
72
  ## Troubleshooting
78
73
 
@@ -81,8 +76,8 @@ Tools:
81
76
  - Confirm the extension ID you pasted matches the loaded extension in `chrome://extensions`
82
77
 
83
78
  **Tab ownership errors**
84
- - Use `browser_list_claims()` to see who owns a tab
85
- - 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
86
81
 
87
82
  ## Uninstall
88
83
 
@@ -90,4 +85,4 @@ Tools:
90
85
  npx @different-ai/opencode-browser uninstall
91
86
  ```
92
87
 
93
- 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
@@ -12595,96 +12595,27 @@ var plugin = async (ctx) => {
12595
12595
  return toolResultText(data, "Waited");
12596
12596
  }
12597
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
12598
  browser_query: tool({
12610
- description: "Read data from the page using selectors (supports shadow DOM + same-origin iframes).",
12599
+ description: "Read data from the page using selectors, optional wait, or page_text extraction (shadow DOM + same-origin iframes).",
12611
12600
  args: {
12612
- selector: schema.string(),
12601
+ selector: schema.string().optional(),
12613
12602
  mode: schema.string().optional(),
12614
12603
  attribute: schema.string().optional(),
12615
12604
  property: schema.string().optional(),
12616
12605
  index: schema.number().optional(),
12617
12606
  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
12607
  timeoutMs: schema.number().optional(),
12633
12608
  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
12609
  pattern: schema.string().optional(),
12649
12610
  flags: schema.string().optional(),
12650
- limit: schema.number().optional(),
12651
12611
  tabId: schema.number().optional()
12652
12612
  },
12653
- async execute({ mode, pattern, flags, limit, tabId }, ctx2) {
12613
+ async execute({ selector, mode, attribute, property, index, limit, timeoutMs, pollMs, pattern, flags, tabId }, ctx2) {
12654
12614
  const data = await brokerRequest("tool", {
12655
- tool: "extract",
12656
- args: { mode, pattern, flags, limit, tabId }
12615
+ tool: "query",
12616
+ args: { selector, mode, attribute, property, index, limit, timeoutMs, pollMs, pattern, flags, tabId }
12657
12617
  });
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);
12618
+ return toolResultText(data, "Query failed");
12688
12619
  }
12689
12620
  })
12690
12621
  }
@@ -105,10 +105,7 @@ async function executeTool(toolName, args) {
105
105
  type: toolType,
106
106
  screenshot: toolScreenshot,
107
107
  snapshot: toolSnapshot,
108
- extract: toolExtract,
109
108
  query: toolQuery,
110
- wait_for: toolWaitFor,
111
- execute_script: toolExecuteScript,
112
109
  scroll: toolScroll,
113
110
  wait: toolWait,
114
111
  }
@@ -128,276 +125,408 @@ async function getTabById(tabId) {
128
125
  return tabId ? await chrome.tabs.get(tabId) : await getActiveTab()
129
126
  }
130
127
 
131
- async function toolGetActiveTab() {
132
- const tab = await getActiveTab()
133
- return { tabId: tab.id, content: { tabId: tab.id, url: tab.url, title: tab.title } }
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
134
136
  }
135
137
 
136
- async function toolNavigate({ url, tabId }) {
137
- if (!url) throw new Error("URL is required")
138
- const tab = await getTabById(tabId)
139
- await chrome.tabs.update(tab.id, { url })
138
+ async function pageOps(command, args) {
139
+ const options = args || {}
140
+ const MAX_DEPTH = 6
140
141
 
141
- await new Promise((resolve) => {
142
- const listener = (updatedTabId, info) => {
143
- if (updatedTabId === tab.id && info.status === "complete") {
144
- chrome.tabs.onUpdated.removeListener(listener)
145
- resolve()
146
- }
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)
147
149
  }
148
- chrome.tabs.onUpdated.addListener(listener)
149
- setTimeout(() => {
150
- chrome.tabs.onUpdated.removeListener(listener)
151
- resolve()
152
- }, 30000)
153
- })
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
+ }
154
157
 
155
- return { tabId: tab.id, content: `Navigated to ${url}` }
156
- }
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
+ }
157
166
 
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
- }
167
+ function deepQuerySelectorAll(sel, rootDoc) {
168
+ const out = []
169
+ const seen = new Set()
166
170
 
167
- async function toolClick({ selector, tabId, index = 0 }) {
168
- if (!selector) throw new Error("Selector is required")
169
- const tab = await getTabById(tabId)
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
+ }
170
178
 
171
- const selectorList = normalizeSelectorList(selector)
179
+ function walkRoot(root, depth) {
180
+ if (!root || depth > MAX_DEPTH) return
181
+ try {
182
+ addAll(root.querySelectorAll(sel))
183
+ } catch {
184
+ return
185
+ }
172
186
 
173
- const result = await chrome.scripting.executeScript({
174
- target: { tabId: tab.id },
175
- func: (selectors, index) => {
176
- function safeString(v) {
177
- return typeof v === "string" ? v : ""
187
+ const tree = root.querySelectorAll ? root.querySelectorAll("*") : []
188
+ for (const el of tree) {
189
+ if (el.shadowRoot) {
190
+ walkRoot(el.shadowRoot, depth + 1)
191
+ }
178
192
  }
179
193
 
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
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 {}
187
200
  }
201
+ }
188
202
 
189
- function deepQuerySelectorAll(sel, rootDoc) {
190
- const out = []
191
- const seen = new Set()
203
+ walkRoot(rootDoc || document, 0)
204
+ return out
205
+ }
192
206
 
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
- }
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
+ }
200
219
 
201
- function walkRoot(root, depth) {
202
- if (!root || depth > 6) return
220
+ function clickElement(el) {
221
+ try {
222
+ el.scrollIntoView({ block: "center", inline: "center" })
223
+ } catch {}
203
224
 
204
- try {
205
- addAll(root.querySelectorAll(sel))
206
- } catch {
207
- // Invalid selector
208
- return
209
- }
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 }
210
229
 
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
- }
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 {}
217
237
 
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
- }
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)
228
284
  }
285
+ pushContent(before)
286
+ pushContent(after)
287
+ } catch {}
288
+ }
289
+ return out.join("\n")
290
+ }
229
291
 
230
- walkRoot(rootDoc || document, 0)
231
- return out
232
- }
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
+ }
233
307
 
234
- function tryClick(el) {
235
- try {
236
- el.scrollIntoView({ block: "center", inline: "center" })
237
- } catch {}
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
+ }
238
324
 
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)
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
+ }
242
342
 
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 {}
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
+ }
251
350
 
252
- try {
253
- el.click()
254
- } catch {}
255
- }
351
+ try {
352
+ match.chosen.scrollIntoView({ block: "center", inline: "center" })
353
+ } catch {}
256
354
 
257
- for (const sel of selectors) {
258
- const s = safeString(sel)
259
- if (!s) continue
355
+ try {
356
+ match.chosen.focus()
357
+ } catch {}
260
358
 
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
- }
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
268
376
  }
377
+ match.chosen.dispatchEvent(new Event("input", { bubbles: true }))
378
+ return { ok: true, selectorUsed: match.selectorUsed }
379
+ }
269
380
 
270
- return { success: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
271
- },
272
- args: [selectorList, index],
273
- world: "ISOLATED",
274
- })
381
+ return { ok: false, error: `Element is not typable: ${match.selectorUsed} (${tag.toLowerCase()})` }
382
+ }
275
383
 
276
- if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Click failed")
277
- const used = result[0]?.result?.selectorUsed || selector
278
- return { tabId: tab.id, content: `Clicked ${used}` }
279
- }
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
+ }
280
400
 
281
- async function toolType({ selector, text, tabId, clear = false, index = 0 }) {
282
- if (!selector) throw new Error("Selector is required")
283
- if (text === undefined) throw new Error("Text is required")
284
- const tab = await getTabById(tabId)
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
+ }
285
413
 
286
- const selectorList = normalizeSelectorList(selector)
414
+ if (!selectors.length) {
415
+ return { ok: false, error: "Selector is required" }
416
+ }
287
417
 
288
- const result = await chrome.scripting.executeScript({
289
- target: { tabId: tab.id },
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
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))
298
426
  }
427
+ }
299
428
 
300
- function deepQuerySelectorAll(sel, rootDoc) {
301
- const out = []
302
- const seen = new Set()
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
+ }
303
436
 
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
- }
437
+ if (!match.chosen) {
438
+ return { ok: false, error: `No matches for selectors: ${selectors.join(", ")}` }
439
+ }
311
440
 
312
- function walkRoot(root, depth) {
313
- if (!root || depth > 6) return
441
+ if (mode === "text") {
442
+ const text = (match.chosen.innerText || match.chosen.textContent || "").trim()
443
+ return { ok: true, selectorUsed: match.selectorUsed, value: text }
444
+ }
314
445
 
315
- try {
316
- addAll(root.querySelectorAll(sel))
317
- } catch {
318
- return
319
- }
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
+ }
320
450
 
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
- }
451
+ if (mode === "attribute") {
452
+ const value = options.attribute ? match.chosen.getAttribute(options.attribute) : null
453
+ return { ok: true, selectorUsed: match.selectorUsed, value }
454
+ }
327
455
 
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
- }
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
+ }
336
460
 
337
- walkRoot(rootDoc || document, 0)
338
- return out
339
- }
461
+ if (mode === "html") {
462
+ return { ok: true, selectorUsed: match.selectorUsed, value: match.chosen.outerHTML }
463
+ }
340
464
 
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
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 },
351
476
  }
477
+ }
352
478
 
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
479
+ return { ok: false, error: `Unknown mode: ${mode}` }
480
+ }
359
481
 
360
- try {
361
- el.scrollIntoView({ block: "center", inline: "center" })
362
- } catch {}
482
+ return { ok: false, error: `Unknown command: ${String(command)}` }
483
+ }
363
484
 
364
- try {
365
- el.focus()
366
- } catch {}
485
+ async function toolGetActiveTab() {
486
+ const tab = await getActiveTab()
487
+ return { tabId: tab.id, content: { tabId: tab.id, url: tab.url, title: tab.title } }
488
+ }
367
489
 
368
- const tag = el.tagName
369
- const isTextInput = tag === "INPUT" || tag === "TEXTAREA"
490
+ async function toolNavigate({ url, tabId }) {
491
+ if (!url) throw new Error("URL is required")
492
+ const tab = await getTabById(tabId)
493
+ await chrome.tabs.update(tab.id, { url })
370
494
 
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
- }
495
+ await new Promise((resolve) => {
496
+ const listener = (updatedTabId, info) => {
497
+ if (updatedTabId === tab.id && info.status === "complete") {
498
+ chrome.tabs.onUpdated.removeListener(listener)
499
+ resolve()
500
+ }
501
+ }
502
+ chrome.tabs.onUpdated.addListener(listener)
503
+ setTimeout(() => {
504
+ chrome.tabs.onUpdated.removeListener(listener)
505
+ resolve()
506
+ }, 30000)
507
+ })
378
508
 
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
- }
509
+ return { tabId: tab.id, content: `Navigated to ${url}` }
510
+ }
389
511
 
390
- return { success: false, error: `Element is not typable: ${sel} (${tag.toLowerCase()})` }
391
- }
512
+ async function toolClick({ selector, tabId, index = 0 }) {
513
+ if (!selector) throw new Error("Selector is required")
514
+ const tab = await getTabById(tabId)
392
515
 
393
- return { success: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
394
- },
395
- args: [selectorList, text, !!clear, index],
396
- world: "ISOLATED",
397
- })
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}` }
520
+ }
521
+
522
+ async function toolType({ selector, text, tabId, clear = false, index = 0 }) {
523
+ if (!selector) throw new Error("Selector is required")
524
+ if (text === undefined) throw new Error("Text is required")
525
+ const tab = await getTabById(tabId)
398
526
 
399
- if (!result[0]?.result?.success) throw new Error(result[0]?.result?.error || "Type failed")
400
- const used = result[0]?.result?.selectorUsed || 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
401
530
  return { tabId: tab.id, content: `Typed "${text}" into ${used}` }
402
531
  }
403
532
 
@@ -554,377 +683,57 @@ async function toolSnapshot({ tabId }) {
554
683
  return { tabId: tab.id, content: JSON.stringify(result[0]?.result, null, 2) }
555
684
  }
556
685
 
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],
641
- })
642
-
643
- return { tabId: tab.id, content: JSON.stringify(result[0]?.result, null, 2) }
644
- }
645
-
646
686
  async function toolGetTabs() {
647
687
  const tabs = await chrome.tabs.query({})
648
688
  const out = tabs.map((t) => ({ id: t.id, url: t.url, title: t.title, active: t.active, windowId: t.windowId }))
649
689
  return { content: JSON.stringify(out, null, 2) }
650
690
  }
651
691
 
652
- async function toolQuery({ tabId, selector, mode = "text", attribute, property, limit = 50, index = 0 }) {
653
- if (!selector) throw new Error("selector 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")
654
706
  const tab = await getTabById(tabId)
655
707
 
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",
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,
767
719
  })
768
720
 
769
- const r = result[0]?.result
770
- if (!r?.ok) throw new Error(r?.error || "Query failed")
721
+ if (!result?.ok) throw new Error(result?.error || "Query failed")
771
722
 
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) }
723
+ if (mode === "list" || mode === "property" || mode === "exists" || mode === "page_text") {
724
+ return { tabId: tab.id, content: JSON.stringify(result, null, 2) }
775
725
  }
776
726
 
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.
857
- async function toolExecuteScript({ code, tabId }) {
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
-
869
- const tab = await getTabById(tabId)
870
- const result = await chrome.scripting.executeScript({
871
- target: { tabId: tab.id },
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],
902
- })
903
-
904
- return { tabId: tab.id, content: JSON.stringify(result[0]?.result) }
727
+ return { tabId: tab.id, content: typeof result.value === "string" ? result.value : JSON.stringify(result.value) }
905
728
  }
906
729
 
907
730
  async function toolScroll({ x = 0, y = 0, selector, tabId }) {
908
731
  const tab = await getTabById(tabId)
909
- const sel = selector || null
910
-
911
- await chrome.scripting.executeScript({
912
- target: { tabId: tab.id },
913
- func: (scrollX, scrollY, sel) => {
914
- if (sel) {
915
- const el = document.querySelector(sel)
916
- if (el) {
917
- el.scrollIntoView({ behavior: "smooth", block: "center" })
918
- return
919
- }
920
- }
921
- window.scrollBy(scrollX, scrollY)
922
- },
923
- args: [x, y, sel],
924
- world: "ISOLATED",
925
- })
926
732
 
927
- 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}` }
928
737
  }
929
738
 
930
739
  async function toolWait({ ms = 1000, tabId }) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCode Browser Automation",
4
- "version": "4.1.0",
4
+ "version": "4.2.1",
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.1.0",
3
+ "version": "4.2.1",
4
4
  "description": "Browser automation plugin for OpenCode (native messaging + per-tab ownership).",
5
5
  "type": "module",
6
6
  "bin": {