Skip to content

refactor(tabs): cut the surface to serve / list / suggest (v0.7.0) - #34

Merged
colevels merged 8 commits into
mainfrom
refactor/tabs-three-commands
Jul 21, 2026
Merged

refactor(tabs): cut the surface to serve / list / suggest (v0.7.0)#34
colevels merged 8 commits into
mainfrom
refactor/tabs-three-commands

Conversation

@colevels

Copy link
Copy Markdown
Owner

v0.6.0 was the widest this ever got: eight tabs subcommands around a long-polling
bridge. The loop those commands existed to serve is three steps — the extension pushes
tabs, the agent reads them, the agent proposes a script the user accepts or denies — and
Claude Code's /loop already owns the pacing tabs watch was built for.

Breaking. Pin v0.6.0 if you depend on a command that disappears.

The surface

tabs serve           Start the local bridge the extension exports your tabs to
tabs list            Show the tabs the extension last sent, and recent answers
tabs suggest <file>  Propose a script, with a note they read before deciding

Removed: tabs watch, tabs push, tabs check, tabs history, tabs prompt, and the
--port / --out / --wait / --no-wait / --variant flags. docs,
login/whoami/logout and update are untouched; init now installs one skill
instead of two.

+1,709 / −4,022 lines, 38 → 32 source files.

What changed, and why

The bridge lost every long poll. Protocol 3 has five routes: POST /tabs,
POST /suggestion, GET /suggestion, POST /decision, GET /health. Gone are the three
long polls only the CLI ever issued (GET /tabs, GET /history, GET /decision) and the
protocol-1 /script pair — along with park()/wake(), the waiter sets, and the
idleTimeout: 0 workaround that existed only because Bun kills a 10s-idle request.

A shipped protocol-2 extension is unaffected. The four routes it actually calls are
unchanged, byte for byte. It reaches GET /suggestion first and only fell back to
/script on a 404, so it never notices that pair is gone.

suggest is fire-and-forget. Instead of holding a socket open across a human
decision, serve records the verdict in a five-entry ring inside tabs.json, and
tabs list reports it. That gives the agent memory of "denied, and why" which survives a
tab change and a restart of the bridge — and an undecided entry is the natural "don't
pile a second proposal on the first" signal.

tabs list prints the extension's own rendered snapshot rather than building a table
from raw tabs. It is the exact format the skill is written against, it carries the group
titles and windows the table dropped, and it keeps the CLI from reimplementing (and
drifting from) the extension's renderSnapshot. When the payload has no snapshot it says
so rather than growing a second renderer.

One skill instead of two. With push and check gone, a one-off request and a
standing watch are the same three steps, so tabbrew-tabs and tabbrew-auto had nothing
left to disagree about (they differed on whether to confirm a DEL in chat — the panel's
Accept card is the confirmation either way). init deletes an orphaned tabbrew-auto,
which would otherwise keep telling the agent to run a command that no longer exists.

Deleting check took the vendored simulator with it, which ends this repo's
obligation to mirror the extension executor's phase order. Only the grammar is shared now.

Bugs found and fixed during review

An adversarial review and a docs audit ran against the refactor. Four things they caught:

  • The loop could wedge permanently. A suggestion queued but never claimed became
    undeliverable after a bridge restart (pending is RAM-only) yet stayed PENDING
    forever — and the skill stops on PENDING. One Ctrl+C ended the watch for the
    session. serve now closes those out at startup, using a persisted claimedAt so it
    distinguishes "never delivered" from "may still be on screen" as a fact, not a guess.
  • --out moved only the writer. list and suggest kept reading
    config.serve.outPath, so serve --out ./here.json left them silently reporting a
    stale default file — the same reader/writer split --port was removed for. Flag
    removed; TABBREW_TABS_PATH moves all three.
  • Concurrent writes could roll each other back. The extension POSTs /tabs while the
    CLI POSTs /suggestion; both raced a rename. Writes are serialized now.
  • tabs-history.jsonl was orphaned. Removing the writer and tabs history --clear
    left upgrading users with a file of closed-tab titles and URLs and no supported way to
    delete it. serve now deletes it on startup and says so.

Verification

typecheck + test + build pass. Beyond that, the accept/deny round trip only exists
across the browser boundary, so it was driven by hand with curl standing in for the
extension:

  • full loop: post tabs → suggest → claim → deny with a reason → re-post tabs → tabs list
    shows DENIED with the reason; same for accept, and the ring survives a serve restart
  • restart reconciliation both ways: unclaimed becomes STALE and unblocks the loop;
    claimed stays PENDING and a later Accept still lands as ACCEPTED
  • 50 concurrent writers land version 26 of 26, ring cap holds, file stays parseable
  • garbage tolerance: a ring containing null, a bare string and a field-less object
    renders without crashing; a v0.6.0 tabs.json with no suggestions key still works
  • forbidden_host on a spoofed Host:; tabs.json written 0600
  • init install / re-run / uninstall, including deletion of a planted tabbrew-auto
  • docs/commands.html checked in Chrome at 1280px and 390px — tags balance, no console
    errors, no horizontal overflow, card counts match the reach legend

Not in this PR

The extension side. Both the tab uplink and the suggestion poll are gated on
step === 'cli' && cliPhase === 'waiting' in OrganizeFlow.tsx, and Accept lands on
step: 'done' — so a /loop gets one round before it is talking to nobody. Gating on
autoMode instead is the fix, in colevels/tabbrew. No lockstep needed: the shipped
extension works against this bridge unchanged.

The one interim regression is the developer-mode Connect panel, which polls
GET /script. Nothing fills that queue once tabs push is gone, so it goes with it.

🤖 Generated with Claude Code

colevels and others added 8 commits July 21, 2026 20:57
v0.6.0 grew eight `tabs` subcommands around a long-poll bridge. The loop they
existed to serve is three steps — the extension pushes tabs, the agent reads
them, the agent proposes a script the user accepts or denies — and Claude Code's
/loop already owns the pacing that `tabs watch` was built for.

Removed: `tabs watch`, `tabs push`, `tabs check`, `tabs history`, `tabs prompt`.

The bridge loses every long poll. `GET /tabs`, `GET /history` and `GET /decision`
are gone (the extension never called the first two), along with `park`/`wake`,
the delta ring, and the `idleTimeout: 0` workaround that only existed because
Bun kills 10s-idle requests. The protocol-1 `/script` routes go too; a shipped
extension reaches `GET /suggestion` first and only falls back on a 404.

`suggest` no longer waits for a verdict. `serve` records it in a five-entry
suggestion ring inside tabs.json instead, and `tabs list` prints it — so the
agent's memory of "denied, and why" survives both a tab change and a restart of
the bridge, with no socket held open across a human decision. An undecided entry
is also the natural "don't pile on a second proposal" signal.

`tabs list` now prints the extension's own rendered snapshot markdown rather than
building a table from the raw tabs. It's the format the skill is written against,
it carries the group titles and windows the table dropped, and it keeps the CLI
from reimplementing (and drifting from) the extension's renderSnapshot.

Deleting `check` takes the vendored simulator with it, which ends this repo's
obligation to mirror the extension executor's phase order.

One skill replaces two: with `push` and `check` gone, a one-off request and a
watch loop are the same three steps, so tabbrew-tabs and tabbrew-auto had
nothing left to disagree about. `init` deletes an orphaned tabbrew-auto, which
would otherwise keep telling the agent to run a command that no longer exists.

`--port` is dropped: the extension hard-codes 49227 in both manifests'
optional_host_permissions, so a bridge anywhere else was never reachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The page is a hand-written mirror of registry.ts with no generator and no test,
so it had to be carried over by hand either way. Rewritten rather than patched:
the point of this release is that the model now fits on one screen, and five
struck-out cards in an eight-card grid would not have shown that.

New up front: a "what changed" section that names each removed command and where
its job went — a returning reader's first question is whether the command they
remember is gone, and the honest answer is more useful than quietly dropping it.

The two workflow diagrams collapse into one. Answering "tidy my tabs" and running
a standing watch were already the same three commands; only the scheduler differs,
and that now lives in the agent. The right-hand column shows the /loop invocation
and a real `tabs list` pass, since that output is the whole input to a decision.

The bridge section gains a greyed "gone in protocol 3" block listing the five
removed routes, and the state section gains the annotated tabs.json — with the
suggestion ring on disk, that file is the entire contract between the three
commands, and nothing else documents it.

Verified in Chrome at 1280px and 390px: tags balance, no console errors, no
horizontal overflow, and the card counts match the reach legend (4/2/6/1 = 13).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both files described the eight-command surface. Rewritten against the shipped
code rather than edited around: the README's two tab sections (a "local bridge"
named after `tabs push`, and a separate auto-mode section) collapse into one
"tab loop" section, since serve → list → suggest is now the whole story and the
one-off and watching cases no longer differ.

The README's worked examples are real captured output, not invented — driven
against a scratch bridge on port 49999 with curl standing in for the extension.
It also gains the "Testing each subcommand" section CLAUDE.md had been pointing
at for two releases without it existing.

CLAUDE.md's cross-repo note is rewritten rather than trimmed, because what this
repo owes upstream actually changed: deleting simulate.ts ends the obligation to
mirror the extension executor's phase order, what stays vendored is parser.ts
plus the parse-side types, and SKILL.md is not vendored at all — it documents
commands that don't exist upstream, so its source of truth is here.

Restored the pointer to colevels/tabbrew-skill that the resync dropped. That
package is the chat-shaped skill you paste into ChatGPT or claude.ai; it is not
what `init` installs, but it still exists and still works, so it is reframed
rather than deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three handlers persist tabs.json, and two of them belong to different clients —
the extension POSTs /tabs on every tab change while the CLI POSTs /suggestion —
so two writes really can be in flight at once. Each one serialized the state it
saw on entry and then raced to rename its temp file into place, so the later
rename could land the older snapshot: a version bump rolled back, or a queued
suggestion dropped from the ring.

Writes now go through one promise chain, which also means each write reads the
state at its own turn rather than at its caller's. The chain swallows its own
rejection so a single failed write can't leave it permanently rejected and skip
every persist after it; the caller still sees the real error.

Verified with 40 concurrent writers against a scratch bridge: version lands on
exactly 21 of 21, the ring holds its cap, and the file stays parseable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`--out` moved only the writer. `tabs list` and `tabs suggest` read
`config.serve.outPath` unconditionally, so `tabs serve --out ./here.json` left
both of them silently reporting a stale default file — the same reader/writer
split `--port` was removed for, reintroduced one release later by the flag that
survived the cut. `TABBREW_TABS_PATH` moves all three at once and is now the
only override.

Doc corrections, all found by auditing the prose against the running binary:

- README claimed nothing outside `tabs` changed. `init` did: one skill instead
  of two, `--variant` gone, and it now deletes an orphaned `tabbrew-auto`.
- README counted "two routes a protocol-2 extension actually uses". It calls
  four — `POST /tabs` and `GET /health` as well — which understated the
  compatibility surface. The comment in tabs-serve.ts had the same undercount
  and also omitted `GET /decision` from the removed list.
- Both docs called the suggestion ring unconditionally durable. `persist()`
  no-ops until the extension has posted tabs at least once, so a suggestion
  queued before that lives in memory only. Degenerate in practice — an agent
  with no tabs has nothing to write a script about — but not what was written.
- CLAUDE.md still described commands.html as un-mirrored; it was rewritten in
  a72d00c, and reading that paragraph would send a maintainer to redo it.
- CLAUDE.md's "subsystems share only config, ui, util" missed `fsops` and
  `table`, and its `TABBREW_NO_BROWSER` row scoped the flag to `login` when it
  gates every `openBrowser` caller, `docs open` included.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three problems an adversarial review of the refactor turned up.

**The loop could wedge permanently.** Once the extension pops a suggestion,
nothing can tell the bridge whether it is still on screen — the user may have
closed the panel or the browser — so an unanswered suggestion stayed PENDING
forever. SKILL.md tells the agent to stop while the newest entry is PENDING, so
a single ignored card ended the watch for the rest of the session. `tabs list`
now reports an unanswered suggestion older than 15 minutes as UNANSWERED, and
the skill reads that as "they aren't looking", not as "keep waiting". Time is a
poor signal, but after the pop it is the only one there is.

**tabs-history.jsonl was orphaned.** v0.7.0 stopped writing the delta log and
removed `tabs history --clear` — which left every upgrading user with a file of
titles and URLs of tabs they had *closed*, and no supported way to delete it.
That file was the only place this CLI ever accumulated browsing history at rest.
`tabs serve` now deletes it on startup and says so. Deleting the data with the
feature is the only honest option; keeping the writer without a deleter was
called out as unacceptable when the subsystem was designed, and removing the
writer alone was the same mistake wearing a different hat.

**`tabs list` was brittle and lied in --json.** A null or non-object entry in
the ring threw and took the whole tab snapshot down with it; entries are now
read defensively, since the file is hand-editable. `--json` returned an English
sentence when nothing had been exported yet, so a caller that asked for machine
output had to special-case a parse failure to tell "nothing yet" from "broken".

Also drops `summarizeOps`, whose byVerb/delCount/affectedCount had no reader
left after `tabs check` was deleted — `ops.length` is the whole of what suggest
needs.

Re-verified after the change: 50 concurrent writers land version 26 of 26,
UNANSWERED ages correctly, and a ring containing null, a bare string and a
field-less object renders without crashing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The queue is RAM and the ring is on disk, so the two disagreed after a restart:
a suggestion that had been written as undecided but never claimed became
unreachable in both directions — the extension's next GET /suggestion returns
204, and no POST /decision will ever name it — while `tabs list` went on showing
it as PENDING. PENDING is exactly what the skill treats as "wait, don't
propose", so one Ctrl+C on the bridge silently ended the watch for the rest of
the session, with no CLI command left to reset it.

`serve` now marks those `stale` at startup and says why. This is a fact rather
than a guess: records carry `claimedAt`, so a suggestion the extension *did*
claim before the restart is left alone — it may still be on screen with a live
Accept button, and its decision still arrives with an id that is in the restored
ring. Verified both ways: unclaimed becomes STALE and unblocks the loop; claimed
stays PENDING and Accept still lands as ACCEPTED after a restart.

That leaves the 15-minute UNANSWERED aging in `tabs list` doing only what it can
honestly do — guessing about a card that was delivered and may or may not still
be in front of someone. The two mechanisms are documented as different in kind,
because conflating them is how the next reader talks themselves into deleting
the precise one.

Found by an adversarial review of the refactor; it was the one finding of
nineteen that survived refutation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Docs list defaults its entry name to the page <title>, and every push
creates a new row rather than replacing one — so without the version the
sidepanel shows several identically-named copies and no way to tell which
describes the current command surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@colevels
colevels merged commit fb3f460 into main Jul 21, 2026
1 check passed
@colevels
colevels deleted the refactor/tabs-three-commands branch July 21, 2026 21:26
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