Skip to content

feat(tabs): fall back to port 49228, and prove the bridge is ours - #35

Merged
colevels merged 1 commit into
mainfrom
feat/bridge-port-fallback
Jul 22, 2026
Merged

feat(tabs): fall back to port 49228, and prove the bridge is ours#35
colevels merged 1 commit into
mainfrom
feat/bridge-port-fallback

Conversation

@colevels

Copy link
Copy Markdown
Owner

Consumer half of colevels/tabbrew#99. Merge that one first — this repo tells
users to click a button and leave a screen open that ships over there.

Port fallback

config.serve.port becomes config.serve.ports = [49227, 49228]. tabs serve
binds the first free one; tabs suggest connects to the first that answers.
Neither end has to be told which is in use.

Still no --port flag: the extension lists exactly these two in both manifests'
optional_host_permissions, so a bridge anywhere else is invisible to Chrome no
matter what is listening. The list is a cross-repo contract — changing it means
changing this array, both manifests, and the extension's BRIDGE_PORTS
together. TABBREW_SERVE_PORT now pins a single port, for tests.

Identity, not just reachability (src/bridge.ts)

This is the part that makes a second port safe. With one fixed port, guessing
wrong was survivable. With a fallback, a foreign JSON service squatting on 49228
would be treated as the bridge — and tabs suggest would hand it a script
describing the user's tabs.

GET /health now carries service: "tabbrew-bridge". Never rename or drop
that field.
Bridges already in the wild predate it, so looksLikeBridge also
accepts ok: true plus a numeric protocol/tabsVersion — a shape a generic
{"ok":true} health endpoint does not have. The extension implements the same
predicate; if the two drift, one end adopts a service the other rejects.

Probing is sequential in preference order at both ends, with a 1.5s timeout —
with two bridges up, "the lowest port" is stable where "first to reply" is a
race that could point the CLI at one bridge while Chrome talks to the other.

A busy port is diagnosed, not just skipped

On EADDRINUSE, serve asks who holds it:

Holder Behaviour
A stranger step to 49228, and say why it moved
Another TabBrew bridge refuse to start

Starting a second bridge would be a silent dead end: Chrome takes the lowest
port that answers, so the new one would receive nothing while the user waited
for tabs that never arrived. The refusal is also what keeps the single
outPath state file to one writer — two bridges would otherwise clobber each
other's tabs.json.

Wording

Every instruction that said click Send to Claude Code and switch Auto
mode
on
would now send users hunting for a toggle that no longer exists. All
of them become click Connect to TabBrew CLI and leave that screen open:
tabs-list (3 strings), tabs-serve next-steps, awareness.ts,
tabbrew-script/SKILL.md, README.md, CLAUDE.md, and the
TABBREW_SERVE_PORT help line.

Verification

  • bun run typecheck, bun test (20/20), bun run build all clean.
  • Peer refusal tested live. A real bridge from an older build was running on
    49227 — one without the service marker — and the new serve identified it
    correctly through the legacy shape path, which is exactly the backward-compat
    case:

    ✗ A TabBrew bridge is already running on 127.0.0.1:49227 — use that one, or
    stop it first.

  • Identity and discovery tested in isolation, 6/6: {ok:true} squatter and
    a grafana-ish body both rejected; marker bridge and legacy bridge both
    accepted; dead port rejected; and discoverBridge skips a squatter on the low
    port to find the real bridge on the high one.
  • Not exercised live: the stranger→fallback path, because 49227 was held by
    a real bridge for the duration. The peer path and the predicate that drives
    both are covered.

🤖 Generated with Claude Code

`tabs serve` now takes the first free port of 49227/49228 and `tabs suggest`
finds whichever is answering, so neither end has to be told which one is in use.

A second port is only safe with an identity check. With one fixed port, guessing
wrong was survivable — the request failed and the user was told nothing was
listening. With a fallback, a foreign JSON service squatting on 49228 would
otherwise be treated as the bridge, and `tabs suggest` would hand it a script
describing the user's tabs. So `GET /health` now returns
`service: "tabbrew-bridge"`, and `src/bridge.ts` skips anything that answers
without proving what it is. Bridges already in the wild predate the marker,
hence the fallback shape test (`ok: true` plus a numeric protocol/tabsVersion).
The extension implements the identical predicate; the two must not drift.

A busy port is diagnosed rather than merely skipped. On EADDRINUSE, `serve`
probes who holds it: a stranger means step to the fallback, but another TabBrew
bridge means refuse to start. A second bridge would be a silent dead end —
Chrome takes the lowest port that answers, so the new one would sit there
receiving nothing while the user waited. That refusal is also what keeps the
single `outPath` state file to one writer.

Wording follows the extension: the sidepanel card is "Connect to TabBrew CLI"
and there is no Auto mode toggle any more — the screen itself is the switch.
Every instruction telling users to find that toggle would now send them looking
for something that doesn't exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@colevels
colevels merged commit e25def8 into main Jul 22, 2026
1 check passed
@colevels
colevels deleted the feat/bridge-port-fallback branch July 22, 2026 21:12
@colevels colevels mentioned this pull request Jul 22, 2026
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