refactor(tabs): cut the surface to serve / list / suggest (v0.7.0) - #34
Merged
Conversation
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>
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.
v0.6.0was the widest this ever got: eighttabssubcommands around a long-pollingbridge. 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
/loopalready owns the pacingtabs watchwas built for.Breaking. Pin
v0.6.0if you depend on a command that disappears.The surface
Removed:
tabs watch,tabs push,tabs check,tabs history,tabs prompt, and the--port/--out/--wait/--no-wait/--variantflags.docs,login/whoami/logoutandupdateare untouched;initnow installs one skillinstead 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 threelong polls only the CLI ever issued (
GET /tabs,GET /history,GET /decision) and theprotocol-1
/scriptpair — along withpark()/wake(), the waiter sets, and theidleTimeout: 0workaround 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 /suggestionfirst and only fell back to/scripton a 404, so it never notices that pair is gone.suggestis fire-and-forget. Instead of holding a socket open across a humandecision,
serverecords the verdict in a five-entry ring insidetabs.json, andtabs listreports it. That gives the agent memory of "denied, and why" which survives atab 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 listprints the extension's own rendered snapshot rather than building a tablefrom 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 saysso rather than growing a second renderer.
One skill instead of two. With
pushandcheckgone, a one-off request and astanding watch are the same three steps, so
tabbrew-tabsandtabbrew-autohad nothingleft to disagree about (they differed on whether to confirm a
DELin chat — the panel'sAccept card is the confirmation either way).
initdeletes an orphanedtabbrew-auto,which would otherwise keep telling the agent to run a command that no longer exists.
Deleting
checktook the vendored simulator with it, which ends this repo'sobligation 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:
undeliverable after a bridge restart (
pendingis RAM-only) yet stayedPENDINGforever — and the skill stops on
PENDING. OneCtrl+Cended the watch for thesession.
servenow closes those out at startup, using a persistedclaimedAtso itdistinguishes "never delivered" from "may still be on screen" as a fact, not a guess.
--outmoved only the writer.listandsuggestkept readingconfig.serve.outPath, soserve --out ./here.jsonleft them silently reporting astale default file — the same reader/writer split
--portwas removed for. Flagremoved;
TABBREW_TABS_PATHmoves all three./tabswhile theCLI POSTs
/suggestion; both raced a rename. Writes are serialized now.tabs-history.jsonlwas orphaned. Removing the writer andtabs history --clearleft upgrading users with a file of closed-tab titles and URLs and no supported way to
delete it.
servenow deletes it on startup and says so.Verification
typecheck+test+buildpass. Beyond that, the accept/deny round trip only existsacross the browser boundary, so it was driven by hand with curl standing in for the
extension:
tabs listshows
DENIEDwith the reason; same for accept, and the ring survives aserverestartSTALEand unblocks the loop;claimed stays
PENDINGand a later Accept still lands asACCEPTEDnull, a bare string and a field-less objectrenders without crashing; a v0.6.0
tabs.jsonwith nosuggestionskey still worksforbidden_hoston a spoofedHost:;tabs.jsonwritten0600initinstall / re-run / uninstall, including deletion of a plantedtabbrew-autodocs/commands.htmlchecked in Chrome at 1280px and 390px — tags balance, no consoleerrors, 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'inOrganizeFlow.tsx, and Accept lands onstep: 'done'— so a/loopgets one round before it is talking to nobody. Gating onautoModeinstead is the fix, incolevels/tabbrew. No lockstep needed: the shippedextension works against this bridge unchanged.
The one interim regression is the developer-mode Connect panel, which polls
GET /script. Nothing fills that queue oncetabs pushis gone, so it goes with it.🤖 Generated with Claude Code