Downloads that never reach the header hook (form POST results, service-worker
responses, clicks Firefox routes straight to its downloader) surface here.
If one looks like the daemon's, offer it FIRST and only cancel + erase
Firefox's copy on {action:"take"} — a failed or slow offer can never leave
the user with nothing. blob:/data: downloads are left to Firefox (the daemon
can't fetch a blob URL).
- offered-urls.ts: short-lived, bounded TTL set of URLs the header hook has
already offered; the safety net checks it (via wasOffered) so nothing is
double-handled. Hook gains an onOffered hook to populate it.
- background/index.ts: both paths share one offer(), getCookies, rules
mirror, and OfferedUrls instance.
13 new tests incl. fail-open (offer rejects -> 'error', ignore ->
'offer_declined', cancel() throwing after take still returns 'taken').
101 tests green; web-ext lint clean.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_012Y9RU58hD1BuwP82DySUHk
Owner: lane EXT. See ../docs/agents/AGENT-EXT.md and ../docs/05-extension-spec.md. Firefox MV3, TypeScript. Zero download logic.
Layout
src/background/transport/ Transport interface + WebSocket and native-messaging impls,
runtime picker. See docs/adr/0003 for why there are two.
src/shared/protocol/ GENERATED from ../contracts — never hand-edit.
tests/ vitest; webextension-polyfill is mocked in tests/setup.ts.
Develop
npm ci
npm run typecheck # tsc --noEmit, strict
npm test # vitest run
npm run lint # web-ext lint (AMO rules) — needs manifest.json
Build against tools/mockd over the WebSocket transport; veloxd is not required.
Transport quick start
import { createTransport } from './src/background/transport/index.js';
const t = await createTransport(); // reads the Options override; default 'auto'
t.onStateChange((s) => renderDot(s));
const rules = await t.call('capture.getRules', {});
createTransport resolves with a live, self-reconnecting transport. When veloxd is not
up yet it still resolves (WebSocket, status disconnected, retrying) — callers render the
red dot. It rejects only when the user explicitly forced native messaging and that failed.
Capture code treats every call() rejection as fail-open.