@different-ai/opencode-browser 4.3.2 → 4.4.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/.opencode/skill/browser-automation/SKILL.md +7 -1
- package/README.md +66 -7
- package/bin/agent-gateway.cjs +129 -0
- package/bin/cli.js +254 -31
- package/bin/tool-test.ts +35 -0
- package/dist/plugin.js +768 -46
- package/extension/background.js +180 -35
- package/extension/manifest.json +4 -1
- package/package.json +6 -2
package/extension/background.js
CHANGED
|
@@ -140,6 +140,7 @@ async function runInPage(tabId, command, args) {
|
|
|
140
140
|
async function pageOps(command, args) {
|
|
141
141
|
const options = args || {}
|
|
142
142
|
const MAX_DEPTH = 6
|
|
143
|
+
const DEFAULT_TIMEOUT_MS = 2000
|
|
143
144
|
|
|
144
145
|
function safeString(value) {
|
|
145
146
|
return typeof value === "string" ? value : ""
|
|
@@ -157,6 +158,48 @@ async function pageOps(command, args) {
|
|
|
157
158
|
return parts.length ? parts : [selector.trim()].filter(Boolean)
|
|
158
159
|
}
|
|
159
160
|
|
|
161
|
+
function stripQuotes(value) {
|
|
162
|
+
return safeString(value).replace(/^['"]|['"]$/g, "")
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function normalizeText(value) {
|
|
166
|
+
return safeString(value).replace(/\s+/g, " ").trim().toLowerCase()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function matchesText(value, target) {
|
|
170
|
+
if (!target) return false
|
|
171
|
+
const normTarget = normalizeText(target)
|
|
172
|
+
if (!normTarget) return false
|
|
173
|
+
const normValue = normalizeText(value)
|
|
174
|
+
return normValue === normTarget || normValue.includes(normTarget)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalizeLocatorKey(key) {
|
|
178
|
+
if (key === "css") return "css"
|
|
179
|
+
if (key === "label" || key === "field") return "label"
|
|
180
|
+
if (key === "aria" || key === "aria-label") return "aria"
|
|
181
|
+
if (key === "placeholder") return "placeholder"
|
|
182
|
+
if (key === "name") return "name"
|
|
183
|
+
if (key === "role") return "role"
|
|
184
|
+
if (key === "text") return "text"
|
|
185
|
+
if (key === "id") return "id"
|
|
186
|
+
return null
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parseLocator(raw) {
|
|
190
|
+
const trimmed = safeString(raw).trim()
|
|
191
|
+
if (!trimmed) return { kind: "css", value: "", raw: "" }
|
|
192
|
+
const match = trimmed.match(/^([a-zA-Z_-]+)\s*(=|:)\s*(.+)$/)
|
|
193
|
+
if (match) {
|
|
194
|
+
const key = match[1].toLowerCase()
|
|
195
|
+
const kind = normalizeLocatorKey(key)
|
|
196
|
+
if (kind) {
|
|
197
|
+
return { kind, value: stripQuotes(match[3]), raw: trimmed }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { kind: "css", value: trimmed, raw: trimmed }
|
|
201
|
+
}
|
|
202
|
+
|
|
160
203
|
function isVisible(el) {
|
|
161
204
|
if (!el) return false
|
|
162
205
|
const rect = el.getBoundingClientRect()
|
|
@@ -206,19 +249,135 @@ async function pageOps(command, args) {
|
|
|
206
249
|
return out
|
|
207
250
|
}
|
|
208
251
|
|
|
209
|
-
function
|
|
252
|
+
function getAriaLabelledByText(el) {
|
|
253
|
+
const ids = safeString(el?.getAttribute?.("aria-labelledby")).split(/\s+/).filter(Boolean)
|
|
254
|
+
if (!ids.length) return ""
|
|
255
|
+
const parts = []
|
|
256
|
+
for (const id of ids) {
|
|
257
|
+
const ref = document.getElementById(id)
|
|
258
|
+
if (ref) parts.push(ref.innerText || ref.textContent || "")
|
|
259
|
+
}
|
|
260
|
+
return parts.join(" ")
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function findByAttribute(attr, target, allowedTags) {
|
|
264
|
+
if (!target) return []
|
|
265
|
+
const nodes = deepQuerySelectorAll(`[${attr}]`, document)
|
|
266
|
+
return nodes.filter((el) => {
|
|
267
|
+
if (Array.isArray(allowedTags) && allowedTags.length && !allowedTags.includes(el.tagName)) return false
|
|
268
|
+
return matchesText(el.getAttribute(attr), target)
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function findByLabelText(target) {
|
|
273
|
+
if (!target) return []
|
|
274
|
+
const results = []
|
|
275
|
+
const seen = new Set()
|
|
276
|
+
const labels = deepQuerySelectorAll("label", document)
|
|
277
|
+
for (const label of labels) {
|
|
278
|
+
if (!matchesText(label.innerText || label.textContent || "", target)) continue
|
|
279
|
+
const control = label.control || label.querySelector("input, textarea, select")
|
|
280
|
+
if (control && !seen.has(control)) {
|
|
281
|
+
seen.add(control)
|
|
282
|
+
results.push(control)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const labelled = deepQuerySelectorAll("[aria-labelledby]", document)
|
|
286
|
+
for (const el of labelled) {
|
|
287
|
+
if (!matchesText(getAriaLabelledByText(el), target)) continue
|
|
288
|
+
if (!seen.has(el)) {
|
|
289
|
+
seen.add(el)
|
|
290
|
+
results.push(el)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return results
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function findByRole(target) {
|
|
297
|
+
if (!target) return []
|
|
298
|
+
const nodes = deepQuerySelectorAll("[role]", document)
|
|
299
|
+
return nodes.filter((el) => matchesText(el.getAttribute("role"), target))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function findByName(target) {
|
|
303
|
+
return findByAttribute("name", target)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function findByText(target) {
|
|
307
|
+
if (!target) return []
|
|
308
|
+
const results = []
|
|
309
|
+
const seen = new Set()
|
|
310
|
+
const candidates = deepQuerySelectorAll(
|
|
311
|
+
"button, a, label, option, summary, [role='button'], [role='link'], [role='tab'], [role='menuitem']",
|
|
312
|
+
document
|
|
313
|
+
)
|
|
314
|
+
for (const el of candidates) {
|
|
315
|
+
if (!matchesText(el.innerText || el.textContent || "", target)) continue
|
|
316
|
+
if (!seen.has(el)) {
|
|
317
|
+
seen.add(el)
|
|
318
|
+
results.push(el)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const inputs = deepQuerySelectorAll("input[type='button'], input[type='submit'], input[type='reset']", document)
|
|
322
|
+
for (const el of inputs) {
|
|
323
|
+
if (!matchesText(el.value || "", target)) continue
|
|
324
|
+
if (!seen.has(el)) {
|
|
325
|
+
seen.add(el)
|
|
326
|
+
results.push(el)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return results
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function resolveLocator(locator) {
|
|
333
|
+
if (locator.kind === "css") {
|
|
334
|
+
const value = safeString(locator.value)
|
|
335
|
+
if (!value) return []
|
|
336
|
+
return deepQuerySelectorAll(value, document)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (locator.kind === "label") return findByLabelText(locator.value)
|
|
340
|
+
if (locator.kind === "aria") return findByAttribute("aria-label", locator.value)
|
|
341
|
+
if (locator.kind === "placeholder") return findByAttribute("placeholder", locator.value, ["INPUT", "TEXTAREA"])
|
|
342
|
+
if (locator.kind === "name") return findByName(locator.value)
|
|
343
|
+
if (locator.kind === "role") return findByRole(locator.value)
|
|
344
|
+
if (locator.kind === "text") return findByText(locator.value)
|
|
345
|
+
|
|
346
|
+
if (locator.kind === "id") {
|
|
347
|
+
const idValue = safeString(locator.value).trim()
|
|
348
|
+
if (!idValue) return []
|
|
349
|
+
const escaped = window.CSS && window.CSS.escape ? window.CSS.escape(idValue) : idValue.replace(/[^a-zA-Z0-9_-]/g, "\\$&")
|
|
350
|
+
return deepQuerySelectorAll(`#${escaped}`, document)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return []
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function resolveMatchesOnce(selectors, index) {
|
|
210
357
|
for (const sel of selectors) {
|
|
211
|
-
const
|
|
212
|
-
if (!
|
|
213
|
-
const matches =
|
|
358
|
+
const locator = parseLocator(sel)
|
|
359
|
+
if (!locator.value) continue
|
|
360
|
+
const matches = resolveLocator(locator)
|
|
214
361
|
if (!matches.length) continue
|
|
215
362
|
const visible = matches.filter(isVisible)
|
|
216
|
-
const chosen = visible[index] || matches[index]
|
|
217
|
-
return { selectorUsed:
|
|
363
|
+
const chosen = visible[index] || matches[index] || null
|
|
364
|
+
return { selectorUsed: locator.raw, matches, chosen }
|
|
218
365
|
}
|
|
219
366
|
return { selectorUsed: selectors[0] || "", matches: [], chosen: null }
|
|
220
367
|
}
|
|
221
368
|
|
|
369
|
+
async function resolveMatches(selectors, index, timeoutMs, pollMs) {
|
|
370
|
+
let match = resolveMatchesOnce(selectors, index)
|
|
371
|
+
if (timeoutMs > 0) {
|
|
372
|
+
const start = Date.now()
|
|
373
|
+
while (!match.matches.length && Date.now() - start < timeoutMs) {
|
|
374
|
+
await new Promise((r) => setTimeout(r, pollMs))
|
|
375
|
+
match = resolveMatchesOnce(selectors, index)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return match
|
|
379
|
+
}
|
|
380
|
+
|
|
222
381
|
function clickElement(el) {
|
|
223
382
|
try {
|
|
224
383
|
el.scrollIntoView({ block: "center", inline: "center" })
|
|
@@ -333,14 +492,14 @@ async function pageOps(command, args) {
|
|
|
333
492
|
const mode = typeof options.mode === "string" && options.mode ? options.mode : "text"
|
|
334
493
|
const selectors = normalizeSelectorList(options.selector)
|
|
335
494
|
const index = Number.isFinite(options.index) ? options.index : 0
|
|
336
|
-
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs :
|
|
495
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS
|
|
337
496
|
const pollMs = Number.isFinite(options.pollMs) ? options.pollMs : 200
|
|
338
497
|
const limit = Number.isFinite(options.limit) ? options.limit : mode === "page_text" ? 20000 : 50
|
|
339
498
|
const pattern = typeof options.pattern === "string" ? options.pattern : null
|
|
340
499
|
const flags = typeof options.flags === "string" ? options.flags : "i"
|
|
341
500
|
|
|
342
501
|
if (command === "click") {
|
|
343
|
-
const match = resolveMatches(selectors, index)
|
|
502
|
+
const match = await resolveMatches(selectors, index, timeoutMs, pollMs)
|
|
344
503
|
if (!match.chosen) {
|
|
345
504
|
return { ok: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
|
|
346
505
|
}
|
|
@@ -351,7 +510,7 @@ async function pageOps(command, args) {
|
|
|
351
510
|
if (command === "type") {
|
|
352
511
|
const text = options.text
|
|
353
512
|
const shouldClear = !!options.clear
|
|
354
|
-
const match = resolveMatches(selectors, index)
|
|
513
|
+
const match = await resolveMatches(selectors, index, timeoutMs, pollMs)
|
|
355
514
|
if (!match.chosen) {
|
|
356
515
|
return { ok: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
|
|
357
516
|
}
|
|
@@ -393,7 +552,7 @@ async function pageOps(command, args) {
|
|
|
393
552
|
const value = typeof options.value === "string" ? options.value : null
|
|
394
553
|
const label = typeof options.label === "string" ? options.label : null
|
|
395
554
|
const optionIndex = Number.isFinite(options.optionIndex) ? options.optionIndex : null
|
|
396
|
-
const match = resolveMatches(selectors, index)
|
|
555
|
+
const match = await resolveMatches(selectors, index, timeoutMs, pollMs)
|
|
397
556
|
if (!match.chosen) {
|
|
398
557
|
return { ok: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
|
|
399
558
|
}
|
|
@@ -453,7 +612,7 @@ async function pageOps(command, args) {
|
|
|
453
612
|
const scrollX = Number.isFinite(options.x) ? options.x : 0
|
|
454
613
|
const scrollY = Number.isFinite(options.y) ? options.y : 0
|
|
455
614
|
if (selectors.length) {
|
|
456
|
-
const match = resolveMatches(selectors, index)
|
|
615
|
+
const match = await resolveMatches(selectors, index, timeoutMs, pollMs)
|
|
457
616
|
if (!match.chosen) {
|
|
458
617
|
return { ok: false, error: `Element not found for selectors: ${selectors.join(", ")}` }
|
|
459
618
|
}
|
|
@@ -469,12 +628,7 @@ async function pageOps(command, args) {
|
|
|
469
628
|
if (command === "query") {
|
|
470
629
|
if (mode === "page_text") {
|
|
471
630
|
if (selectors.length && timeoutMs > 0) {
|
|
472
|
-
|
|
473
|
-
while (Date.now() - start < timeoutMs) {
|
|
474
|
-
const match = resolveMatches(selectors, index)
|
|
475
|
-
if (match.matches.length) break
|
|
476
|
-
await new Promise((r) => setTimeout(r, pollMs))
|
|
477
|
-
}
|
|
631
|
+
await resolveMatches(selectors, index, timeoutMs, pollMs)
|
|
478
632
|
}
|
|
479
633
|
return { ok: true, value: getPageText(limit, pattern, flags) }
|
|
480
634
|
}
|
|
@@ -483,16 +637,7 @@ async function pageOps(command, args) {
|
|
|
483
637
|
return { ok: false, error: "Selector is required" }
|
|
484
638
|
}
|
|
485
639
|
|
|
486
|
-
|
|
487
|
-
if (timeoutMs > 0) {
|
|
488
|
-
const start = Date.now()
|
|
489
|
-
while (Date.now() - start < timeoutMs) {
|
|
490
|
-
match = resolveMatches(selectors, index)
|
|
491
|
-
if (mode === "exists" && match.matches.length) break
|
|
492
|
-
if (mode !== "exists" && match.chosen) break
|
|
493
|
-
await new Promise((r) => setTimeout(r, pollMs))
|
|
494
|
-
}
|
|
495
|
-
}
|
|
640
|
+
const match = await resolveMatches(selectors, index, timeoutMs, pollMs)
|
|
496
641
|
|
|
497
642
|
if (mode === "exists") {
|
|
498
643
|
return {
|
|
@@ -586,35 +731,35 @@ async function toolNavigate({ url, tabId }) {
|
|
|
586
731
|
return { tabId: tab.id, content: `Navigated to ${url}` }
|
|
587
732
|
}
|
|
588
733
|
|
|
589
|
-
async function toolClick({ selector, tabId, index = 0 }) {
|
|
734
|
+
async function toolClick({ selector, tabId, index = 0, timeoutMs, pollMs }) {
|
|
590
735
|
if (!selector) throw new Error("Selector is required")
|
|
591
736
|
const tab = await getTabById(tabId)
|
|
592
737
|
|
|
593
|
-
const result = await runInPage(tab.id, "click", { selector, index })
|
|
738
|
+
const result = await runInPage(tab.id, "click", { selector, index, timeoutMs, pollMs })
|
|
594
739
|
if (!result?.ok) throw new Error(result?.error || "Click failed")
|
|
595
740
|
const used = result.selectorUsed || selector
|
|
596
741
|
return { tabId: tab.id, content: `Clicked ${used}` }
|
|
597
742
|
}
|
|
598
743
|
|
|
599
|
-
async function toolType({ selector, text, tabId, clear = false, index = 0 }) {
|
|
744
|
+
async function toolType({ selector, text, tabId, clear = false, index = 0, timeoutMs, pollMs }) {
|
|
600
745
|
if (!selector) throw new Error("Selector is required")
|
|
601
746
|
if (text === undefined) throw new Error("Text is required")
|
|
602
747
|
const tab = await getTabById(tabId)
|
|
603
748
|
|
|
604
|
-
const result = await runInPage(tab.id, "type", { selector, text, clear, index })
|
|
749
|
+
const result = await runInPage(tab.id, "type", { selector, text, clear, index, timeoutMs, pollMs })
|
|
605
750
|
if (!result?.ok) throw new Error(result?.error || "Type failed")
|
|
606
751
|
const used = result.selectorUsed || selector
|
|
607
752
|
return { tabId: tab.id, content: `Typed "${text}" into ${used}` }
|
|
608
753
|
}
|
|
609
754
|
|
|
610
|
-
async function toolSelect({ selector, value, label, optionIndex, tabId, index = 0 }) {
|
|
755
|
+
async function toolSelect({ selector, value, label, optionIndex, tabId, index = 0, timeoutMs, pollMs }) {
|
|
611
756
|
if (!selector) throw new Error("Selector is required")
|
|
612
757
|
if (value === undefined && label === undefined && optionIndex === undefined) {
|
|
613
758
|
throw new Error("value, label, or optionIndex is required")
|
|
614
759
|
}
|
|
615
760
|
const tab = await getTabById(tabId)
|
|
616
761
|
|
|
617
|
-
const result = await runInPage(tab.id, "select", { selector, value, label, optionIndex, index })
|
|
762
|
+
const result = await runInPage(tab.id, "select", { selector, value, label, optionIndex, index, timeoutMs, pollMs })
|
|
618
763
|
if (!result?.ok) throw new Error(result?.error || "Select failed")
|
|
619
764
|
const used = result.selectorUsed || selector
|
|
620
765
|
const valueText = result.value ? String(result.value) : ""
|
|
@@ -820,10 +965,10 @@ async function toolQuery({
|
|
|
820
965
|
return { tabId: tab.id, content: typeof result.value === "string" ? result.value : JSON.stringify(result.value) }
|
|
821
966
|
}
|
|
822
967
|
|
|
823
|
-
async function toolScroll({ x = 0, y = 0, selector, tabId }) {
|
|
968
|
+
async function toolScroll({ x = 0, y = 0, selector, tabId, timeoutMs, pollMs }) {
|
|
824
969
|
const tab = await getTabById(tabId)
|
|
825
970
|
|
|
826
|
-
const result = await runInPage(tab.id, "scroll", { x, y, selector })
|
|
971
|
+
const result = await runInPage(tab.id, "scroll", { x, y, selector, timeoutMs, pollMs })
|
|
827
972
|
if (!result?.ok) throw new Error(result?.error || "Scroll failed")
|
|
828
973
|
const target = result.selectorUsed ? `to ${result.selectorUsed}` : `by (${x}, ${y})`
|
|
829
974
|
return { tabId: tab.id, content: `Scrolled ${target}` }
|
package/extension/manifest.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "OpenCode Browser Automation",
|
|
4
|
-
"
|
|
4
|
+
"short_name": "OpenCode Browser",
|
|
5
|
+
"version": "4.4.0",
|
|
5
6
|
"description": "Browser automation for OpenCode",
|
|
7
|
+
"homepage_url": "https://github.com/different-ai/opencode-browser",
|
|
8
|
+
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu9KICDxZh63m3QeQX13b37J/p7eG+cIEk4UDv+Phr7W/rqruy8DtdLFZ3WC37JYXo2Qk2iTWzIuBYjnc367RMY4Khu3f5hRyp67nvujuNY+0ACkrNGJk/viTodcW3CDcib9gijQQmPI1ZFkbec3qG0CN1hFxyn6pkdQZ8rnU9uo8ctntHIXrcfaHvQNT3AIVQfe8UjWPgalB0YCq9RamPVfMu6bG+6QLZGSKbvYDn0o5f8A/JZkm6K0MIii2EH61Oszhb/9GzZOciKqRZN+0mo23xbWHDp4ofDFyfQyMi83D3nDNldVGPSkIYlQWZaKHalaCoBRK+gzz7uuFoGrcsQIDAQAB",
|
|
6
9
|
"permissions": [
|
|
7
10
|
"tabs",
|
|
8
11
|
"activeTab",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@different-ai/opencode-browser",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.0",
|
|
4
4
|
"description": "Browser automation plugin for OpenCode (native messaging + per-tab ownership).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"build": "bun build src/plugin.ts --target=node --outfile=dist/plugin.js",
|
|
23
23
|
"install": "node bin/cli.js install",
|
|
24
24
|
"uninstall": "node bin/cli.js uninstall",
|
|
25
|
-
"status": "node bin/cli.js status"
|
|
25
|
+
"status": "node bin/cli.js status",
|
|
26
|
+
"tool-test": "bun bin/tool-test.ts"
|
|
26
27
|
},
|
|
27
28
|
"keywords": [
|
|
28
29
|
"opencode",
|
|
@@ -45,6 +46,9 @@
|
|
|
45
46
|
"peerDependencies": {
|
|
46
47
|
"@opencode-ai/plugin": "*"
|
|
47
48
|
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"agent-browser": "^0.4.0"
|
|
51
|
+
},
|
|
48
52
|
"devDependencies": {
|
|
49
53
|
"@opencode-ai/plugin": "*",
|
|
50
54
|
"bun-types": "*"
|