Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,11 +271,25 @@ only because Bun kills a 10s-idle request.
the real problem is that nothing was passed in. It sends `basedOn` (the tab-state version
it reasoned about) so the extension can warn before the user accepts a plan written
against tabs that have since moved.
- There is **no `--port` flag** on anything. The extension hard-codes 49227 in both
manifests' `optional_host_permissions`, so a bridge on any other port is unreachable from
the browser — an option that could only ever be wrong. `TABBREW_SERVE_PORT` survives for
tests, and `config.serve.port` is the single place it's resolved, so the listener and the
client cannot disagree.
- There is **no `--port` flag** on anything. The extension lists exactly 49227 and 49228 in
both manifests' `optional_host_permissions`, so a bridge on any other port is unreachable
from the browser — an option that could only ever be wrong. `config.serve.ports` is the
single place the list is resolved, so the listener (`tabs serve`) and the client
(`tabs suggest`) cannot disagree; `TABBREW_SERVE_PORT` pins one port, for tests.
- **Two ports means proving identity, not just reachability** (`src/bridge.ts`). `tabs
serve` takes the first free port; `tabs suggest` takes the first that *answers as a
bridge*. `GET /health` carries `service: "tabbrew-bridge"` for exactly this — never
rename or drop that field. Older bridges predate it, so `looksLikeBridge` also accepts
`ok: true` plus a numeric `protocol`/`tabsVersion`; the extension's `localServer.ts`
implements the identical predicate and the two must not drift, or one end will adopt a
service the other rejects. Probing is sequential in preference order at both ends — 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`, `tabs 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. This is also what keeps the single `outPath` state file to one writer.
- `tabs serve` is a **tolerant reader** of the tab payload — two extension surfaces POST
different shapes (raw `chrome.Tab` from the developer-mode panel, leaner `TabSnapshot`
from the side panel), so `StoredTab` types only the fields common to both and everything
Expand Down Expand Up @@ -424,7 +438,7 @@ hosted TabBrew server at `https://www.tabbrew.com`:
| `TABBREW_RELEASE_URL` | `github.com/$REPO/releases/latest` | Override the `update` latest-release redirect URL |
| `TABBREW_DOWNLOAD_BASE_URL` | `github.com/$REPO/releases/latest/download` | Override the `update` release-asset download base |
| `TABBREW_DOWNLOAD_TIMEOUT_MS` | `120000` | `update` binary-download timeout (separate from `TABBREW_TIMEOUT_MS`) |
| `TABBREW_SERVE_PORT` | `49227` | Loopback port for `tabs serve` (listens) and `tabs suggest` (connects). **A test override** — there is no `--port`, and the extension hard-codes 49227 |
| `TABBREW_SERVE_PORT` | `49227,49228` | Pins a single loopback port for `tabs serve` (listens) and `tabs suggest` (connects), instead of scanning both. **A test override** — there is no `--port`, and Chrome only reaches those two |
| `TABBREW_TABS_PATH` | `~/.config/tabbrew/tabs.json` | Where `tabs serve` saves the exported tabs + suggestion ring (read by `tabs list`) |
| `TABBREW_TOKEN` | *(unset)* | Use this token directly; **wins over the stored file** (for CI/CD) |
| `TABBREW_NO_BROWSER` | *(unset)* | Print URLs instead of launching a browser (`login`, `docs open`) |
Expand Down
29 changes: 21 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ the next read. The CLI is on one side of that loop only — **it cannot change a
### 1. Start the bridge

```bash
tabbrew tabs serve # 127.0.0.1:49227 — blocks until Ctrl+C
tabbrew tabs serve # 127.0.0.1:49227 (or :49228) — blocks until Ctrl+C
TABBREW_TABS_PATH=./tabs.json tabbrew tabs serve # put the state file elsewhere
```

Expand All @@ -261,9 +261,15 @@ just the writer, which left the other two silently reading a stale default; it's
the same reason `--port` is.

It blocks, so give it its own shell (or the background). Then, in Chrome, open the
TabBrew sidepanel, click **Send to Claude Code**, and switch **Auto mode** on — that is
what keeps sending tabs as they change. (Developer mode → Tab List → **Send to CLI**
exports too, but without the rendered snapshot — see below.)
TabBrew sidepanel and click **Connect to TabBrew CLI** — leave that screen open and it
keeps sending tabs as they change; navigate away and it stops. There is no toggle to
find. (Developer mode → Tab List → **Send to CLI** exports too, but without the rendered
snapshot — see below.)

If `49227` is already taken by something else, the bridge falls back to `49228` and says
so; Chrome checks both. If it's taken by *another TabBrew bridge*, it refuses to start a
second one instead — Chrome always uses the lowest port that answers, so the second would
never receive anything.

### 2. Read the tabs

Expand Down Expand Up @@ -413,10 +419,17 @@ routes a protocol-2 extension actually calls (`POST /tabs`, `GET /suggestion`,
`POST /decision`, `GET /health`) are unchanged; what protocol 3 dropped is the three long
polls only the CLI ever issued, and the legacy `/script` pair.

**There is no `--port` flag.** The extension hard-codes `49227` in both manifests'
`optional_host_permissions`, so a bridge listening anywhere else is unreachable from the
browser — a flag that could only ever produce a broken setup isn't worth having.
`TABBREW_SERVE_PORT` still moves it, for tests.
**There is no `--port` flag.** The extension lists exactly `49227` and `49228` in both
manifests' `optional_host_permissions`, so a bridge listening anywhere else is unreachable
from the browser — a flag that could only ever produce a broken setup isn't worth having.
`tabs serve` picks the first of the two that's free and `tabs suggest` finds whichever is
answering, so neither end has to be told. `TABBREW_SERVE_PORT` pins a single port, for
tests.

Both ends verify **identity**, not just reachability: `GET /health` returns
`service: "tabbrew-bridge"`, and a port that answers without proving it's a bridge is
skipped rather than adopted. That check is what makes a second port safe — otherwise any
JSON service squatting on `49228` would be handed a script describing your tabs.

### Security model

Expand Down
11 changes: 6 additions & 5 deletions src/awareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ TabBrew (OAuth 2.0 device flow) and running agent-facing tools. Use it from the
opens from the sidepanel Docs view ("send this to tabbrew", "ส่งเข้า tabbrew").
- The user wants their open Chrome tabs organized/closed/grouped, either as a
one-off ("จัดแท็บให้หน่อย", "close the duplicates") or as a standing watch
("auto mode", "เฝ้าแท็บให้หน่อย", a \`/loop\`). Both are the same three steps —
("เฝ้าแท็บให้หน่อย", "keep an eye on my tabs", a \`/loop\`). Both are the same three steps —
follow the installed \`tabbrew-tabs\` skill and see **Managing tabs** below.

## Commands
Expand All @@ -43,8 +43,8 @@ TabBrew (OAuth 2.0 device flow) and running agent-facing tools. Use it from the
suggestions. Prints the extension's own snapshot format (\`# Cross-window /
# Windows / # Groups / # Tabs\` JSONL) — that's the format to write ops against.
Check the age it reports before trusting the tab ids; it's a file on disk, and it
only refreshes while the sidepanel is open with Auto mode on. \`--json\` for the raw
payload.
only refreshes while the sidepanel sits on the **Connect to TabBrew CLI** screen.
\`--json\` for the raw payload.
- \`tabbrew tabs suggest <file|-> --note "…"\` — validate a TabBrew Script and put it in
front of the user. \`--note\` is **required**: one plain sentence, in the user's own
language, leading with anything destructive ("ปิดแท็บ YouTube 6 อัน แล้วรวม github เป็น
Expand All @@ -54,8 +54,9 @@ TabBrew (OAuth 2.0 device flow) and running agent-facing tools. Use it from the
## Managing tabs (generate a TabBrew Script)
The DSL has six verbs, one per line: \`DEL\` \`PIN\` \`UNPIN\` \`GROUP\` \`UNGROUP\` \`MOVE\`.
The tabs come from the bridge: \`tabbrew tabs serve\` is running, and the user has
clicked **Send to Claude Code** in the TabBrew sidepanel (with **Auto mode** on if
they want it to keep streaming).
clicked **Connect to TabBrew CLI** in the TabBrew sidepanel and left that screen
open — it streams tab changes for as long as it's showing, and stops when they
navigate away.

Then, per the installed \`tabbrew-tabs\` skill:
1. \`tabbrew tabs list\` — read the snapshot and the recent suggestions. If the newest
Expand Down
92 changes: 92 additions & 0 deletions src/bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Finding the bridge on 127.0.0.1, and being sure it *is* the bridge.
*
* `tabs serve` may bind more than one port (see `config.serve.ports`), so both
* the other end of this CLI (`tabs suggest`) and `serve` itself — deciding
* whether a busy port is a peer or a stranger — need one honest answer to "is
* there a TabBrew bridge here?".
*
* The identity check is the point. With a single fixed port, guessing wrong was
* survivable: the request failed and the user was told nothing was listening.
* With a fallback port, a foreign JSON service squatting on one of them would
* otherwise be treated as the bridge — and `tabs suggest` would hand it a
* script describing the user's tabs. The extension applies the same rule at its
* end (`looksLikeBridge` in the extension's `lib/localServer.ts`); the two must
* stay in agreement or one end will adopt a service the other rejects.
*/

/** Marker `GET /health` carries so a bridge is identifiable, not merely reachable. */
export const BRIDGE_SERVICE = "tabbrew-bridge";

/** A stranger holding the socket open must not hang the caller. */
const PROBE_TIMEOUT_MS = 1500;

export type BridgeHealth = {
/** Which port answered. */
port: number;
/** Wire protocol the bridge speaks; 0 if it is too old to say. */
protocol: number;
/** Tab-state version it currently holds; 0 before any export. */
tabsVersion: number;
/** Whether a suggestion is queued and unclaimed; null if it didn't say. */
hasPending: boolean | null;
};

/**
* Ours, or just *something* on the port?
*
* `service` is the unambiguous answer and every bridge from this version on
* sends it. Older bridges — a user who upgraded the extension but not the CLI —
* don't, hence the shape test: `ok: true` plus a numeric `protocol`/`tabsVersion`,
* which a generic `{"ok":true}` health endpoint does not carry.
*/
export function looksLikeBridge(body: unknown): boolean {
if (!body || typeof body !== "object") return false;
const b = body as Record<string, unknown>;
if (b.service === BRIDGE_SERVICE) return true;
return (
b.ok === true &&
(typeof b.protocol === "number" || typeof b.tabsVersion === "number")
);
}

/** One port, one answer. Null covers every flavour of "not a bridge here". */
export async function probeBridge(port: number): Promise<BridgeHealth | null> {
try {
const res = await fetch(`http://127.0.0.1:${port}/health`, {
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
if (!res.ok) return null;
const body = (await res.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!looksLikeBridge(body)) return null;
return {
port,
protocol: typeof body?.protocol === "number" ? body.protocol : 0,
tabsVersion: typeof body?.tabsVersion === "number" ? body.tabsVersion : 0,
hasPending: typeof body?.hasPending === "boolean" ? body.hasPending : null,
};
} catch {
return null;
}
}

/**
* First bridge in preference order, or null if none is up.
*
* Sequential on purpose, and the extension scans the same list the same way:
* with two bridges running, "the default port" is a stable answer where
* "whichever replied first" is a race that could point the CLI at one bridge
* while Chrome is talking to the other.
*/
export async function discoverBridge(
ports: readonly number[],
): Promise<BridgeHealth | null> {
for (const port of ports) {
const health = await probeBridge(port);
if (health) return health;
}
return null;
}
6 changes: 3 additions & 3 deletions src/commands/tabs-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export async function tabsList(opts: TabsListOptions): Promise<void> {
}
console.log(
c.dim("No tabs exported yet.") +
` Start the bridge with ${c.bold(`${BIN} tabs serve`)}, then click ${c.bold("Send to Claude Code")} in the TabBrew sidepanel.`,
` Start the bridge with ${c.bold(`${BIN} tabs serve`)}, then click ${c.bold("Connect to TabBrew CLI")} in the TabBrew sidepanel.`,
);
return;
}
Expand Down Expand Up @@ -110,7 +110,7 @@ export async function tabsList(opts: TabsListOptions): Promise<void> {
console.error(
c.yellow("! This snapshot is stale.") +
c.dim(
" The extension stopped sending — check that the TabBrew sidepanel is open and Auto mode is on.",
" The extension stopped sending — check the TabBrew sidepanel is still on the Connect to TabBrew CLI screen.",
),
);
}
Expand All @@ -137,7 +137,7 @@ export async function tabsList(opts: TabsListOptions): Promise<void> {
);
console.log(
c.dim(
` Open the TabBrew sidepanel and click ${c.bold("Send to Claude Code")} to get one, ` +
` Open the TabBrew sidepanel and click ${c.bold("Connect to TabBrew CLI")} to get one, ` +
`or read the raw payload with ${c.bold(`${BIN} tabs list --json`)}.`,
),
);
Expand Down
64 changes: 55 additions & 9 deletions src/commands/tabs-serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { mkdir } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { atomicWrite, readFileOrNull, removeFileIfExists } from "../fsops";
import { config } from "../config";
import { BRIDGE_SERVICE, probeBridge } from "../bridge";
import { BIN, c } from "../ui";

/** The port is already in use, or the server died on start. */
Expand Down Expand Up @@ -134,7 +135,6 @@ const isDecision = (v: unknown): v is DecisionKind =>
* only override.
*/
export async function tabsServe(): Promise<void> {
const port = config.serve.port;
const outPath = config.serve.outPath;

await mkdir(dirname(outPath), { recursive: true, mode: 0o700 });
Expand Down Expand Up @@ -366,9 +366,15 @@ export async function tabsServe(): Promise<void> {
// Cheap, non-destructive reachability check — the extension pings this to
// show a connected/disconnected status, separate from claiming a pending
// script. `ok` must stay, older extensions test it.
//
// `service` is what makes this route an *identity* check and not just a
// reachability one: now that both ends scan more than one port, whoever
// answers has to prove it's the bridge before it gets handed a script or
// a window's worth of tab URLs. Never rename or drop it.
if (req.method === "GET" && url.pathname === "/health") {
return json({
ok: true,
service: BRIDGE_SERVICE,
protocol: PROTOCOL,
tabsVersion: tabState?.version ?? 0,
hasPending: pending !== null,
Expand All @@ -380,12 +386,47 @@ export async function tabsServe(): Promise<void> {
}
}

let server: ReturnType<typeof Bun.serve>;
try {
server = Bun.serve({ hostname: "127.0.0.1", port, fetch: handleRequest });
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new ServeError(`Couldn't start the server on port ${port}: ${detail}`);
/**
* Bind the first free port, but ask *who* holds a busy one before moving on.
*
* A stranger on 49227 is exactly what the fallback exists for — step to the
* next port and carry on. Another TabBrew bridge is the opposite: starting a
* second one would be a silent dead end, because Chrome scans the same list
* in the same order and would keep talking to the first bridge while this one
* sat there receiving nothing. Say so and stop instead.
*/
let server: ReturnType<typeof Bun.serve> | null = null;
let port = 0;
const taken: string[] = [];

for (const candidate of config.serve.ports) {
try {
server = Bun.serve({
hostname: "127.0.0.1",
port: candidate,
fetch: handleRequest,
});
port = candidate;
break;
} catch (err) {
const peer = await probeBridge(candidate);
if (peer) {
throw new ServeError(
`A TabBrew bridge is already running on 127.0.0.1:${candidate} — use that one, or stop it first. ` +
`Starting a second bridge wouldn't help: Chrome always takes the lowest port that answers.`,
);
}
const detail = err instanceof Error ? err.message : String(err);
taken.push(`${candidate} (${detail})`);
}
}

if (!server) {
throw new ServeError(
`Couldn't bind any bridge port — ${taken.join(", ")}. ` +
`Free one of 127.0.0.1:${config.serve.ports.join(" or :")} and try again; ` +
`Chrome can only reach those.`,
);
}

// What the user needs is "am I up, and what do I do next in the browser" —
Expand All @@ -400,12 +441,17 @@ export async function tabsServe(): Promise<void> {
c.dim(" left by an older version — it recorded tabs you had closed."),
);
}
if (port !== config.serve.ports[0]) {
console.log(
` ${c.dim("Port")} ${config.serve.ports[0]} ${c.dim("was busy — Chrome knows to look here too.")}`,
);
}
console.log("");
console.log(
` ${c.bold("Next, in Chrome:")} open the TabBrew sidepanel, click ${c.bold("Send to Claude Code")},`,
` ${c.bold("Next, in Chrome:")} open the TabBrew sidepanel and click ${c.bold("Connect to TabBrew CLI")}.`,
);
console.log(
` ${c.dim("and switch")} ${c.bold("Auto mode")} ${c.dim("on. Then read the tabs with")} \`${BIN} tabs list\`.`,
` ${c.dim("Leave that screen open — it stops sending when you leave. Then read the tabs with")} \`${BIN} tabs list\`.`,
);
console.log("");
console.log(c.dim("Press Ctrl+C to stop."));
Expand Down
16 changes: 14 additions & 2 deletions src/commands/tabs-suggest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { resolve } from "node:path";
import { config } from "../config";
import { discoverBridge } from "../bridge";
import { readFileOrNull } from "../fsops";
import { parseTabbrewScript } from "../tabbrew-script/parser";
import {
Expand Down Expand Up @@ -70,7 +71,16 @@ export async function tabsSuggest(
);
}

const port = config.serve.port;
// Find the bridge rather than assume its port, and confirm it *is* the bridge
// before posting: this request carries a script describing the user's tabs,
// which is not something to hand to whatever happens to hold the socket.
const bridge = await discoverBridge(config.serve.ports);
if (!bridge) {
throw new TabsBridgeError(
`No TabBrew bridge is listening on 127.0.0.1:${config.serve.ports.join(" or :")} — start one with \`${BIN} tabs serve\` first.`,
);
}
const port = bridge.port;
const basedOn = await lastSeenVersion();

let res: Response;
Expand All @@ -81,8 +91,10 @@ export async function tabsSuggest(
body: JSON.stringify({ script, note, basedOn, opCount: ops.length }),
});
} catch {
// It answered /health a moment ago, so this is a bridge that went down
// mid-command rather than one that was never there.
throw new TabsBridgeError(
`Nothing is listening on 127.0.0.1:${port} — start the bridge with \`${BIN} tabs serve\` first.`,
`The bridge on 127.0.0.1:${port} stopped responding — restart it with \`${BIN} tabs serve\` and send this again.`,
);
}

Expand Down
Loading