diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 1e1d381..1bdd7e0 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -64,7 +64,14 @@ directory + `vite.config.ts` inline string + `src/background/`.
concatenating header + buffered chunks yields a byte sequence a browser
would play.
-**Plans**: TBD
+**Plans**: 7 plans
+- [ ] 01-01-PLAN.md — Doc cascade: amend DEC-003 / DEC-010 / RETIRE constraints / swap manifest permissions (D-A1..D-A6)
+- [ ] 01-02-PLAN.md — Wave-0 test infrastructure: Vitest install + 4 RED test files + fixtures placeholder
+- [ ] 01-03-PLAN.md — Offscreen recorder TDD: ring buffer + codec strict-mode + getDisplayMedia + track-ended cleanup; D-13 fallback skeleton pre-staged
+- [ ] 01-04-PLAN.md — Port keepalive + OFFSCREEN_READY handshake (TDD): replaces alarms keepalive on offscreen side
+- [ ] 01-05-PLAN.md — SW shrink: delete legacy buffer + alarms + IndexedDB + tabCapture paths; wire SW-side onConnect host
+- [ ] 01-06-PLAN.md — Build pipeline collapse: delete vite.config.ts inline plugin + top-level offscreen/ dir; declare rollupOptions.input
+- [ ] 01-07-PLAN.md — Manual smoke + ffprobe D-12 acceptance gate; commit regression fixture
### Phase 2: Stabilize DOM + event capture privacy
**Goal**: rrweb captures DOM events on typical pages and the user-event log
diff --git a/.planning/phases/01-stabilize-video-pipeline/01-01-PLAN.md b/.planning/phases/01-stabilize-video-pipeline/01-01-PLAN.md
new file mode 100644
index 0000000..82378a7
--- /dev/null
+++ b/.planning/phases/01-stabilize-video-pipeline/01-01-PLAN.md
@@ -0,0 +1,434 @@
+---
+phase: 01-stabilize-video-pipeline
+plan: 01
+type: execute
+wave: 0
+depends_on: []
+files_modified:
+ - .planning/PROJECT.md
+ - .planning/REQUIREMENTS.md
+ - .planning/ROADMAP.md
+ - .planning/intel/decisions.md
+ - .planning/intel/constraints.md
+ - manifest.json
+autonomous: true
+requirements:
+ - REQ-video-ring-buffer
+requirements_addressed:
+ - REQ-video-ring-buffer
+
+must_haves:
+ truths:
+ - "PROJECT.md DEC-003, DEC-010 reflect the getDisplayMedia + port-keepalive amendments"
+ - "REQUIREMENTS.md REQ-video-ring-buffer no longer says 'active-tab' and binds to getDisplayMedia"
+ - "ROADMAP.md Phase 1 Success Criterion #2 no longer references tab re-attach"
+ - "intel/decisions.md DEC-003 and DEC-010 carry an Amendment block"
+ - "intel/constraints.md CON-tab-capture-binding and CON-service-worker-keepalive are RETIRED with a CON-display-capture-binding added"
+ - "manifest.json permissions list contains desktopCapture (not tabCapture) and drops the now-unused alarms entry"
+ - "Every code-touching plan (02-07) sees a consistent doc baseline before it runs"
+ artifacts:
+ - path: ".planning/PROJECT.md"
+ provides: "Amended DEC-003 / DEC-010 rows and amended Constraints section"
+ contains: "getDisplayMedia"
+ - path: ".planning/REQUIREMENTS.md"
+ provides: "REQ-video-ring-buffer wording without 'active-tab' and bound to getDisplayMedia"
+ contains: "getDisplayMedia"
+ - path: ".planning/ROADMAP.md"
+ provides: "Phase 1 Success Criterion #2 with tab-reattach clause removed"
+ contains: "MediaRecorder"
+ - path: ".planning/intel/decisions.md"
+ provides: "DEC-003 and DEC-010 with Amendment blocks"
+ contains: "## Amendment"
+ - path: ".planning/intel/constraints.md"
+ provides: "RETIRED markers + new CON-display-capture-binding"
+ contains: "CON-display-capture-binding"
+ - path: "manifest.json"
+ provides: "Permission swap"
+ contains: "desktopCapture"
+ key_links:
+ - from: "manifest.json"
+ to: "intel/constraints.md"
+ via: "lockstep permission set"
+ pattern: "desktopCapture"
+ - from: "intel/decisions.md"
+ to: "PROJECT.md"
+ via: "Amendment block referenced from Key Decisions table"
+ pattern: "AMENDED"
+---
+
+
+Doc-cascade for D-A1..D-A6 + manifest permission swap (D-05/D-A6). This is
+Wave 0 work that MUST precede every code-touching plan in this phase so
+downstream agents see a consistent baseline (DEC-003 amended to
+`getDisplayMedia`, DEC-010 amended to port keepalive, manifest permissions
+swapped to `desktopCapture`). Plan 01 makes zero code-runtime changes — only
+text edits to planning docs and a single permissions edit in `manifest.json`.
+
+Purpose: Phase 2 and later phases will read PROJECT.md / REQUIREMENTS.md /
+ROADMAP.md to understand what was decided. Leaving them stale would carry the
+old `chrome.tabCapture` contract forward and cause downstream agents to
+silently re-introduce active-tab assumptions.
+
+Output: Six amended doc files + the manifest permissions block in its final
+Phase-1 shape.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/REQUIREMENTS.md
+@.planning/ROADMAP.md
+@.planning/intel/decisions.md
+@.planning/intel/constraints.md
+@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
+@manifest.json
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| extension manifest → Chrome runtime | Permissions block declares the API surface Chrome will grant; minimising this is the policy boundary |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-1-02 | Information Disclosure / over-privileged | `manifest.json` permissions array | mitigate | Drop `tabCapture` and `alarms` (no longer used); add `desktopCapture` (matches CONTEXT.md D-05 / D-A6). `host_permissions: [""]` and `activeTab` retained — already justified by Phase 3 screenshot path. Grep gate: `grep -v '^#' manifest.json \| grep -c '"tabCapture"' == 0 && grep -v '^#' manifest.json \| grep -c '"desktopCapture"' == 1` |
+
+(T-1-01 codec downgrade and T-1-04 port-hijack are addressed in Plans 03 and 04. T-1-03 stream-content-leakage is an accepted residual risk per CONTEXT.md D-04 — recorded in Plan 03's threat model.)
+
+
+
+
+
+ Task 1: Amend intel/decisions.md DEC-003 and DEC-010 (D-A1)
+ .planning/intel/decisions.md
+
+ - .planning/intel/decisions.md (lines 48-60 DEC-003, lines 145-153 DEC-010)
+ - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Doc Amendments (precede code)" (D-A1)
+
+
+Append an Amendment block to DEC-003 immediately AFTER line 59 (the `Confirming source` line). The amendment block reads VERBATIM:
+
+```markdown
+
+## Amendment (Phase 01-stabilize-video-pipeline, 2026-05-15)
+
+- AMENDED-BY: Phase 01 CONTEXT.md D-01..D-05
+- Replace `chrome.tabCapture.capture()` with `navigator.mediaDevices.getDisplayMedia()` called from the offscreen document.
+- Offscreen document is created with `chrome.offscreen.Reason.DISPLAY_MEDIA` (replaces `USER_MEDIA`).
+- Codec/bitrate/timeslice binding unchanged: `video/webm; codecs=vp9` @ 400 000 bps, MediaRecorder timeslice 2000 ms.
+- Trade-off accepted: SPEC §1 "silent operation" is given up — Chrome's permanent "Sharing your screen" indicator is shown while recording. Phase 1 accepts this in exchange for broader capture coverage and elimination of `tabCapture` user-gesture juggling.
+- Tab-switch re-attachment clause is REMOVED — `getDisplayMedia` captures a screen/window, not a tab. There is nothing to re-attach.
+- Manifest permission `tabCapture` is REPLACED with `desktopCapture` (the latter is harmless: `getDisplayMedia` is a web standard API and does NOT actually require `desktopCapture`, but we declare it for clarity per CONTEXT.md D-05).
+```
+
+Then append an Amendment block to DEC-010 immediately AFTER line 152 (the `chrome.alarms` decision line). The amendment block reads VERBATIM:
+
+```markdown
+
+## Amendment (Phase 01-stabilize-video-pipeline, 2026-05-15)
+
+- AMENDED-BY: Phase 01 CONTEXT.md D-17..D-18
+- Replace `chrome.alarms`-driven 20 s keepalive with a long-lived `chrome.runtime.connect` port opened from the offscreen document to the Service Worker. The port emits a `PING` message every 25 s; both directions of traffic reset the SW's 30 s idle timer per Chrome 110+ semantics (developer.chrome.com/blog/longer-esw-lifetimes).
+- The `alarms` permission is removed from `manifest.json` (it is no longer used by Phase 1; Phase 2 / 3 may re-add if needed).
+- Port lifetime cap (~5 minutes per Chromium-extensions community gist sunnyguan/f94058f66fab89e59e75b1ac1bf1a06e) is mitigated by reconnecting on `onDisconnect` and pre-emptively at ~290 s.
+- See `.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md` Pattern 5 for the canonical implementation.
+```
+
+Do not modify any other text in decisions.md.
+
+
+ grep -c "AMENDED-BY: Phase 01" .planning/intel/decisions.md
+
+
+ - `grep -c "AMENDED-BY: Phase 01" .planning/intel/decisions.md` returns 2
+ - `grep -c "getDisplayMedia" .planning/intel/decisions.md` returns at least 1
+ - `grep -c "port" .planning/intel/decisions.md` returns at least 1
+ - DEC-003 still has its original text intact (the amendment is APPENDED, not replacing)
+ - DEC-010 still has its original text intact (the amendment is APPENDED, not replacing)
+
+ Both decisions carry an Amendment block; downstream phases that grep DEC-003 / DEC-010 now find the new contract alongside the original.
+
+
+
+ Task 2: Amend intel/constraints.md — retire two, add one (D-A2)
+ .planning/intel/constraints.md
+
+ - .planning/intel/constraints.md (lines 95-111 CON-tab-capture-binding and CON-service-worker-keepalive)
+ - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Doc Amendments (precede code)" (D-A2)
+
+
+Two edits, both in `.planning/intel/constraints.md`:
+
+(1) Append a RETIRED block immediately AFTER line 102 (the end of CON-tab-capture-binding). VERBATIM text:
+
+```markdown
+
+### RETIRED (Phase 01-stabilize-video-pipeline, 2026-05-15)
+
+- RETIRED-BY: Phase 01 CONTEXT.md D-01 / D-A2
+- Reason: This phase replaces `chrome.tabCapture` with `navigator.mediaDevices.getDisplayMedia()`. The new API is not active-tab-bound; the recorder captures a screen / window selected once via Chrome's native picker and continues across tab switches.
+- Replacement: CON-display-capture-binding (below).
+```
+
+(2) Append a RETIRED block immediately AFTER line 111 (the end of CON-service-worker-keepalive). VERBATIM text:
+
+```markdown
+
+### RETIRED (Phase 01-stabilize-video-pipeline, 2026-05-15)
+
+- RETIRED-BY: Phase 01 CONTEXT.md D-17 / D-A2
+- Reason: This phase replaces alarms-driven keepalive with a long-lived `chrome.runtime.connect` port between offscreen and Service Worker. Port-message traffic resets the SW idle timer per Chrome 110+ semantics.
+- Replacement: CON-display-capture-binding (binds the port-keepalive expectations alongside the new capture API).
+```
+
+(3) Append a brand-new constraint immediately AFTER the existing `CON-buffer-storage` block (after line 196). VERBATIM text:
+
+```markdown
+
+---
+
+## CON-display-capture-binding
+
+- Source: Phase 01 CONTEXT.md D-01..D-17, RESEARCH.md Patterns 1 & 5
+- Type: api-contract
+- Constraint: Video capture uses `navigator.mediaDevices.getDisplayMedia()` invoked once per session from the offscreen document with `chrome.offscreen.Reason.DISPLAY_MEDIA`. The Service Worker is kept alive by a long-lived `chrome.runtime.connect({ name: 'video-keepalive' })` port opened by the offscreen, with traffic in both directions at a minimum cadence of 25 s and pre-emptive reconnect at 290 s.
+- Replaces: CON-tab-capture-binding (RETIRED), CON-service-worker-keepalive (RETIRED).
+- UX trade-off: Chrome's permanent "Sharing your screen" indicator is shown while recording. SPEC §1 silent-operation property is intentionally relaxed.
+```
+
+Do not modify any other text in constraints.md.
+
+
+ grep -c "RETIRED-BY: Phase 01" .planning/intel/constraints.md
+
+
+ - `grep -c "RETIRED-BY: Phase 01" .planning/intel/constraints.md` returns 2
+ - `grep -c "## CON-display-capture-binding" .planning/intel/constraints.md` returns 1
+ - The original `## CON-tab-capture-binding` and `## CON-service-worker-keepalive` headings still exist (RETIRED is APPENDED below them, not replacing them)
+ - `grep -c "video-keepalive" .planning/intel/constraints.md` returns at least 1
+
+ Two retired constraints carry RETIRED-BY markers; CON-display-capture-binding exists as the consolidated replacement constraint.
+
+
+
+ Task 3: Amend PROJECT.md Key Decisions table and Constraints section (D-A3)
+ .planning/PROJECT.md
+
+ - .planning/PROJECT.md (lines 79-110 Constraints section, lines 113-134 Key Decisions table)
+ - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Doc Amendments (precede code)" (D-A3)
+
+
+Three edits in `.planning/PROJECT.md`:
+
+(1) DEC-003 row in the Key Decisions table — find line 124 starting `| **DEC-003**:` and replace the entire row with VERBATIM:
+
+```markdown
+| **DEC-003**: Active video via `getDisplayMedia()` (vp9 / 400 kbps / 2000 ms) | AMENDED by Phase 01: SPEC §2/§4.1/§7 originally specified `chrome.tabCapture`; Phase 01 swaps to `getDisplayMedia` invoked in the offscreen document with `chrome.offscreen.Reason.DISPLAY_MEDIA`. Codec/bitrate/timeslice binding unchanged. See `.planning/intel/decisions.md` DEC-003 Amendment. | — Pending | locked (Phase 1, post-Amendment) |
+```
+
+(2) DEC-010 row — find line 131 starting `| **DEC-010**:` and replace the entire row with VERBATIM:
+
+```markdown
+| **DEC-010**: Service Worker keepalive via long-lived port | AMENDED by Phase 01: SPEC §8 originally specified `chrome.alarms` at 20 s; Phase 01 swaps to a `chrome.runtime.connect` port between offscreen and SW with 25 s ping cadence and 290 s pre-emptive reconnect. See `.planning/intel/decisions.md` DEC-010 Amendment. | — Pending | locked (Phase 1, post-Amendment) |
+```
+
+(3) Constraints section — find line 100-101 (the `chrome.alarms` keepalive bullet) and replace with VERBATIM:
+
+```markdown
+- **Service Worker lifecycle**: MV3 SW unloads after ~30 s idle; a long-lived
+ `chrome.runtime.connect` port from offscreen to SW emits a PING every 25 s
+ to keep the SW alive (CON-display-capture-binding, AMENDED from
+ CON-service-worker-keepalive).
+- **Tab capture binding**: REMOVED (CON-tab-capture-binding RETIRED). The new
+ `getDisplayMedia` binding (CON-display-capture-binding) is screen/window-
+ scoped, not tab-scoped, and survives tab switches without re-attach.
+```
+
+(this replaces the two bullets currently at lines 97-102 — the existing SW-keepalive bullet AND the existing tab-capture-binding bullet — with the two replacement bullets above. The bullet ordering and the rest of the Constraints section are unchanged.)
+
+Do not modify any other text in PROJECT.md.
+
+
+ grep -c "AMENDED by Phase 01" .planning/PROJECT.md
+
+
+ - `grep -c "AMENDED by Phase 01" .planning/PROJECT.md` returns 2 (DEC-003 + DEC-010 rows)
+ - `grep -c "getDisplayMedia" .planning/PROJECT.md` returns at least 1
+ - `grep -c "long-lived port" .planning/PROJECT.md` returns at least 1
+ - `grep -c "RETIRED" .planning/PROJECT.md` returns at least 1
+ - The `## Key Decisions` heading and the `## Constraints` heading still exist
+ - All other rows of the Key Decisions table are untouched
+
+ PROJECT.md's Key Decisions table and Constraints section reflect the new contract; downstream readers of PROJECT.md see the amendments without needing to drill into intel/.
+
+
+
+ Task 4: Amend REQUIREMENTS.md REQ-video-ring-buffer (D-A4)
+ .planning/REQUIREMENTS.md
+
+ - .planning/REQUIREMENTS.md (lines 19-27 REQ-video-ring-buffer block)
+ - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Doc Amendments (precede code)" (D-A4)
+
+
+Replace the entire REQ-video-ring-buffer entry (currently lines 19-27 starting `- [ ] **REQ-video-ring-buffer**`) with VERBATIM:
+
+```markdown
+- [ ] **REQ-video-ring-buffer**: The extension maintains an in-memory ring
+ buffer containing the most recent 30 seconds of captured video. AMENDED in
+ Phase 01: video is acquired via `navigator.mediaDevices.getDisplayMedia()`
+ invoked from the offscreen document (with `chrome.offscreen.Reason.DISPLAY_MEDIA`),
+ NOT `chrome.tabCapture` as originally specified. The captured stream is
+ screen-or-window-scoped per the operator's one-time selection in Chrome's
+ native picker, and continues unchanged across tab switches. Encoding is
+ unchanged: `video/webm; codecs=vp9` @ 400 000 bps with a `MediaRecorder`
+ timeslice of 2000 ms; a single continuous recorder runs for the whole
+ session. The first emitted chunk (WebM header) is retained indefinitely;
+ subsequent chunks rotate out by the 30-second TTL rule. Bindings: DEC-003
+ (AMENDED), DEC-009, CON-video-window, CON-video-codec,
+ CON-webm-header-retention, CON-display-capture-binding (replaces RETIRED
+ CON-tab-capture-binding).
+ - SPEC §10 acceptance criteria: #2, #3, #7.
+```
+
+Do not modify any other text in REQUIREMENTS.md.
+
+
+ grep -c "AMENDED in" .planning/REQUIREMENTS.md
+
+
+ - `grep -c "AMENDED in" .planning/REQUIREMENTS.md` returns at least 1
+ - `grep -c "getDisplayMedia" .planning/REQUIREMENTS.md` returns at least 1
+ - `grep -c "active-tab video" .planning/REQUIREMENTS.md` returns 0 (the old "active-tab" wording is removed)
+ - `grep -c "REQ-video-ring-buffer" .planning/REQUIREMENTS.md` returns at least 2 (the bullet + the Traceability table row)
+ - The Traceability table row for REQ-video-ring-buffer remains intact
+
+ REQ-video-ring-buffer reads correctly for Phase 1 and binds the new constraint set.
+
+
+
+ Task 5: Amend ROADMAP.md Phase 1 description + Success Criterion #2 (D-A5)
+ .planning/ROADMAP.md
+
+ - .planning/ROADMAP.md (lines 25, 33-66 Phase 1 block)
+ - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Doc Amendments (precede code)" (D-A5)
+
+
+Two edits in `.planning/ROADMAP.md`:
+
+(1) Replace line 25 VERBATIM with:
+
+```markdown
+- [ ] **Phase 1: Stabilize video pipeline** — Collapse offscreen duality, fix MediaRecorder shadow, fix WebM ring buffer playability, replace `chrome.tabCapture` with offscreen `getDisplayMedia` (AMENDED from original DEC-003)
+```
+
+(2) Replace Success Criterion #2 (currently lines 58-61 in the `**Success Criteria** (what must be TRUE):` block — "With the extension loaded and a tab open, a single continuous `MediaRecorder` ... without losing the WebM container header.") with VERBATIM:
+
+```markdown
+ 2. With the extension loaded and an operator session active, a single
+ continuous `MediaRecorder` is running on the operator-selected
+ screen/window source with timeslice 2000 ms; the recorder continues
+ unchanged across tab switches (no tab re-attach logic; AMENDED from the
+ original wording). The WebM container header is retained in the ring
+ buffer indefinitely.
+```
+
+Do not modify any other text in ROADMAP.md.
+
+
+ grep -c "AMENDED" .planning/ROADMAP.md
+
+
+ - `grep -c "AMENDED" .planning/ROADMAP.md` returns at least 2
+ - `grep -c "tab re-attach" .planning/ROADMAP.md` returns 0 (the old phrase is gone)
+ - `grep -c "getDisplayMedia" .planning/ROADMAP.md` returns at least 1
+ - Phases 2-5 sections remain untouched
+
+ ROADMAP.md Phase 1 description and Success Criterion #2 reflect the new contract; readers of ROADMAP.md see the amendment without needing to drill into CONTEXT.md.
+
+
+
+ Task 6: Manifest permission swap — tabCapture → desktopCapture, drop alarms (D-A6 / D-05)
+ manifest.json
+
+ - manifest.json (lines 6-14 permissions block)
+ - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Doc Amendments (precede code)" (D-A6)
+ - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`manifest.json` (lines 444-476)
+
+
+Replace the `"permissions": [...]` array (currently lines 6-14) VERBATIM with:
+
+```json
+ "permissions": [
+ "desktopCapture",
+ "activeTab",
+ "downloads",
+ "scripting",
+ "storage",
+ "offscreen"
+ ],
+```
+
+Specifically:
+- Replace `"tabCapture"` → `"desktopCapture"` (per D-A6).
+- Remove `"alarms"` (the alarms keepalive is deleted in Plan 05; declaring an unused permission expands attack surface, mitigating T-1-02).
+- Keep `"activeTab"` (needed for `chrome.tabs.captureVisibleTab` in Phase 3 screenshot path — current code uses it at src/background/index.ts:190).
+- Keep `"downloads"` (used at src/background/index.ts:305).
+- Keep `"scripting"` (content script injection).
+- Keep `"storage"` (currently unused by code but retained per SPEC §7; do not remove in this phase — that is Phase 5 hardening territory).
+- Keep `"offscreen"` (required to call `chrome.offscreen.createDocument`).
+
+Do not modify any other field in `manifest.json` (manifest_version, name, version, description, host_permissions, background, content_scripts, action, icons all remain untouched).
+
+
+ node -e "const m=require('./manifest.json'); const p=m.permissions; if(p.includes('tabCapture')||p.includes('alarms')){process.exit(1)}; if(!p.includes('desktopCapture')||!p.includes('offscreen')){process.exit(2)}; console.log('ok')"
+
+
+ - `grep -c '"tabCapture"' manifest.json` returns 0
+ - `grep -c '"desktopCapture"' manifest.json` returns 1
+ - `grep -c '"alarms"' manifest.json` returns 0
+ - `grep -c '"offscreen"' manifest.json` returns 1
+ - `grep -c '"activeTab"' manifest.json` returns 1
+ - `node -e "require('./manifest.json')"` exits 0 (JSON valid)
+ - `host_permissions` array still contains `""` (unchanged)
+
+ manifest.json carries the final Phase-1 permissions set; downstream code-touching plans (02-07) operate against a manifest that matches the new API contract.
+
+
+
+
+
+After all six tasks land:
+
+1. `grep -c "AMENDED" .planning/PROJECT.md .planning/REQUIREMENTS.md .planning/ROADMAP.md` returns at least 4 lines (each file has at least one amendment marker; the precise totals are checked in each task's acceptance_criteria).
+2. `grep -c "RETIRED-BY: Phase 01" .planning/intel/constraints.md` returns 2.
+3. `grep -c "AMENDED-BY: Phase 01" .planning/intel/decisions.md` returns 2.
+4. `grep -c '"tabCapture"' manifest.json` returns 0 and `grep -c '"desktopCapture"' manifest.json` returns 1.
+5. `node -e "require('./manifest.json')"` exits 0 (JSON valid).
+6. `node -e "require('./.planning/config.json')"` exits 0 (we did not touch config.json).
+7. Every code-touching plan in Waves 0..2 can grep the doc baseline (manifest.json, PROJECT.md, REQUIREMENTS.md, ROADMAP.md, decisions.md, constraints.md) and find the amendments — no plan needs to back-patch a doc.
+
+Commit cadence: ONE git commit per task (six atomic commits). Each commit message follows `docs(01-01): amend {file} per D-{ID}` style. The orchestrator handles commit creation.
+
+
+
+- All six tasks complete and verified.
+- No file in the dependency closure carries the pre-amendment wording.
+- manifest.json `permissions` array exactly matches the list specified in Task 6's action block.
+- Plans 02..07 can run against a self-consistent doc baseline (no late doc patches required mid-phase).
+
+
+
diff --git a/.planning/phases/01-stabilize-video-pipeline/01-02-PLAN.md b/.planning/phases/01-stabilize-video-pipeline/01-02-PLAN.md
new file mode 100644
index 0000000..7606a3c
--- /dev/null
+++ b/.planning/phases/01-stabilize-video-pipeline/01-02-PLAN.md
@@ -0,0 +1,629 @@
+---
+phase: 01-stabilize-video-pipeline
+plan: 02
+type: execute
+wave: 0
+depends_on: ["01"]
+files_modified:
+ - package.json
+ - vitest.config.ts
+ - tests/offscreen/ring-buffer.test.ts
+ - tests/offscreen/codec-check.test.ts
+ - tests/offscreen/handshake.test.ts
+ - tests/offscreen/port.test.ts
+ - tests/fixtures/.gitkeep
+autonomous: true
+requirements:
+ - REQ-video-ring-buffer
+requirements_addressed:
+ - REQ-video-ring-buffer
+
+must_haves:
+ truths:
+ - "`node_modules/` exists and Vitest is installed under devDependencies"
+ - "`vitest.config.ts` exists at repo root and runs Node-environment tests under tests/**/*.test.ts"
+ - "`npm test` runs `vitest run` and exits with a non-zero code (because the 4 RED tests fail — they import modules that do not exist yet)"
+ - "The four RED test files exist and EACH attempts to import from `src/offscreen/recorder` — the four tests pin the contracts Plans 03 and 04 must satisfy"
+ - "`tests/fixtures/` directory exists and is committed (empty for now; Plan 07 will produce a real WebM into it manually)"
+ artifacts:
+ - path: "package.json"
+ provides: "vitest devDep + npm test script + vitest-chrome mock"
+ contains: "\"vitest\""
+ - path: "vitest.config.ts"
+ provides: "node-env test config matching project tsconfig"
+ contains: "environment: 'node'"
+ - path: "tests/offscreen/ring-buffer.test.ts"
+ provides: "RED tests for header-pinning + 30 s trim (D-10/D-11)"
+ contains: "first chunk is header"
+ - path: "tests/offscreen/codec-check.test.ts"
+ provides: "RED test for codec strict-mode (D-20)"
+ contains: "vp9 unsupported"
+ - path: "tests/offscreen/handshake.test.ts"
+ provides: "RED test for OFFSCREEN_READY (Pattern 4)"
+ contains: "OFFSCREEN_READY"
+ - path: "tests/offscreen/port.test.ts"
+ provides: "RED test for port reconnect (Pattern 5 / Pitfall 4)"
+ contains: "reconnects"
+ - path: "tests/fixtures/.gitkeep"
+ provides: "marker so the fixtures directory survives clean checkouts"
+ contains: ""
+ key_links:
+ - from: "tests/offscreen/*.test.ts"
+ to: "src/offscreen/recorder.ts"
+ via: "import { ... } from '../../src/offscreen/recorder'"
+ pattern: "from '../../src/offscreen/recorder'"
+ - from: "package.json"
+ to: "vitest.config.ts"
+ via: "npm test script"
+ pattern: "\"test\": \"vitest run\""
+---
+
+
+Wave 0 test infrastructure. Install Vitest, write `vitest.config.ts`, write
+the four FAILING test files that pin the contracts for Plans 03 (recorder)
+and 04 (handshake + port), and add the `npm test` script.
+
+These tests are RED on purpose — they import from `src/offscreen/recorder`,
+which Plan 03 creates. Until Plan 03 GREEN-implements the module, every test
+in this batch fails with a module-resolution error. That is the Nyquist
+sampling signal Plans 03 / 04 must drive to PASS.
+
+Purpose: This phase has `workflow.tdd_mode: true`. RED-first means the test
+file ships BEFORE the production code; Plans 03 and 04 then commit the
+minimum implementation to flip each test GREEN. Establishing the test
+fixtures here, in a single coherent plan, avoids a Plan-03 RED commit
+collision with a Plan-04 RED commit.
+
+Output: Vitest installed, 4 RED test files, vitest.config.ts, npm test
+script, tests/fixtures/ directory placeholder.
+
+
+
+@$HOME/.claude/get-shit-done/workflows/execute-plan.md
+@$HOME/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md
+@.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md
+@.planning/phases/01-stabilize-video-pipeline/01-VALIDATION.md
+@package.json
+@tsconfig.json
+@src/shared/types.ts
+
+
+
+
+
+Expected exports from `src/offscreen/recorder.ts` (created in Plan 03):
+
+```typescript
+// Ring-buffer (pure functions; testable in Node without a real Blob)
+export function addChunk(blob: { size: number }, timestamp: number): void;
+export function trimAged(now: number): void;
+export function getBuffer(): Array<{ data: { size: number }; timestamp: number; isFirst?: boolean }>;
+export function resetBuffer(): void;
+
+// Codec strict-mode (D-20)
+export function assertCodecSupported(): void; // throws Error if vp9 unsupported
+
+// Constants (so tests can reference the same window value Plan 03 uses)
+export const VIDEO_BUFFER_DURATION_MS: number; // = 30_000
+```
+
+Expected side-effects of importing `src/offscreen/recorder.ts`:
+
+- `chrome.runtime.onMessage.addListener` called at least once
+- `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' })` called exactly once (Pattern 4)
+- `chrome.runtime.connect({ name: 'video-keepalive' })` called exactly once at module load (Pattern 5)
+- On the connected port firing `onDisconnect`, the module immediately calls `chrome.runtime.connect` again (Pitfall 4 reconnect)
+
+These contracts are what the 4 RED tests below check.
+
+
+
+## Trust Boundaries
+
+| Boundary | Description |
+|----------|-------------|
+| test runner → source modules | Vitest test files import from the source tree; a misconfigured `include` could leak production code-paths into the runtime |
+
+## STRIDE Threat Register
+
+| Threat ID | Category | Component | Disposition | Mitigation Plan |
+|-----------|----------|-----------|-------------|-----------------|
+| T-1-NEW-02-01 | Tampering / supply-chain | `package.json` Vitest install | mitigate | Pin Vitest at a major version (`^3` or whatever `npm view vitest version` shows on install day) and let `npm install` produce a deterministic `package-lock.json`. No `vitest-chrome` dep: lightweight inline stub in each test instead, so we don't widen the supply chain for a four-file test setup. |
+| T-1-NEW-02-02 | Information Disclosure | test output | accept | Tests run in Node; no captured user video can leak. Tests use mocked Blob (`{ size: N } as Blob`) — no real screen content involved. |
+
+(Phase-wide T-1-01..T-1-04 are addressed in their respective implementation plans.)
+
+
+
+
+
+ Task 1: Install Vitest, add npm test script, run npm install
+ package.json
+
+ - package.json (lines 1-21 — currently no Vitest, no test script)
+ - .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Environment Availability" + §"Wave 0 Gaps" (lines 1058-1078 + 1147-1162)
+
+
+Three edits to `package.json`. Use the Edit tool, NOT a manual JSON rewrite (preserve formatting):
+
+(1) Add `vitest` to `devDependencies`. Pin to the latest stable major at install time. Run this command first to discover the current version:
+```bash
+LATEST_VITEST=$(npm view vitest version)
+echo "Latest vitest is $LATEST_VITEST"
+```
+Then add to `devDependencies` in `package.json`: `"vitest": "^"` where `` is the major number returned (e.g., `^3` if `npm view vitest version` returns `3.1.4`). Do NOT pin a beta/RC; if the latest is non-stable, fall back to the previous stable major (verify with `npm view vitest versions --json | tail -20`).
+
+(2) Add an `"test": "vitest run"` script to the `scripts` block. The full `scripts` block becomes VERBATIM:
+```json
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "test": "vitest run"
+ },
+```
+
+(3) Run `npm install` in the repo root:
+```bash
+npm install
+```
+This both installs Vitest AND brings the existing devDependencies up to date (the current repo has no `node_modules/` per RESEARCH.md Runtime State Inventory). Confirm `node_modules/vitest/` exists after.
+
+If `npm install` produces a `package-lock.json` for the first time (it should — there is no committed lockfile today), this is expected; commit it together with the package.json change.
+
+
+ test -d node_modules/vitest && grep -c "\"vitest\"" package.json && grep -c "\"test\": \"vitest run\"" package.json
+
+
+ - `test -d node_modules/vitest` exits 0
+ - `grep -c "\"vitest\"" package.json` returns at least 1
+ - `grep -c "\"test\": \"vitest run\"" package.json` returns 1
+ - `node -e "require('./package.json')"` exits 0 (JSON valid)
+ - `npx vitest --version` prints a version string (Vitest CLI reachable)
+ - `package-lock.json` exists at repo root (committed)
+
+ Vitest available; `npm test` is wired but will fail because the 4 RED tests are about to be added in the next tasks.
+
+
+
+ Task 2: Create vitest.config.ts
+ vitest.config.ts
+
+ - tsconfig.json (lines 1-20 — `strict: true`, `target: ES2020`, `moduleResolution: bundler`)
+ - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`vitest.config.ts` (lines 684-712)
+ - vite.config.ts (lines 1-12 — `defineConfig` shape to mirror)
+
+
+Create `vitest.config.ts` at the repo root with VERBATIM content:
+
+```typescript
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'node',
+ include: ['tests/**/*.test.ts'],
+ reporters: 'dot',
+ typecheck: {
+ enabled: false,
+ },
+ },
+});
+```
+
+Notes:
+- `environment: 'node'` — Vitest 3+ shims Blob via undici in Node mode, so our test code can construct `{ size: 1024 } as unknown as Blob` without a real DOM. Confirmed in RESEARCH.md §"Validation Architecture" line 1104.
+- `include: ['tests/**/*.test.ts']` — scoped strictly to `tests/`; production code under `src/` is never picked up as a test.
+- `typecheck.enabled: false` — `tsc --noEmit` runs separately as part of `npm run build`. Running typecheck via Vitest would duplicate work.
+
+Do not add `globals: true` — we want explicit `import { describe, it, expect } from 'vitest'` in every test for clarity.
+
+Do not add path aliases — `tsconfig.json` does not define any, and tests can use relative imports (`../../src/offscreen/recorder`).
+
+
+ node -e "const c=require('./vitest.config.ts'.replace('.ts','.js'));" 2>/dev/null; test -f vitest.config.ts && grep -c "environment: 'node'" vitest.config.ts
+
+
+ - `test -f vitest.config.ts` exits 0
+ - `grep -c "environment: 'node'" vitest.config.ts` returns 1
+ - `grep -c "include: \['tests/\*\*/\*.test.ts'\]" vitest.config.ts` returns 1
+ - `grep -c "defineConfig" vitest.config.ts` returns 1
+ - `npx vitest run --reporter=dot 2>&1` produces output (even if all tests fail with module-resolution errors — that's expected because tests don't exist yet at this task)
+
+ `vitest.config.ts` is committed; `npx vitest run` finds zero test files but exits cleanly (no config error).
+
+
+
+ Task 3: Create tests/offscreen/ring-buffer.test.ts (RED — first chunk + trim 30s)
+ tests/offscreen/ring-buffer.test.ts, tests/fixtures/.gitkeep
+
+ - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`tests/offscreen/ring-buffer.test.ts` (lines 534-568)
+ - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md (D-10, D-11)
+ - .planning/intel/constraints.md §CON-video-window + §CON-webm-header-retention
+ - src/background/index.ts (lines 26-75 — the ring-buffer logic that is being relocated to `src/offscreen/recorder.ts` in Plan 03; the test is written against the relocated module's export surface)
+
+
+ - Test 1 (`first chunk is header`): When `resetBuffer()` is called then `addChunk({ size: 1024 } as Blob, 1_000)` is called, `getBuffer()[0].isFirst` MUST be `true`.
+ - Test 2 (`second chunk is NOT header`): A second `addChunk` produces an entry with `isFirst: false` (or `undefined`).
+ - Test 3 (`trim 30s — keeps header, evicts aged tail`): With one header chunk at t=0, one body chunk at t=10_000, one body chunk at t=35_000, and `trimAged(40_000)`, the resulting buffer MUST start with `isFirst: true` and MUST be length >= 2 (header + the t=35_000 chunk; the t=10_000 chunk has age 30_000 ms which is at the boundary — Plan 03 uses strict `<` so it gets trimmed at exactly 30_000 but the test only asserts ≥ 2 to be tolerant).
+ - Test 4 (`trim with empty buffer`): `trimAged(0)` on an empty buffer does not throw.
+
+
+Create `tests/offscreen/ring-buffer.test.ts` with VERBATIM content:
+
+```typescript
+import { describe, it, expect, beforeEach } from 'vitest';
+import { addChunk, trimAged, getBuffer, resetBuffer } from '../../src/offscreen/recorder';
+
+describe('ring buffer', () => {
+ beforeEach(() => resetBuffer());
+
+ it('first chunk is header', () => {
+ addChunk({ size: 1024 } as unknown as Blob, 1_000);
+ const buf = getBuffer();
+ expect(buf.length).toBe(1);
+ expect(buf[0].isFirst).toBe(true);
+ });
+
+ it('second chunk is NOT header', () => {
+ addChunk({ size: 1024 } as unknown as Blob, 1_000);
+ addChunk({ size: 512 } as unknown as Blob, 2_000);
+ const buf = getBuffer();
+ expect(buf.length).toBe(2);
+ expect(buf[0].isFirst).toBe(true);
+ expect(buf[1].isFirst).toBeFalsy();
+ });
+
+ it('trim 30s — keeps header, evicts aged tail', () => {
+ addChunk({ size: 1024 } as unknown as Blob, 0); // header at t=0
+ addChunk({ size: 512 } as unknown as Blob, 10_000); // t=10s
+ addChunk({ size: 512 } as unknown as Blob, 35_000); // t=35s
+ trimAged(40_000); // now=40s
+ const buf = getBuffer();
+ expect(buf[0].isFirst).toBe(true); // header survives unconditionally
+ expect(buf.length).toBeGreaterThanOrEqual(2); // header + at least the t=35s chunk
+ // The header chunk's age (40s) does NOT cause it to be trimmed.
+ const headerStillThere = buf.some((c) => c.isFirst);
+ expect(headerStillThere).toBe(true);
+ });
+
+ it('trim with empty buffer does not throw', () => {
+ expect(() => trimAged(0)).not.toThrow();
+ expect(getBuffer()).toEqual([]);
+ });
+});
+```
+
+Also create `tests/fixtures/.gitkeep` with empty content (Plan 07 will manually drop a known-good last_30sec.webm into this directory after the manual smoke test).
+
+
+ test -f tests/offscreen/ring-buffer.test.ts && test -f tests/fixtures/.gitkeep && (npx vitest run tests/offscreen/ring-buffer.test.ts 2>&1 | grep -qE "Cannot find module|Failed to resolve")
+
+
+ - `test -f tests/offscreen/ring-buffer.test.ts` exits 0
+ - `test -f tests/fixtures/.gitkeep` exits 0
+ - The test file imports from `'../../src/offscreen/recorder'` (verify: `grep -c "from '../../src/offscreen/recorder'" tests/offscreen/ring-buffer.test.ts` returns 1)
+ - `npx vitest run tests/offscreen/ring-buffer.test.ts 2>&1` exits NON-ZERO AND contains either "Cannot find module" or "Failed to resolve" — this is the RED gate (the source module doesn't exist yet; Plan 03 will create it)
+ - No `as any` and no `@ts-ignore` in the test file (CLAUDE.md naming rules)
+
+ Four-test RED file for the ring buffer exists. Vitest can find it but fails to import the source module — exactly the RED gate Plan 03 will flip to GREEN.
+
+
+
+ Task 4: Create tests/offscreen/codec-check.test.ts (RED — codec strict-mode)
+ tests/offscreen/codec-check.test.ts
+
+ - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`tests/offscreen/codec-check.test.ts` (lines 572-603)
+ - .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Claude's Discretion" (codec strictness)
+ - .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Pattern 6: Codec strict-mode" + §"Example E"
+
+
+ - Test 1 (`throws on unsupported vp9 and emits RECORDING_ERROR`): When `MediaRecorder.isTypeSupported` is mocked to return `false`, `assertCodecSupported()` MUST throw an Error whose message contains the literal substring `"vp9 unsupported"`. It MUST also call `chrome.runtime.sendMessage` with an object that has `type: 'RECORDING_ERROR'`.
+ - Test 2 (`does not throw when vp9 IS supported`): When `MediaRecorder.isTypeSupported` returns `true`, `assertCodecSupported()` MUST NOT throw and MUST NOT call `chrome.runtime.sendMessage`.
+
+
+Create `tests/offscreen/codec-check.test.ts` with VERBATIM content:
+
+```typescript
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+interface ChromeStub {
+ runtime: { sendMessage: ReturnType };
+}
+
+interface GlobalWithChrome {
+ chrome?: ChromeStub;
+ MediaRecorder?: { isTypeSupported: (mime: string) => boolean };
+}
+
+describe('codec strict mode', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ (globalThis as unknown as GlobalWithChrome).chrome = {
+ runtime: { sendMessage: vi.fn() },
+ };
+ });
+
+ it('throws on unsupported vp9 and emits RECORDING_ERROR', async () => {
+ (globalThis as unknown as GlobalWithChrome).MediaRecorder = {
+ isTypeSupported: vi.fn().mockReturnValue(false),
+ };
+ const mod = await import('../../src/offscreen/recorder');
+ expect(() => mod.assertCodecSupported()).toThrow(/vp9 unsupported/);
+ const stub = (globalThis as unknown as GlobalWithChrome).chrome!;
+ expect(stub.runtime.sendMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'RECORDING_ERROR' })
+ );
+ });
+
+ it('does not throw when vp9 IS supported', async () => {
+ (globalThis as unknown as GlobalWithChrome).MediaRecorder = {
+ isTypeSupported: vi.fn().mockReturnValue(true),
+ };
+ const mod = await import('../../src/offscreen/recorder');
+ expect(() => mod.assertCodecSupported()).not.toThrow();
+ const stub = (globalThis as unknown as GlobalWithChrome).chrome!;
+ expect(stub.runtime.sendMessage).not.toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'RECORDING_ERROR' })
+ );
+ });
+});
+```
+
+Notes:
+- `vi.resetModules()` between tests is critical: the module-import side-effects (Pattern 4 OFFSCREEN_READY, Pattern 5 port.connect) happen ONCE per module load; without reset, the test isolation breaks across the four test files.
+- Mock `chrome.runtime` with a minimal stub — no `vitest-chrome` dependency.
+- Type the mock with explicit interfaces (no `as any`, per CLAUDE.md).
+
+
+ test -f tests/offscreen/codec-check.test.ts && (npx vitest run tests/offscreen/codec-check.test.ts 2>&1 | grep -qE "Cannot find module|Failed to resolve")
+
+
+ - `test -f tests/offscreen/codec-check.test.ts` exits 0
+ - `grep -c "vp9 unsupported" tests/offscreen/codec-check.test.ts` returns at least 1
+ - `grep -c "RECORDING_ERROR" tests/offscreen/codec-check.test.ts` returns at least 1
+ - `npx vitest run tests/offscreen/codec-check.test.ts 2>&1` exits NON-ZERO AND output contains "Cannot find module" or "Failed to resolve"
+ - No `as any` and no `@ts-ignore` in the test file
+
+ RED test for codec strict-mode exists. Plan 03 will export `assertCodecSupported` to flip it GREEN.
+
+
+
+ Task 5: Create tests/offscreen/handshake.test.ts and tests/offscreen/port.test.ts (RED)
+ tests/offscreen/handshake.test.ts, tests/offscreen/port.test.ts
+
+ - .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`tests/offscreen/handshake.test.ts` + §`tests/offscreen/port.test.ts` (lines 607-680)
+ - .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Pattern 4: OFFSCREEN_READY handshake" + §"Pattern 5: Long-lived port" + §"Pitfall 4"
+ - src/shared/types.ts (line 18 — `OFFSCREEN_READY` declared but unused)
+
+
+ handshake.test.ts:
+ - Test 1 (`sends OFFSCREEN_READY after listener registration`): Importing `src/offscreen/recorder` MUST call `chrome.runtime.sendMessage` with an object whose `type` is `'OFFSCREEN_READY'` exactly once. The call MUST happen AFTER `chrome.runtime.onMessage.addListener` is called.
+
+ port.test.ts:
+ - Test 1 (`connects on module load`): Importing `src/offscreen/recorder` MUST call `chrome.runtime.connect({ name: 'video-keepalive' })` exactly once.
+ - Test 2 (`reconnects when port disconnects`): When the connected port fires its registered `onDisconnect` listener, the module MUST call `chrome.runtime.connect` a second time within the synchronous tick.
+
+
+Create `tests/offscreen/handshake.test.ts` with VERBATIM content:
+
+```typescript
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+interface PortStub {
+ name: string;
+ postMessage: ReturnType;
+ onMessage: { addListener: ReturnType };
+ onDisconnect: { addListener: ReturnType };
+ disconnect: ReturnType;
+}
+
+interface ChromeStub {
+ runtime: {
+ id: string;
+ sendMessage: (m: unknown) => void;
+ onMessage: { addListener: ReturnType };
+ connect: () => PortStub;
+ };
+}
+
+interface GlobalWithChrome {
+ chrome?: ChromeStub;
+ MediaRecorder?: { isTypeSupported: (mime: string) => boolean };
+}
+
+function buildChromeStub(calls: unknown[]): ChromeStub {
+ return {
+ runtime: {
+ id: 'ext-id-test',
+ sendMessage: (m: unknown) => {
+ calls.push(m);
+ },
+ onMessage: { addListener: vi.fn() },
+ connect: () => ({
+ name: 'video-keepalive',
+ postMessage: vi.fn(),
+ onMessage: { addListener: vi.fn() },
+ onDisconnect: { addListener: vi.fn() },
+ disconnect: vi.fn(),
+ }),
+ },
+ };
+}
+
+describe('OFFSCREEN_READY handshake', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ (globalThis as unknown as GlobalWithChrome).MediaRecorder = {
+ isTypeSupported: vi.fn().mockReturnValue(true),
+ };
+ });
+
+ it('sends OFFSCREEN_READY after listener registration', async () => {
+ const calls: unknown[] = [];
+ const stub = buildChromeStub(calls);
+ (globalThis as unknown as GlobalWithChrome).chrome = stub;
+ await import('../../src/offscreen/recorder');
+ expect(stub.runtime.onMessage.addListener).toHaveBeenCalled();
+ expect(calls).toEqual(
+ expect.arrayContaining([expect.objectContaining({ type: 'OFFSCREEN_READY' })])
+ );
+ const readyCount = calls.filter(
+ (m): m is { type: string } =>
+ typeof m === 'object' && m !== null && (m as { type?: unknown }).type === 'OFFSCREEN_READY'
+ ).length;
+ expect(readyCount).toBe(1);
+ });
+});
+```
+
+Create `tests/offscreen/port.test.ts` with VERBATIM content:
+
+```typescript
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+interface PortStub {
+ name: string;
+ postMessage: ReturnType;
+ onMessage: { addListener: ReturnType };
+ onDisconnect: { addListener: (fn: () => void) => void };
+ disconnect: ReturnType;
+}
+
+interface ChromeStub {
+ runtime: {
+ id: string;
+ sendMessage: ReturnType;
+ onMessage: { addListener: ReturnType };
+ connect: () => PortStub;
+ };
+}
+
+interface GlobalWithChrome {
+ chrome?: ChromeStub;
+ MediaRecorder?: { isTypeSupported: (mime: string) => boolean };
+}
+
+describe('port reconnect', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ (globalThis as unknown as GlobalWithChrome).MediaRecorder = {
+ isTypeSupported: vi.fn().mockReturnValue(true),
+ };
+ });
+
+ it('connects on module load', async () => {
+ let connectCount = 0;
+ const disconnectListeners: Array<() => void> = [];
+ const stub: ChromeStub = {
+ runtime: {
+ id: 'ext-id-test',
+ sendMessage: vi.fn(),
+ onMessage: { addListener: vi.fn() },
+ connect: () => {
+ connectCount++;
+ return {
+ name: 'video-keepalive',
+ postMessage: vi.fn(),
+ onMessage: { addListener: vi.fn() },
+ onDisconnect: {
+ addListener: (fn: () => void) => disconnectListeners.push(fn),
+ },
+ disconnect: vi.fn(),
+ };
+ },
+ },
+ };
+ (globalThis as unknown as GlobalWithChrome).chrome = stub;
+ await import('../../src/offscreen/recorder');
+ expect(connectCount).toBe(1);
+ });
+
+ it('reconnects when port disconnects', async () => {
+ let connectCount = 0;
+ const disconnectListeners: Array<() => void> = [];
+ const stub: ChromeStub = {
+ runtime: {
+ id: 'ext-id-test',
+ sendMessage: vi.fn(),
+ onMessage: { addListener: vi.fn() },
+ connect: () => {
+ connectCount++;
+ return {
+ name: 'video-keepalive',
+ postMessage: vi.fn(),
+ onMessage: { addListener: vi.fn() },
+ onDisconnect: {
+ addListener: (fn: () => void) => disconnectListeners.push(fn),
+ },
+ disconnect: vi.fn(),
+ };
+ },
+ },
+ };
+ (globalThis as unknown as GlobalWithChrome).chrome = stub;
+ await import('../../src/offscreen/recorder');
+ expect(connectCount).toBe(1);
+ // Fire the disconnect — module should reconnect
+ disconnectListeners.forEach((fn) => fn());
+ expect(connectCount).toBeGreaterThanOrEqual(2);
+ });
+});
+```
+
+
+ test -f tests/offscreen/handshake.test.ts && test -f tests/offscreen/port.test.ts && (npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts 2>&1 | grep -qE "Cannot find module|Failed to resolve")
+
+
+ - Both test files exist
+ - `grep -c "OFFSCREEN_READY" tests/offscreen/handshake.test.ts` returns at least 1
+ - `grep -c "video-keepalive" tests/offscreen/port.test.ts` returns at least 1
+ - `grep -c "reconnects" tests/offscreen/port.test.ts` returns at least 1
+ - `npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts 2>&1` exits NON-ZERO AND output contains "Cannot find module" or "Failed to resolve"
+ - No `as any` and no `@ts-ignore` in either test file (the cast pattern is `as unknown as X` which is acceptable per CLAUDE.md — narrows progressively without bypassing the type-checker)
+
+ Two more RED tests pin the handshake (Pattern 4) and port reconnect (Pattern 5 / Pitfall 4) contracts. Plan 04 will flip both to GREEN.
+
+
+
+
+
+After all five tasks land:
+
+1. `test -d node_modules/vitest && test -f vitest.config.ts && test -f package-lock.json` — exits 0.
+2. `ls tests/offscreen/*.test.ts | wc -l` returns 4 (ring-buffer, codec-check, handshake, port).
+3. `ls tests/fixtures/.gitkeep` exits 0.
+4. `npx vitest run 2>&1 | grep -cE "Failed to resolve|Cannot find module"` returns at least 4 (one per test file — all RED because `src/offscreen/recorder.ts` doesn't exist yet).
+5. `npx tsc --noEmit 2>&1` — the test files reference a module that doesn't exist, so TypeScript will error. THIS IS EXPECTED. Plan 03 will resolve this. To make the RED gate clean: confirm the typescript errors are ONLY about the missing module (`Cannot find module '../../src/offscreen/recorder'`), not about test syntax mistakes.
+
+Commit cadence: ONE commit per task (five atomic commits). Commit messages: `test(01-02): wave-0 setup — install vitest`, `test(01-02): add vitest.config.ts`, `test(01-02): add RED ring-buffer tests`, `test(01-02): add RED codec-check tests`, `test(01-02): add RED handshake + port tests`.
+
+The "RED gate" is met because:
+- The 4 test files import from `'../../src/offscreen/recorder'`
+- That module does not yet exist (Plan 03 creates it)
+- Therefore every test fails at the IMPORT step (not at the assertion step)
+- This is exactly the Nyquist TDD signal: a contract pinned in tests, an implementation gap waiting to be filled.
+
+
+
+- Vitest installed and runnable (`npx vitest --version` works)
+- `npm test` script exists and runs `vitest run`
+- Four RED test files exist, each importing `src/offscreen/recorder` (which doesn't exist yet — this is INTENTIONAL)
+- `tests/fixtures/` directory committed (empty placeholder via .gitkeep)
+- No `as any` / `@ts-ignore` in any test file
+- All five commits land cleanly
+
+
+
diff --git a/.planning/phases/01-stabilize-video-pipeline/01-03-PLAN.md b/.planning/phases/01-stabilize-video-pipeline/01-03-PLAN.md
new file mode 100644
index 0000000..bf30979
--- /dev/null
+++ b/.planning/phases/01-stabilize-video-pipeline/01-03-PLAN.md
@@ -0,0 +1,676 @@
+---
+phase: 01-stabilize-video-pipeline
+plan: 03
+type: tdd
+wave: 1
+depends_on: ["02"]
+files_modified:
+ - src/offscreen/recorder.ts
+ - src/offscreen/index.html
+ - src/shared/logger.ts
+ - src/shared/types.ts
+autonomous: true
+requirements:
+ - REQ-video-ring-buffer
+requirements_addressed:
+ - REQ-video-ring-buffer
+
+must_haves:
+ truths:
+ - "`src/offscreen/recorder.ts` exists and exports the symbols Plan 02 tests against: addChunk, trimAged, getBuffer, resetBuffer, assertCodecSupported, VIDEO_BUFFER_DURATION_MS"
+ - "Running `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` exits 0 with all tests green"
+ - "Buffer holds at most: 1 header chunk + every chunk with arrival timestamp newer than now-30_000ms"
+ - "Codec strictly bound to `video/webm;codecs=vp9` at 400 000 bps with `MediaRecorder.isTypeSupported` gate; no fallback chain (D-20)"
+ - "`MediaRecorder.start(2000)` is called on session start (timeslice = 2000 ms per SPEC §4.1)"
+ - "On `MediaStreamTrack.onended`, the buffer is cleared and a `RECORDING_ERROR` of `'user-stopped-sharing'` is emitted to SW"
+ - "Restart-segments fallback (D-13) is pre-staged as a commented-out skeleton at the bottom of recorder.ts so Plan 07's fallback path doesn't require a re-plan"
+ - "`src/offscreen/index.html` exists at the source path and references `./recorder.ts`"
+ - "`src/shared/logger.ts` has an `OffscreenLogger` class with `[OS:...]` prefix"
+ - "`src/shared/types.ts` carries the new `PortMessageType` and `PortMessage` types; `VIDEO_CHUNK` and `VIDEO_CHUNK_SAVED` removed"
+ artifacts:
+ - path: "src/offscreen/recorder.ts"
+ provides: "Ring buffer + getDisplayMedia + MediaRecorder + codec strict-mode + track-ended handler"
+ min_lines: 150
+ exports: ["addChunk", "trimAged", "getBuffer", "resetBuffer", "assertCodecSupported", "VIDEO_BUFFER_DURATION_MS"]
+ - path: "src/offscreen/index.html"
+ provides: "crxjs-managed offscreen entry point"
+ contains: "./recorder.ts"
+ - path: "src/shared/logger.ts"
+ provides: "OffscreenLogger added alongside existing Logger / ContentLogger"
+ contains: "export class OffscreenLogger"
+ - path: "src/shared/types.ts"
+ provides: "Port message types added; deleted VIDEO_CHUNK / VIDEO_CHUNK_SAVED variants"
+ contains: "PortMessage"
+ key_links:
+ - from: "src/offscreen/recorder.ts"
+ to: "src/shared/types.ts"
+ via: "import type { Message, VideoChunk, PortMessage } from '../shared/types'"
+ pattern: "from '../shared/types'"
+ - from: "src/offscreen/recorder.ts"
+ to: "src/shared/logger.ts"
+ via: "import { OffscreenLogger } from '../shared/logger'"
+ pattern: "from '../shared/logger'"
+ - from: "src/offscreen/index.html"
+ to: "src/offscreen/recorder.ts"
+ via: "
+