@vymalo/opencode-browser 0.7.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vymalo contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,321 @@
1
+ # @vymalo/opencode-browser
2
+
3
+ > Give an OpenCode agent hands in a real browser — open tabs, click, type, scroll, screenshot —
4
+ > organized into **named tab groups**.
5
+
6
+ [![npm](https://img.shields.io/npm/v/@vymalo/opencode-browser)](https://www.npmjs.com/package/@vymalo/opencode-browser)
7
+ ![node: >=22](https://img.shields.io/badge/node-%3E%3D22-339933)
8
+ ![license: MIT](https://img.shields.io/badge/license-MIT-blue)
9
+
10
+ This is the **OpenCode plugin** half of a dual plugin. It registers `browser_*` tools the model
11
+ can call and hosts a localhost WebSocket **bridge**. A companion **browser extension**
12
+ (Chromium MV3 + Firefox) connects to the bridge and drives real tabs.
13
+
14
+ > Browser extensions can't host servers, so the plugin is the server and the extension dials
15
+ > out to it. The plugin runs on Bun (inside OpenCode) and serves the bridge with `Bun.serve`.
16
+
17
+ ```mermaid
18
+ flowchart LR
19
+ Model[OpenCode model] -->|browser_* tools| Plugin["@vymalo/opencode-browser<br/>(bridge host)"]
20
+ Plugin <-->|ws://127.0.0.1:4517<br/>token handshake| Ext[Browser extension]
21
+ Ext -->|CDP / content script| Tabs[(Real browser tabs<br/>in named groups)]
22
+ ```
23
+
24
+ ## Table of contents
25
+
26
+ - [How it works](#how-it-works)
27
+ - [Install](#install)
28
+ - [Quickstart](#quickstart)
29
+ - [Options](#options)
30
+ - [The 33 tools](#the-33-tools)
31
+ - [Targeting elements](#targeting-elements--prefer-refs)
32
+ - [Screenshots](#screenshots)
33
+ - [Named groups](#named-groups)
34
+ - [Scoping tools per agent](#scoping-tools-per-agent)
35
+ - [Multiple browsers & agents](#multiple-browsers--agents)
36
+ - [Executors: CDP vs content-script](#executors-cdp-vs-content-script)
37
+ - [Stopping / releasing control](#stopping--releasing-control)
38
+ - [Security](#security)
39
+ - [Troubleshooting](#troubleshooting)
40
+
41
+ ## How it works
42
+
43
+ 1. OpenCode loads the plugin; it binds a WebSocket bridge on `127.0.0.1:<port>` and prints a
44
+ shared `token` (or uses the one you set).
45
+ 2. You load the extension once, paste the bridge URL + token into its dashboard, and connect.
46
+ 3. The model calls a `browser_*` tool → the plugin sends a command frame over the bridge → the
47
+ extension executes it against a real tab → the result comes back to the model.
48
+
49
+ The extension and plugin agree on a small, dependency-free wire protocol; the same protocol is
50
+ mirrored into the extension so the two never drift.
51
+
52
+ ## Install
53
+
54
+ ```jsonc
55
+ // opencode.json
56
+ {
57
+ "$schema": "https://opencode.ai/config.json",
58
+ "plugin": [["@vymalo/opencode-browser", { "port": 4517 }]]
59
+ }
60
+ ```
61
+
62
+ On first run with no `token`, the plugin generates one and logs it once
63
+ (`browser_bridge_token_generated`). Paste that into the extension's dashboard along with the
64
+ bridge URL (`ws://127.0.0.1:4517`).
65
+
66
+ Get the extension from the [Releases page](https://github.com/vymalo/opencode-oauth2/releases)
67
+ (`opencode-browser-extension-<version>-chrome.zip` / `-firefox.zip`), from the Chrome Web Store /
68
+ Firefox Add-ons, or build it from
69
+ [`apps/browser-extension`](https://github.com/vymalo/opencode-oauth2/tree/main/apps/browser-extension).
70
+
71
+ ## Quickstart
72
+
73
+ A typical first session, end to end:
74
+
75
+ ```text
76
+ You: Open example.com in a group called "research" and tell me the page heading.
77
+ Model: browser_open({ group: "research", url: "https://example.com" })
78
+ browser_snapshot({ group: "research" }) → refs e1, e2, …
79
+ browser_get_text({ group: "research" })
80
+ → "Example Domain — This domain is for use in illustrative examples…"
81
+
82
+ You: Screenshot it.
83
+ Model: browser_screenshot({ group: "research", fullPage: true })
84
+ → "Saved screenshot to .opencode/browser/research/2026-…png (1280×3200)."
85
+ (the model then reads that file to see it)
86
+ ```
87
+
88
+ A titled **tab group** named "research" appears in the browser; the extension dashboard logs
89
+ every action and keeps a screenshot gallery.
90
+
91
+ ## Options
92
+
93
+ Second argument to the plugin tuple in `opencode.json`:
94
+
95
+ | Option | Default | Meaning |
96
+ | --- | --- | --- |
97
+ | `enabled` | `true` | Master switch. |
98
+ | `host` | `"127.0.0.1"` | Bind interface. **Keep it loopback** — see [Security](#security). |
99
+ | `port` | `4517` | Bridge port. |
100
+ | `token` | _generated_ | Shared secret the extension must present. Empty string ⇒ generated + logged. |
101
+ | `groups` | `["page","control"]` | Tool groups to register (`page` \| `control` \| `debug`). |
102
+ | `executor` | `"auto"` | `auto` \| `cdp` \| `content` — forwarded to the extension as a preference. |
103
+ | `timeoutMs` | `30000` | Per-command timeout. |
104
+ | `screenshotDir` | `".opencode/browser"` | Screenshot output dir (relative → resolved against the worktree). |
105
+
106
+ ## The 33 tools
107
+
108
+ Names are stable `browser_*` identifiers, partitioned into three **groups** gated by the
109
+ `groups` option. Every tool takes a `group` (the named tab group) unless noted.
110
+
111
+ ### `page` — observe (8, default on)
112
+
113
+ | Tool | Key args | Does |
114
+ | --- | --- | --- |
115
+ | `browser_snapshot` | `group` | Accessibility/DOM outline with stable **refs** the other tools target. Start here. |
116
+ | `browser_get_text` | `group, tabId?` | Visible/readable text of the active tab. |
117
+ | `browser_get_html` | `group, ref?/selector?, outer?` | HTML of an element or the document. |
118
+ | `browser_get_attribute` | `group, ref?/selector, name?` | Tag, text, value, and attributes of an element. |
119
+ | `browser_query` | `group, selector, limit?` | Match a CSS selector → list of elements with fresh refs. |
120
+ | `browser_tabs` | `group?` | List groups and their tabs. Omit `group` for everything you own. |
121
+ | `browser_targets` | — | List connected browsers (for multi-browser routing). |
122
+ | `browser_screenshot` | `group, fullPage?, tabId?` | PNG to disk; returns the path. See [Screenshots](#screenshots). |
123
+
124
+ ### `control` — drive (19, default on)
125
+
126
+ | Tool | Key args | Does |
127
+ | --- | --- | --- |
128
+ | `browser_open` | `group, url?, focus?, target?` | Open a tab in the group (creates the group on first use). |
129
+ | `browser_navigate` | `group, url, tabId?` | Navigate an existing tab. |
130
+ | `browser_back` / `browser_forward` / `browser_reload` | `group, tabId?` | History / reload. |
131
+ | `browser_activate` | `group, tabId?` | Bring a tab to the foreground. |
132
+ | `browser_click` | `group, ref?/selector?/x?,y?, button?` | Click an element or coordinates. |
133
+ | `browser_double_click` | `group, ref?/selector?/x?,y?` | Double-click. |
134
+ | `browser_hover` | `group, ref?/selector?/x?,y?` | Hover. |
135
+ | `browser_drag` | `group, fromRef?/fromSelector?, ref?/selector?` | Drag source → target. |
136
+ | `browser_type` | `group, text, ref?/selector?, submit?` | Type into a field (optionally press Enter). |
137
+ | `browser_fill` | `group, fields:[{ref?/selector,value}]` | Batch-fill several fields. |
138
+ | `browser_select` | `group, ref?/selector, value?/values?` | Choose `<select>` option(s). |
139
+ | `browser_press_key` | `group, key` | Press a key or chord (`"Enter"`, `"Control+a"`). |
140
+ | `browser_scroll` | `group, deltaX?, deltaY?, ref?, to?` | Scroll the page or an element. |
141
+ | `browser_upload` | `group, ref?/selector, paths:[…]` | Attach file(s) to a file input (CDP/Chromium). |
142
+ | `browser_wait` | `group, ms?/selector?, state?` | Wait a fixed delay or for a selector. |
143
+ | `browser_close` | `group, tabId?` | Close a tab, or the whole group if `tabId` omitted. |
144
+ | `browser_release` | `group?` | Hand the browser back (detach CDP) without closing tabs. |
145
+
146
+ ### `debug` — powerful / sensitive (6, **off by default**)
147
+
148
+ Enable with `{ "groups": ["page","control","debug"] }`.
149
+
150
+ | Tool | Key args | Does |
151
+ | --- | --- | --- |
152
+ | `browser_eval` | `group, code, tabId?` | Evaluate arbitrary JavaScript in the page. |
153
+ | `browser_console` | `group` | Recent console messages. |
154
+ | `browser_network` | `group` | Recent network requests. |
155
+ | `browser_handle_dialog` | `group, accept?, promptText?` | Accept/dismiss a native `alert`/`confirm`/`prompt`. |
156
+ | `browser_set_viewport` | `group, width, height, mobile?, deviceScaleFactor?` | Emulate a viewport (CDP/Chromium only). |
157
+ | `browser_cookies` | `op, url?, name?, …` | Get/list/set/clear cookies (profile-wide). |
158
+
159
+ ## Targeting elements — prefer refs
160
+
161
+ Two ways to address an element:
162
+
163
+ - **By ref (recommended)** — call `browser_snapshot` (or `browser_query`), get back stable refs
164
+ like `e1`, `e2`, then `browser_click({ group, ref: "e3" })`. Refs survive minor layout shifts
165
+ and are far more reliable than brittle selectors.
166
+ - **By CSS selector or coordinates** — `selector: "#login"` or `x`/`y`. Useful for canvases,
167
+ maps, and elements a snapshot doesn't expose.
168
+
169
+ ## Screenshots
170
+
171
+ OpenCode tool output is **text-only** — it can't carry an image. So the plugin writes the PNG to
172
+ `<screenshotDir>/<group>/<timestamp>.png` and returns the path; the model then opens it with
173
+ OpenCode's built-in `read` tool (which renders images). `fullPage: true` captures the whole
174
+ scrollable page (CDP `captureBeyondViewport` on Chromium; scroll-and-stitch on the content
175
+ executor). The extension also keeps a copy in its dashboard gallery.
176
+
177
+ > Driving from a non-OpenCode MCP client instead? `@vymalo/opencode-browser-mcp` returns
178
+ > screenshots as **inline images** (MCP can carry them).
179
+
180
+ ## Named groups
181
+
182
+ Every action targets a **group** — a named bucket of tabs the agent created. On Chromium groups
183
+ map to real `chrome.tabGroups` (titled, colored); on Firefox they're a logical registry. Groups
184
+ keep each task's tabs isolated and inspectable, and are the unit of ownership when multiple
185
+ agents share one bridge.
186
+
187
+ ## Scoping tools per agent
188
+
189
+ There are **two independent levers**, and they answer different questions:
190
+
191
+ | Lever | Scope | Use it to |
192
+ | --- | --- | --- |
193
+ | `groups` plugin option | **Global** — which tools are *registered* at all | Set the org-wide default surface. |
194
+ | OpenCode agent `tools` map | **Per-agent** — which registered tools an agent may *call* | Give each agent only the tools it needs. |
195
+
196
+ If your team keeps **all groups enabled by default** (`"groups": ["page","control","debug"]`),
197
+ the `groups` option won't narrow anything — every `browser_*` tool is registered. To restrict an
198
+ individual agent, use OpenCode's per-agent **`tools`** map (a `{ "<tool>": true|false }` record on
199
+ the agent). The tool names are stable `browser_*` identifiers (see [the 33 tools](#the-33-tools)),
200
+ so you target them directly. This is OpenCode's own mechanism — nothing plugin-specific.
201
+
202
+ ### Denylist — drop the sensitive tools (most common)
203
+
204
+ Keep everything except the `debug` group and destructive actions for a general agent:
205
+
206
+ ```jsonc
207
+ // opencode.json
208
+ {
209
+ "plugin": [["@vymalo/opencode-browser", {}]], // all groups stay registered
210
+ "agent": {
211
+ "build": {
212
+ "tools": {
213
+ "browser_eval": false,
214
+ "browser_cookies": false,
215
+ "browser_console": false,
216
+ "browser_network": false,
217
+ "browser_handle_dialog": false,
218
+ "browser_set_viewport": false,
219
+ "browser_close": false
220
+ }
221
+ }
222
+ }
223
+ }
224
+ ```
225
+
226
+ ### Allowlist — a read-only research agent (observe, never drive)
227
+
228
+ Disable the whole namespace with a wildcard, then re-enable only the `page` tools you want. A
229
+ more specific key wins over the `browser_*` wildcard:
230
+
231
+ ```jsonc
232
+ {
233
+ "agent": {
234
+ "researcher": {
235
+ "description": "Reads pages, never changes them.",
236
+ "tools": {
237
+ "browser_*": false,
238
+ "browser_open": true,
239
+ "browser_navigate": true,
240
+ "browser_snapshot": true,
241
+ "browser_get_text": true,
242
+ "browser_query": true,
243
+ "browser_screenshot": true,
244
+ "browser_tabs": true
245
+ }
246
+ }
247
+ }
248
+ }
249
+ ```
250
+
251
+ ### Markdown agents
252
+
253
+ The same `tools` map works in a `.opencode/agent/<name>.md` front-matter file:
254
+
255
+ ```markdown
256
+ ---
257
+ description: Reads pages, never changes them.
258
+ tools:
259
+ browser_*: false
260
+ browser_snapshot: true
261
+ browser_get_text: true
262
+ browser_screenshot: true
263
+ ---
264
+ You are a read-only web researcher. Use browser_snapshot to get refs, then read.
265
+ ```
266
+
267
+ > The plugin-level `groups` option is still useful as a hard floor — e.g. **never** register
268
+ > `debug` org-wide with `{ "groups": ["page","control"] }`, then no agent can re-enable
269
+ > `browser_eval` no matter its `tools` map. Use `groups` for "nobody gets this," and the agent
270
+ > `tools` map for "this agent only gets these."
271
+
272
+ ## Multiple browsers & agents
273
+
274
+ The bridge is an **auto-elect broker**: multiple agents (plugin, MCP, sessions) and multiple
275
+ executors (browsers) can share one bridge. The first process to bind the port becomes the host;
276
+ others join as guests. Groups are owned by the agent that created them (owner-exclusive), and
277
+ ownership is rebuilt automatically on failover. Use `browser_targets` to see connected browsers
278
+ and `browser_open({ target })` to pick one. Full design:
279
+ [`plans/multi-client-routing.md`](https://github.com/vymalo/opencode-oauth2/blob/main/plans/multi-client-routing.md).
280
+
281
+ ## Executors: CDP vs content-script
282
+
283
+ - **CDP executor** (`chrome.debugger`, Chromium) — trusted input, full-page capture, console &
284
+ network logs. Shows Chrome's "being debugged" banner — an intentional signal.
285
+ - **Content-script executor** — synthetic events + `captureVisibleTab`; the Firefox-safe
286
+ fallback, also used when the debugger is unavailable.
287
+
288
+ `executor: "auto"` picks CDP on Chromium when the `debugger` permission is granted, else the
289
+ content script. Force one with `"cdp"` / `"content"`.
290
+
291
+ ## Stopping / releasing control
292
+
293
+ Control is **plugin-managed** — you never disconnect by hand. It's released when:
294
+
295
+ - the model/plugin calls **`browser_release`** (mid-session), or
296
+ - **OpenCode exits** — the plugin's `exit` hook shuts the bridge down, and the dropped socket
297
+ independently makes the extension release. A hard kill releases too.
298
+
299
+ ## Security
300
+
301
+ The bridge binds `127.0.0.1` only and requires a token handshake. It grants the model control of
302
+ a **real browser profile** — **use a dedicated or throwaway Chrome profile**, not your daily one.
303
+ The `chrome.debugger` banner is a deliberate, continuous indicator that automation is active.
304
+ `debug` tools (`browser_eval`, `browser_cookies`, …) are off by default for this reason. See the
305
+ consolidated [`docs/security.md`](https://github.com/vymalo/opencode-oauth2/blob/main/docs/security.md).
306
+
307
+ ## Troubleshooting
308
+
309
+ | Symptom | Likely cause |
310
+ | --- | --- |
311
+ | Extension won't connect | URL/token mismatch, or another process already on the port. Re-copy the logged token. |
312
+ | "being debugged" banner stays after a session | Normal until release; `browser_release` or exiting OpenCode clears it. |
313
+ | `set_viewport` / full-page differs on Firefox | CDP-only features; Firefox uses the content executor. |
314
+ | Screenshot path returned but model "can't see" it | The model must `read` the path — output is text-only. |
315
+
316
+ Symptom-keyed fixes: [`docs/troubleshooting.md`](https://github.com/vymalo/opencode-oauth2/blob/main/docs/troubleshooting.md).
317
+ Full architecture, wire protocol, and tool reference: [`docs/browser.md`](https://github.com/vymalo/opencode-oauth2/blob/main/docs/browser.md).
318
+
319
+ ## License
320
+
321
+ [MIT](https://github.com/vymalo/opencode-oauth2/blob/main/LICENSE) © vymalo contributors
@@ -0,0 +1,54 @@
1
+ import type { AgentEndpoint } from "./broker.js";
2
+ import type { Logger } from "./logging.js";
3
+ import { type BrowserAction } from "./protocol.js";
4
+ /** Minimal socket the agent-client drives — provided per runtime by the endpoint. */
5
+ export interface AgentSocket {
6
+ send(data: string): void;
7
+ close(): void;
8
+ }
9
+ export interface AgentSocketHandlers {
10
+ onOpen(): void;
11
+ onMessage(data: string): void;
12
+ onClose(): void;
13
+ }
14
+ export type AgentSocketFactory = (url: string, handlers: AgentSocketHandlers) => AgentSocket;
15
+ export interface AgentClientOptions {
16
+ url: string;
17
+ token: string;
18
+ label?: string;
19
+ timeoutMs: number;
20
+ }
21
+ export interface AgentClientDeps {
22
+ logger: Logger;
23
+ createSocket: AgentSocketFactory;
24
+ /** Called when the connection drops (host gone) — the endpoint re-elects. */
25
+ onClose?: () => void;
26
+ }
27
+ export declare class AgentClientError extends Error {
28
+ readonly code?: string;
29
+ constructor(message: string, code?: string);
30
+ }
31
+ /**
32
+ * Connects to a running broker as an `agent` and brokers request/response for the
33
+ * host adapter's tools (the guest path). One connection attempt + lifecycle; the
34
+ * endpoint orchestrates re-election/reconnect via `onClose`.
35
+ */
36
+ export declare class AgentClient implements AgentEndpoint {
37
+ private readonly opts;
38
+ private readonly deps;
39
+ private socket;
40
+ private ready;
41
+ private closed;
42
+ private readonly pending;
43
+ private readyWaiters;
44
+ constructor(opts: AgentClientOptions, deps: AgentClientDeps);
45
+ connect(): Promise<void>;
46
+ stop(): void;
47
+ release(): void;
48
+ send(action: BrowserAction, group: string, params: Record<string, unknown>, signal?: AbortSignal, target?: string): Promise<unknown>;
49
+ private waitReady;
50
+ private handleResult;
51
+ private clearPending;
52
+ private settleReject;
53
+ private rejectAllPending;
54
+ }
@@ -0,0 +1,180 @@
1
+ import { decodeFrame, encodeFrame, helloFrame, nextId, PROTOCOL_VERSION } from "./protocol.js";
2
+ export class AgentClientError extends Error {
3
+ code;
4
+ constructor(message, code) {
5
+ super(message);
6
+ this.name = "AgentClientError";
7
+ this.code = code;
8
+ }
9
+ }
10
+ /**
11
+ * Connects to a running broker as an `agent` and brokers request/response for the
12
+ * host adapter's tools (the guest path). One connection attempt + lifecycle; the
13
+ * endpoint orchestrates re-election/reconnect via `onClose`.
14
+ */
15
+ export class AgentClient {
16
+ opts;
17
+ deps;
18
+ socket = null;
19
+ ready = false;
20
+ closed = false;
21
+ pending = new Map();
22
+ readyWaiters = [];
23
+ constructor(opts, deps) {
24
+ this.opts = opts;
25
+ this.deps = deps;
26
+ }
27
+ connect() {
28
+ return new Promise((resolve, reject) => {
29
+ let settled = false;
30
+ this.socket = this.deps.createSocket(this.opts.url, {
31
+ onOpen: () => {
32
+ this.socket?.send(encodeFrame(helloFrame(this.opts.token, { role: "agent", client: this.opts.label })));
33
+ // Resolve only once the broker replies `ready`.
34
+ },
35
+ onMessage: (data) => {
36
+ const frame = decodeFrame(data);
37
+ if (!frame) {
38
+ return;
39
+ }
40
+ if (frame.type === "ready") {
41
+ this.ready = true;
42
+ for (const w of this.readyWaiters.splice(0)) {
43
+ w();
44
+ }
45
+ if (!settled) {
46
+ settled = true;
47
+ resolve();
48
+ }
49
+ }
50
+ else if (frame.type === "result") {
51
+ this.handleResult(frame);
52
+ }
53
+ },
54
+ onClose: () => {
55
+ this.ready = false;
56
+ this.rejectAllPending(new AgentClientError("disconnected from bridge", "disconnected"));
57
+ this.socket = null;
58
+ if (!settled) {
59
+ settled = true;
60
+ reject(new AgentClientError("bridge connection closed before ready", "connect_failed"));
61
+ }
62
+ if (!this.closed) {
63
+ this.deps.onClose?.();
64
+ }
65
+ }
66
+ });
67
+ });
68
+ }
69
+ stop() {
70
+ this.closed = true;
71
+ this.socket?.close();
72
+ this.socket = null;
73
+ this.rejectAllPending(new AgentClientError("agent stopped", "stopped"));
74
+ }
75
+ release() {
76
+ // Best-effort: ask the broker to release this agent's browsers.
77
+ void this.send("release", "", {}).catch(() => { });
78
+ }
79
+ async send(action, group, params, signal, target) {
80
+ if (signal?.aborted) {
81
+ throw new AgentClientError("aborted", "aborted");
82
+ }
83
+ await this.waitReady(signal);
84
+ const socket = this.socket;
85
+ if (!socket) {
86
+ throw new AgentClientError("not connected to the bridge", "disconnected");
87
+ }
88
+ const id = nextId();
89
+ const frame = {
90
+ v: PROTOCOL_VERSION,
91
+ type: "command",
92
+ id,
93
+ action,
94
+ group,
95
+ params,
96
+ target
97
+ };
98
+ return new Promise((resolve, reject) => {
99
+ const timer = this.opts.timeoutMs > 0
100
+ ? setTimeout(() => this.settleReject(id, new AgentClientError(`command '${action}' timed out`, "timeout")), this.opts.timeoutMs)
101
+ : null;
102
+ let detachAbort = null;
103
+ if (signal) {
104
+ const onAbort = () => this.settleReject(id, new AgentClientError("aborted", "aborted"));
105
+ signal.addEventListener("abort", onAbort, { once: true });
106
+ detachAbort = () => signal.removeEventListener("abort", onAbort);
107
+ }
108
+ this.pending.set(id, { resolve, reject, timer, detachAbort });
109
+ try {
110
+ socket.send(encodeFrame(frame));
111
+ }
112
+ catch (err) {
113
+ this.settleReject(id, new AgentClientError(`failed to send '${action}': ${err instanceof Error ? err.message : String(err)}`, "send_failed"));
114
+ }
115
+ });
116
+ }
117
+ waitReady(signal) {
118
+ if (this.ready) {
119
+ return Promise.resolve();
120
+ }
121
+ if (this.closed || !this.socket) {
122
+ return Promise.reject(new AgentClientError("not connected to the bridge", "disconnected"));
123
+ }
124
+ return new Promise((resolve, reject) => {
125
+ const onAbort = () => reject(new AgentClientError("aborted", "aborted"));
126
+ if (signal) {
127
+ signal.addEventListener("abort", onAbort, { once: true });
128
+ }
129
+ this.readyWaiters.push(() => {
130
+ if (signal) {
131
+ signal.removeEventListener("abort", onAbort);
132
+ }
133
+ resolve();
134
+ });
135
+ });
136
+ }
137
+ handleResult(frame) {
138
+ const p = this.pending.get(frame.id);
139
+ if (!p) {
140
+ return;
141
+ }
142
+ this.clearPending(frame.id);
143
+ if (frame.ok) {
144
+ p.resolve(frame.data);
145
+ }
146
+ else {
147
+ p.reject(new AgentClientError(frame.error?.message ?? "bridge error", frame.error?.code));
148
+ }
149
+ }
150
+ clearPending(id) {
151
+ const p = this.pending.get(id);
152
+ if (!p) {
153
+ return;
154
+ }
155
+ if (p.timer) {
156
+ clearTimeout(p.timer);
157
+ }
158
+ p.detachAbort?.();
159
+ this.pending.delete(id);
160
+ }
161
+ settleReject(id, err) {
162
+ const p = this.pending.get(id);
163
+ if (!p) {
164
+ return;
165
+ }
166
+ this.clearPending(id);
167
+ p.reject(err);
168
+ }
169
+ rejectAllPending(err) {
170
+ for (const [id, p] of this.pending) {
171
+ if (p.timer) {
172
+ clearTimeout(p.timer);
173
+ }
174
+ p.detachAbort?.();
175
+ this.pending.delete(id);
176
+ p.reject(err);
177
+ }
178
+ }
179
+ }
180
+ //# sourceMappingURL=agent-client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-client.js","sourceRoot":"","sources":["../src/agent-client.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,70 @@
1
+ import type { Logger } from "./logging.js";
2
+ import { type BrowserAction } from "./protocol.js";
3
+ import type { BridgeTransport } from "./transport.js";
4
+ /** Thrown when a command can't be routed or the executor reports failure. */
5
+ export declare class BrokerError extends Error {
6
+ readonly code?: string;
7
+ constructor(message: string, code?: string);
8
+ }
9
+ /** What an agent (local or remote) uses to drive browsers through the broker. */
10
+ export interface AgentEndpoint {
11
+ send(action: BrowserAction, group: string, params: Record<string, unknown>, signal?: AbortSignal, target?: string): Promise<unknown>;
12
+ /** Release this agent's browsers (detach the debugger) without closing tabs. */
13
+ release(): void;
14
+ }
15
+ export interface BrokerOptions {
16
+ host: string;
17
+ port: number;
18
+ token: string;
19
+ /** Executor preference forwarded to extensions in `ready`. */
20
+ executor?: "auto" | "cdp" | "content";
21
+ /** Per-command timeout in ms; `<= 0` disables. */
22
+ timeoutMs: number;
23
+ }
24
+ export interface BrokerDeps {
25
+ logger: Logger;
26
+ transport: BridgeTransport;
27
+ }
28
+ /**
29
+ * Hosts the WebSocket server and routes commands between **agents** (producers:
30
+ * the plugin, the MCP server, guest adapters) and **executors** (the browser
31
+ * extensions), keyed by named-group ownership. Generalizes the old single-client
32
+ * bridge. Runtime-agnostic via the injected transport.
33
+ */
34
+ export declare class Broker {
35
+ private readonly opts;
36
+ private readonly deps;
37
+ private readonly executors;
38
+ private readonly executorById;
39
+ private readonly agents;
40
+ private readonly groupOwner;
41
+ private readonly pending;
42
+ private primaryExecutorId;
43
+ private agentSeq;
44
+ private execSeq;
45
+ private started;
46
+ constructor(opts: BrokerOptions, deps: BrokerDeps);
47
+ get executorCount(): number;
48
+ start(): Promise<void>;
49
+ stop(): void;
50
+ /** An in-process agent (the host adapter's own tools), bypassing a socket. */
51
+ createLocalAgent(): AgentEndpoint;
52
+ private onMessage;
53
+ private handleHello;
54
+ private rebuildFromExecutor;
55
+ private onClose;
56
+ private route;
57
+ private resolveExecutor;
58
+ private pickExecutor;
59
+ private sendToExecutor;
60
+ private handleResult;
61
+ private handleAgentCommand;
62
+ private routeEvent;
63
+ private listTargets;
64
+ private aggregateTabs;
65
+ private releaseForAgent;
66
+ private safeSend;
67
+ private clearPending;
68
+ private settleReject;
69
+ private rejectAllPending;
70
+ }