@vymalo/opencode-browser 0.11.0 → 0.14.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.
Files changed (44) hide show
  1. package/dist/agent-client.d.ts +36 -36
  2. package/dist/agent-client.js +201 -189
  3. package/dist/agent-client.js.map +1 -1
  4. package/dist/broker.d.ts +85 -87
  5. package/dist/broker.js +614 -599
  6. package/dist/broker.js.map +1 -1
  7. package/dist/catalog.d.ts +76 -76
  8. package/dist/catalog.js +826 -596
  9. package/dist/catalog.js.map +1 -1
  10. package/dist/endpoint.d.ts +42 -42
  11. package/dist/endpoint.js +132 -131
  12. package/dist/endpoint.js.map +1 -1
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/lib.js +1 -0
  16. package/dist/lib.js.map +1 -1
  17. package/dist/logging.d.ts +6 -6
  18. package/dist/logging.js +60 -63
  19. package/dist/logging.js.map +1 -1
  20. package/dist/node-transport.d.ts +6 -6
  21. package/dist/node-transport.js +48 -44
  22. package/dist/node-transport.js.map +1 -1
  23. package/dist/opencode.d.ts +10 -10
  24. package/dist/opencode.js +143 -146
  25. package/dist/opencode.js.map +1 -1
  26. package/dist/protocol.d.ts +127 -127
  27. package/dist/protocol.js +148 -128
  28. package/dist/protocol.js.map +1 -1
  29. package/dist/schema.d.ts +32 -32
  30. package/dist/schema.js +39 -44
  31. package/dist/schema.js.map +1 -1
  32. package/dist/token-file.d.ts +4 -4
  33. package/dist/token-file.js +68 -60
  34. package/dist/token-file.js.map +1 -1
  35. package/dist/tools.d.ts +11 -11
  36. package/dist/tools.js +128 -123
  37. package/dist/tools.js.map +1 -1
  38. package/dist/transport.d.ts +17 -17
  39. package/dist/transport.js +3 -9
  40. package/dist/transport.js.map +1 -1
  41. package/dist/types.d.ts +61 -61
  42. package/dist/types.js +1 -0
  43. package/dist/types.js.map +1 -1
  44. package/package.json +3 -3
package/dist/broker.js CHANGED
@@ -1,610 +1,625 @@
1
1
  import { cancelFrame, decodeFrame, encodeFrame, errorFrame, nextId, PROTOCOL_VERSION, rejectedFrame, resultFrame, tokenFingerprint } from "./protocol.js";
2
2
  /** Thrown when a command can't be routed or the executor reports failure. */
3
3
  export class BrokerError extends Error {
4
- code;
5
- constructor(message, code) {
6
- super(message);
7
- this.name = "BrokerError";
8
- this.code = code;
9
- }
4
+ code;
5
+ constructor(message, code) {
6
+ super(message);
7
+ this.name = "BrokerError";
8
+ this.code = code;
9
+ }
10
10
  }
11
11
  /**
12
- * Hard ceiling for any per-command timeout, so a tool requesting a long
13
- * human-paced deadline can't pin a command open indefinitely. 10 minutes.
14
- */
15
- export const DEFAULT_MAX_COMMAND_MS = 600_000;
12
+ * Hard ceiling for any per-command timeout, so a tool requesting a long
13
+ * human-paced deadline can't pin a command open indefinitely. 10 minutes.
14
+ */
15
+ export const DEFAULT_MAX_COMMAND_MS = 6e5;
16
16
  /** Actions that aren't group-scoped — routed to the primary/target executor. */
17
17
  const GLOBAL_ACTIONS = new Set(["cookies"]);
18
18
  /**
19
- * Hosts the WebSocket server and routes commands between **agents** (producers:
20
- * the plugin, the MCP server, guest adapters) and **executors** (the browser
21
- * extensions), keyed by named-group ownership. Generalizes the old single-client
22
- * bridge. Runtime-agnostic via the injected transport.
23
- */
19
+ * Hosts the WebSocket server and routes commands between **agents** (producers:
20
+ * the plugin, the MCP server, guest adapters) and **executors** (the browser
21
+ * extensions), keyed by named-group ownership. Generalizes the old single-client
22
+ * bridge. Runtime-agnostic via the injected transport.
23
+ */
24
24
  export class Broker {
25
- opts;
26
- deps;
27
- executors = new Map();
28
- executorById = new Map();
29
- agents = new Map();
30
- groupOwner = new Map();
31
- pending = new Map();
32
- primaryExecutorId = null;
33
- agentSeq = 0;
34
- execSeq = 0;
35
- started = false;
36
- constructor(opts, deps) {
37
- this.opts = opts;
38
- this.deps = deps;
39
- }
40
- get executorCount() {
41
- return this.executorById.size;
42
- }
43
- async start() {
44
- if (this.started) {
45
- return;
46
- }
47
- await this.deps.transport.listen({ host: this.opts.host, port: this.opts.port }, {
48
- onOpen: () => { },
49
- onMessage: (conn, data) => this.onMessage(conn, data),
50
- onClose: (conn) => this.onClose(conn)
51
- });
52
- this.started = true;
53
- this.deps.logger.info("browser_broker_listening", {
54
- host: this.opts.host,
55
- port: this.opts.port
56
- });
57
- }
58
- stop() {
59
- this.rejectAllPending(new BrokerError("broker stopped"));
60
- this.executors.clear();
61
- this.executorById.clear();
62
- this.agents.clear();
63
- this.groupOwner.clear();
64
- this.primaryExecutorId = null;
65
- this.started = false;
66
- this.deps.transport.stop();
67
- }
68
- /** An in-process agent (the host adapter's own tools), bypassing a socket. */
69
- createLocalAgent() {
70
- const id = `local:${++this.agentSeq}`;
71
- return {
72
- send: (action, group, params, signal, target, timeoutMs) => this.route(id, { action, group, params, target, timeoutMs }, signal),
73
- release: () => this.releaseForAgent(id)
74
- };
75
- }
76
- // ─── connection handling ───────────────────────────────────────────────────
77
- onMessage(conn, raw) {
78
- const frame = decodeFrame(raw);
79
- if (!frame) {
80
- return;
81
- }
82
- if (frame.type === "hello") {
83
- this.handleHello(conn, frame);
84
- return;
85
- }
86
- const executor = this.executors.get(conn);
87
- if (executor) {
88
- if (frame.type === "result") {
89
- this.handleResult(frame);
90
- }
91
- else if (frame.type === "event") {
92
- this.routeEvent(frame);
93
- }
94
- else if (frame.type === "ping") {
95
- this.safeSend(conn, { v: PROTOCOL_VERSION, type: "pong" });
96
- }
97
- return;
98
- }
99
- const agent = this.agents.get(conn);
100
- if (agent) {
101
- if (frame.type === "command") {
102
- void this.handleAgentCommand(agent, conn, frame);
103
- }
104
- else if (frame.type === "ping") {
105
- this.safeSend(conn, { v: PROTOCOL_VERSION, type: "pong" });
106
- }
107
- return;
108
- }
109
- this.deps.logger.warn("browser_frame_unauthenticated", { type: frame.type });
110
- }
111
- handleHello(conn, frame) {
112
- const role = frame.role ?? "extension";
113
- this.deps.logger.trace("browser_handshake_hello", {
114
- role,
115
- client: frame.client,
116
- label: frame.label,
117
- browser: frame.browser
118
- });
119
- if (frame.token !== this.opts.token) {
120
- // The shared token may have rotated under this long-lived host (it resolved
121
- // its token once at boot and otherwise never re-reads bridge.json). Re-read
122
- // it; if the file now carries a different value, adopt it — so a rotation
123
- // reaches a running host without a restart — then re-check the handshake.
124
- this.adoptRotatedToken();
125
- if (frame.token !== this.opts.token) {
126
- // Fingerprints (never the raw tokens) make the mismatch diagnosable from
127
- // the log alone — `expected` vs `got` of the same length but different
128
- // value means a rotated/stale token, not a paste typo. role/client say
129
- // which peer it was.
130
- this.deps.logger.warn("browser_handshake_rejected", {
131
- reason: "bad_token",
132
- role: frame.role ?? "extension",
133
- client: frame.client,
134
- expected: tokenFingerprint(this.opts.token),
135
- got: tokenFingerprint(frame.token)
136
- });
137
- // Tell the dialer *why* before dropping it, so it can show an actionable
138
- // error and stop hammering instead of retrying a token that can't work.
139
- this.safeSend(conn, rejectedFrame("bad_token"));
140
- conn.close();
141
- return;
142
- }
143
- this.deps.logger.info("browser_bridge_token_reloaded", {
144
- fingerprint: tokenFingerprint(this.opts.token)
145
- });
146
- }
147
- if (role === "agent") {
148
- const id = `agent:${++this.agentSeq}`;
149
- this.agents.set(conn, { conn, id });
150
- this.deps.logger.trace("browser_agent_accepted", { id, client: frame.client });
151
- this.deps.logger.info("browser_agent_connected", { id, client: frame.client });
152
- this.safeSend(conn, {
153
- v: PROTOCOL_VERSION,
154
- type: "ready",
155
- server: "opencode-browser",
156
- protocol: PROTOCOL_VERSION,
157
- role: "agent",
158
- clientId: id
159
- });
160
- return;
161
- }
162
- const id = frame.id || `exec:${++this.execSeq}`;
163
- // Replace any stale connection carrying the same id (extension reconnect).
164
- const prior = this.executorById.get(id);
165
- if (prior && prior.conn !== conn) {
166
- this.deps.logger.trace("browser_executor_replace_stale", { id });
167
- this.executors.delete(prior.conn);
168
- prior.conn.close();
169
- }
170
- const info = {
171
- conn,
172
- id,
173
- label: frame.label || id,
174
- browser: frame.browser || "unknown"
175
- };
176
- this.executors.set(conn, info);
177
- this.executorById.set(id, info);
178
- if (!this.primaryExecutorId || !this.executorById.has(this.primaryExecutorId)) {
179
- this.primaryExecutorId = id;
180
- this.deps.logger.trace("browser_primary_executor_set", { id });
181
- }
182
- this.deps.logger.info("browser_executor_connected", {
183
- id,
184
- label: info.label,
185
- browser: info.browser
186
- });
187
- this.safeSend(conn, {
188
- v: PROTOCOL_VERSION,
189
- type: "ready",
190
- server: "opencode-browser",
191
- protocol: PROTOCOL_VERSION,
192
- role: "extension",
193
- clientId: id,
194
- executor: this.opts.executor
195
- });
196
- // Rebuild group ownership from the executor's existing tabs (recovers after a
197
- // broker re-election; groups come back orphaned and are adopted on first use).
198
- void this.rebuildFromExecutor(info);
199
- }
200
- /**
201
- * Re-read the shared token (via the injected `reloadToken`) and adopt it if the
202
- * source of truth now holds a different, non-empty value. Lets a rotation in
203
- * `bridge.json` reach this running host. No-op without a `reloadToken` dep, or
204
- * when the file is unchanged/unavailable — so a genuinely wrong token still
205
- * gets rejected. Reads only on a mismatch, so it's not on the hot path.
206
- */
207
- adoptRotatedToken() {
208
- const latest = this.deps.reloadToken?.();
209
- if (latest && latest.length > 0 && latest !== this.opts.token) {
210
- this.opts.token = latest;
211
- }
212
- }
213
- async rebuildFromExecutor(executor) {
214
- try {
215
- const data = (await this.sendToExecutor(executor, "broker", {
216
- action: "tabs",
217
- group: "",
218
- params: {}
219
- }));
220
- for (const g of data.groups ?? []) {
221
- if (g.name && !this.groupOwner.has(g.name)) {
222
- this.groupOwner.set(g.name, { executorId: executor.id, agentId: null });
223
- this.deps.logger.trace("browser_group_orphan_rebuilt", {
224
- group: g.name,
225
- executorId: executor.id
226
- });
227
- }
228
- }
229
- }
230
- catch {
231
- /* best-effort */
232
- }
233
- }
234
- onClose(conn) {
235
- const executor = this.executors.get(conn);
236
- if (executor) {
237
- this.executors.delete(conn);
238
- // Only forget the id mapping if it still points at this connection.
239
- if (this.executorById.get(executor.id)?.conn === conn) {
240
- this.executorById.delete(executor.id);
241
- for (const [group, owner] of this.groupOwner) {
242
- if (owner.executorId === executor.id) {
243
- this.deps.logger.trace("browser_group_dropped_executor_gone", {
244
- group,
245
- executorId: executor.id
246
- });
247
- this.groupOwner.delete(group);
248
- }
249
- }
250
- for (const [reqId, p] of this.pending) {
251
- if (p.executorId === executor.id) {
252
- this.settleReject(reqId, new BrokerError("the browser disconnected", "executor_gone"));
253
- }
254
- }
255
- }
256
- if (this.primaryExecutorId === executor.id) {
257
- this.primaryExecutorId = this.executorById.keys().next().value ?? null;
258
- }
259
- this.deps.logger.info("browser_executor_disconnected", { id: executor.id });
260
- return;
261
- }
262
- const agent = this.agents.get(conn);
263
- if (agent) {
264
- this.agents.delete(conn);
265
- // Orphan this agent's groups (adoptable by the next user) AND send a
266
- // release frame to the executors it owned, so a guest agent dropping
267
- // (MCP server / guest OpenCode exit) detaches the CDP debugger/banner
268
- // instead of leaving it attached until a later manual release.
269
- this.releaseForAgent(agent.id);
270
- for (const [reqId, p] of this.pending) {
271
- if (p.agentId === agent.id) {
272
- // The executor may still be running this command — cancel it so any
273
- // open UI (e.g. a feedback overlay) is torn down, not just orphaned.
274
- this.abandon(reqId, new BrokerError("agent disconnected", "agent_gone"));
275
- }
276
- }
277
- this.deps.logger.info("browser_agent_disconnected", { id: agent.id });
278
- }
279
- }
280
- // ─── routing ───────────────────────────────────────────────────────────────
281
- route(agentId, cmd, signal) {
282
- if (cmd.action === "targets") {
283
- return Promise.resolve(this.listTargets());
284
- }
285
- if (cmd.action === "release") {
286
- this.releaseForAgent(agentId);
287
- return Promise.resolve({ ok: true });
288
- }
289
- if (cmd.action === "tabs" && !cmd.group) {
290
- return this.aggregateTabs();
291
- }
292
- let executor;
293
- try {
294
- executor = GLOBAL_ACTIONS.has(cmd.action)
295
- ? this.pickExecutor(cmd.target)
296
- : this.resolveExecutor(agentId, cmd);
297
- }
298
- catch (err) {
299
- return Promise.reject(err);
300
- }
301
- return this.sendToExecutor(executor, agentId, cmd, signal);
302
- }
303
- resolveExecutor(agentId, cmd) {
304
- const owner = this.groupOwner.get(cmd.group);
305
- if (owner) {
306
- if (owner.agentId && owner.agentId !== agentId) {
307
- this.deps.logger.trace("browser_group_denied", {
308
- group: cmd.group,
309
- agentId,
310
- ownerAgentId: owner.agentId
311
- });
312
- throw new BrokerError(`group "${cmd.group}" is owned by another client`, "group_owned");
313
- }
314
- if (owner.agentId === null) {
315
- this.deps.logger.trace("browser_group_adopted", { group: cmd.group, agentId });
316
- }
317
- owner.agentId = agentId; // claim (incl. adopting an orphan)
318
- const exec = this.executorById.get(owner.executorId);
319
- if (!exec) {
320
- throw new BrokerError(`the browser for group "${cmd.group}" is disconnected`, "executor_gone");
321
- }
322
- return exec;
323
- }
324
- if (cmd.action !== "open") {
325
- throw new BrokerError(`group "${cmd.group}" has no open tabs — call browser_open first`, "no_group");
326
- }
327
- const exec = this.pickExecutor(cmd.target);
328
- this.groupOwner.set(cmd.group, { executorId: exec.id, agentId });
329
- this.deps.logger.trace("browser_group_assigned", {
330
- group: cmd.group,
331
- agentId,
332
- executorId: exec.id
333
- });
334
- return exec;
335
- }
336
- pickExecutor(target) {
337
- if (this.executorById.size === 0) {
338
- throw new BrokerError("no browser extension is connected to the bridge — open the extension and confirm it shows 'connected'", "not_connected");
339
- }
340
- if (target) {
341
- const byId = this.executorById.get(target);
342
- if (byId) {
343
- return byId;
344
- }
345
- const byLabel = [...this.executorById.values()].filter((e) => e.label === target);
346
- if (byLabel.length === 1) {
347
- return byLabel[0];
348
- }
349
- if (byLabel.length > 1) {
350
- throw new BrokerError(`target "${target}" is ambiguous — use the browser id`, "ambiguous_target");
351
- }
352
- throw new BrokerError(`no connected browser matches target "${target}"`, "unknown_target");
353
- }
354
- const primary = this.primaryExecutorId
355
- ? this.executorById.get(this.primaryExecutorId)
356
- : undefined;
357
- if (primary) {
358
- return primary;
359
- }
360
- for (const exec of this.executorById.values()) {
361
- return exec;
362
- }
363
- throw new BrokerError("no browser extension is connected", "not_connected");
364
- }
365
- /** Effective per-command timeout: the override (or global), clamped to the ceiling. */
366
- timeoutFor(override) {
367
- const requested = override !== undefined ? override : this.opts.timeoutMs;
368
- if (requested <= 0) {
369
- return 0;
370
- }
371
- const ceiling = this.opts.maxCommandMs ?? DEFAULT_MAX_COMMAND_MS;
372
- return Math.min(requested, ceiling);
373
- }
374
- sendToExecutor(executor, agentId, cmd, signal) {
375
- if (signal?.aborted) {
376
- return Promise.reject(new BrokerError("aborted", "aborted"));
377
- }
378
- const reqId = nextId();
379
- const frame = {
380
- v: PROTOCOL_VERSION,
381
- type: "command",
382
- id: reqId,
383
- action: cmd.action,
384
- group: cmd.group,
385
- params: cmd.params
386
- };
387
- const timeoutMs = this.timeoutFor(cmd.timeoutMs);
388
- this.deps.logger.trace("browser_command_dispatched", {
389
- id: reqId,
390
- action: cmd.action,
391
- group: cmd.group,
392
- executorId: executor.id,
393
- agentId,
394
- timeoutMs
395
- });
396
- return new Promise((resolve, reject) => {
397
- const timer = timeoutMs > 0
398
- ? setTimeout(() => {
399
- this.deps.logger.trace("browser_command_timeout_fired", {
400
- id: reqId,
401
- action: cmd.action,
402
- timeoutMs
403
- });
404
- this.abandon(reqId, new BrokerError(`command '${cmd.action}' timed out after ${timeoutMs}ms`, "timeout"));
405
- }, timeoutMs)
406
- : null;
407
- let detachAbort = null;
408
- if (signal) {
409
- const onAbort = () => this.abandon(reqId, new BrokerError("aborted", "aborted"));
410
- signal.addEventListener("abort", onAbort, { once: true });
411
- detachAbort = () => signal.removeEventListener("abort", onAbort);
412
- }
413
- this.pending.set(reqId, {
414
- resolve,
415
- reject,
416
- timer,
417
- detachAbort,
418
- agentId,
419
- executorId: executor.id
420
- });
421
- try {
422
- executor.conn.send(encodeFrame(frame));
423
- }
424
- catch (err) {
425
- this.settleReject(reqId, new BrokerError(`failed to deliver '${cmd.action}': ${err instanceof Error ? err.message : String(err)}`, "send_failed"));
426
- }
427
- });
428
- }
429
- handleResult(frame) {
430
- const p = this.pending.get(frame.id);
431
- if (!p) {
432
- this.deps.logger.trace("browser_result_unmatched", { id: frame.id });
433
- return;
434
- }
435
- this.deps.logger.trace("browser_command_result_correlated", {
436
- id: frame.id,
437
- ok: frame.ok,
438
- executorId: p.executorId,
439
- agentId: p.agentId
440
- });
441
- this.clearPending(frame.id);
442
- if (frame.ok) {
443
- p.resolve(frame.data);
444
- }
445
- else {
446
- p.reject(new BrokerError(frame.error?.message ?? "executor error", frame.error?.code));
447
- }
448
- }
449
- async handleAgentCommand(agent, conn, frame) {
450
- this.deps.logger.trace("browser_agent_command_received", {
451
- agentId: agent.id,
452
- id: frame.id,
453
- action: frame.action,
454
- group: frame.group,
455
- target: frame.target
456
- });
457
- try {
458
- const data = await this.route(agent.id, {
459
- action: frame.action,
460
- group: frame.group,
461
- params: frame.params,
462
- target: frame.target,
463
- timeoutMs: frame.timeoutMs
464
- });
465
- this.deps.logger.trace("browser_agent_command_result", {
466
- agentId: agent.id,
467
- id: frame.id,
468
- ok: true
469
- });
470
- this.safeSend(conn, resultFrame(frame.id, data));
471
- }
472
- catch (err) {
473
- const message = err instanceof Error ? err.message : String(err);
474
- const code = err instanceof BrokerError ? err.code : undefined;
475
- this.deps.logger.trace("browser_agent_command_result", {
476
- agentId: agent.id,
477
- id: frame.id,
478
- ok: false,
479
- code
480
- });
481
- this.safeSend(conn, errorFrame(frame.id, message, code));
482
- }
483
- }
484
- routeEvent(frame) {
485
- // Forward to the owning agent if known, else broadcast to all remote agents.
486
- const owner = frame.group ? this.groupOwner.get(frame.group) : undefined;
487
- this.deps.logger.trace("browser_event_routed", {
488
- name: frame.name,
489
- group: frame.group,
490
- ownerAgentId: owner?.agentId ?? null
491
- });
492
- for (const agent of this.agents.values()) {
493
- if (!owner || owner.agentId === null || owner.agentId === agent.id) {
494
- this.safeSend(agent.conn, frame);
495
- }
496
- }
497
- }
498
- listTargets() {
499
- return {
500
- targets: [...this.executorById.values()].map((e) => ({
501
- id: e.id,
502
- label: e.label,
503
- browser: e.browser,
504
- groups: [...this.groupOwner.entries()]
505
- .filter(([, o]) => o.executorId === e.id)
506
- .map(([g]) => g)
507
- }))
508
- };
509
- }
510
- async aggregateTabs() {
511
- const groups = [];
512
- for (const executor of this.executorById.values()) {
513
- try {
514
- const data = (await this.sendToExecutor(executor, "broker", {
515
- action: "tabs",
516
- group: "",
517
- params: {}
518
- }));
519
- for (const g of data.groups ?? []) {
520
- groups.push({ ...g, executor: executor.id });
521
- }
522
- }
523
- catch {
524
- /* skip a browser that errored */
525
- }
526
- }
527
- return { groups };
528
- }
529
- releaseForAgent(agentId) {
530
- const execIds = new Set();
531
- for (const [group, owner] of this.groupOwner) {
532
- if (owner.agentId === agentId) {
533
- execIds.add(owner.executorId);
534
- owner.agentId = null;
535
- this.deps.logger.trace("browser_group_orphaned", { group, agentId });
536
- }
537
- }
538
- // Only release the executors this agent actually owned. An agent that holds
539
- // no groups must release nothing — broadcasting to every executor would
540
- // detach CDP control from other clients' browsers in a shared bridge.
541
- for (const id of execIds) {
542
- const exec = this.executorById.get(id);
543
- if (exec) {
544
- this.safeSend(exec.conn, { v: PROTOCOL_VERSION, type: "release" });
545
- }
546
- }
547
- }
548
- // ─── helpers ─────────────────────────────────────────────────────────────────
549
- safeSend(conn, frame) {
550
- try {
551
- conn.send(encodeFrame(frame));
552
- }
553
- catch {
554
- /* connection went away; close handler cleans up */
555
- }
556
- }
557
- clearPending(reqId) {
558
- const p = this.pending.get(reqId);
559
- if (!p) {
560
- return;
561
- }
562
- if (p.timer) {
563
- clearTimeout(p.timer);
564
- }
565
- p.detachAbort?.();
566
- this.pending.delete(reqId);
567
- }
568
- settleReject(reqId, err) {
569
- const p = this.pending.get(reqId);
570
- if (!p) {
571
- return;
572
- }
573
- this.clearPending(reqId);
574
- p.reject(err);
575
- }
576
- /**
577
- * Give up on a command the executor may still be running (abort, timeout, or
578
- * the requesting agent vanishing): tell the still-connected executor to tear
579
- * down via a `cancel` frame, then reject the pending promise. For an executor
580
- * that's already gone the cancel is skipped — there's nothing to tear down.
581
- */
582
- abandon(reqId, err) {
583
- const p = this.pending.get(reqId);
584
- if (!p) {
585
- return;
586
- }
587
- const exec = this.executorById.get(p.executorId);
588
- this.deps.logger.trace("browser_command_abandoned", {
589
- id: reqId,
590
- executorId: p.executorId,
591
- cancelSent: Boolean(exec),
592
- reason: err.message
593
- });
594
- if (exec) {
595
- this.safeSend(exec.conn, cancelFrame(reqId));
596
- }
597
- this.settleReject(reqId, err);
598
- }
599
- rejectAllPending(err) {
600
- for (const [reqId, p] of this.pending) {
601
- if (p.timer) {
602
- clearTimeout(p.timer);
603
- }
604
- p.detachAbort?.();
605
- this.pending.delete(reqId);
606
- p.reject(err);
607
- }
608
- }
25
+ opts;
26
+ deps;
27
+ executors = new Map();
28
+ executorById = new Map();
29
+ agents = new Map();
30
+ groupOwner = new Map();
31
+ pending = new Map();
32
+ primaryExecutorId = null;
33
+ agentSeq = 0;
34
+ execSeq = 0;
35
+ started = false;
36
+ constructor(opts, deps) {
37
+ this.opts = opts;
38
+ this.deps = deps;
39
+ }
40
+ get executorCount() {
41
+ return this.executorById.size;
42
+ }
43
+ async start() {
44
+ if (this.started) {
45
+ return;
46
+ }
47
+ await this.deps.transport.listen({
48
+ host: this.opts.host,
49
+ port: this.opts.port
50
+ }, {
51
+ onOpen: () => {},
52
+ onMessage: (conn, data) => this.onMessage(conn, data),
53
+ onClose: (conn) => this.onClose(conn)
54
+ });
55
+ this.started = true;
56
+ this.deps.logger.info("browser_broker_listening", {
57
+ host: this.opts.host,
58
+ port: this.opts.port
59
+ });
60
+ }
61
+ stop() {
62
+ this.rejectAllPending(new BrokerError("broker stopped"));
63
+ this.executors.clear();
64
+ this.executorById.clear();
65
+ this.agents.clear();
66
+ this.groupOwner.clear();
67
+ this.primaryExecutorId = null;
68
+ this.started = false;
69
+ this.deps.transport.stop();
70
+ }
71
+ /** An in-process agent (the host adapter's own tools), bypassing a socket. */
72
+ createLocalAgent() {
73
+ const id = `local:${++this.agentSeq}`;
74
+ return {
75
+ send: (action, group, params, signal, target, timeoutMs) => this.route(id, {
76
+ action,
77
+ group,
78
+ params,
79
+ target,
80
+ timeoutMs
81
+ }, signal),
82
+ release: () => this.releaseForAgent(id)
83
+ };
84
+ }
85
+ // ─── connection handling ───────────────────────────────────────────────────
86
+ onMessage(conn, raw) {
87
+ const frame = decodeFrame(raw);
88
+ if (!frame) {
89
+ return;
90
+ }
91
+ if (frame.type === "hello") {
92
+ this.handleHello(conn, frame);
93
+ return;
94
+ }
95
+ const executor = this.executors.get(conn);
96
+ if (executor) {
97
+ if (frame.type === "result") {
98
+ this.handleResult(frame);
99
+ } else if (frame.type === "event") {
100
+ this.routeEvent(frame);
101
+ } else if (frame.type === "ping") {
102
+ this.safeSend(conn, {
103
+ v: PROTOCOL_VERSION,
104
+ type: "pong"
105
+ });
106
+ }
107
+ return;
108
+ }
109
+ const agent = this.agents.get(conn);
110
+ if (agent) {
111
+ if (frame.type === "command") {
112
+ void this.handleAgentCommand(agent, conn, frame);
113
+ } else if (frame.type === "ping") {
114
+ this.safeSend(conn, {
115
+ v: PROTOCOL_VERSION,
116
+ type: "pong"
117
+ });
118
+ }
119
+ return;
120
+ }
121
+ this.deps.logger.warn("browser_frame_unauthenticated", { type: frame.type });
122
+ }
123
+ handleHello(conn, frame) {
124
+ const role = frame.role ?? "extension";
125
+ this.deps.logger.trace("browser_handshake_hello", {
126
+ role,
127
+ client: frame.client,
128
+ label: frame.label,
129
+ browser: frame.browser
130
+ });
131
+ if (frame.token !== this.opts.token) {
132
+ // The shared token may have rotated under this long-lived host (it resolved
133
+ // its token once at boot and otherwise never re-reads bridge.json). Re-read
134
+ // it; if the file now carries a different value, adopt it — so a rotation
135
+ // reaches a running host without a restart — then re-check the handshake.
136
+ this.adoptRotatedToken();
137
+ if (frame.token !== this.opts.token) {
138
+ // Fingerprints (never the raw tokens) make the mismatch diagnosable from
139
+ // the log alone — `expected` vs `got` of the same length but different
140
+ // value means a rotated/stale token, not a paste typo. role/client say
141
+ // which peer it was.
142
+ this.deps.logger.warn("browser_handshake_rejected", {
143
+ reason: "bad_token",
144
+ role: frame.role ?? "extension",
145
+ client: frame.client,
146
+ expected: tokenFingerprint(this.opts.token),
147
+ got: tokenFingerprint(frame.token)
148
+ });
149
+ // Tell the dialer *why* before dropping it, so it can show an actionable
150
+ // error and stop hammering instead of retrying a token that can't work.
151
+ this.safeSend(conn, rejectedFrame("bad_token"));
152
+ conn.close();
153
+ return;
154
+ }
155
+ this.deps.logger.info("browser_bridge_token_reloaded", { fingerprint: tokenFingerprint(this.opts.token) });
156
+ }
157
+ if (role === "agent") {
158
+ const id = `agent:${++this.agentSeq}`;
159
+ this.agents.set(conn, {
160
+ conn,
161
+ id
162
+ });
163
+ this.deps.logger.trace("browser_agent_accepted", {
164
+ id,
165
+ client: frame.client
166
+ });
167
+ this.deps.logger.info("browser_agent_connected", {
168
+ id,
169
+ client: frame.client
170
+ });
171
+ this.safeSend(conn, {
172
+ v: PROTOCOL_VERSION,
173
+ type: "ready",
174
+ server: "opencode-browser",
175
+ protocol: PROTOCOL_VERSION,
176
+ role: "agent",
177
+ clientId: id
178
+ });
179
+ return;
180
+ }
181
+ const id = frame.id || `exec:${++this.execSeq}`;
182
+ // Replace any stale connection carrying the same id (extension reconnect).
183
+ const prior = this.executorById.get(id);
184
+ if (prior && prior.conn !== conn) {
185
+ this.deps.logger.trace("browser_executor_replace_stale", { id });
186
+ this.executors.delete(prior.conn);
187
+ prior.conn.close();
188
+ }
189
+ const info = {
190
+ conn,
191
+ id,
192
+ label: frame.label || id,
193
+ browser: frame.browser || "unknown"
194
+ };
195
+ this.executors.set(conn, info);
196
+ this.executorById.set(id, info);
197
+ if (!this.primaryExecutorId || !this.executorById.has(this.primaryExecutorId)) {
198
+ this.primaryExecutorId = id;
199
+ this.deps.logger.trace("browser_primary_executor_set", { id });
200
+ }
201
+ this.deps.logger.info("browser_executor_connected", {
202
+ id,
203
+ label: info.label,
204
+ browser: info.browser
205
+ });
206
+ this.safeSend(conn, {
207
+ v: PROTOCOL_VERSION,
208
+ type: "ready",
209
+ server: "opencode-browser",
210
+ protocol: PROTOCOL_VERSION,
211
+ role: "extension",
212
+ clientId: id,
213
+ executor: this.opts.executor
214
+ });
215
+ // Rebuild group ownership from the executor's existing tabs (recovers after a
216
+ // broker re-election; groups come back orphaned and are adopted on first use).
217
+ void this.rebuildFromExecutor(info);
218
+ }
219
+ /**
220
+ * Re-read the shared token (via the injected `reloadToken`) and adopt it if the
221
+ * source of truth now holds a different, non-empty value. Lets a rotation in
222
+ * `bridge.json` reach this running host. No-op without a `reloadToken` dep, or
223
+ * when the file is unchanged/unavailable — so a genuinely wrong token still
224
+ * gets rejected. Reads only on a mismatch, so it's not on the hot path.
225
+ */
226
+ adoptRotatedToken() {
227
+ const latest = this.deps.reloadToken?.();
228
+ if (latest && latest.length > 0 && latest !== this.opts.token) {
229
+ this.opts.token = latest;
230
+ }
231
+ }
232
+ async rebuildFromExecutor(executor) {
233
+ try {
234
+ const data = await this.sendToExecutor(executor, "broker", {
235
+ action: "tabs",
236
+ group: "",
237
+ params: {}
238
+ });
239
+ for (const g of data.groups ?? []) {
240
+ if (g.name && !this.groupOwner.has(g.name)) {
241
+ this.groupOwner.set(g.name, {
242
+ executorId: executor.id,
243
+ agentId: null
244
+ });
245
+ this.deps.logger.trace("browser_group_orphan_rebuilt", {
246
+ group: g.name,
247
+ executorId: executor.id
248
+ });
249
+ }
250
+ }
251
+ } catch {}
252
+ }
253
+ onClose(conn) {
254
+ const executor = this.executors.get(conn);
255
+ if (executor) {
256
+ this.executors.delete(conn);
257
+ // Only forget the id mapping if it still points at this connection.
258
+ if (this.executorById.get(executor.id)?.conn === conn) {
259
+ this.executorById.delete(executor.id);
260
+ for (const [group, owner] of this.groupOwner) {
261
+ if (owner.executorId === executor.id) {
262
+ this.deps.logger.trace("browser_group_dropped_executor_gone", {
263
+ group,
264
+ executorId: executor.id
265
+ });
266
+ this.groupOwner.delete(group);
267
+ }
268
+ }
269
+ for (const [reqId, p] of this.pending) {
270
+ if (p.executorId === executor.id) {
271
+ this.settleReject(reqId, new BrokerError("the browser disconnected", "executor_gone"));
272
+ }
273
+ }
274
+ }
275
+ if (this.primaryExecutorId === executor.id) {
276
+ this.primaryExecutorId = this.executorById.keys().next().value ?? null;
277
+ }
278
+ this.deps.logger.info("browser_executor_disconnected", { id: executor.id });
279
+ return;
280
+ }
281
+ const agent = this.agents.get(conn);
282
+ if (agent) {
283
+ this.agents.delete(conn);
284
+ // Orphan this agent's groups (adoptable by the next user) AND send a
285
+ // release frame to the executors it owned, so a guest agent dropping
286
+ // (MCP server / guest OpenCode exit) detaches the CDP debugger/banner
287
+ // instead of leaving it attached until a later manual release.
288
+ this.releaseForAgent(agent.id);
289
+ for (const [reqId, p] of this.pending) {
290
+ if (p.agentId === agent.id) {
291
+ // The executor may still be running this command — cancel it so any
292
+ // open UI (e.g. a feedback overlay) is torn down, not just orphaned.
293
+ this.abandon(reqId, new BrokerError("agent disconnected", "agent_gone"));
294
+ }
295
+ }
296
+ this.deps.logger.info("browser_agent_disconnected", { id: agent.id });
297
+ }
298
+ }
299
+ // ─── routing ───────────────────────────────────────────────────────────────
300
+ route(agentId, cmd, signal) {
301
+ if (cmd.action === "targets") {
302
+ return Promise.resolve(this.listTargets());
303
+ }
304
+ if (cmd.action === "release") {
305
+ this.releaseForAgent(agentId);
306
+ return Promise.resolve({ ok: true });
307
+ }
308
+ if (cmd.action === "tabs" && !cmd.group) {
309
+ return this.aggregateTabs();
310
+ }
311
+ let executor;
312
+ try {
313
+ executor = GLOBAL_ACTIONS.has(cmd.action) ? this.pickExecutor(cmd.target) : this.resolveExecutor(agentId, cmd);
314
+ } catch (err) {
315
+ return Promise.reject(err);
316
+ }
317
+ return this.sendToExecutor(executor, agentId, cmd, signal);
318
+ }
319
+ resolveExecutor(agentId, cmd) {
320
+ const owner = this.groupOwner.get(cmd.group);
321
+ if (owner) {
322
+ if (owner.agentId && owner.agentId !== agentId) {
323
+ this.deps.logger.trace("browser_group_denied", {
324
+ group: cmd.group,
325
+ agentId,
326
+ ownerAgentId: owner.agentId
327
+ });
328
+ throw new BrokerError(`group "${cmd.group}" is owned by another client`, "group_owned");
329
+ }
330
+ if (owner.agentId === null) {
331
+ this.deps.logger.trace("browser_group_adopted", {
332
+ group: cmd.group,
333
+ agentId
334
+ });
335
+ }
336
+ owner.agentId = agentId;
337
+ const exec = this.executorById.get(owner.executorId);
338
+ if (!exec) {
339
+ throw new BrokerError(`the browser for group "${cmd.group}" is disconnected`, "executor_gone");
340
+ }
341
+ return exec;
342
+ }
343
+ if (cmd.action !== "open") {
344
+ throw new BrokerError(`group "${cmd.group}" has no open tabs — call browser_open first`, "no_group");
345
+ }
346
+ const exec = this.pickExecutor(cmd.target);
347
+ this.groupOwner.set(cmd.group, {
348
+ executorId: exec.id,
349
+ agentId
350
+ });
351
+ this.deps.logger.trace("browser_group_assigned", {
352
+ group: cmd.group,
353
+ agentId,
354
+ executorId: exec.id
355
+ });
356
+ return exec;
357
+ }
358
+ pickExecutor(target) {
359
+ if (this.executorById.size === 0) {
360
+ throw new BrokerError("no browser extension is connected to the bridge — open the extension and confirm it shows 'connected'", "not_connected");
361
+ }
362
+ if (target) {
363
+ const byId = this.executorById.get(target);
364
+ if (byId) {
365
+ return byId;
366
+ }
367
+ const byLabel = [...this.executorById.values()].filter((e) => e.label === target);
368
+ if (byLabel.length === 1) {
369
+ return byLabel[0];
370
+ }
371
+ if (byLabel.length > 1) {
372
+ throw new BrokerError(`target "${target}" is ambiguous — use the browser id`, "ambiguous_target");
373
+ }
374
+ throw new BrokerError(`no connected browser matches target "${target}"`, "unknown_target");
375
+ }
376
+ const primary = this.primaryExecutorId ? this.executorById.get(this.primaryExecutorId) : undefined;
377
+ if (primary) {
378
+ return primary;
379
+ }
380
+ for (const exec of this.executorById.values()) {
381
+ return exec;
382
+ }
383
+ throw new BrokerError("no browser extension is connected", "not_connected");
384
+ }
385
+ /** Effective per-command timeout: the override (or global), clamped to the ceiling. */
386
+ timeoutFor(override) {
387
+ const requested = override !== undefined ? override : this.opts.timeoutMs;
388
+ if (requested <= 0) {
389
+ return 0;
390
+ }
391
+ const ceiling = this.opts.maxCommandMs ?? DEFAULT_MAX_COMMAND_MS;
392
+ return Math.min(requested, ceiling);
393
+ }
394
+ sendToExecutor(executor, agentId, cmd, signal) {
395
+ if (signal?.aborted) {
396
+ return Promise.reject(new BrokerError("aborted", "aborted"));
397
+ }
398
+ const reqId = nextId();
399
+ const frame = {
400
+ v: PROTOCOL_VERSION,
401
+ type: "command",
402
+ id: reqId,
403
+ action: cmd.action,
404
+ group: cmd.group,
405
+ params: cmd.params
406
+ };
407
+ const timeoutMs = this.timeoutFor(cmd.timeoutMs);
408
+ this.deps.logger.trace("browser_command_dispatched", {
409
+ id: reqId,
410
+ action: cmd.action,
411
+ group: cmd.group,
412
+ executorId: executor.id,
413
+ agentId,
414
+ timeoutMs
415
+ });
416
+ return new Promise((resolve, reject) => {
417
+ const timer = timeoutMs > 0 ? setTimeout(() => {
418
+ this.deps.logger.trace("browser_command_timeout_fired", {
419
+ id: reqId,
420
+ action: cmd.action,
421
+ timeoutMs
422
+ });
423
+ this.abandon(reqId, new BrokerError(`command '${cmd.action}' timed out after ${timeoutMs}ms`, "timeout"));
424
+ }, timeoutMs) : null;
425
+ let detachAbort = null;
426
+ if (signal) {
427
+ const onAbort = () => this.abandon(reqId, new BrokerError("aborted", "aborted"));
428
+ signal.addEventListener("abort", onAbort, { once: true });
429
+ detachAbort = () => signal.removeEventListener("abort", onAbort);
430
+ }
431
+ this.pending.set(reqId, {
432
+ resolve,
433
+ reject,
434
+ timer,
435
+ detachAbort,
436
+ agentId,
437
+ executorId: executor.id
438
+ });
439
+ try {
440
+ executor.conn.send(encodeFrame(frame));
441
+ } catch (err) {
442
+ this.settleReject(reqId, new BrokerError(`failed to deliver '${cmd.action}': ${err instanceof Error ? err.message : String(err)}`, "send_failed"));
443
+ }
444
+ });
445
+ }
446
+ handleResult(frame) {
447
+ const p = this.pending.get(frame.id);
448
+ if (!p) {
449
+ this.deps.logger.trace("browser_result_unmatched", { id: frame.id });
450
+ return;
451
+ }
452
+ this.deps.logger.trace("browser_command_result_correlated", {
453
+ id: frame.id,
454
+ ok: frame.ok,
455
+ executorId: p.executorId,
456
+ agentId: p.agentId
457
+ });
458
+ this.clearPending(frame.id);
459
+ if (frame.ok) {
460
+ p.resolve(frame.data);
461
+ } else {
462
+ p.reject(new BrokerError(frame.error?.message ?? "executor error", frame.error?.code));
463
+ }
464
+ }
465
+ async handleAgentCommand(agent, conn, frame) {
466
+ this.deps.logger.trace("browser_agent_command_received", {
467
+ agentId: agent.id,
468
+ id: frame.id,
469
+ action: frame.action,
470
+ group: frame.group,
471
+ target: frame.target
472
+ });
473
+ try {
474
+ const data = await this.route(agent.id, {
475
+ action: frame.action,
476
+ group: frame.group,
477
+ params: frame.params,
478
+ target: frame.target,
479
+ timeoutMs: frame.timeoutMs
480
+ });
481
+ this.deps.logger.trace("browser_agent_command_result", {
482
+ agentId: agent.id,
483
+ id: frame.id,
484
+ ok: true
485
+ });
486
+ this.safeSend(conn, resultFrame(frame.id, data));
487
+ } catch (err) {
488
+ const message = err instanceof Error ? err.message : String(err);
489
+ const code = err instanceof BrokerError ? err.code : undefined;
490
+ this.deps.logger.trace("browser_agent_command_result", {
491
+ agentId: agent.id,
492
+ id: frame.id,
493
+ ok: false,
494
+ code
495
+ });
496
+ this.safeSend(conn, errorFrame(frame.id, message, code));
497
+ }
498
+ }
499
+ routeEvent(frame) {
500
+ // Forward to the owning agent if known, else broadcast to all remote agents.
501
+ const owner = frame.group ? this.groupOwner.get(frame.group) : undefined;
502
+ this.deps.logger.trace("browser_event_routed", {
503
+ name: frame.name,
504
+ group: frame.group,
505
+ ownerAgentId: owner?.agentId ?? null
506
+ });
507
+ for (const agent of this.agents.values()) {
508
+ if (!owner || owner.agentId === null || owner.agentId === agent.id) {
509
+ this.safeSend(agent.conn, frame);
510
+ }
511
+ }
512
+ }
513
+ listTargets() {
514
+ return { targets: [...this.executorById.values()].map((e) => ({
515
+ id: e.id,
516
+ label: e.label,
517
+ browser: e.browser,
518
+ groups: [...this.groupOwner.entries()].filter(([, o]) => o.executorId === e.id).map(([g]) => g)
519
+ })) };
520
+ }
521
+ async aggregateTabs() {
522
+ const groups = [];
523
+ for (const executor of this.executorById.values()) {
524
+ try {
525
+ const data = await this.sendToExecutor(executor, "broker", {
526
+ action: "tabs",
527
+ group: "",
528
+ params: {}
529
+ });
530
+ for (const g of data.groups ?? []) {
531
+ groups.push({
532
+ ...g,
533
+ executor: executor.id
534
+ });
535
+ }
536
+ } catch {}
537
+ }
538
+ return { groups };
539
+ }
540
+ releaseForAgent(agentId) {
541
+ const execIds = new Set();
542
+ for (const [group, owner] of this.groupOwner) {
543
+ if (owner.agentId === agentId) {
544
+ execIds.add(owner.executorId);
545
+ owner.agentId = null;
546
+ this.deps.logger.trace("browser_group_orphaned", {
547
+ group,
548
+ agentId
549
+ });
550
+ }
551
+ }
552
+ // Only release the executors this agent actually owned. An agent that holds
553
+ // no groups must release nothing — broadcasting to every executor would
554
+ // detach CDP control from other clients' browsers in a shared bridge.
555
+ for (const id of execIds) {
556
+ const exec = this.executorById.get(id);
557
+ if (exec) {
558
+ this.safeSend(exec.conn, {
559
+ v: PROTOCOL_VERSION,
560
+ type: "release"
561
+ });
562
+ }
563
+ }
564
+ }
565
+ // ─── helpers ─────────────────────────────────────────────────────────────────
566
+ safeSend(conn, frame) {
567
+ try {
568
+ conn.send(encodeFrame(frame));
569
+ } catch {}
570
+ }
571
+ clearPending(reqId) {
572
+ const p = this.pending.get(reqId);
573
+ if (!p) {
574
+ return;
575
+ }
576
+ if (p.timer) {
577
+ clearTimeout(p.timer);
578
+ }
579
+ p.detachAbort?.();
580
+ this.pending.delete(reqId);
581
+ }
582
+ settleReject(reqId, err) {
583
+ const p = this.pending.get(reqId);
584
+ if (!p) {
585
+ return;
586
+ }
587
+ this.clearPending(reqId);
588
+ p.reject(err);
589
+ }
590
+ /**
591
+ * Give up on a command the executor may still be running (abort, timeout, or
592
+ * the requesting agent vanishing): tell the still-connected executor to tear
593
+ * down via a `cancel` frame, then reject the pending promise. For an executor
594
+ * that's already gone the cancel is skipped — there's nothing to tear down.
595
+ */
596
+ abandon(reqId, err) {
597
+ const p = this.pending.get(reqId);
598
+ if (!p) {
599
+ return;
600
+ }
601
+ const exec = this.executorById.get(p.executorId);
602
+ this.deps.logger.trace("browser_command_abandoned", {
603
+ id: reqId,
604
+ executorId: p.executorId,
605
+ cancelSent: Boolean(exec),
606
+ reason: err.message
607
+ });
608
+ if (exec) {
609
+ this.safeSend(exec.conn, cancelFrame(reqId));
610
+ }
611
+ this.settleReject(reqId, err);
612
+ }
613
+ rejectAllPending(err) {
614
+ for (const [reqId, p] of this.pending) {
615
+ if (p.timer) {
616
+ clearTimeout(p.timer);
617
+ }
618
+ p.detachAbort?.();
619
+ this.pending.delete(reqId);
620
+ p.reject(err);
621
+ }
622
+ }
609
623
  }
624
+
610
625
  //# sourceMappingURL=broker.js.map