Release Tabglutton 0.2.0 - #12
Merged
Merged
Conversation
Groundwork for the first public listing on AMO and the Chrome Web Store. No code changes; the version bump and packaging happen separately. - PRIVACY.md: required as a URL by Chrome and inline by AMO. States the local-only data flow, the bridge's opt-in/loopback/token design, and why each permission exists — host_permissions being a capability the user triggers, not a background behaviour. - docs/STORE.md: listing copy for both stores, AMO reviewer notes with reproducible Bun build steps (the build minifies, so source submission is mandatory), all seven Chrome permission justifications, the single-purpose statement, screenshot specs, and a checklist. - docs/LAUNCH.md: posts for r/zen_browser, r/ObsidianMD, r/mcp (with an r/ClaudeAI reframe), X, r/firefox, and Show HN, plus posting order and comment prep. - docs/media/store/: the two cockpit captures downscaled to the 1280x800 the Chrome Web Store requires exactly. Three more shots are still wanted; the shot list is in STORE.md. Release as 0.2.0, not 0.1.3: nine unlisted builds have been signed against this add-on up to 0.1.3.9, and 0.1.3 < 0.1.3.1 by Firefox's version comparison, so 0.1.3 would be a regression AMO rejects. Keep the tabglutton@addons.local ID and upload to the existing add-on's listed channel so dogfooding profiles upgrade in place.
Two independent fixes, plus the store screenshots captured alongside them. sanitizeFileName applied Obsidian Web Clipper's *macOS* branch on every platform. Windows rejects `< > : " / \ ? *`, still reserves the DOS device names, and silently drops trailing dots — so a Windows user clipping `What is C:\Windows\System32?`, or a page simply titled `CON`, handed Obsidian a name the filesystem cannot write. It now branches three ways, reading browser.runtime.getPlatformInfo() through a memoized src/platform.ts (a failed call answers "other", the conservative set, so an unanswerable platform costs an over-sanitized name and never a clip). macOS behaviour is deliberately unchanged, including stripping `/` and `:` with nothing in their place. That is Web Clipper's own rule, and the target user runs both tools: a page clipped by each should land on one note, not two differently-named ones. Linux and BSD get the Windows set rather than their own, because a vault synced to a Windows machine has to satisfy the strictest member. The settings page can now open inside Firefox's Add-ons Manager again, or in its own tab. options_ui.open_in_tab is a static manifest value, so the manifest has to declare the form we cannot produce at runtime — nothing lets an extension navigate to about:addons, while a full tab is just a URL — and openOptionsUi opens that URL for the other choice, raising an already-open settings tab the way openOptionsPage does. Chrome honours the same flag but renders a narrow modal on chrome://extensions that this page does not fit, so build.ts patches its manifest back to open_in_tab: true, options.ts hides the section, and openOptionsUi short-circuits on IS_CHROME before reading the setting. Chrome's behaviour is therefore identical to before this change. Verified live on both engines, since neither surface is reachable from bun test: Chrome over CDP, Zen over Marionette against a rebuilt dist-firefox. Nine unit tests pin the per-platform sanitizer. docs/media/store/ gains the popup captures and the shot list in STORE.md is updated; the Obsidian shot is still outstanding.
The repo root had accumulated five markdown files that are not entry points. BRIDGE.md, DESIGN.md and PRODUCT.md move to docs/ alongside STORE.md and LAUNCH.md; package-source.ts joins the other three scripts in scripts/. All twenty cross-references follow, including the source comments that name BRIDGE.md as the place a decision is written down. Left at the root deliberately: README, LICENSE, CHANGELOG (commit-and-tag- version writes it there), AGENTS.md and its CLAUDE.md symlink (both tools look for them at the root), and PRIVACY.md — that one is about to become a public URL in two store submissions, and it is a legal document sitting next to LICENSE rather than an engineering note. New .gitattributes marks docs/media/ export-ignore. `bun run package` builds the AMO source zip with `git archive HEAD`, and committing the screenshots had taken it from ~260 KB to 4.7 MB of images AMO has no use for. Store assets are now complete. The Obsidian shot is a hand capture (native app, so no script can reach it), scaled 3098x1952 -> 1280x800 and kept alongside its full-resolution original for exactly that reason. Two caveats are recorded in STORE.md rather than quietly shipped: the note's filename is the largest text in the frame and reads `kepanodefuddle …` — correct, and identical to what Obsidian Web Clipper produces, but the least flattering name we generate — and the file tree beside it carries test detritus. The promo tile is generated, not exported: docs/media/promo-tile.html rendered by scripts/shoot-promo-tile.ts, so it can be re-rendered when the palette or wordmark moves. It uses the dark theme because the store's chrome is white and a bone tile dissolves into it. Headless Chrome rather than the CDP harness, so it works from a clean checkout. Three traps, all now held by code or comment: `sips --out foo.png` writes JPEG bytes under a .png name and only warns, so -s format png is mandatory; `@font-face` with a relative URL fetches nothing from a file:// page, whose every document is its own opaque origin, and the tile silently renders in Times; and Chrome 151's --headless writes the screenshot in about a second, then sits two to three minutes before exiting, so the script polls the PNG for a stable size — not mere existence, which hands sips a truncated file — and kills Chrome instead of awaiting it. Warm run 2.5s, byte-identical between runs.
The Chrome build declared host_permissions: ["*://*/*"], which renders as
"Read and change all your data on all websites" on the install prompt and
routes a Web Store submission into manual review. Nothing needs it until
someone clips or turns the bridge on, and both start from a click, so
build.ts now moves it to optional_host_permissions and src/permissions.ts
asks at those two moments. Dedup — the default flow — needs neither grant
and is never interrupted.
Firefox keeps host_permissions: a real signed install grants them, so
moving them would put a prompt in front of every first Devour to solve a
problem that engine does not have. AMO applies no comparable pressure.
Four constraints, each measured rather than assumed:
- requestOrigins must be the first await in a handler. Chrome gates
permissions.request on the click's transient activation, and awaiting
another extension API first spends it. Hence no contains() pre-check:
request() already resolves true without prompting when held.
- It falls back to hasOrigins on a throw or false, and that fallback is
what carries Firefox. Gecko rejects permissions.request outright for an
origin declared in host_permissions ("may only be called from a user
input handler") even from a real gesture; without the fallback the
shared clip path would read that as a denial and kill Devour on Zen.
- A missing grant is not a probe miss. hasHostAccess gates dialling and
latches a needs-access status but never advances the blind-dial counter:
a blind dial is a failed WebSocket connect, the one input to Gecko's
FailDelayManager, so counting it would rebuild the very reconnect
penalty the probe exists to avoid. It re-arms the idle probe instead, so
a grant made in the browser's own permissions UI is picked up in ~3s.
- The bridge cannot ask for anything. readTab checks the grant only after
extraction has already failed — no extra IPC on the hot path — and
reports not-enabled with the remedy rather than Chrome's opaque
"Cannot access contents of url".
No manifest permission is added; the Chrome install set loses all origins.
Verified: bun run check (323 tests, typecheck, format, lint). Live on
throwaway profiles for both engines, since none of this is reachable from
bun test — a clean Chrome install holds no origins, executeScript is
genuinely refused, tabs.query still returns URLs, and an enabled bridge
reports needs-access without dialling; on Gecko a temporary install leaves
Devour and the bridge unaffected, and a real signed install holds
["*://*/*"]. Chrome carries grants across an update, so existing users are
not re-prompted. The native grant dialog is not automatable, so accepting
it and clipping still needs a manual click.
A fixed port is a configuration burden the user should never have carried.
Nothing is guaranteed free on 4589, and the failure it produces is opaque
from both ends: the browser sees a stranger on the port and refuses to
dial, the sidecar cannot bind and cannot attach, and the only remedy is to
find a free number and type it into two places that must agree.
Both ends now walk BRIDGE_PORT_CANDIDATES — an ordered, append-only list of
five ports, verified 2026-08-01 as unassigned by IANA, absent from both
Chromium's and Gecko's restricted-port lists, and below the default Linux
and macOS ephemeral ranges. It is deliberately small: this is deterministic
discovery, not a scan, and both ends have to agree on which hub wins.
Candidates go at the end so independent upgrades keep agreeing. 4589 stays
first, so a normal one-sidecar install does not move.
The port is no longer a bare number on the wire, because a candidate set
turns "something answered" into a routing decision. classifyBridgeProbe
reads the marker and the protocol version together and returns compatible,
incompatible, or foreign; only compatible is ever dialled or handed a token
proof. That rule now covers peer attachment too, which previously offered
its proof to whatever held the port.
Gullet elects across the whole set rather than one port. Every round
discovers a same-token hub across all candidates *before* binding anything,
then binds in canonical order, and re-probes a port it just lost before
moving on. The discovery-first pass is what stops a late-freeing earlier
port from splitting a token realm away from its already-running hub; the
re-probe is what stops a concurrent start from doing the same. A numeric
--port keeps the old single-candidate path exactly; --port auto and no flag
both mean automatic.
The extension rotates one candidate per probe, so discovery costs no extra
WebSocket connects — the idle loop was already probing every 3s, and a
blind dial still consumes at most one candidate per eligible tick. The
successful port is remembered in storage.local under bridgeLastPort and
tried first next time. It is kept out of Settings on purpose: every
Settings write runs the onChanged handler and a full bridge.sync(), and a
discovery cache has no business tearing down a healthy socket.
Options page gains an Automatic / Fixed port choice on .radio-card, with
the numeric field revealed only by Fixed, and the config snippet drops
--port in automatic mode. Status now names the endpoint ("Connected on
20317") and reads "No compatible sidecar found" where it used to raise a
port conflict — in automatic mode a foreign candidate is skipped, not a
problem the user must solve, so the port-conflict latch is fixed-mode only.
Stored settings migrate once: either historical default becomes automatic,
a deliberately chosen custom port keeps fixed semantics.
Four things found in review and fixed here rather than left for later:
- elect() published an electionFault at the top of every round, including
healthy ones. Tool calls read startupError() before awaiting the settling
promise, so any call landing inside a routine re-election — which is what
a hub sidecar exiting triggers, constantly, in short agent sessions —
failed instead of waiting out a handover meant to be invisible. Only a
round that got nowhere publishes now.
- The force path no longer dials unprobed, so its probeMisses reset had
become pure loss: a deliberate user action pushed the blind-dial valve
further away, and that valve is the only escape for an install whose
loopback fetch is filtered and therefore reads silent forever.
- Probe results are logged only when informative. "Silent" is the steady
state of the 3s rotation with no sidecar, and this console gets pasted
wholesale into bug reports.
- loadSettings() is a read called from the onChanged handler itself; it
wrote the migrated mode unconditionally, so a fresh profile persisted a
value identical to the default and paid for a change event and a sync.
Gullet's own probe also leaked a pooled connection per candidate per
round by never consuming the response body.
No manifest or permission change: the loopback origin was already
127.0.0.1 without a port on Chrome's optional set, and Firefox's *://*/*
covers it.
Verified: bun run typecheck, bun test (335 pass), format:check, oxlint.
Twelve new unit tests cover the candidate ordering, probe classification,
the settings migration, and the election's discovery-before-bind rule.
Not yet exercised in a live browser — the rotation, the persisted last
port, and the two-sidecar election are all outside what bun test can
reach, and want a run on both engines before release.
Four fixes from a review pass over the automatic-port work, three of them in code the port change did not introduce but did make reachable. The election wait is the only one that could hang. connections() and request() awaited `settling`, and elect() returns only on success — so a tool call arriving before the first sweep completed parked on a promise that never resolves, and never saw the fault published a second later, because tools.ts reads startupError() *before* the await. Both now share start()'s bounded wait and re-read fault() when it expires, throwing a BridgeRequestError that tools.ts already knows how to turn into a tool error. Deliberately not fixed by publishing a fault at the top of every round: that answers this case at the cost of failing every call that lands inside a routine re-election — a hub sidecar exiting, which happens constantly in short agent sessions — and that handover is meant to be invisible. The host grant is now read once per page instead of once per probe. permissions.contains is a WebExtension API call and every such call resets Chrome's 30s MV3 idle timer, so the 3s idle loop kept the service worker resident for as long as the bridge was enabled with no sidecar — which is this product's normal state, since Gullet lives exactly as long as an agent session. permissions.onAdded / onRemoved invalidate the cache and tick immediately. The trade is recorded in AGENTS.md rather than left implicit: a change that fires neither event, and a `false` that hasOrigins returned from a swallowed throw, are now picked up when the page next starts rather than within IDLE_PROBE_MS. That is bounded only because the worker suspends at all now, with the 30s alarm rebuilding the client. sanitizeFileName stripped trailing dots and spaces before truncating to 245 characters, so the truncation re-exposed exactly what the rule had removed. Sampling over-length titles put ~27% of them ending on a space or a period afterwards — the trailing *space* is the common case, not the trailing dot the rule was written for. Windows drops both on write, so Obsidian would look for a file under a name it was never written under. The normalization now runs after the slice, still Windows-only, so macOS keeps matching Obsidian Web Clipper byte for byte. Superscript device names (COM¹, LPT²) were raised alongside it and are deliberately not handled. They are real, but reaching them needs a note titled exactly that, and diverging from Web Clipper's own rules earns a different filename for the same page — the one thing that function exists to prevent. Finally, package.json and manifest.json were still 0.1.3 while local tags run to v0.1.3.9, so every artifact `bun run package` produced was a version AMO has already seen and would refuse. Verified: bun run typecheck, bun test (336 pass), format:check, oxlint. Two new tests: a tool call racing an election that cannot settle, and a Windows title truncated onto a trailing dot and onto a trailing space.
The 0.2.0 heading was a bare line with a date and nothing under it, because commit-and-tag-version generates sections from Conventional Commits and this history has none — the last generated entry was 0.1.1. So the notes are hand-written, and say what the release is rather than what changed: every 0.1.x was an unlisted build signed for local testing, so for anyone installing from a store this is all of it at once. Checklist corrections. The version bump and `bun run check` are done — web-ext lint reports 0 errors and 3 UNSAFE_VAR_ASSIGNMENT warnings, all innerHTML inside the bundled Defuddle extractor and all pre-existing. The screenshot line claimed three were still missing; the shot list two sections above it has said all five are captured since e4514c1, and what is actually unresolved is narrower: the popup pair is 1200x1200 against Chrome's exact 1280x800, which is a composite-or-drop decision, not a capture. Added the step that was missing entirely: merge first, tag v0.2.0 on main after. Tagging this branch and then squash-merging would leave the tag on a commit main never receives, and PRIVACY.md has to be on main before either store submission for the policy URL to resolve. No .versionrc change was needed — it already bumps manifest.json alongside package.json. The two drifted to 0.1.3 while local tags ran to v0.1.3.9 because those four-part tags come from `sign:dev`, which restores both files afterwards; they were never releases.
The popup is 600px wide, so its captures are 1200x1200 and cannot satisfy Chrome's *exact* 1280x800. AMO has no fixed size, so it ships there unchanged and Chrome takes the five native 1280x800 shots instead — dimensions verified with sips rather than trusted from the filenames. Recorded with what it costs rather than as a clean win. The popup's slot goes to a second theme variant of a view already shown, and on merit the popup is the better image: it is the surface a user touches daily and it is now absent from the Chrome listing entirely. The alternative is worse only right now — compositing onto a bone canvas would make it the one padded image among four full-bleed ones, and the "raw vs composited for the whole set" decision that forces was never written down. That sentence pointed forward at a section that does not exist; the promo tile follows instead. Noted so the next person does not go looking for it. The better five is a hand-captured 1280x800 window with the popup anchored under the toolbar icon, the way obsidian-note was taken, replacing the dark inspector. Chrome's toolbar popup is browser UI and unreachable from CDP, so it cannot be scripted, and listings are editable at any time — it is after-launch work, not a 0.2.0 blocker. Checklist now names the per-store sets, so neither upload has to re-derive which images it wants.
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.
Summary
Prepare Tabglutton 0.2.0 as the first public store release.
User impact
Tabglutton can now be packaged for Firefox/Zen and Chrome. Dedup continues to work without page-content access. Chrome requests site access only when the user invokes Devour or enables the bridge; Firefox retains the host permissions required by its extension model.
The agent bridge now discovers an available local port automatically and handles concurrent sidecars without split-brain startup or indefinite initialization hangs.
Manifest and permission changes
package.jsonandmanifest.jsonto0.2.0*://*/*host permission*://*/*and loopback permissionsVerification
bun run checkbun run packagetabglutton-firefox-0.2.0.ziptabglutton-chrome-0.2.0.ziptabglutton-source-0.2.0.zipRelease sequence
After merge, tag the resulting
maincommit asv0.2.0, check out that tag, and runbun run packageagain for the final store-upload artifacts. Do not tag this branch before a squash merge, because that tag would not point to the commit that lands onmain.