Agent bridge: Gullet sidecar and five read/file/close tools - #11
Merged
Conversation
Lets a coding agent see and manage open tabs through Tabglutton, over an opt-in loopback WebSocket. Architecture and rationale in BRIDGE.md. - gullet/: zero-dependency sidecar, MCP over stdio on one side and a loopback WebSocket hub on the other. Sibling package, never bundled. - src/bridge-protocol.ts: wire contract, imported by both halves so they typecheck against one definition. - src/bridge-client.ts: socket lifecycle with a 30s alarm-driven redial, so a sidecar started mid-session is picked up without user action. - src/bridge-methods.ts: tabs_list, tab_read, tab_clip, tabs_close, undo_close. Read, file, and close only -- no navigation, clicking, typing, or arbitrary script execution. - src/undo-log.ts: close/undo trail, capped by batch and entry count. - Auth: challenge/response over SHA-256 so the token never crosses the wire, plus an extension-origin gate on the upgrade. manifest.json gains the alarms permission and, critically, an explicit content_security_policy.extension_pages. Firefox's MV3 default includes upgrade-insecure-requests, which rewrites ws://127.0.0.1 to wss:// -- loopback included -- leaving the sidecar with a TLS ClientHello and both ends silent but for close code 1015. Verified live against Zen: all five tools end to end, plus a sidecar started mid-session connecting on its own. Chrome builds and shares every module but is not yet driven end to end; tab_read against a genuinely discarded tab is likewise unexercised. bun run check green: 256 tests, typecheck clean on both projects, oxlint 0/0, web-ext lint 0 errors.
Drove the definition of done against Chrome 150, covering the two cases the Zen run could not reach: - With Zen and Chrome connected at once (12 tabs across both), a tab-scoped call that names no browser is refused with ambiguous-target instead of guessing between them. - tab_read on a genuinely discarded tab returns a clean tab-discarded. Verifying the second turned up a Chrome behaviour worth guarding: Chrome gives a discarded tab a brand new id (tabs.discard(766110265) hands back 766110267), while Firefox keeps it. Triage is list-then-act, so an agent holding a listing taken before a memory-pressure unload will hit ids that no longer resolve even though every tab is still there -- and a bare "No tab with id N" reads as "it was closed". Both no-such-id errors now carry a hint to re-list. No behaviour change beyond the two error strings.
Quality pass over the bridge v1 diff. No new behaviour, but four idle costs and one already-diverged duplicate are worth calling out. Efficiency: - The 30s redial alarm was armed unconditionally in start(), before the isConfigured check, and never cleared. Every install paid a periodic wake forever; on Chrome MV3 each one cold-starts the service worker and re-runs init -- a full tabs.query plus dedup pass -- roughly 2,880 times a day to rediscover bridgeEnabled: false. Now created only while the bridge is on, and cleared when it is off. - The badge repainted on every phase transition. With the bridge on and no sidecar, idle -> connecting -> idle every 30s meant two full query+dedup passes a minute to draw identical pixels. Only an actual connect/disconnect gets through now, which also drops the second name for bridge.status. - The options page polled the background every 2s, which on Chrome MV3 pins the service worker awake for as long as the page is open. The background pushes each transition instead. - tabs_close issued one tabs.get per id, and a triage run closes tabs by the hundred. One listing into a Map now, which also makes closing consistent with the listing the ids came from. Reuse: - clip-format.ts gains resolveClipRequest. The clipboard -> legacy-URI fallback was implemented in both the popup's Devour and the bridge's tab_clip, and the two copies had already diverged. - Shared toHex/randomHex, isBridgePort, OBSIDIAN_HANDOFF_GAP_MS, and HelloMessage. Gullet routes off isBridgeMethod rather than a second hand-maintained method list that a sixth method would silently miss. Dead code: pendingAuth (write-only), bridgeError, BridgeMethodMap, JsonRpcRequest, TaggedTabsResult, the usage() wrapper, and four casts the parsers did not need. background.ts's settings listener filtered storage keys by blocklist, importing UNDO_LOG_KEY only to ignore it; it now matches positively against defaults(), so a future non-setting key cannot silently start triggering reloads. getTabOrFail() owns the lookup, the not-found code, and STALE_ID_HINT together, so the documented "every no-such-id error" invariant is structural rather than remembered. Left for review: parseMessage casts rather than validates, so both consumers re-narrow the same fields under different policies; and clipTab's wake: false skips the load-completion wait as well as the reload, so tab_read can extract a half-built DOM. bun run check green: 256 tests, typecheck clean on both projects, oxlint 0/0, web-ext lint 0 errors.
Addresses a review pass over the agent bridge. - Regenerating the bridge token now tears down the live socket. The socket pins the token it authenticated with and `sync()` compares against it, so a sidecar holding a revoked token cannot keep serving read/clip/close. - `undo_close` preserves privacy context. Closed entries record `incognito`, and a recorded window id is trusted only when a live window with that id shares the tab's context — ids restart after a browser restart while the log persists. A private tab reopens in a private window or stays failed; it is never dropped into a normal window where its URL would enter history/sync. - Batches restore in ascending index order within each window, so a low-index insert no longer shifts a tab already placed. - Entries that fail to reopen stay in the log under the same batch id (`retainEntries`) instead of the whole batch being dropped, so `undo_close` can be retried. The log is re-read before the write so a close recorded during a slow undo is not clobbered. - `tabs_close` deduplicates tab ids. On Chrome a repeated id made `tabs.remove` reject the whole call after closing the tab. - `tab_clip` is annotated `destructiveHint: true`: `close: true` ends in `tabs.remove`, and MCP annotations are per tool, not per call. Found while verifying: Chrome leaves `tab.url` empty until a navigation commits, so a tab closed mid-load was recorded with no URL at all and could not be undone. Both `tabs_list` and the undo log now fall back to `pendingUrl`. Verified: bun run check (261 tests, clean lint/format/typecheck), plus a scripted live run of the real hub against Chrome 150 over CDP — 17 checks covering every item above, six of which fail against the pre-fix build.
Ran the same live suite against Zen 1.21.9b over Marionette that the previous commit ran against Chrome 150 over CDP: 15 checks, all passing. Against pre-fix code 11 of them fail, including the two P1s reproduced concretely — private URLs restored into a *normal* window, and a revoked token still being served on its open socket. The run corrected a claim made in the previous commit. Gecko does not fill `tab.url` in immediately: like Chrome it withholds the address until the navigation commits, but it reports `about:blank` and exposes the target nowhere, so a tab closed mid-load reopens blank. That is a limitation rather than a bug we can fix — nothing in the API carries the pending URL — and it is narrow, since triage acts on tabs that came from a listing. The `tabUrl` fallback stays Chrome-only; the comment and AGENTS.md now say so accurately. Also noted from the run: Zen mirrors its essential tabs into every new window, so closing "this window's tabs" is a larger batch there than it appears.
Quality pass over the bridge branch — no behavior changes. Reuse: `asRecord` existed three times across the extension and the sidecar with two different miss conventions, and the unknown-to- BridgeError conversion was written on both ends. Both now live in `bridge-protocol.ts`, the module both runtimes already share, alongside a new `toBridgeError`/`errorMessage`. `delay()` was byte-identical in `background.ts` and `bridge-methods.ts`; one copy now sits in `clip-format.ts` beside the gap constant it waits out. `tab_clip` used to derive the note path in parallel with the URL it sent, so `ObsidianClipRequest` carries `file` and `clipFilePath` goes private. Efficiency: `tabs.onRemoved`/`onCreated` fire once per tab and each kicked off a full `tabs.query` plus duplicate grouping. `tabs_close` removes a triage batch (~180 tabs) in one call and `undo_close` recreates it, so a single batch meant ~180 full recomputes to land on one badge number. Now trailing-edge coalesced at 250ms. Simplification: `readTab` returned a wrapper whose `tab` no caller read; `Phase` had a fourth state nothing branched on, which also pushed a duplicate status message to the options page; `parseMessage`'s eight-case fallthrough became a Set membership test; `BRIDGE_ALARM` was exported with no external importer; `undo-log.ts` used five declarations for two numbers; `options.ts` had a fourth verbatim copy of the debounce block. Also shared the gullet test connection fixtures, and noted two spots that can drift silently: `GULLET_VERSION` against `gullet/package.json`, and the options-page port bounds against `isBridgePort()`. Verified with `bun run check`: typecheck (extension + sidecar) clean, 261 tests pass, oxfmt clean, oxlint 0 errors. The 3 remaining web-ext warnings are pre-existing `innerHTML` notices in `src/clip-current.js`. No manifest or permission changes.
Naming: "Gullet" stays the internal name for the sidecar, but everything a user or an agent sees now says Tabglutton. The MCP server registers as `tabglutton`, so tools namespace under one product name, and the config snippet on the options page emits `mcpServers.tabglutton` with `TABGLUTTON_TOKEN`. `GULLET_TOKEN`/`GULLET_PORT` still work as aliases, so no existing config breaks; `TABGLUTTON_*` wins when both are set. Versioning: versions are major.minor.patch.build and Firefox accepts at most four parts, with the fourth reserved for signed test builds. But `sign-dev.ts` used the whole `package.json` version as its base, and `ebbb933 Release 0.1.2.1` had committed a four-part version — so the next signed build would have been `0.1.2.1.1`, which AMO rejects. It now slices to the release triple. It also counts the build number from `max(highest local tag, any fourth part in package.json)`. The counter previously came only from git tags, which are local and unpushed: losing them silently reset it to `.1` and would have re-issued a version AMO had already seen, violating its unique-and-increasing rule. Restored `package.json`/`manifest.json` to a three-part `0.1.3`, which is the invariant `sign-dev.ts` was always written to preserve (it restores both files on exit) and the only shape `commit-and-tag-version` can reason about, since semver has no fourth position. Next signed build is `0.1.3.1`. Documented the scheme in AGENTS.md so it does not drift again. Verified with `bun run check`: typecheck clean, 263 tests pass (2 new, covering both env spellings), oxfmt clean, oxlint 0 errors. Confirmed at runtime that the server reports `tabglutton` over MCP and that `TABGLUTTON_TOKEN` completes the handshake, and that `build:firefox` stamps 0.1.3 into the built manifest.
A port conflict — nearly always a second sidecar from another agent session — makes hub.listen() throw, and main() answered that with `return 1`. That exits before serveStdio, so the client never completes `initialize` and reports only "MCP startup failed: ... connection closed: initialize response", which names neither the cause nor the fix. The missing-token case five lines below already had this right: stay up, and let tool calls explain the problem so the agent can relay it. Both faults now travel one path, a `startupError` on ToolContext, replacing the single-purpose `tokenConfigured` flag. Verified by running two sidecars on one port: the second now completes initialize and answers tabs_list with the port-conflict explanation rather than dying.
The bridge was unavailable roughly a third of the time, and every tool call that landed in a gap answered "no browser is connected" — which read as a misconfiguration and sent debugging down the wrong path entirely. The background page is an event page. Gecko suspends it after extensions.background.idle.timeout (30s), and suspension destroys its WebSocket. WebSocket traffic is not activity: only WebExtension API calls reset that timer, so the bridge's own heartbeat could not prevent its own suspension. Nothing recovered the socket until the 30s alarm, so the connection sawtoothed. Measured on Zen 1.21.9b, idle: a drop every 20-60s with a ~30s hole after each. Two halves, because neither is sufficient alone. Extension: a keepalive that touches runtime.getPlatformInfo() every 20s to hold the idle timer off. It is earned by traffic rather than armed on connect — every served request extends a 5 minute window — because a browser nobody is talking to has no business being held awake. It is deliberately independent of the socket, since holding the page up across a reconnect is when it matters most; `disable()` now separates "bridge switched off" from "socket dropped" so only the former stops it. Sidecar: Hub.connectionsWithin() waits one reconnect period for a browser instead of answering no-connection instantly, so a call arriving before the alarm has fired becomes a slow first call rather than a failure. Released only on a passed handshake — an unauthenticated socket is not a browser we can serve — and on shutdown. Verified against a real Zen on a scratch profile, reading the sidecar's own connect/disconnect log: 5 drops in 4 minutes unarmed, zero drops in the 4 minutes after one tabs_list, and the connection released after 5m30s (5 minute linger plus one suspension boundary) with churn resuming at baseline — so it holds while an agent works and lets go afterwards.
Two independent faults in the same file, both found while chasing the bridge churn. save() persists the whole settings object from DOM state and nothing stopped it running before load() had populated those fields. The change listener on the bridge toggle calls it directly, so flipping an unrelated switch during that window would write an empty bridgeToken over a real one — revoking the sidecar's access as a side effect. Guarded on a `loaded` flag, and the token is now omitted from the write when blank: the field is readonly and Generate is its only writer, so empty means "not populated", never "the user cleared it". refreshBridgeStatus() raced load(), and its background-asleep fallback read an unpopulated checkbox and reported "Off" for a bridge that was connected. Now sequenced after load(), and the fallback mirrors BridgeClient.isConfigured() — enabled *and* holding a token — rather than guessing from the toggle alone. This is not a rare path: the background page is suspended most of the time it is not in use.
Both harnesses are configured project-locally rather than globally, since this repo is where the bridge gets exercised: .mcp.json for Claude Code, .codex/config.toml for Codex, which reads it with no flag. Neither carries the token. Both spawn through a shell that reads it from the gitignored .env at launch, so the config files are safe to commit and the credential stays in one place. .env.sample documents where to get it.
Each callback removes itself from the set, which Set iteration already handles, so the defensive spread only tripped oxlint. Back to zero warnings.
background.ts calls bridge.start() at module top level, so it runs on every event-page restart — and syncAlarm() called alarms.create() unconditionally. create() clears and replaces a same-named alarm, which restarts its countdown, so each wake pushed the next fire out by another 30s. A browser generating tab events faster than the period (713 open tabs will do it) could keep the alarm from ever firing, starving the reconnect path that is meant to be the guaranteed one. Observed as a first tool call still answering no-connection after the sidecar's full 35s wait, with the bridge connecting fine on a retry. Now the alarm is only created when one is not already scheduled.
Three related changes to the agent bridge, all driven by live testing on Zen 1.21.9b against a real ~975-tab session. tabs_load: wake unloaded tabs so tab_read can reach them. Most tabs in a large backlog are discarded, and until now the only remedy was for the user to click each one. Batched (<=20, three at a time) rather than the per-tab shape BRIDGE.md sketched, because loading is dominated by the network wait and a triage run has tens of survivors; each tab comes back ready/pending/failed. Its budget sits under BRIDGE_REQUEST_TIMEOUT_MS on purpose: a batch that overran would reach the agent as a bare timeout even though most of its tabs had loaded. It is the bridge's first tool that acts on a page rather than reading one, so it ships behind its own setting (bridgeAllowTabLoad, default off) and answers not-enabled until the user turns it on. Verified end to end: two discarded tabs loaded, neither stole focus, both extracted through Defuddle. Reconnect: four defects, all found by reading the path after a bridge call answered "no browser is connected" for over a minute. - The keepalive armed on the first served request, not on connect, so a socket that came up and sat idle was suspended out from under itself before any call arrived. A live socket already proves a session is open, since Gullet exits with its agent harness. - Its deadline was derived at connect and only checked after the phase closed, so a long-idle connection reached its drop with a deadline already past and stopped keeping the page awake exactly when the redial needed it. Now renewed while open, so it measures from the drop. - bridge.start() ran after probeHeuristic() and refreshBadge() — three tabs.query calls and a dedup pass over every tab, re-paid on every event-page wake, all ahead of the dial. - The alarm could fire while init was still awaiting loadSettings(), read bridgeEnabled from the defaults, and tear down instead of dialling. The dial and the handshake now have separate deadlines. Sharing the handshake's 5s aborted every attempt before it could land: Gecko delays repeated failed WebSocket connections to an endpoint that keeps refusing, which is exactly what an idle reconnect loop looks like, and each abort was itself another failed connect. Verified with a healthy sidecar (curl got 101 plus the challenge frame) sitting through eight alarm periods while the extension dialled and timed out every cycle. Hub mode: the sidecar no longer assumes it owns the browser. Whichever Gullet binds the port serves it; later ones attach as peers over the same socket and proxy their MCP calls through, so several agent sessions share one browser connection. When the hub exits its peers re-race, and binding is the election, so the OS settles it atomically. The old design read a bind failure as "another session has it" and told the user to close that session — but nothing guarantees one Gullet per session: a single codex process was observed spawning two eight seconds apart, with its MCP client bound to the loser. No retry rate fixes that, because the winner is the loser's own sibling. Peers reuse the browser handshake and are told apart by an optional role on the hello; they are held in their own map, so a peer can never be offered to an agent as a browser. The extension half of hub mode is type-only, so a signed build predating it is unaffected. Verification: bun run check (284 tests). tabs_load verified live on Gecko; the reconnect fixes and hub mode are not yet verified against two real agent sessions and a real browser.
The 25s dial deadline from the previous commit wedged the bridge completely: Gecko applies `network.websocket.delay-failed-reconnects` *before* issuing the TCP connect, so any deadline shorter than the current delay aborts every attempt before it can land — and each abort is itself another failed connect, inflating the delay it keeps losing to. Verified live on Zen against a sidecar answering `curl` in 0.47ms with a 101, dialling and timing out at a flat 25s forever. Two changes, both aimed at the same feedback loop: - BRIDGE_DIAL_TIMEOUT_MS 25s -> 120s, so it bounds only a socket that neither opens nor errors rather than racing the browser's backoff. - The fast-retry burst is armed only after losing an *established* connection, never after a dial that failed to land. Retrying into a port that has never answered is what manufactured the delay. Verified on Zen 1.21.9b: 0.1.3.6 connected in ~3s through an already accumulated delay, with no browser restart to clear it. tabs_list, tabs_load (2 discarded tabs -> 2 ready), and tab_read all succeeded, with a second sidecar attached as a peer throughout. bun run check: 284 pass, 0 fail.
Keeps Gecko's reconnect penalty at zero instead of merely survivable. BridgeClient now asks `http://127.0.0.1:<port>/` a plain GET on each alarm tick and opens a socket only if something answers — Gullet's 403 for a non-upgrade request counts, since the question is "is a server there", not "is it well". Only failed *WebSocket* connects feed FailDelayManager, so an HTTP probe costs nothing. Previously an enabled bridge with no sidecar reached the 60s ceiling after ~7 idle minutes and stayed there, and because the delay is measured from the last failure while we re-dial every 30s, the first connect of a session landed anywhere in 0-60s. That is the "stuck on Connecting..." symptom, produced with every part of the bridge healthy. Now bounded by the alarm period instead. The probe is never a gate: after PROBE_MISSES_BEFORE_DIALLING_BLIND it dials anyway, so a fetch blocked by some future local-network rule degrades to the old behaviour rather than to a bridge that never dials. A deliberate settings change still dials immediately, unprobed. Also corrects a claim I put in the previous commit's comments. Reading netwerk/protocol/websocket/WebSocketChannel.cpp: the backoff grows x1.5 from 200-400ms and is capped at 60s (kWSReconnectMaxDelay), which is where the otherwise arbitrary-looking 120s dial timeout gets its 2x headroom. Aborting a socket before it connects is explicitly excluded from the backoff (NS_ERROR_NOT_CONNECTED), so "each abort is itself another failed connect" was wrong. The real accumulator was the 8-retries-per-wake burst against an empty port, which is what the previous commit gated — right fix, wrong stated reason. bun run check: 284 pass, 0 fail. Both targets build.
Review of #11 found two things the hub/peer design made reachable that were only latent before it. recordClosed was a bare read-modify-write over storage.local, and bridge requests genuinely run concurrently: bridge-client.ts dispatches each frame as its own void this.onMessage(...), and electing a hub exists precisely so several agent sessions drive one browser. Two tabs_close calls interleaving there both read the same log, both appended their batch, and the second write dropped the first — those tabs closed with no batch left for undo_close to find, which is the one guarantee the whole close path is built on. Every undo-log access now goes through a queue (src/serialize.ts); undo_close holds it across its restores rather than just its critical sections, which also makes a double undo of one batch safe, since the second caller re-reads inside the lock. The Obsidian handoff queue was already this shape by hand and now shares the primitive. serveStdio awaited each dispatch before reading the next line, freezing the session — ping and notifications/cancelled included — for the length of every call. The probe in ebfa44c takes most of the sting out of it by bounding the connect wait under BRIDGE_CONNECT_WAIT_MS, but parallel tool calls still queued and a cancellation could not arrive during the only work it could cancel. Requests now dispatch concurrently and only the writes are serialized. Deliberately not relying on Writable queueing chunks in call order: that is probably true, but it is Bun over a pipe, a tabs_list frame for a few hundred tabs is far past PIPE_BUF, and this area has already cost days on assumed platform behaviour. write() there returns false for backpressure while still completing, so the callback is the only honest signal. McpTransport is injectable so the concurrency is testable. Also from the review: - tabs_close pairs each tab with its undo entry before removing anything. A tab with no committed URL is left open and reported as skipped rather than closed off the end of the log — same class as the tabUrl bug, and it was closing tabs it counted as recorded. Ids that no longer resolve come back as missing; closed now always equals entries.length. tab_clip({ close: true }) holds the same invariant. - tabs_list settles per browser instead of Promise.all, so one bad connection no longer discards the listing another already returned. All of them failing is still an error, not an empty tab list. - The hub reaps a socket that opens and never proves the token. - A handler that throws answers the id instead of leaving the client waiting on it forever. - Trailing --port/--token are rejected rather than silently defaulting. - peer.ts declares Bun's WebSocket headers option instead of casting the call site through unknown. - The options page masks the access token behind a Show toggle. bun run check: 308 pass, 0 fail, oxlint 0/0, web-ext lint 0 errors. Both targets build.
Codex reviewed the branch and found ten things; nine needed fixing. The undo-log clobber it flagged P1 had already landed in 1ad6fff. elect() looped while (!this.stopped) and never threw, so the await in Supervisor.start() never returned when the port was held by something that would not authenticate — another service, or a Gullet carrying a different token. main awaits start() before serveStdio, so the MCP server never answered initialize, and the startupError machinery written for exactly this case was unreachable. That silently undid 4364f39 when the Supervisor arrived. start() now bounds the *wait*, not the election: it races elect() against ELECTION_START_TIMEOUT_MS and throws, while the election carries on underneath with a gap that backs off to 5s. The reason is published on Supervisor.fault() rather than only thrown, and ToolContext.startupError became a function so every tool call re-reads it — otherwise a port that frees up mid-session would go on being refused against a snapshot taken at startup. startTimeoutMs is injectable so the give-up path is testable. tabs.remove(ids) is not all-or-nothing. Chrome removes in order and rejects the whole call at the first id that no longer resolves, leaving the tabs ahead of it closed and the ones behind it open — AGENTS.md already recorded this for a duplicate id, and a stale id takes the identical path without any duplicate involved, which is the common case since Chrome mints a new id on every discard. The tool returned only an error while tabs were gone, and the batch — written first, correctly — described tabs that were still open. removeTabs now treats a rejection as a demotion rather than an answer: it retries each id alone and then asks the browser which ids still exist. Absence is the signal, not the retry's own result; a tab the batch call already took rejects the retry too, and reading that as "still open" would drop its undo entry, which is the one close undo_close could never reverse. tabs_close builds both the report and the batch from what actually happened — refused tabs join skipped, reconcileBatch narrows the log, closed still equals entries.length, and closing nothing fails instead of handing back a batchId for an empty batch. The mirror image in tab_clip({ close: true }): a rejected remove left the batch in storage while the result said closed: false, and an id-less undo_close takes the newest batch, so that orphan was precisely what the next undo would reopen. It now checks whether the tab survived before dropping the batch or reporting the close. Also from the review: - Settings carries bridgeToken, and the ready line logged it on every wake of the event page. That token grants read, clip, and close over every tab, and this console output has been pasted into agent sessions throughout the bridge debugging. It goes through loggableSettings now; any token from a build before this should be regenerated. - sync() forced an unprobed dial on any settings change, but background .ts calls it for any key — editing dedup scope with no sidecar running rebuilt exactly the Gecko reconnect penalty ebfa44c added the probe to prevent. It forces only when bridgeEnabled, bridgePort, or bridgeToken actually differ from the last-seen values. - minimum_chrome_version 116 -> 120. Chrome clamps extension alarms to a one-minute minimum below 120 (kMV2ReleaseDelayMinimum vs the MV3 30s), so a sleeping worker would be woken after the 35s BRIDGE_CONNECT_WAIT_MS had already answered "no browser is connected". Unpacked builds never reproduce it — their floor is 1s. - A fallback restore into a new window is seeded with the entry's URL, since windows.create always brings a tab of its own and undo was leaving a blank one behind every time the original window was gone. - That same fallback dropped pinned, silently restoring a pinned tab as an ordinary one. - parseInt kept the digits it managed to read, so --port 4588oops and TABGLUTTON_PORT=4588.5 both bound 4588 — a port the user never named, while every browser dialling the one they did name is refused. bun run check: 311 pass, 0 fail, oxlint 0/0, web-ext lint 0 errors. Both targets build.
The first tabs_list of a session fails with `timeout` at the full 45s BRIDGE_REQUEST_TIMEOUT_MS and an immediate retry of the same call succeeds. Observed on Zen 1.21.9b at 730 tabs in one window with extension 0.1.3.7, and recurring rather than a one-off. Writing it down rather than guessing at a fix, because the obvious read is wrong: this is not a reconnect fault. The connection is provably healthy at the time — a tab_read on the same connectionId answers instantly and correctly — and exactly one browser is registered, since selectOne would otherwise have reported ambiguous-target. tabsList is also unchanged across the whole fix series. That leaves two hypotheses the observation does not separate: the background page being single-threaded and still inside probeHeuristic / refreshBadge over the full tab set, or the ~253 KB response frame itself (tab_read, which worked at the same moment, is a fraction of the size). BRIDGE.md carries the brief and the discriminating test; AGENTS.md carries a pointer, mainly so the next reader does not spend the time to re-derive that the bridge is connected. Verified live this session and unrelated to the above: election fault now surfaces through MCP in 4s instead of hanging initialize, stale ids in a close batch report as `missing` while the good tabs still close, and an undone close restores pinned state at its original index.
The doc still opened "Architecture doc for the planned agent interface" long after the bridge shipped, which invites reading its contents as proposals — actively wrong for Trust boundary, which is enforced in code and depended on. Says so now, and points at gullet/README.md for running it and gullet/src/tools.ts for exact signatures, the latter being authoritative because it is executable. Phasing 53 -> 25 lines. It was carrying two different things: a record of what each phase proved, which is worth keeping, and a set of still-open unknowns buried inside prose about finished work, which is where they go to be missed. The unknowns moved to Open questions — Chrome tabs_load id churn with the reasoning behind STALE_ID_HINT intact, and the remaining v1.1 items. What shipped when is git's job and is no longer restated. Net 370 -> 357 lines: the section shrank by more than the file did, because most of what came out was relocated rather than dropped.
Session-start connects lost a race between two ~30s timers: discovery was strictly alarm-cadenced while BRIDGE_CONNECT_WAIT_MS was 35s, and alarm jitter plus init() at ~1000 tabs ate the margin. Now: - BridgeClient re-probes the port every 3s while the page is awake (IDLE_PROBE_MS); the 30s alarm is demoted to the suspension backstop. Idle-loop misses never count toward dialling blind — only ticks from outside the loop do — so the escape valve cannot rebuild the reconnect penalty the probe exists to avoid. A durable (storage.session) miss counter was tried and reverted the same session; the revert story is recorded in AGENTS.md and BRIDGE.md. - BRIDGE_CONNECT_WAIT_MS is 45s: one full alarm period plus real slop, so even the backstop path fits inside the first call. - Peers inherit the hub's connect wait (hub.ts) and their outer RPC deadline sits PEER_RPC_SLACK_MS above the hub's inner budget, so a hub still legitimately waiting can never read as a dead one. - Client deadlines are documented: a first call can hold ~90s, most MCP clients default to 60s; gullet/README.md explains MCP_TOOL_TIMEOUT and tool_timeout_sec, and .codex/config.toml sets the latter to 120. Live testing exonerated this path and caught the real residual failure: after 4h idle the probe found a fresh hub in 180ms, then the dial itself hung for 2x120s with no SYN on the wire, in a ~1050-tab Zen whose own Push service was failing with NS_ERROR_SOCKET_CREATE_FAILED; a browser restart cleared it and connects became instant. The reproduction and the diagnostic tells are recorded in BRIDGE.md, AGENTS.md, and the gullet README's troubleshooting section. Verified: bun run check (typecheck, format:check, oxlint, web-ext lint, 311 tests) plus live sessions against signed builds 0.1.3.8/0.1.3.9.
Cleanup from a four-angle review (reuse / simplification / efficiency / altitude) of the branch diff. No behavior changes on the live-validated reconnect timing; bridge-client.ts is touched only to share the memoized getBrowserInfo lookup. - Reuse: gullet's stdout write chain is the shared createTaskQueue from src/serialize.ts (mcp.test.ts's ordering tests cover it now); delay() lives in serialize.ts and serves both halves; four identical tabs.get try/catch wrappers collapse into tryGetTab; the stale-id hint has one owner (missingTabReason) so tabs_load's per-tab errors cannot drift. - Dead code: the hub's unsupplied onConnectionsChanged option, the single-field Peer wrapper, sendPeer (byte-identical to send), an unreachable disjunct in selectOne, a no-op cast in config parsing, a clipFilePath default no caller could reach, and peer.ts's stale BunWebSocket cast workaround (gullet has no DOM lib to conflict with). - Altitude: BridgeBackend.connections() drops the timeout knob only the hub role honoured — the wait lives in the Supervisor (test override via connectWaitMs), so the roles cannot diverge. Peer stop() rejects in-flight calls through the shared rejectPending instead of stranding them, without touching onLost/re-election semantics. - Efficiency: removeTabs asks the browser only about ids whose retry rejected (one saved tabs.get IPC per plainly-closed tab on Chrome's common demotion path, provably the same result); the badge no longer regroups ~1000 tabs on bridge-only settings writes (value-compared, since Firefox reports unchanged keys); getBrowserInfo is memoized across its two same-wake callers; SETTING_KEYS is hoisted out of the storage listener; probeHeuristic's two tab queries run concurrently. - Two misplaced docblocks moved onto the functions they describe; the "Settled, not all" comment reworded to match the Promise.all it sits on. Deferred as follow-ups (medium refactors of live-only-tested paths): unifying tabsClose/tabClip's close transaction, a shared PendingCalls helper for hub/peer, shared handshake-crypto helpers. Verified: bun run check (typecheck, format:check, oxlint, web-ext lint, 311 tests).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements Phase 1 of
BRIDGE.md: a coding agent (Claude Code, Codex, any MCPclient) can see and manage the user's open tabs through Tabglutton.
What a user sees
Nothing, unless they opt in. A new Agent bridge section in the options page
holds an on/off toggle, a port, and a generated access token. With it off, no
socket is ever opened. With it on, the toolbar badge shows a terracotta dot
while an agent session is connected.
There is no app to launch. The agent harness starts the sidecar as an ordinary
stdio MCP server; the extension's alarm-driven loop finds it within 30s and
completes a handshake. Session ends, sidecar exits, extension goes back to idle
dialing.
Trust boundary
Deliberately read, file, and close only — no navigation, clicking, typing,
or arbitrary script execution. Five tools:
tabs_list,tab_read,tab_clip,tabs_close,undo_close. Every close is recorded before it happens, soundo_closecan put a batch back.Shape
gullet/— zero-dependency sidecar. MCP over stdio on one side, a loopbackWebSocket hub on the other. Sibling package, never bundled into
dist-*.src/bridge-protocol.ts— the wire contract, imported by both halves soextension and sidecar typecheck against one definition.
src/bridge-client.ts— socket lifecycle, handshake, heartbeat, reconnect.src/bridge-methods.ts— the five tools overbrowser.*.src/undo-log.ts— the close/undo trail, capped by batch and entry count.Auth is challenge/response over SHA-256, so the token never crosses the wire,
plus an extension-origin gate on the upgrade — that check is what stops a
hostile page from opening the socket from inside the browser. Tab-scoped calls
refuse to guess when two browsers are connected, returning
ambiguous-targetrather than acting on the wrong one.
Manifest / permission changes
alarms(reconnect wake). Nothing else — the WebSocketneeds no permission, and
*://*/*already covered the clipper.sessionswas evaluated and rejected. Matching a recently-closed sessionto a log entry is only possible by URL, which is ambiguous with duplicate tabs
— the exact case this extension exists for. Recreating from the log via
tabs.createis deterministic and restores pin state and index.content_security_policy.extension_pagesis now declared explicitly. Thisone is load-bearing: Firefox's default MV3 CSP includes
upgrade-insecure-requests, which rewritesws://127.0.0.1towss://,loopback included. The sidecar then receives a TLS ClientHello, so its
fetchhandler never runs — the connection fails invisibly from both ends, with
close code 1015 as the only symptom. Documented in
AGENTS.mdandgullet/README.mdso the next person loses minutes instead of hours.Verification
bun run checkgreen — 256 tests, typecheck clean on both projects (onTypeScript 7), oxlint 0/0,
web-ext lint0 errors plus the 3 pre-existingDefuddle
innerHTMLwarnings. Both targets build.Unit tests cover protocol, auth, config, target selection, MCP framing, and the
undo log;
gullet/tests/hub.test.tsstands up a real loopback socket for thehandshake and request routing.
The browser-API surface can't be unit tested, so all five tools were driven end
to end against live Zen and live Chrome 150, on TypeScript 7 and Defuddle
0.19. Also verified live:
call naming no
browseris refused rather than guessing.extension reload — the alarm fires on a true 30s cadence on Firefox.
tab_readon a genuinely discarded tab returns a cleantab-discarded.Known gaps
chrome.tabs.discard()can manufacture the fixture over CDP. The guard is oneshared, target-agnostic line, but the Firefox path is unproven — and it is the
one that matters most, since Zen restores tabs lazily and a large session is
full of discarded tabs from the moment it opens.
tabs.discard(766110265)returns766110267); Firefox keeps it. Since triage is list-then-act, a stale listingcan point at ids that no longer resolve even though the tabs are all still
there. Both "no such tab id" errors now tell the agent to re-list rather than
letting it read as "the tab was closed." Not a full fix — a real one would
need stable identity across a discard.
Note on the options UI
The options page gained a visible section, so per the repo guidelines this
wants a screenshot;
ghcan't attach images from the CLI, so I've left that forthe browser.