Skip to content

Release Tabglutton 0.2.0 - #12

Merged
mlsimon734 merged 11 commits into
mainfrom
release/0.2.0
Aug 2, 2026
Merged

Release Tabglutton 0.2.0#12
mlsimon734 merged 11 commits into
mainfrom
release/0.2.0

Conversation

@mlsimon734

Copy link
Copy Markdown
Owner

Summary

Prepare Tabglutton 0.2.0 as the first public store release.

  • add Chrome support alongside Firefox and Zen from the shared WebExtension source
  • add first-use site-access requests on Chrome so install no longer requires the broad all-sites warning
  • harden Gullet bridge discovery, election, reconnect behavior, and concurrent agent-session support
  • add platform-aware Obsidian note naming and settings-page layout controls
  • fix popup data refresh while the Devour cockpit is open
  • add the privacy policy, release notes, store submission guide, launch copy, screenshots, and Chrome promo tile

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

  • bump package.json and manifest.json to 0.2.0
  • preserve Firefox's required *://*/* host permission
  • patch Chrome builds to use optional *://*/* and loopback permissions
  • keep the explicit extension CSP needed for the loopback WebSocket
  • package target-specific Firefox and Chrome manifests from the shared source

Verification

  • bun run check
    • TypeScript extension and Gullet typechecks
    • oxfmt check
    • oxlint: 0 warnings, 0 errors
    • web-ext lint: 0 errors, 3 known Defuddle bundle warnings
    • Bun tests: 336 passed, 0 failed
  • bun run package
    • tabglutton-firefox-0.2.0.zip
    • tabglutton-chrome-0.2.0.zip
    • tabglutton-source-0.2.0.zip
  • repository pre-push hooks passed

Release sequence

After merge, tag the resulting main commit as v0.2.0, check out that tag, and run bun run package again 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 on main.

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.
@mlsimon734
mlsimon734 marked this pull request as ready for review August 2, 2026 19:30
@mlsimon734
mlsimon734 merged commit c4f95f1 into main Aug 2, 2026
1 check passed
@mlsimon734
mlsimon734 deleted the release/0.2.0 branch August 2, 2026 19:30
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