Skip to content

Agent bridge: Gullet sidecar and five read/file/close tools - #11

Merged
mlsimon734 merged 23 commits into
mainfrom
bridge-v1
Jul 29, 2026
Merged

Agent bridge: Gullet sidecar and five read/file/close tools#11
mlsimon734 merged 23 commits into
mainfrom
bridge-v1

Conversation

@mlsimon734

Copy link
Copy Markdown
Owner

Implements Phase 1 of BRIDGE.md: a coding agent (Claude Code, Codex, any MCP
client) 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, so
undo_close can put a batch back.

Shape

  • gullet/ — zero-dependency sidecar. MCP over stdio on one side, a loopback
    WebSocket hub on the other. Sibling package, never bundled into dist-*.
  • src/bridge-protocol.ts — the wire contract, imported by both halves so
    extension and sidecar typecheck against one definition.
  • src/bridge-client.ts — socket lifecycle, handshake, heartbeat, reconnect.
  • src/bridge-methods.ts — the five tools over browser.*.
  • 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-target
rather than acting on the wrong one.

Manifest / permission changes

  • New permission: alarms (reconnect wake). Nothing else — the WebSocket
    needs no permission, and *://*/* already covered the clipper.
  • sessions was evaluated and rejected. Matching a recently-closed session
    to 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.create is deterministic and restores pin state and index.
  • content_security_policy.extension_pages is now declared explicitly. This
    one is load-bearing: Firefox's default MV3 CSP includes
    upgrade-insecure-requests, which rewrites ws://127.0.0.1 to wss://,
    loopback included. The sidecar then receives a TLS ClientHello, so its fetch
    handler never runs — the connection fails invisibly from both ends, with
    close code 1015 as the only symptom. Documented in AGENTS.md and
    gullet/README.md so the next person loses minutes instead of hours.

Verification

bun run check green — 256 tests, typecheck clean on both projects (on
TypeScript 7), oxlint 0/0, web-ext lint 0 errors plus the 3 pre-existing
Defuddle innerHTML warnings. Both targets build.

Unit tests cover protocol, auth, config, target selection, MCP framing, and the
undo log; gullet/tests/hub.test.ts stands up a real loopback socket for the
handshake 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:

  • With both browsers connected at once (14 tabs across the two), a tab-scoped
    call naming no browser is refused rather than guessing.
  • A sidecar started mid-session is picked up by the idle reconnect loop with no
    extension reload — the alarm fires on a true 30s cadence on Firefox.
  • tab_read on a genuinely discarded tab returns a clean tab-discarded.

Known gaps

  • The discarded-tab path was exercised on Chrome only, where
    chrome.tabs.discard() can manufacture the fixture over CDP. The guard is one
    shared, 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.
  • Chrome assigns a discarded tab a new id (tabs.discard(766110265) returns
    766110267); Firefox keeps it. Since triage is list-then-act, a stale listing
    can 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; gh can't attach images from the CLI, so I've left that for
the browser.

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).
@mlsimon734
mlsimon734 merged commit 43e3a71 into main Jul 29, 2026
1 check passed
@mlsimon734
mlsimon734 deleted the bridge-v1 branch July 29, 2026 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant