Status: design / not yet implemented. Follow-up to the browser plugin + MCP server.
Today the bridge is single-everything: one connected extension (client), latest-hello-wins,
commands go to this.client, and each adapter (plugin / MCP server) hosts its own bridge
server. Consequences:
- Two adapters can't run at once — they contend for
127.0.0.1:4517(second fails to bind), and an extension can only dial one of them. - One agent can't drive more than one browser/profile.
- Both axes: multiple executors (browsers) and multiple producers (agents — plugin + MCP, or several sessions) sharing one bridge.
- Auto-elect embedded broker: no separate daemon. The first adapter to start hosts the broker in-process; later adapters connect to it as clients; re-elect if the host exits.
- Routing by group ownership (+ optional
target): the named group is the routing unit.
Non-goals (v1): cross-machine bridges; per-agent auth/tokens (one shared token); concurrent drivers of the same group (groups are owner-exclusive).
Every connection declares a role in hello:
hello { v, type:"hello", token, role:"agent"|"extension", label?,
id?, // extensions: stable per-install id (persisted in IndexedDB)
browser? } // extensions: "chrome" | "firefox" | UA hint
- extension = executor (drives tabs). Default when
roleis absent (back-compat with shipped builds). Sends a stableid(generated once, persisted) and a user-editablelabelfrom the dashboard (e.g.work-chrome). - agent = producer (issues commands): the plugin, the MCP server, a guest adapter.
An agent names a browser by label or id. browser_targets lists connected executors as
{ id, label, browser, groups[] }; browser_open's optional target accepts a label or an id
(label resolved to id; ambiguous label → error). Labels are edited in the extension dashboard and
default to the auto-id, so targeting works out of the box and reads nicely once labelled.
Whoever wins election runs a role-aware broker holding:
executors: Map<connId, Conn>— connected extensions (browsers).agents: Map<connId, Conn>— connected agents (incl. the host's own in-process loopback agent).groupOwner: Map<group, { executorId, agentId }>— who runs group G and who created it.pending: Map<brokerReqId, { agentId, agentReqId }>— to route results back.primaryExecutor— default executor for new groups when notargetis given.
agent ──command{id,action,group,params,target?}──▶ broker ──command{id':…}──▶ executor
agent ◀──────────result{id}────────────────────── broker ◀──result{id'}──────
- Broker resolves the executor:
- known group → its
groupOwner.executorId(error if that browser disconnected). - new group (
browser_open) → thetargetexecutor, elseprimaryExecutor; recordgroupOwner = { executor, agent }. - broker-handled (
browser_targets,browser_release) → answered/broadcast by the broker, not forwarded.
- known group → its
- Broker mints
brokerReqId, storespending, forwards to the executor with the new id. - Executor
result{brokerReqId}→ broker maps back →result{agentReqId}to the origin agent. - Executor
event→ routed to the owning agent (tab/group events) or broadcast (generic).
A group is owned by the agent connection that created it; only that connection may target it.
Another live agent gets group "X" is owned by another client — no sharing, no cross-agent
handoff. So the plugin and the MCP server must use distinct group names; they can't drive
each other's groups. Agents already choose names, so this is mostly a safety rail against two
producers stepping on the same tabs.
Orphans. Ownership is by connection. When the owning agent disconnects (normal exit, crash, or failover), its groups become orphaned and adoptable: the next agent that references such a group claims it (one-time). This is what keeps tabs usable after an agent goes away while still forbidding concurrent cross-agent access. (Orphan ≠ sharing — only one live owner ever.)
Election = the bind. On startup an adapter tries to bind the port:
- bind succeeds → host: run the broker. The host's own tools call the broker directly
in-process (not via a real loopback socket) —
connect()returns a uniformsend()that, in host mode, dispatches straight into the broker. - bind fails (EADDRINUSE) → guest: connect to the running broker as an
agentover WS (a new agent-side client, mirroring the extension'sBridgeClient);send()goes over that socket.
The OS makes bind atomic, so simultaneous starts resolve cleanly (one host, rest guests).
Shared token (so multi-agent stays zero-config). A guest can't guess an auto-generated token,
so adapters coordinate via a per-user state file <cacheDir>/opencode-browser/bridge.json
({ port, token }, mode 0600): the host writes it on bind; guests read it to authenticate. If
the operator sets token explicitly (plugin option / OCB_TOKEN), that wins and is written to the
file. The extension still needs the token pasted into its dashboard (it can't read the FS) —
unchanged. Net: adapters auto-share; the human still pastes once.
Failover when the host exits (OpenCode quits / MCP server killed):
- Its server socket closes → guests' agent connections and all executor connections drop.
- Guests + extensions reconnect with backoff. On each retry a guest re-attempts the bind → one guest wins election and becomes the new host/broker; the rest reconnect as guests; extensions reconnect to the new host.
- State rebuild: the dead broker's
groupOwnermap is gone. The new broker repopulates the executor side by querying each reconnected extension for its existing groups (the extension'sGroupRegistrypersists in IndexedDB), recordinggroup → executor. The agent owner is unknown, so every pre-failover group is orphaned per the isolation rule above — the next agent to reference it adopts it. Tabs survive; live ownership re-forms on use.
Accepted window (decided: fail-fast + retry): during re-election (a few hundred ms to one
backoff interval) commands reject immediately with a clear bridge re-electing error and the
agent/model retries — no buffering/replay. This brief blip is the main cost of the no-daemon
choice and is documented for users.
HelloFrame:role?: "agent" | "extension"(default extension),label?.ReadyFrame:clientId(assigned),roleecho.CommandFrame: optionaltarget?(broker-only; executors ignore).- New broker→executor query to rebuild ownership: reuse the
tabsaction (returns the executor's groups) — no new frame needed. - New tool
browser_targets(grouppage): list connected browsers (broker answers). browser_opengains optionaltargetto choose the browser for a new group.
broker.ts(new, inopencode-browser): the role-aware broker — a generalization of today'sBridge, still behind theBridgeTransportseam (DI-testable: feed fake agent + executor conns, assert routing).agent-client.ts(new): connects to a broker over WS asrole:"agent",send()returns the correlated result (reuses the reconnect/backoff shape from the extension'sBridgeClient).connect()(new, replaces directbridge.start()): try-bind → host (broker + loopback agent) or guest (agent-client); exposes a uniformsend(action, group, params, signal)to tools regardless of mode, plus failover re-election.- MCP server: same
connect()(Nodewsfor both the bind/broker and the guest client). - Extension:
hellosendsrole:"extension"; reconnect already handles a re-elected host. Add a small "report my groups" reply for ownership rebuild (thetabshandler already lists them). - Tools/catalog: add
browser_targets; addtargettobrowser_open.
Unchanged trust model: loopback bind + one shared token; both roles present it. Owner-exclusive groups add light isolation between agents. The token is still printed once by whoever generates it.
- Broker routing (pure, DI): command→correct executor by group ownership; result correlation back to the origin agent; new-group target selection; owner-exclusivity rejection; event routing.
- Election: two fake transports racing the bind → exactly one host.
- Failover: kill host transport → a guest re-binds → ownership rebuilt from executors'
tabs. - agent-client: reconnect/backoff, result correlation, abort.
- Roles + multi-executor (Axis 1):
hello.role, broker withgroupOwnerrouting,target+browser_targets, single host agent (in-process loopback). No guests yet. - Guest agents (Axis 2):
agent-client+ try-bindconnect(); plugin & MCP become host-or- guest. Owner-exclusive groups. - Failover: guest re-election on host exit + ownership rebuild from executors; "re-electing" error + retry.
- Scope: both axes (multi-executor + multi-agent).
- Topology: auto-elect embedded broker (no daemon); direct in-process broker call for the host.
- Routing key: group ownership + optional
target. - Target identity: dashboard label + stable auto-id fallback.
- Group access: strictly owner-exclusive; orphaned groups are adoptable once the owner is gone.
- Re-election window: fail-fast + retry (no buffering).
- Token in multi-agent: shared via a
0600state file (bridge.json); explicit option wins. - Default executor (multiple browsers, no
target): first-connected, overridable by a "default browser" toggle in the dashboard. - Host symmetry: symmetric auto-elect — plugin or MCP server may host; revisit only if failover proves flaky in practice.
targetarg: always present but optional; tool docs say "omit unless you have multiple browsers connected" to keep single-browser setups quiet.
Design final — ready to implement when picked up, phased: roles + multi-executor → guest
agents + auto-elect → failover. Build as its own PR off main after the browser plugin / MCP PR
lands, so the failover / re-election code gets focused review.