From 0048c845cf74b0b23112f07fcd622910f5d4c696 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 7 May 2026 01:20:13 -0700 Subject: [PATCH 01/60] feat: add browser-swarm extension bridge POC --- .claude-plugin/marketplace.json | 15 + README.md | 1 + skills/browser-swarm/SKILL.md | 106 +++ skills/browser-swarm/extension/manifest.json | 27 + .../browser-swarm/extension/service-worker.js | 381 +++++++++++ skills/browser-swarm/package-lock.json | 36 + skills/browser-swarm/package.json | 15 + skills/browser-swarm/scripts/e2e-poc.mjs | 136 ++++ .../browser-swarm/scripts/launch-chrome.mjs | 89 +++ skills/browser-swarm/scripts/swarm-relay.mjs | 628 ++++++++++++++++++ 10 files changed, 1434 insertions(+) create mode 100644 skills/browser-swarm/SKILL.md create mode 100644 skills/browser-swarm/extension/manifest.json create mode 100644 skills/browser-swarm/extension/service-worker.js create mode 100644 skills/browser-swarm/package-lock.json create mode 100644 skills/browser-swarm/package.json create mode 100644 skills/browser-swarm/scripts/e2e-poc.mjs create mode 100644 skills/browser-swarm/scripts/launch-chrome.mjs create mode 100644 skills/browser-swarm/scripts/swarm-relay.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index bba1a258..e778d0fc 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -24,6 +24,21 @@ "./skills/browser" ] }, + { + "name": "browser-swarm", + "source": "./", + "description": "Coordinate multiple browser agents in one real Chrome profile through a Chrome extension bridge, colored tab group, and target-bound browse CLI endpoints.", + "version": "0.0.1", + "author": { + "name": "Browserbase" + }, + "category": "automation", + "keywords": ["browser", "swarm", "chrome-extension", "tab-groups", "stagehand", "understudy", "browse-cli"], + "strict": false, + "skills": [ + "./skills/browser-swarm" + ] + }, { "name": "functions", "source": "./", diff --git a/README.md b/README.md index 723da7ca..7bddebd9 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This plugin includes the following skills (see `skills/` for details): | Skill | Description | |-------|-------------| | [browser](skills/browser/SKILL.md) | Automate web browser interactions via CLI commands — supports remote Browserbase sessions with anti-bot stealth, CAPTCHA solving, and residential proxies | +| [browser-swarm](skills/browser-swarm/SKILL.md) | Coordinate multiple browser agents in one real Chrome profile through a Chrome extension bridge, colored tab group, and target-bound browse CLI endpoints | | [browserbase-cli](skills/browserbase-cli/SKILL.md) | Use the official `bb` CLI for Browserbase Functions and platform API workflows including sessions, projects, contexts, extensions, fetch, and dashboard | | [functions](skills/functions/SKILL.md) | Deploy serverless browser automation to Browserbase cloud using the `bb` CLI | | [site-debugger](skills/site-debugger/SKILL.md) | Diagnose and fix failing browser automations — analyzes bot detection, selectors, timing, auth, and captchas, then generates a tested site playbook | diff --git a/skills/browser-swarm/SKILL.md b/skills/browser-swarm/SKILL.md new file mode 100644 index 00000000..621ac3fd --- /dev/null +++ b/skills/browser-swarm/SKILL.md @@ -0,0 +1,106 @@ +--- +name: browser-swarm +description: Coordinate multiple browser agents in one real Chrome profile through a Chrome extension bridge, a colored tab group, and target-bound browse CLI endpoints. +compatibility: "Requires Node.js 20+, Chrome, the browse CLI (`npm install -g @browserbasehq/browse-cli`), and a locally loaded browser-swarm Chrome extension." +license: MIT +allowed-tools: Bash +--- + +# Browser Swarm + +Use this skill when one task benefits from several independent browser workstreams that should share the user's real Chrome profile, cookies, and extensions. + +The swarm has three parts: + +1. A local relay script in `scripts/swarm-relay.mjs`. +2. A bare Manifest V3 Chrome extension in `extension/`. +3. One `browse --ws ` context per worker. + +The extension is transport and scope. The `browse` CLI is still the agent-facing browser API. Each worker gets a target-bound CDP URL that exposes only its assigned tab, so `browse` can keep using its active-page model without cross-agent tab races. + +## Setup + +From the skills repo: + +```bash +cd skills/browser-swarm +npm install +``` + +Start the relay: + +```bash +node scripts/swarm-relay.mjs serve --port 19989 +``` + +Load the extension: + +```bash +node scripts/launch-chrome.mjs +``` + +For a persistent install, load `skills/browser-swarm/extension` from `chrome://extensions` as an unpacked extension. The relay listens only on `127.0.0.1`. + +## Create A Swarm + +Allocate one tab per workstream: + +```bash +node scripts/swarm-relay.mjs ensure \ + --count 3 \ + --label flights \ + --label rentals \ + --label dinner \ + --url "https://www.google.com/travel/flights" \ + --url "https://www.google.com/search?q=san+diego+surfing+rentals+downtown" \ + --url "https://www.kayak.com/San-Diego.10760.guide" \ + --json +``` + +The response contains a `wsUrl` per target. Hand exactly one `wsUrl` to each worker. + +## Worker Contract + +Every worker must: + +- Use only its assigned `wsUrl`. +- Never use `tab_switch`. +- Return concrete evidence: final URL, title, useful extracted facts, and screenshot path when relevant. +- Avoid irreversible actions such as purchases, reservations, or form submission without explicit user confirmation. + +Worker prompt shape: + +```text +You own the "flights" browser-swarm tab. +Use browse with this exact target-bound CDP endpoint: + + +Run commands like: +browse --ws "" snapshot --compact --json +browse --ws "" open "https://www.google.com/travel/flights" --json +browse --ws "" get title --json + +Do not switch tabs. Do not use any other browser target. Find options, collect evidence, and report concise results. +``` + +## Offsite Pattern + +For a task like "plan an offsite to San Diego next week - we need flights booked, surfing rentals and dinner near downtown": + +1. Create three tabs: `flights`, `rentals`, `dinner`. +2. Spawn one worker per tab. +3. Assign `flights` to Google Flights or Kayak flights. +4. Assign `rentals` to San Diego surf rental search/results. +5. Assign `dinner` to restaurants near downtown San Diego. +6. Aggregate worker evidence into one plan and list any actions requiring approval before booking. + +## Why This POC Does Not Require `browse --target` + +First-class target-scoped browse commands are still the long-term API. This POC derisks the bridge without waiting for that patch by making the relay expose a separate virtual browser endpoint per tab: + +```bash +browse --ws "ws://127.0.0.1:19989/devtools/browser/" +``` + +That endpoint advertises only one target to Playwright/Stagehand, so the existing `browse` active-page commands resolve to the owned tab. This is the next-best solution until `browse --target ` lands. + diff --git a/skills/browser-swarm/extension/manifest.json b/skills/browser-swarm/extension/manifest.json new file mode 100644 index 00000000..baa2d1fa --- /dev/null +++ b/skills/browser-swarm/extension/manifest.json @@ -0,0 +1,27 @@ +{ + "manifest_version": 3, + "name": "Browser Swarm Bridge", + "version": "0.1.0", + "description": "Bridge a scoped Chrome tab group to browser-swarm agents through localhost.", + "permissions": [ + "alarms", + "debugger", + "storage", + "tabGroups", + "tabs" + ], + "host_permissions": [ + "" + ], + "background": { + "service_worker": "service-worker.js", + "type": "module" + }, + "action": { + "default_title": "Browser Swarm Bridge" + }, + "content_security_policy": { + "extension_pages": "script-src 'self'; connect-src 'self' ws://127.0.0.1:* http://127.0.0.1:*; object-src 'none';" + } +} + diff --git a/skills/browser-swarm/extension/service-worker.js b/skills/browser-swarm/extension/service-worker.js new file mode 100644 index 00000000..124a0fc7 --- /dev/null +++ b/skills/browser-swarm/extension/service-worker.js @@ -0,0 +1,381 @@ +const DEFAULT_PORT = 19989; +const GROUP_TITLE = "browser-swarm"; +const GROUP_COLOR = "cyan"; +const RECONNECT_MS = 2000; + +let ws = null; +let connectTimer = null; +let autoAttachParams = null; +let nextSyntheticSession = 1; +const targetsByTab = new Map(); +const childSessions = new Map(); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function send(message) { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(message)); + } +} + +function scheduleConnect() { + if (connectTimer) return; + connectTimer = setTimeout(() => { + connectTimer = null; + connect(); + }, RECONNECT_MS); +} + +async function connect() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; + + const { port = DEFAULT_PORT } = await chrome.storage.local.get({ port: DEFAULT_PORT }); + const socket = new WebSocket(`ws://127.0.0.1:${port}/extension`); + ws = socket; + + socket.onopen = async () => { + send({ + type: "hello", + extension: "browser-swarm", + version: chrome.runtime.getManifest().version, + targets: await listAttachedTargets() + }); + }; + + socket.onmessage = async (event) => { + let message; + try { + message = JSON.parse(event.data); + } catch (error) { + return; + } + + if (message.type !== "request") return; + + try { + const result = await handleRequest(message); + send({ type: "response", id: message.id, result }); + } catch (error) { + send({ + type: "response", + id: message.id, + error: error instanceof Error ? error.message : String(error) + }); + } + }; + + socket.onclose = () => { + if (ws === socket) ws = null; + scheduleConnect(); + }; + + socket.onerror = () => { + try { + socket.close(); + } catch {} + }; +} + +async function handleRequest(message) { + const { method, params = {} } = message; + + switch (method) { + case "ensureTabs": + return ensureTabs(params); + case "listTargets": + return { targets: await listAttachedTargets() }; + case "forwardCDPCommand": + return forwardCDPCommand(params); + case "createTarget": + return createTarget(params); + case "closeTarget": + return closeTarget(params); + default: + throw new Error(`Unknown extension method: ${method}`); + } +} + +async function ensureTabs(params) { + const count = Number(params.count || 1); + const labels = Array.isArray(params.labels) ? params.labels : []; + const urls = Array.isArray(params.urls) ? params.urls : []; + const title = typeof params.groupTitle === "string" ? params.groupTitle : GROUP_TITLE; + const color = typeof params.groupColor === "string" ? params.groupColor : GROUP_COLOR; + + let groupId = await findGroup(title); + const groupTabs = groupId === null ? [] : await chrome.tabs.query({ groupId }); + const tabs = [...groupTabs]; + + while (tabs.length < count) { + const index = tabs.length; + const url = urls[index] || "about:blank"; + const tab = await chrome.tabs.create({ url, active: index === 0 }); + tabs.push(tab); + } + + const tabIds = tabs.map((tab) => tab.id).filter((id) => typeof id === "number"); + if (tabIds.length > 0) { + if (groupId === null) { + groupId = await chrome.tabs.group({ tabIds }); + await chrome.tabGroups.update(groupId, { title, color, collapsed: false }); + } else { + await chrome.tabs.group({ tabIds, groupId }); + await chrome.tabGroups.update(groupId, { title, color, collapsed: false }); + } + } + + const attached = []; + for (let i = 0; i < Math.min(count, tabs.length); i++) { + const tab = tabs[i]; + if (!tab.id) continue; + if (urls[i] && tab.url !== urls[i] && !tab.url?.startsWith(urls[i])) { + await chrome.tabs.update(tab.id, { url: urls[i], active: i === 0 }); + await waitForTabLoad(tab.id, 15000).catch(() => {}); + } + const target = await attachTab(tab.id, labels[i] || `tab-${i + 1}`); + attached.push(target); + } + + await syncGroupForAttached(title, color); + return { groupId, targets: attached }; +} + +async function findGroup(title) { + const groups = await chrome.tabGroups.query({ title }); + return groups.length > 0 ? groups[0].id : null; +} + +async function syncGroupForAttached(title, color) { + const tabIds = Array.from(targetsByTab.keys()); + if (tabIds.length === 0) return; + + let groupId = await findGroup(title); + if (groupId === null) { + groupId = await chrome.tabs.group({ tabIds }); + } else { + await chrome.tabs.group({ tabIds, groupId }); + } + await chrome.tabGroups.update(groupId, { title, color, collapsed: false }); +} + +async function waitForTabLoad(tabId, timeoutMs) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const tab = await chrome.tabs.get(tabId); + if (tab.status === "complete") return; + await sleep(100); + } +} + +async function createTarget(params) { + const tab = await chrome.tabs.create({ + url: params.url || "about:blank", + active: false + }); + if (!tab.id) throw new Error("Chrome did not return a tab id"); + await waitForTabLoad(tab.id, 15000).catch(() => {}); + const target = await attachTab(tab.id, params.label || "created"); + await syncGroupForAttached(params.groupTitle || GROUP_TITLE, params.groupColor || GROUP_COLOR); + return { targetId: target.targetId, target }; +} + +async function closeTarget(params) { + const target = findTarget(params); + if (!target) return { success: false }; + await chrome.tabs.remove(target.tabId); + targetsByTab.delete(target.tabId); + return { success: true }; +} + +function findTarget(params) { + if (params.tabId && targetsByTab.has(params.tabId)) return targetsByTab.get(params.tabId); + if (params.targetId) { + for (const target of targetsByTab.values()) { + if (target.targetId === params.targetId) return target; + } + } + if (params.sessionId) { + for (const target of targetsByTab.values()) { + if (target.sessionId === params.sessionId) return target; + } + const child = childSessions.get(params.sessionId); + if (child) return targetsByTab.get(child.tabId); + } + return null; +} + +async function attachTab(tabId, label) { + const existing = targetsByTab.get(tabId); + if (existing?.state === "connected") { + return existing; + } + + const debuggee = { tabId }; + try { + await chrome.debugger.attach(debuggee, "1.3"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("Another debugger is already attached")) { + throw error; + } + } + + await chrome.debugger.sendCommand(debuggee, "Page.enable").catch(() => {}); + await chrome.debugger.sendCommand(debuggee, "Runtime.enable").catch(() => {}); + + if (autoAttachParams) { + await chrome.debugger.sendCommand(debuggee, "Target.setAutoAttach", autoAttachParams).catch(() => {}); + } + + const info = await chrome.debugger.sendCommand(debuggee, "Target.getTargetInfo"); + const targetInfo = normalizeTargetInfo(info.targetInfo, tabId); + const sessionId = existing?.sessionId || `swarm-tab-${Date.now().toString(36)}-${nextSyntheticSession++}`; + const target = { + tabId, + label, + sessionId, + targetId: targetInfo.targetId, + targetInfo, + state: "connected" + }; + targetsByTab.set(tabId, target); + return target; +} + +function normalizeTargetInfo(info, tabId) { + return { + targetId: info?.targetId || `tab-${tabId}`, + type: info?.type || "page", + title: info?.title || "", + url: info?.url || "about:blank", + attached: true, + canAccessOpener: false, + browserContextId: info?.browserContextId + }; +} + +async function listAttachedTargets() { + const targets = []; + for (const [tabId, target] of targetsByTab.entries()) { + try { + const info = await chrome.debugger.sendCommand({ tabId }, "Target.getTargetInfo"); + const targetInfo = normalizeTargetInfo(info.targetInfo, tabId); + const updated = { ...target, targetId: targetInfo.targetId, targetInfo }; + targetsByTab.set(tabId, updated); + targets.push(updated); + } catch { + targetsByTab.delete(tabId); + } + } + return targets; +} + +async function forwardCDPCommand(params) { + const target = findTarget(params); + if (!target) { + throw new Error(`No browser-swarm tab for ${JSON.stringify({ + targetId: params.targetId, + sessionId: params.sessionId, + tabId: params.tabId + })}`); + } + + if (params.method === "Target.setAutoAttach" && !params.sessionId) { + autoAttachParams = params.params || {}; + await Promise.all(Array.from(targetsByTab.keys()).map((tabId) => + chrome.debugger.sendCommand({ tabId }, "Target.setAutoAttach", autoAttachParams).catch(() => {}) + )); + return {}; + } + + const debuggee = { tabId: target.tabId }; + const childSession = params.sessionId && params.sessionId !== target.sessionId + ? params.sessionId + : undefined; + const debuggerSession = childSession ? { ...debuggee, sessionId: childSession } : debuggee; + + if (params.method === "Runtime.enable") { + await chrome.debugger.sendCommand(debuggerSession, "Runtime.disable").catch(() => {}); + } + + return chrome.debugger.sendCommand(debuggerSession, params.method, params.params || {}); +} + +chrome.debugger.onEvent.addListener((source, method, params) => { + const tabId = source.tabId; + if (!tabId || !targetsByTab.has(tabId)) return; + + const target = targetsByTab.get(tabId); + if (method === "Target.attachedToTarget" && params?.sessionId) { + childSessions.set(params.sessionId, { + tabId, + targetId: params.targetInfo?.targetId + }); + } + if (method === "Target.detachedFromTarget" && params?.sessionId) { + childSessions.delete(params.sessionId); + } + + send({ + type: "cdpEvent", + tabId, + targetId: target.targetId, + sessionId: source.sessionId || target.sessionId, + method, + params + }); +}); + +chrome.debugger.onDetach.addListener((source) => { + if (!source.tabId) return; + const target = targetsByTab.get(source.tabId); + targetsByTab.delete(source.tabId); + if (target) { + send({ + type: "targetDetached", + tabId: source.tabId, + targetId: target.targetId, + sessionId: target.sessionId + }); + } +}); + +chrome.tabs.onRemoved.addListener((tabId) => { + const target = targetsByTab.get(tabId); + targetsByTab.delete(tabId); + if (target) { + send({ + type: "targetDetached", + tabId, + targetId: target.targetId, + sessionId: target.sessionId + }); + } +}); + +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === "browser-swarm-heartbeat") { + connect(); + send({ type: "ping" }); + } +}); + +chrome.runtime.onInstalled.addListener(() => { + chrome.alarms.create("browser-swarm-heartbeat", { periodInMinutes: 0.1 }); + connect(); +}); + +chrome.runtime.onStartup.addListener(() => { + chrome.alarms.create("browser-swarm-heartbeat", { periodInMinutes: 0.1 }); + connect(); +}); + +chrome.action.onClicked.addListener(() => { + connect(); +}); + +chrome.alarms.create("browser-swarm-heartbeat", { periodInMinutes: 0.1 }); +connect(); + diff --git a/skills/browser-swarm/package-lock.json b/skills/browser-swarm/package-lock.json new file mode 100644 index 00000000..6507b678 --- /dev/null +++ b/skills/browser-swarm/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "@browserbase/browser-swarm-skill", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@browserbase/browser-swarm-skill", + "version": "0.1.0", + "dependencies": { + "ws": "^8.18.0" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/skills/browser-swarm/package.json b/skills/browser-swarm/package.json new file mode 100644 index 00000000..3d2acb30 --- /dev/null +++ b/skills/browser-swarm/package.json @@ -0,0 +1,15 @@ +{ + "name": "@browserbase/browser-swarm-skill", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "serve": "node scripts/swarm-relay.mjs serve", + "launch": "node scripts/launch-chrome.mjs", + "e2e": "node scripts/e2e-poc.mjs" + }, + "dependencies": { + "ws": "^8.18.0" + } +} + diff --git a/skills/browser-swarm/scripts/e2e-poc.mjs b/skills/browser-swarm/scripts/e2e-poc.mjs new file mode 100644 index 00000000..d7377cbf --- /dev/null +++ b/skills/browser-swarm/scripts/e2e-poc.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const skillDir = resolve(__dirname, ".."); +const artifactsDir = "/tmp/browser-swarm-e2e"; +const port = Number(process.env.BROWSER_SWARM_PORT || 19989); + +mkdirSync(artifactsDir, { recursive: true }); +spawnSync("pkill", ["-f", "/tmp/browser-swarm-e2e-profile"], { stdio: "ignore" }); + +function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd || skillDir, + env: { ...process.env, ...(options.env || {}) }, + stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => stdout += chunk.toString()); + child.stderr.on("data", (chunk) => stderr += chunk.toString()); + child.on("exit", (code) => { + const result = { command: [command, ...args].join(" "), code, stdout, stderr }; + if (code === 0) resolve(result); + else reject(Object.assign(new Error(`${result.command} exited ${code}\n${stderr}`), { result })); + }); + }); +} + +async function waitForHealth(timeoutMs = 30000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(`http://127.0.0.1:${port}/health`); + const json = await res.json(); + if (json.extensionConnected) return json; + } catch {} + await new Promise((resolve) => setTimeout(resolve, 500)); + } + throw new Error("Timed out waiting for relay and extension connection"); +} + +const relay = spawn("node", ["scripts/swarm-relay.mjs", "serve", "--port", String(port)], { + cwd: skillDir, + stdio: ["ignore", "pipe", "pipe"] +}); + +let relayLog = ""; +relay.stdout.on("data", (chunk) => relayLog += chunk.toString()); +relay.stderr.on("data", (chunk) => relayLog += chunk.toString()); + +try { + await new Promise((resolve) => setTimeout(resolve, 1000)); + const launch = await run("node", [ + "scripts/launch-chrome.mjs", + "--fresh", + "--profile", + "/tmp/browser-swarm-e2e-profile" + ]); + + const health = await waitForHealth(); + const ensure = await run("node", [ + "scripts/swarm-relay.mjs", + "ensure", + "--port", + String(port), + "--count", + "3", + "--label", + "flights", + "--label", + "rentals", + "--label", + "dinner", + "--url", + "https://www.google.com/travel/flights", + "--url", + "https://www.google.com/search?q=san+diego+surfboard+rentals+downtown", + "--url", + "https://www.kayak.com/San-Diego.10760.guide", + "--json" + ]); + + const swarm = JSON.parse(ensure.stdout); + const browseResults = []; + for (const target of swarm.targets.slice(0, 3)) { + const title = await run("browse", ["--ws", target.wsUrl, "get", "title", "--json"]); + const url = await run("browse", ["--ws", target.wsUrl, "get", "url", "--json"]); + const snapshot = await run("browse", ["--ws", target.wsUrl, "snapshot", "--compact", "--json"]); + const screenshotPath = resolve(artifactsDir, `${target.label || target.targetId}.png`); + const screenshot = await run("browse", ["--ws", target.wsUrl, "screenshot", screenshotPath, "--json"]); + browseResults.push({ + label: target.label, + targetId: target.targetId, + wsUrl: target.wsUrl, + title: JSON.parse(title.stdout), + url: JSON.parse(url.stdout), + snapshotPreview: JSON.parse(snapshot.stdout).tree.slice(0, 500), + screenshot: JSON.parse(screenshot.stdout) + }); + } + + const report = { + prompt: "plan an offsite to san diego next week - we need flights booked, surfing rentals and dinner near downtown", + launch: JSON.parse(launch.stdout), + health, + targets: swarm.targets.map((target) => ({ + label: target.label, + targetId: target.targetId, + wsUrl: target.wsUrl, + url: target.targetInfo.url, + title: target.targetInfo.title + })), + browseResults + }; + const reportPath = resolve(artifactsDir, "report.json"); + writeFileSync(reportPath, JSON.stringify(report, null, 2)); + console.log(JSON.stringify({ status: "PASS", reportPath, report }, null, 2)); +} catch (error) { + const reportPath = resolve(artifactsDir, "failure.log"); + writeFileSync(reportPath, [ + error instanceof Error ? error.stack : String(error), + "", + "Relay log:", + relayLog + ].join("\n")); + console.error(JSON.stringify({ status: "FAIL", reportPath, error: error.message }, null, 2)); + process.exitCode = 1; +} finally { + relay.kill("SIGTERM"); + spawnSync("pkill", ["-f", "/tmp/browser-swarm-e2e-profile"], { stdio: "ignore" }); +} diff --git a/skills/browser-swarm/scripts/launch-chrome.mjs b/skills/browser-swarm/scripts/launch-chrome.mjs new file mode 100644 index 00000000..e84adec5 --- /dev/null +++ b/skills/browser-swarm/scripts/launch-chrome.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const skillDir = resolve(__dirname, ".."); +const extensionDir = resolve(skillDir, "extension"); + +function parseArgs(argv) { + const opts = { + profile: "/tmp/browser-swarm-chrome-profile", + url: "about:blank", + fresh: false + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--profile") opts.profile = argv[++i]; + else if (arg === "--url") opts.url = argv[++i]; + else if (arg === "--fresh") opts.fresh = true; + else if (arg === "--help" || arg === "-h") { + console.log(`Usage: launch-chrome.mjs [--profile ] [--url ] [--fresh]`); + process.exit(0); + } + } + return opts; +} + +function chromePath() { + const playwrightChromium = findPlaywrightChromium(); + const candidates = [ + process.env.CHROME_PATH, + playwrightChromium, + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", + "google-chrome", + "chromium", + "chromium-browser" + ].filter(Boolean); + for (const candidate of candidates) { + if (candidate.includes("/") && existsSync(candidate)) return candidate; + if (!candidate.includes("/")) return candidate; + } + throw new Error("Could not find Chrome. Set CHROME_PATH."); +} + +function findPlaywrightChromium() { + const cacheDir = `${process.env.HOME}/Library/Caches/ms-playwright`; + if (!existsSync(cacheDir)) return null; + const installs = readdirSync(cacheDir) + .filter((name) => name.startsWith("chromium-")) + .sort() + .reverse(); + for (const install of installs) { + const candidate = `${cacheDir}/${install}/chrome-mac/Chromium.app/Contents/MacOS/Chromium`; + if (existsSync(candidate)) return candidate; + } + return null; +} + +const opts = parseArgs(process.argv.slice(2)); +if (opts.fresh && existsSync(opts.profile)) { + rmSync(opts.profile, { recursive: true, force: true }); +} +mkdirSync(opts.profile, { recursive: true }); + +const args = [ + `--user-data-dir=${opts.profile}`, + `--load-extension=${extensionDir}`, + `--disable-extensions-except=${extensionDir}`, + "--disable-features=DisableLoadExtensionCommandLineSwitch", + "--no-first-run", + "--no-default-browser-check", + opts.url +]; + +const child = spawn(chromePath(), args, { + detached: true, + stdio: "ignore" +}); +child.unref(); + +console.log(JSON.stringify({ + launched: true, + pid: child.pid, + profile: opts.profile, + extensionDir +}, null, 2)); diff --git a/skills/browser-swarm/scripts/swarm-relay.mjs b/skills/browser-swarm/scripts/swarm-relay.mjs new file mode 100644 index 00000000..bac901ea --- /dev/null +++ b/skills/browser-swarm/scripts/swarm-relay.mjs @@ -0,0 +1,628 @@ +#!/usr/bin/env node +import http from "node:http"; +import { randomUUID } from "node:crypto"; +import { WebSocketServer } from "ws"; + +const DEFAULT_PORT = 19989; +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_GROUP_TITLE = "browser-swarm"; +const DEFAULT_GROUP_COLOR = "cyan"; + +function parseArgs(argv) { + const [command = "help", ...rest] = argv; + const opts = { command, labels: [], urls: [], json: false }; + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]; + if (arg === "--port") opts.port = Number(rest[++i]); + else if (arg === "--host") opts.host = rest[++i]; + else if (arg === "--count") opts.count = Number(rest[++i]); + else if (arg === "--label") opts.labels.push(rest[++i]); + else if (arg === "--url") opts.urls.push(rest[++i]); + else if (arg === "--target-id") opts.targetId = rest[++i]; + else if (arg === "--group-title") opts.groupTitle = rest[++i]; + else if (arg === "--group-color") opts.groupColor = rest[++i]; + else if (arg === "--json") opts.json = true; + else if (arg === "--compact") opts.compact = true; + else if (arg === "--path") opts.path = rest[++i]; + else if (arg === "--expr") opts.expr = rest[++i]; + else if (arg === "--help" || arg === "-h") opts.help = true; + else if (!opts._) opts._ = [arg]; + else opts._.push(arg); + } + return opts; +} + +function usage() { + console.log(`Usage: + node scripts/swarm-relay.mjs serve [--host 127.0.0.1] [--port 19989] + node scripts/swarm-relay.mjs ensure --count [--label ]... [--url ]... [--json] + node scripts/swarm-relay.mjs tabs [--json] + node scripts/swarm-relay.mjs browse-url --target-id + node scripts/swarm-relay.mjs navigate --target-id + node scripts/swarm-relay.mjs eval --target-id --expr + node scripts/swarm-relay.mjs screenshot --target-id --path +`); +} + +function json(res, status, value) { + const body = JSON.stringify(value, null, 2); + res.writeHead(status, { + "content-type": "application/json", + "content-length": Buffer.byteLength(body) + }); + res.end(body); +} + +async function readBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const raw = Buffer.concat(chunks).toString("utf8"); + return raw ? JSON.parse(raw) : {}; +} + +function print(value, asJson = false) { + if (asJson || typeof value !== "string") { + console.log(JSON.stringify(value, null, 2)); + } else { + console.log(value); + } +} + +async function post(path, body, port = DEFAULT_PORT, host = DEFAULT_HOST) { + const response = await fetch(`http://${host}:${port}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body || {}) + }); + const text = await response.text(); + const parsed = text ? JSON.parse(text) : {}; + if (!response.ok) { + throw new Error(parsed.error || `HTTP ${response.status}`); + } + return parsed; +} + +async function get(path, port = DEFAULT_PORT, host = DEFAULT_HOST) { + const response = await fetch(`http://${host}:${port}${path}`); + const text = await response.text(); + const parsed = text ? JSON.parse(text) : {}; + if (!response.ok) { + throw new Error(parsed.error || `HTTP ${response.status}`); + } + return parsed; +} + +class Relay { + constructor({ host, port }) { + this.host = host; + this.port = port; + this.extension = null; + this.extensionRequests = new Map(); + this.targets = new Map(); + this.clients = new Set(); + this.server = http.createServer((req, res) => this.handleHttp(req, res)); + this.wss = new WebSocketServer({ noServer: true }); + this.server.on("upgrade", (req, socket, head) => this.handleUpgrade(req, socket, head)); + } + + listen() { + return new Promise((resolve) => { + this.server.listen(this.port, this.host, resolve); + }); + } + + async handleHttp(req, res) { + try { + const url = new URL(req.url, `http://${this.host}:${this.port}`); + if (req.method === "GET" && url.pathname === "/health") { + json(res, 200, { + ok: true, + extensionConnected: Boolean(this.extension), + targetCount: this.targets.size + }); + return; + } + + if (req.method === "GET" && (url.pathname === "/json/version" || url.pathname === "/json/version/")) { + json(res, 200, { + Browser: "BrowserSwarm/0.1.0", + "Protocol-Version": "1.3", + webSocketDebuggerUrl: this.browserWsUrl() + }); + return; + } + + if (req.method === "GET" && ["/json/list", "/json/list/", "/json", "/json/"].includes(url.pathname)) { + json(res, 200, this.targetList()); + return; + } + + if (req.method === "GET" && url.pathname === "/swarm/tabs") { + await this.refreshTargets(); + json(res, 200, { targets: this.enrichedTargets() }); + return; + } + + if (req.method === "POST" && url.pathname === "/swarm/ensure") { + const body = await readBody(req); + const result = await this.ensureTabs(body); + json(res, 200, result); + return; + } + + if (req.method === "POST" && url.pathname === "/swarm/navigate") { + const body = await readBody(req); + const result = await this.forwardToTarget(body.targetId, "Page.navigate", { url: body.url }); + json(res, 200, result); + return; + } + + if (req.method === "POST" && url.pathname === "/swarm/eval") { + const body = await readBody(req); + const result = await this.forwardToTarget(body.targetId, "Runtime.evaluate", { + expression: body.expr, + awaitPromise: true, + returnByValue: true + }); + json(res, 200, result); + return; + } + + if (req.method === "POST" && url.pathname === "/swarm/screenshot") { + const body = await readBody(req); + const result = await this.forwardToTarget(body.targetId, "Page.captureScreenshot", { + format: "png", + captureBeyondViewport: true + }); + json(res, 200, result); + return; + } + + json(res, 404, { error: `No route for ${req.method} ${url.pathname}` }); + } catch (error) { + json(res, 500, { error: error instanceof Error ? error.message : String(error) }); + } + } + + handleUpgrade(req, socket, head) { + const url = new URL(req.url, `http://${this.host}:${this.port}`); + this.wss.handleUpgrade(req, socket, head, (ws) => { + if (url.pathname === "/extension") { + this.attachExtension(ws); + } else if (url.pathname.startsWith("/devtools/browser")) { + const parts = url.pathname.split("/").filter(Boolean); + const targetId = parts.length > 2 ? decodeURIComponent(parts[2]) : null; + this.attachCdpClient(ws, targetId); + } else { + ws.close(1008, "Unknown browser-swarm endpoint"); + } + }); + } + + browserWsUrl(targetId = null) { + const suffix = targetId ? `/${encodeURIComponent(targetId)}` : ""; + return `ws://${this.host}:${this.port}/devtools/browser${suffix}`; + } + + targetList(targetId = null) { + return this.enrichedTargets() + .filter((target) => !targetId || target.targetId === targetId) + .map((target) => ({ + id: target.targetId, + type: target.targetInfo.type, + title: target.targetInfo.title, + description: target.label || target.targetInfo.title, + url: target.targetInfo.url, + webSocketDebuggerUrl: this.browserWsUrl(target.targetId), + devtoolsFrontendUrl: `/devtools/inspector.html?ws=${this.host}:${this.port}/devtools/browser/${target.targetId}` + })); + } + + enrichedTargets(targetId = null) { + return Array.from(this.targets.values()) + .filter((target) => !targetId || target.targetId === targetId) + .map((target) => ({ + ...target, + wsUrl: this.browserWsUrl(target.targetId) + })); + } + + attachExtension(ws) { + if (this.extension && this.extension.readyState === this.extension.OPEN) { + this.extension.close(4001, "Replaced by new extension connection"); + } + this.extension = ws; + + ws.on("message", (raw) => this.handleExtensionMessage(raw)); + ws.on("close", () => { + if (this.extension === ws) this.extension = null; + }); + ws.on("error", () => {}); + } + + handleExtensionMessage(raw) { + let message; + try { + message = JSON.parse(raw.toString()); + } catch { + return; + } + + if (message.type === "hello") { + this.mergeTargets(message.targets || []); + return; + } + + if (message.type === "response") { + const pending = this.extensionRequests.get(message.id); + if (!pending) return; + clearTimeout(pending.timer); + this.extensionRequests.delete(message.id); + if (message.error) pending.reject(new Error(message.error)); + else pending.resolve(message.result); + return; + } + + if (message.type === "cdpEvent") { + this.forwardEvent(message); + return; + } + + if (message.type === "targetDetached") { + this.targets.delete(message.targetId); + this.broadcast({ + method: "Target.detachedFromTarget", + params: { + sessionId: message.sessionId, + targetId: message.targetId + } + }, message.targetId); + } + } + + sendToExtension(method, params = {}) { + if (!this.extension || this.extension.readyState !== this.extension.OPEN) { + throw new Error("Browser Swarm extension is not connected"); + } + + const id = randomUUID(); + const payload = { type: "request", id, method, params }; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.extensionRequests.delete(id); + reject(new Error(`Extension request timed out: ${method}`)); + }, 30000); + this.extensionRequests.set(id, { resolve, reject, timer }); + this.extension.send(JSON.stringify(payload)); + }); + } + + mergeTargets(targets) { + for (const target of targets) { + if (!target?.targetId || !target?.targetInfo) continue; + this.targets.set(target.targetId, { + ...target, + targetInfo: { + ...target.targetInfo, + attached: true + } + }); + } + } + + async refreshTargets() { + if (!this.extension || this.extension.readyState !== this.extension.OPEN) return; + const result = await this.sendToExtension("listTargets"); + this.targets.clear(); + this.mergeTargets(result.targets || []); + } + + async ensureTabs(body) { + const result = await this.sendToExtension("ensureTabs", { + count: body.count || 1, + labels: body.labels || [], + urls: body.urls || [], + groupTitle: body.groupTitle || DEFAULT_GROUP_TITLE, + groupColor: body.groupColor || DEFAULT_GROUP_COLOR + }); + this.mergeTargets(result.targets || []); + return { + ...result, + targets: this.enrichedTargets() + }; + } + + attachCdpClient(ws, targetId) { + const client = { + id: randomUUID(), + ws, + targetId, + autoAttach: false + }; + this.clients.add(client); + + ws.on("message", (raw) => this.handleCdpMessage(client, raw)); + ws.on("close", () => this.clients.delete(client)); + ws.on("error", () => {}); + } + + async handleCdpMessage(client, raw) { + let message; + try { + message = JSON.parse(raw.toString()); + } catch { + return; + } + + const { id, method, params = {}, sessionId } = message; + try { + const result = await this.handleCdpCommand(client, method, params, sessionId); + if (id !== undefined) { + this.sendCdp(client, { id, sessionId, result: result || {} }); + } + this.emitSyntheticEvents(client, method, params, sessionId, result); + } catch (error) { + if (id !== undefined) { + this.sendCdp(client, { + id, + sessionId, + error: { + message: error instanceof Error ? error.message : String(error) + } + }); + } + } + } + + async handleCdpCommand(client, method, params, sessionId) { + const targets = this.targetsForClient(client); + const firstTarget = targets[0]; + + if (!sessionId) { + switch (method) { + case "Browser.getVersion": + return { + protocolVersion: "1.3", + product: "Chrome/BrowserSwarm", + revision: "browser-swarm", + userAgent: "BrowserSwarm/0.1.0", + jsVersion: "V8" + }; + case "Browser.setDownloadBehavior": + return {}; + case "Target.setDiscoverTargets": + return {}; + case "Target.setAutoAttach": + client.autoAttach = Boolean(params.autoAttach); + await this.sendToExtension("forwardCDPCommand", { + method, + params, + targetId: firstTarget?.targetId + }).catch(() => {}); + return {}; + case "Target.getTargets": + return { + targetInfos: targets.map((target) => ({ + ...target.targetInfo, + attached: true + })) + }; + case "Target.getTargetInfo": { + const target = this.findTarget(params.targetId, client); + return { targetInfo: { ...target.targetInfo, attached: true } }; + } + case "Target.attachToTarget": { + const target = this.findTarget(params.targetId, client); + return { sessionId: target.sessionId }; + } + case "Target.createTarget": { + const result = await this.sendToExtension("createTarget", { + url: params.url || "about:blank", + groupTitle: DEFAULT_GROUP_TITLE, + groupColor: DEFAULT_GROUP_COLOR + }); + this.mergeTargets([result.target]); + return { targetId: result.targetId }; + } + case "Target.closeTarget": { + const target = this.findTarget(params.targetId, client); + return this.sendToExtension("closeTarget", { targetId: target.targetId }); + } + } + } + + const target = this.findTargetBySession(sessionId, client); + return this.sendToExtension("forwardCDPCommand", { + targetId: target.targetId, + tabId: target.tabId, + sessionId, + method, + params + }); + } + + emitSyntheticEvents(client, method, params, sessionId, result) { + if (method === "Target.setAutoAttach" && !sessionId && params?.autoAttach) { + for (const target of this.targetsForClient(client)) { + this.sendCdp(client, { + method: "Target.attachedToTarget", + params: { + sessionId: target.sessionId, + targetInfo: { ...target.targetInfo, attached: true }, + waitingForDebugger: false + } + }); + } + } + + if (method === "Target.setDiscoverTargets" && params?.discover) { + for (const target of this.targetsForClient(client)) { + this.sendCdp(client, { + method: "Target.targetCreated", + params: { + targetInfo: { ...target.targetInfo, attached: true } + } + }); + } + } + + if (method === "Target.attachToTarget" && result?.sessionId) { + const target = this.findTarget(params.targetId, client); + this.sendCdp(client, { + method: "Target.attachedToTarget", + params: { + sessionId: result.sessionId, + targetInfo: { ...target.targetInfo, attached: true }, + waitingForDebugger: false + } + }); + } + } + + targetsForClient(client) { + const all = Array.from(this.targets.values()); + if (!client.targetId) return all; + return all.filter((target) => target.targetId === client.targetId); + } + + findTarget(targetId, client) { + const targets = this.targetsForClient(client); + const target = targetId + ? targets.find((candidate) => candidate.targetId === targetId) + : targets[0]; + if (!target) { + throw new Error(`No target available${targetId ? ` for ${targetId}` : ""}`); + } + return target; + } + + findTargetBySession(sessionId, client) { + const targets = this.targetsForClient(client); + if (!sessionId) return this.findTarget(null, client); + const target = targets.find((candidate) => candidate.sessionId === sessionId); + if (target) return target; + return this.findTarget(null, client); + } + + async forwardToTarget(targetId, method, params) { + const target = this.findTarget(targetId, { targetId }); + return this.sendToExtension("forwardCDPCommand", { + targetId: target.targetId, + tabId: target.tabId, + sessionId: target.sessionId, + method, + params + }); + } + + forwardEvent(event) { + if (event.method === "Target.targetInfoChanged" && event.params?.targetInfo?.targetId) { + const existing = this.targets.get(event.params.targetInfo.targetId); + if (existing) { + this.targets.set(existing.targetId, { + ...existing, + targetInfo: { + ...existing.targetInfo, + ...event.params.targetInfo, + attached: true + } + }); + } + } + + this.broadcast({ + method: event.method, + sessionId: event.sessionId, + params: event.params + }, event.targetId); + } + + broadcast(message, targetId = null) { + for (const client of this.clients) { + if (client.ws.readyState !== client.ws.OPEN) continue; + if (client.targetId && targetId && client.targetId !== targetId) continue; + this.sendCdp(client, message); + } + } + + sendCdp(client, message) { + if (client.ws.readyState === client.ws.OPEN) { + client.ws.send(JSON.stringify(message)); + } + } +} + +async function runCli(opts) { + const port = opts.port || DEFAULT_PORT; + const host = opts.host || DEFAULT_HOST; + if (opts.help || opts.command === "help") { + usage(); + return; + } + + if (opts.command === "serve") { + const relay = new Relay({ host, port }); + await relay.listen(); + console.log(JSON.stringify({ + listening: true, + host, + port, + extensionEndpoint: `ws://${host}:${port}/extension`, + browserEndpoint: `ws://${host}:${port}/devtools/browser` + }, null, 2)); + return; + } + + if (opts.command === "ensure") { + const result = await post("/swarm/ensure", { + count: opts.count || Math.max(opts.labels.length, opts.urls.length, 1), + labels: opts.labels, + urls: opts.urls, + groupTitle: opts.groupTitle || DEFAULT_GROUP_TITLE, + groupColor: opts.groupColor || DEFAULT_GROUP_COLOR + }, port, host); + print(result, opts.json); + return; + } + + if (opts.command === "tabs") { + const result = await get("/swarm/tabs", port, host); + print(result, opts.json); + return; + } + + if (opts.command === "browse-url") { + if (!opts.targetId) throw new Error("--target-id is required"); + print(`ws://${host}:${port}/devtools/browser/${encodeURIComponent(opts.targetId)}`, false); + return; + } + + if (opts.command === "navigate") { + if (!opts.targetId || !opts._?.[0]) throw new Error("navigate requires --target-id and URL"); + const result = await post("/swarm/navigate", { targetId: opts.targetId, url: opts._[0] }, port, host); + print(result, opts.json); + return; + } + + if (opts.command === "eval") { + if (!opts.targetId || !opts.expr) throw new Error("eval requires --target-id and --expr"); + const result = await post("/swarm/eval", { targetId: opts.targetId, expr: opts.expr }, port, host); + print(result, opts.json); + return; + } + + if (opts.command === "screenshot") { + if (!opts.targetId) throw new Error("screenshot requires --target-id"); + const result = await post("/swarm/screenshot", { targetId: opts.targetId }, port, host); + print(result, opts.json); + return; + } + + throw new Error(`Unknown command: ${opts.command}`); +} + +runCli(parseArgs(process.argv.slice(2))).catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); + From 9b923c9c6da7cf14b26fbefef1c25755b21121fa Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 8 May 2026 01:33:52 -0700 Subject: [PATCH 02/60] docs: clarify real browser setup for browser-swarm --- skills/browser-swarm/SKILL.md | 65 ++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/skills/browser-swarm/SKILL.md b/skills/browser-swarm/SKILL.md index 621ac3fd..622a865d 100644 --- a/skills/browser-swarm/SKILL.md +++ b/skills/browser-swarm/SKILL.md @@ -1,14 +1,14 @@ --- name: browser-swarm -description: Coordinate multiple browser agents in one real Chrome profile through a Chrome extension bridge, a colored tab group, and target-bound browse CLI endpoints. -compatibility: "Requires Node.js 20+, Chrome, the browse CLI (`npm install -g @browserbasehq/browse-cli`), and a locally loaded browser-swarm Chrome extension." +description: Coordinate multiple browser agents in one real Chromium-family profile through a Chrome extension bridge, a colored tab group, and target-bound browse CLI endpoints. +compatibility: "Requires Node.js 20+, a Chromium-family browser with extension support, the browse CLI (`npm install -g @browserbasehq/browse-cli`), a locally loaded browser-swarm Chrome extension, and the `/browser` skill for CLI command reference." license: MIT allowed-tools: Bash --- # Browser Swarm -Use this skill when one task benefits from several independent browser workstreams that should share the user's real Chrome profile, cookies, and extensions. +Use this skill when one task benefits from several independent browser workstreams that should share the user's real browser profile, cookies, and extensions. The swarm has three parts: @@ -33,13 +33,50 @@ Start the relay: node scripts/swarm-relay.mjs serve --port 19989 ``` -Load the extension: +### Real Browser Mode + +Use this mode when the user wants the swarm in their own browser profile, for example Arc, Chrome, Chrome Canary, Chromium, or Chrome for Testing. + +Do not guess which browser/profile to use. If the user has not named one, ask. Default-browser detection is not enough because it does not identify the desired profile, space, or test browser. On macOS it may come from LaunchServices, on Windows from default app registry associations, and on Linux from `xdg-settings`, but those are only hints. + +The user must approve/install the extension in the browser they want controlled: + +1. Open that browser's extension management page, such as `chrome://extensions` or the browser-specific equivalent like `arc://extensions`. +2. Enable developer mode if needed. +3. Load `skills/browser-swarm/extension` as an unpacked extension. +4. Confirm the relay is connected: + +```bash +curl -s http://127.0.0.1:19989/health +``` + +Proceed only when `extensionConnected` is `true`. If it is false, ask the user to confirm the extension is installed and enabled in the chosen browser/profile. + +Do not try to install an unpacked extension into an already-running personal browser profile without the user's approval. The only automated install path in this POC is launching a separate browser process with `--load-extension`, which creates a separate test browser rather than using the user's active browser. + +### Disposable Test Browser Mode + +Use this mode only for e2e tests, demos, and throwaway profiles. It launches a separate browser profile: ```bash node scripts/launch-chrome.mjs ``` -For a persistent install, load `skills/browser-swarm/extension` from `chrome://extensions` as an unpacked extension. The relay listens only on `127.0.0.1`. +The relay listens only on `127.0.0.1`. + +## Prerequisites + +The `/browser` skill contains the canonical `browse` CLI command reference. Ensure it is installed, then read it: + +```bash +# Install if not already present +npx skills add browserbase/skills --skill browser -a '*' -g -y + +# Load the command reference into context +cat ~/.agents/skills/browser/SKILL.md +``` + +Use only commands from that reference. Do not invent flags or subcommands. ## Create A Swarm @@ -63,24 +100,27 @@ The response contains a `wsUrl` per target. Hand exactly one `wsUrl` to each wor Every worker must: -- Use only its assigned `wsUrl`. +- Use only its assigned `wsUrl` by passing `--ws ""` on every `browse` command. - Never use `tab_switch`. +- Only use commands documented in the `/browser` skill. - Return concrete evidence: final URL, title, useful extracted facts, and screenshot path when relevant. - Avoid irreversible actions such as purchases, reservations, or form submission without explicit user confirmation. +When writing worker prompts, read the `/browser` skill's SKILL.md and include its Commands section in each worker prompt so the worker agent knows exact syntax. Workers are subagents with no prior context. + Worker prompt shape: ```text -You own the "flights" browser-swarm tab. +You own the "