docs(01): create phase 1 plans for video pipeline stabilization .planning/phases/01-stabilize-video-pipeline/01-01-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-02-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-03-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-04-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-05-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-06-PLAN.md .planning/phases/01-stabilize-video-pipeline/01-07-PLAN.md .planning/ROADMAP.md

This commit is contained in:
2026-05-15 16:37:45 +02:00
parent 094eef0307
commit 178fdd5b77
9 changed files with 4112 additions and 1 deletions

View File

@@ -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"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<threat_model>
## 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: ["<all_urls>"]` 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.)
</threat_model>
<tasks>
<task type="auto">
<name>Task 1: Amend intel/decisions.md DEC-003 and DEC-010 (D-A1)</name>
<files>.planning/intel/decisions.md</files>
<read_first>
- .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)
</read_first>
<action>
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.
</action>
<verify>
<automated>grep -c "AMENDED-BY: Phase 01" .planning/intel/decisions.md</automated>
</verify>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<done>Both decisions carry an Amendment block; downstream phases that grep DEC-003 / DEC-010 now find the new contract alongside the original.</done>
</task>
<task type="auto">
<name>Task 2: Amend intel/constraints.md — retire two, add one (D-A2)</name>
<files>.planning/intel/constraints.md</files>
<read_first>
- .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)
</read_first>
<action>
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.
</action>
<verify>
<automated>grep -c "RETIRED-BY: Phase 01" .planning/intel/constraints.md</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Two retired constraints carry RETIRED-BY markers; CON-display-capture-binding exists as the consolidated replacement constraint.</done>
</task>
<task type="auto">
<name>Task 3: Amend PROJECT.md Key Decisions table and Constraints section (D-A3)</name>
<files>.planning/PROJECT.md</files>
<read_first>
- .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)
</read_first>
<action>
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.
</action>
<verify>
<automated>grep -c "AMENDED by Phase 01" .planning/PROJECT.md</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>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/.</done>
</task>
<task type="auto">
<name>Task 4: Amend REQUIREMENTS.md REQ-video-ring-buffer (D-A4)</name>
<files>.planning/REQUIREMENTS.md</files>
<read_first>
- .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)
</read_first>
<action>
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.
</action>
<verify>
<automated>grep -c "AMENDED in" .planning/REQUIREMENTS.md</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>REQ-video-ring-buffer reads correctly for Phase 1 and binds the new constraint set.</done>
</task>
<task type="auto">
<name>Task 5: Amend ROADMAP.md Phase 1 description + Success Criterion #2 (D-A5)</name>
<files>.planning/ROADMAP.md</files>
<read_first>
- .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)
</read_first>
<action>
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.
</action>
<verify>
<automated>grep -c "AMENDED" .planning/ROADMAP.md</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>Task 6: Manifest permission swap — tabCapture → desktopCapture, drop alarms (D-A6 / D-05)</name>
<files>manifest.json</files>
<read_first>
- 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)
</read_first>
<action>
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).
</action>
<verify>
<automated>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')"</automated>
</verify>
<acceptance_criteria>
- `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 `"<all_urls>"` (unchanged)
</acceptance_criteria>
<done>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.</done>
</task>
</tasks>
<verification>
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.
</verification>
<success_criteria>
- 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).
</success_criteria>
<output>
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-01-SUMMARY.md` with:
- Which 6 files were amended and at which line ranges
- The exact text of the new CON-display-capture-binding block (so downstream plans can quote it)
- Confirmation of all six acceptance grep checks
- Six commit SHAs (one per task)
</output>

View File

@@ -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\""
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<interfaces>
<!-- The recorder module that Plan 03 will create. These tests are written
against THIS interface. Plan 03 MUST export every symbol listed here. -->
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.
</interfaces>
<threat_model>
## 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.)
</threat_model>
<tasks>
<task type="auto">
<name>Task 1: Install Vitest, add npm test script, run npm install</name>
<files>package.json</files>
<read_first>
- 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)
</read_first>
<action>
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": "^<MAJOR>"` where `<MAJOR>` 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.
</action>
<verify>
<automated>test -d node_modules/vitest && grep -c "\"vitest\"" package.json && grep -c "\"test\": \"vitest run\"" package.json</automated>
</verify>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<done>Vitest available; `npm test` is wired but will fail because the 4 RED tests are about to be added in the next tasks.</done>
</task>
<task type="auto">
<name>Task 2: Create vitest.config.ts</name>
<files>vitest.config.ts</files>
<read_first>
- 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)
</read_first>
<action>
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`).
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<done>`vitest.config.ts` is committed; `npx vitest run` finds zero test files but exits cleanly (no config error).</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Create tests/offscreen/ring-buffer.test.ts (RED — first chunk + trim 30s)</name>
<files>tests/offscreen/ring-buffer.test.ts, tests/fixtures/.gitkeep</files>
<read_first>
- .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)
</read_first>
<behavior>
- 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.
</behavior>
<action>
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).
</action>
<verify>
<automated>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")</automated>
</verify>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Create tests/offscreen/codec-check.test.ts (RED — codec strict-mode)</name>
<files>tests/offscreen/codec-check.test.ts</files>
<read_first>
- .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"
</read_first>
<behavior>
- 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`.
</behavior>
<action>
Create `tests/offscreen/codec-check.test.ts` with VERBATIM content:
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
interface ChromeStub {
runtime: { sendMessage: ReturnType<typeof vi.fn> };
}
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).
</action>
<verify>
<automated>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")</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>RED test for codec strict-mode exists. Plan 03 will export `assertCodecSupported` to flip it GREEN.</done>
</task>
<task type="auto" tdd="true">
<name>Task 5: Create tests/offscreen/handshake.test.ts and tests/offscreen/port.test.ts (RED)</name>
<files>tests/offscreen/handshake.test.ts, tests/offscreen/port.test.ts</files>
<read_first>
- .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)
</read_first>
<behavior>
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.
</behavior>
<action>
Create `tests/offscreen/handshake.test.ts` with VERBATIM content:
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
interface PortStub {
name: string;
postMessage: ReturnType<typeof vi.fn>;
onMessage: { addListener: ReturnType<typeof vi.fn> };
onDisconnect: { addListener: ReturnType<typeof vi.fn> };
disconnect: ReturnType<typeof vi.fn>;
}
interface ChromeStub {
runtime: {
id: string;
sendMessage: (m: unknown) => void;
onMessage: { addListener: ReturnType<typeof vi.fn> };
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<typeof vi.fn>;
onMessage: { addListener: ReturnType<typeof vi.fn> };
onDisconnect: { addListener: (fn: () => void) => void };
disconnect: ReturnType<typeof vi.fn>;
}
interface ChromeStub {
runtime: {
id: string;
sendMessage: ReturnType<typeof vi.fn>;
onMessage: { addListener: ReturnType<typeof vi.fn> };
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);
});
});
```
</action>
<verify>
<automated>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")</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>Two more RED tests pin the handshake (Pattern 4) and port reconnect (Pattern 5 / Pitfall 4) contracts. Plan 04 will flip both to GREEN.</done>
</task>
</tasks>
<verification>
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.
</verification>
<success_criteria>
- 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
</success_criteria>
<output>
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-02-SUMMARY.md` with:
- Vitest version that was actually installed
- Exact list of new files (paths and line counts)
- Output of `npx vitest run 2>&1` showing all 4 tests RED at the import step
- Commit list (five commits)
- Note: "Plan 03 must export {addChunk, trimAged, getBuffer, resetBuffer, assertCodecSupported, VIDEO_BUFFER_DURATION_MS} from src/offscreen/recorder.ts to flip these tests to GREEN. Plan 04 wires up the OFFSCREEN_READY send (handshake test) and the port.connect + reconnect-on-disconnect (port tests)."
</output>

View File

@@ -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: "<script type=\"module\" src=\"./recorder.ts\">"
pattern: "./recorder.ts"
---
<objective>
GREEN-implement the offscreen recorder module that owns:
- `getDisplayMedia` capture (CONTEXT.md D-01..D-03)
- single continuous `MediaRecorder` at timeslice 2000 ms (D-09)
- in-memory ring buffer with header retention + 30 s age trim (D-10, D-11)
- codec strict-mode (D-20 / RESEARCH.md Pattern 6)
- `MediaStreamTrack.onended` cleanup (RESEARCH.md Example F)
- restart-segments fallback pre-staged as a documented skeleton (D-13)
This plan flips the 2 ring-buffer + codec test files (created RED in Plan 02)
to GREEN. It does NOT yet wire up the port-keepalive or the OFFSCREEN_READY
handshake — that is Plan 04. To keep this plan inside its context budget, the
port-side code in `recorder.ts` is left as a small import-time placeholder
that Plan 04 fills in. The Plan-02 port + handshake tests therefore remain
RED until Plan 04 lands, which is the intended choreography per the
wave-1 dependency graph (Plans 03 and 04 run in parallel; Plan 02 wrote both
sets of RED tests in advance).
Purpose: REQ-video-ring-buffer's load-bearing logic lives here. The ring
buffer is a pure function — exactly the TDD sweet spot. The remaining
non-pure side (getDisplayMedia + MediaRecorder construction + track.onended)
is wired in the same module because the buffer is private state owned by it.
Both test files attached to this plan run in pure Node and don't need a
browser.
Output: `src/offscreen/recorder.ts` (the new authoritative source-of-truth),
`src/offscreen/index.html` (crxjs entry point), `src/shared/logger.ts`
extended with `OffscreenLogger`, `src/shared/types.ts` cleaned up.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.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
@tests/offscreen/ring-buffer.test.ts
@tests/offscreen/codec-check.test.ts
@src/background/index.ts
@src/shared/types.ts
@src/shared/logger.ts
@offscreen/index.ts
</context>
<interfaces>
<!-- Plan 02 already wrote tests that pin this module's contracts.
This plan implements the module to satisfy those tests. -->
Required exports from `src/offscreen/recorder.ts` (verified against the RED tests):
```typescript
export const VIDEO_BUFFER_DURATION_MS: number;
export function addChunk(blob: Blob, timestamp: number): void;
export function trimAged(now: number): void;
export function getBuffer(): VideoChunk[];
export function resetBuffer(): void;
export function assertCodecSupported(): void; // throws Error on unsupported vp9
```
Required side-effects of importing the module (pinned by Plan 02 tests):
- `chrome.runtime.onMessage.addListener(...)` called exactly once
- `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY', ... })` called exactly once
- `chrome.runtime.connect({ name: 'video-keepalive' })` called exactly once
The OFFSCREEN_READY send AND the port connect are owned by Plan 04 — Plan 03
adds a small bootstrap stub that calls them in the right order so that
the Plan-02 ring-buffer + codec tests can stub `chrome.runtime` minimally
without crashing on the bootstrap. The full port reconnect + ping logic
lands in Plan 04.
VideoChunk type (from src/shared/types.ts:27-31, unchanged):
```typescript
export interface VideoChunk {
data: Blob;
timestamp: number;
isFirst?: boolean;
}
```
</interfaces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser getDisplayMedia picker → offscreen `MediaStream` | The operator selects what to share; offscreen receives a track they chose |
| `MediaRecorder.isTypeSupported` → recorder.start path | A spoofed `MediaRecorder` could attempt to influence codec selection (only matters in test fixtures, but the strict-mode prevents production codec downgrade) |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-1-01 | Tampering — codec downgrade | `MediaRecorder` constructor | mitigate | `assertCodecSupported()` calls `MediaRecorder.isTypeSupported('video/webm;codecs=vp9')` BEFORE constructing the recorder; if it returns false, throws an Error and emits `RECORDING_ERROR` to SW. No vp8 / h264 / default fallback chain. The strict mode covers `MediaRecorder` itself being absent (the codec-check test mocks both cases). Grep gate: `grep -v '^#' src/offscreen/recorder.ts \| grep -cE 'codecs=(vp8\|h264)'` returns 0 (no fallback codec strings in the module). |
| T-1-03 | Information Disclosure — captured stream contains other apps' content | `getDisplayMedia` stream | accept | This is the documented residual risk per CONTEXT.md D-04. The Chrome "Sharing your screen" indicator is the user-facing signal; the operator chose to share. No code-level mitigation is possible (the API is supposed to capture the screen). Documented as accepted risk in 01-RESEARCH.md §"Security Domain". |
| T-1-NEW-03-01 | DoS — unbounded buffer growth on a stuck timestamp | `addChunk` + `trimAged` | mitigate | The trim function uses arrival timestamp; if a clock anomaly produces a negative or stuck `now`, the buffer is bounded above by SPEC §10 #9 (50 MB RAM ceiling) anyway. Defensive belt: the recorder also exposes `getBuffer().length` to SW so a healthchecker can observe growth. No active rate-limit needed for Phase 1. |
</threat_model>
<feature>
<name>Offscreen recorder module — ring buffer + getDisplayMedia + codec strict-mode + track-ended cleanup</name>
<files>src/offscreen/recorder.ts (new), src/offscreen/index.html (new), src/shared/logger.ts (modify), src/shared/types.ts (modify)</files>
<behavior>
RED already exists (Plan 02). GREEN must satisfy:
Ring buffer (from `tests/offscreen/ring-buffer.test.ts`):
- After `resetBuffer()`, `getBuffer()` returns `[]`
- First `addChunk` produces an entry with `isFirst: true`; the first-chunk flag is sticky for the FIRST add only and survives subsequent trims
- Second and later `addChunk` calls produce entries with `isFirst: false` (or undefined — `falsy`)
- `trimAged(now)` removes every entry whose `timestamp < now - VIDEO_BUFFER_DURATION_MS` AND whose `isFirst` is not `true`
- `VIDEO_BUFFER_DURATION_MS === 30_000`
- `trimAged` on empty buffer does not throw
Codec strict-mode (from `tests/offscreen/codec-check.test.ts`):
- `assertCodecSupported()` calls `MediaRecorder.isTypeSupported('video/webm;codecs=vp9')`
- If the return is falsy: throw a new `Error` whose message starts with `'vp9 unsupported'` AND call `chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: <message> })` BEFORE throwing
- If the return is truthy: do not throw and do not emit RECORDING_ERROR
Bootstrap side-effects (pinned by Plan 02 handshake + port tests but Plan 03 only needs to STUB them — Plan 04 fills in):
- At module import time, register `chrome.runtime.onMessage.addListener(...)` BEFORE calling `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' })` (handshake ordering — Plan 04 elaborates)
- Call `chrome.runtime.connect({ name: 'video-keepalive' })` once (Plan 04 wires the ping loop and reconnect)
</behavior>
<implementation>
See task list — three tasks: RED-verify, GREEN-implement, then types + logger ergonomics.
</implementation>
</feature>
<tasks>
<task type="auto">
<name>Task 1: RED-verify — confirm Plan 02 tests fail at module resolution</name>
<files>(none modified)</files>
<read_first>
- tests/offscreen/ring-buffer.test.ts (Plan 02 output)
- tests/offscreen/codec-check.test.ts (Plan 02 output)
</read_first>
<action>
Run the two test files this plan owns and CAPTURE the failure mode:
```bash
npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts 2>&1 | tee /tmp/01-03-red.log
```
The output MUST contain "Cannot find module '../../src/offscreen/recorder'" (or "Failed to resolve" — Vitest's wording varies by version). The exit code MUST be non-zero. Both of these confirm the RED gate is in place from Plan 02.
If either condition is NOT met:
- If the test passes: Plan 02 wrote a degenerate test — STOP and escalate (a passing test before the implementation exists means the test is wrong).
- If the test fails for a different reason (e.g., syntax error in the test file): STOP and escalate to Plan 02 owner.
Otherwise, RED is confirmed; proceed to Task 2.
This is a verify-only step; nothing is committed.
</action>
<verify>
<automated>grep -qE "Cannot find module|Failed to resolve" /tmp/01-03-red.log && ! npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts 2>&1 | tail -1 | grep -q "passed"</automated>
</verify>
<acceptance_criteria>
- `/tmp/01-03-red.log` exists and is non-empty
- Contains "Cannot find module" or "Failed to resolve"
- Exit code of the vitest invocation was non-zero
</acceptance_criteria>
<done>RED gate confirmed. Plan 02 test files import a module that does not yet exist. Proceed to GREEN.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: GREEN — write src/offscreen/recorder.ts and src/offscreen/index.html</name>
<files>src/offscreen/recorder.ts, src/offscreen/index.html</files>
<read_first>
- src/background/index.ts (lines 26-75 — ring-buffer logic being relocated)
- src/shared/types.ts (the VideoChunk interface to import)
- src/shared/logger.ts (Logger class shape; OffscreenLogger lands in Task 3)
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md (lines 35-225 — composed pattern for the new module)
- .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Example A" + §"Example E" + §"Example F" (lines 882-893, 949-971, 973-989)
- tests/offscreen/ring-buffer.test.ts (the export surface to match)
- tests/offscreen/codec-check.test.ts (the export surface to match)
</read_first>
<behavior>
After Task 2 ships, the following MUST hold:
```bash
npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts --reporter=dot
# exit 0 — all 6 tests pass (4 in ring-buffer, 2 in codec-check)
```
</behavior>
<action>
Write the new `src/offscreen/recorder.ts`. Use the VERBATIM module below — line-by-line, comments and all (Russian section headers preserved per CONTEXT.md "Established patterns"):
```typescript
// src/offscreen/recorder.ts — Phase 01 source of truth (replaces dead
// offscreen/index.ts and the inline string in vite.config.ts:13-216).
// Owns: getDisplayMedia capture, MediaRecorder lifecycle, in-memory ring
// buffer with WebM-header retention + 30 s age trim, codec strict-mode,
// track.onended cleanup. Port keepalive + OFFSCREEN_READY handshake are
// wired by Plan 04 in the matching wave-1 lane.
import { OffscreenLogger } from '../shared/logger';
import type { Message, VideoChunk } from '../shared/types';
// ─── Константы (per CON-video-codec, CON-video-window) ──────────────────
export const VIDEO_BUFFER_DURATION_MS = 30_000; // 30 секунд
const VIDEO_MIME = 'video/webm;codecs=vp9'; // D-20 strict — no fallback
const VIDEO_BITRATE = 400_000; // CON-video-codec
const TIMESLICE_MS = 2_000; // SPEC §4.1
const PORT_NAME = 'video-keepalive'; // Plan 04 owns the ping loop
const logger = new OffscreenLogger('Recorder');
// ─── Состояние модуля (module-level, NOT inside startRecording — fixes audit P0 #1 shadow) ───
let videoRecorder: MediaRecorder | null = null; // renamed from 'mediaRecorder' to prevent shadowing
let mediaStream: MediaStream | null = null;
let videoBuffer: VideoChunk[] = [];
let firstChunkSaved = false;
let keepalivePort: chrome.runtime.Port | null = null; // Plan 04 fills the lifecycle
// ─── Кольцевой буфер (pure functions — testable in Node) ────────────────
export function addChunk(blob: Blob, timestamp: number): void {
const chunk: VideoChunk = {
data: blob,
timestamp,
isFirst: !firstChunkSaved,
};
if (!firstChunkSaved) {
firstChunkSaved = true;
logger.log('First chunk (WebM header) pinned, size:', blob.size);
}
videoBuffer.push(chunk);
trimAged(timestamp);
}
export function trimAged(now: number): void {
const cutoff = now - VIDEO_BUFFER_DURATION_MS;
videoBuffer = videoBuffer.filter((chunk) => {
if (chunk.isFirst) {
return true;
}
return chunk.timestamp >= cutoff;
});
}
export function getBuffer(): VideoChunk[] {
return videoBuffer;
}
export function resetBuffer(): void {
videoBuffer = [];
firstChunkSaved = false;
}
// ─── Проверка кодека (D-20 strict-mode — no fallback chain) ─────────────
export function assertCodecSupported(): void {
const supported =
typeof MediaRecorder !== 'undefined' &&
typeof MediaRecorder.isTypeSupported === 'function' &&
MediaRecorder.isTypeSupported(VIDEO_MIME);
if (!supported) {
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '<unknown>';
const errMessage = `vp9 unsupported. UA=${ua}`;
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: errMessage });
throw new Error(errMessage);
}
}
// ─── Захват экрана (getDisplayMedia inside offscreen — D-01) ────────────
async function startRecording(): Promise<void> {
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
logger.warn('Recording already active; ignoring duplicate START_RECORDING');
return;
}
try {
assertCodecSupported(); // throws if vp9 missing — no fallback
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false, // SPEC §9 — Phase 2 / CAP-01 territory
});
mediaStream = stream;
videoRecorder = new MediaRecorder(stream, {
mimeType: VIDEO_MIME,
videoBitsPerSecond: VIDEO_BITRATE,
});
videoRecorder.ondataavailable = onDataAvailable;
videoRecorder.onerror = (event) => logger.error('MediaRecorder error:', event);
// Track end detection — RESEARCH.md Example F. Attach to ALL tracks
// (Pitfall 6) so an audio-track edge case won't silently desync.
stream.getTracks().forEach((track) => {
track.addEventListener('ended', onUserStoppedSharing, { once: true });
});
videoRecorder.start(TIMESLICE_MS);
logger.log('Recording started, mime:', VIDEO_MIME, 'timeslice:', TIMESLICE_MS, 'ms');
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
logger.error('startRecording failed:', msg);
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: msg });
throw error;
}
}
function onDataAvailable(event: BlobEvent): void {
if (!event.data || event.data.size === 0) {
return;
}
addChunk(event.data, Date.now());
}
function onUserStoppedSharing(): void {
logger.log('Operator stopped sharing — clearing buffer');
resetBuffer();
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
videoRecorder.stop();
}
if (mediaStream !== null) {
mediaStream.getTracks().forEach((t) => t.stop());
mediaStream = null;
}
videoRecorder = null;
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: 'user-stopped-sharing' });
}
function stopRecording(): void {
if (videoRecorder !== null && videoRecorder.state !== 'inactive') {
videoRecorder.stop();
logger.log('Recording stopped manually');
}
}
// ─── Bootstrap (Plan 04 wires the full port + handshake) ────────────────
// Plan 03 commits this minimal bootstrap so that Plan 02's ring-buffer +
// codec tests can mock `chrome.runtime` without crashing on module import.
// Plan 04 elaborates the OFFSCREEN_READY handshake and the port reconnect
// loop without rewriting this module's exports.
function isFromOwnExtension(sender: chrome.runtime.MessageSender | undefined): boolean {
return sender?.id === chrome.runtime.id;
}
chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => {
if (!isFromOwnExtension(sender)) {
return false;
}
switch (message.type) {
case 'START_RECORDING':
startRecording().then(() => sendResponse({ ok: true })).catch((err) => sendResponse({ ok: false, error: String(err) }));
return true;
case 'STOP_RECORDING':
stopRecording();
sendResponse({ ok: true });
return false;
default:
return false;
}
});
// Plan 04 will replace this stub with the full reconnect + ping loop.
keepalivePort = chrome.runtime.connect({ name: PORT_NAME });
chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' });
// Touch the keepalive var so noUnusedLocals doesn't complain — Plan 04
// uses it. Once Plan 04 lands, this line is removed in its refactor pass.
void keepalivePort;
// ─── D-13 fallback: restart-segments skeleton (pre-staged per CONTEXT.md) ──
// Activated only if the Phase 07 ffprobe gate fails on the simpler
// continuous-recorder + age-trim approach. See RESEARCH.md Pattern 3.
//
// const SEGMENT_MS = 10_000;
// const MAX_SEGMENTS = 3;
// let segments: Blob[] = [];
// let currentChunks: Blob[] = [];
// function rotateSegment(): void { videoRecorder?.stop(); /* onstop assembles a segment */ }
// function onSegmentStopped(): void {
// segments.push(new Blob(currentChunks, { type: 'video/webm' }));
// if (segments.length > MAX_SEGMENTS) segments.shift();
// currentChunks = [];
// if (mediaStream) {
// videoRecorder = new MediaRecorder(mediaStream, { mimeType: VIDEO_MIME, videoBitsPerSecond: VIDEO_BITRATE });
// videoRecorder.ondataavailable = (e) => { if (e.data.size > 0) currentChunks.push(e.data); };
// videoRecorder.onstop = onSegmentStopped;
// videoRecorder.start();
// setTimeout(rotateSegment, SEGMENT_MS);
// }
// }
```
Then write `src/offscreen/index.html` with VERBATIM content:
```html
<!doctype html>
<html>
<head>
<meta charset="UTF-8">
<title>Mokosh Recorder</title>
</head>
<body>
<script type="module" src="./recorder.ts"></script>
</body>
</html>
```
Notes:
- The exported pure functions (`addChunk`, `trimAged`, `getBuffer`, `resetBuffer`, `assertCodecSupported`) are deliberately decoupled from the impure `getDisplayMedia` path so they can be exercised in Node — exactly what Plan 02's ring-buffer + codec tests do.
- The handshake `OFFSCREEN_READY` send + the `chrome.runtime.connect` call are minimal stubs that satisfy the import-time test side-effects. Plan 04 elaborates them (adds ping loop, adds onDisconnect reconnect, adds REQUEST_BUFFER handler) — the changes Plan 04 makes are ADDITIVE within the same module, so they do NOT break Plan 03's tests.
- The `as any` count in this module is zero, the `@ts-ignore` count is zero (per CLAUDE.md naming rule + CONTEXT.md "Established patterns").
After writing both files, run:
```bash
npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts --reporter=dot
npx tsc --noEmit
```
Both MUST exit 0.
</action>
<verify>
<automated>npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `test -f src/offscreen/recorder.ts && test -f src/offscreen/index.html` exits 0
- `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` exits 0 with 6 passing tests
- `npx tsc --noEmit` exits 0
- `grep -c "export function addChunk" src/offscreen/recorder.ts` returns 1
- `grep -c "export function trimAged" src/offscreen/recorder.ts` returns 1
- `grep -c "export function getBuffer" src/offscreen/recorder.ts` returns 1
- `grep -c "export function resetBuffer" src/offscreen/recorder.ts` returns 1
- `grep -c "export function assertCodecSupported" src/offscreen/recorder.ts` returns 1
- `grep -c "VIDEO_BUFFER_DURATION_MS = 30_000" src/offscreen/recorder.ts` returns 1
- `grep -v '^#' src/offscreen/recorder.ts | grep -cE "codecs=(vp8|h264)"` returns 0 (T-1-01 mitigation — no fallback codec)
- `grep -c "as any" src/offscreen/recorder.ts` returns 0 (CLAUDE.md rule)
- `grep -c "@ts-ignore" src/offscreen/recorder.ts` returns 0
- `grep -c "start(2000)" src/offscreen/recorder.ts` returns 0 BUT `grep -c "TIMESLICE_MS" src/offscreen/recorder.ts` returns at least 2 (start uses the constant)
- `grep -c "./recorder.ts" src/offscreen/index.html` returns 1
</acceptance_criteria>
<done>Two tests files green; ring buffer and codec strict-mode behavior implemented; offscreen HTML entry exists; tsc clean.</done>
</task>
<task type="auto">
<name>Task 3: Logger + types ergonomics — add OffscreenLogger, clean up shared/types.ts</name>
<files>src/shared/logger.ts, src/shared/types.ts</files>
<read_first>
- src/shared/logger.ts (lines 1-51 — Logger and ContentLogger shapes)
- src/shared/types.ts (lines 1-68 — full current state)
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §"Shared Patterns / Logging" (lines 716-759)
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`src/shared/types.ts` (lines 480-530)
</read_first>
<action>
Two edits.
(1) `src/shared/logger.ts` — append a new `OffscreenLogger` class at the END of the file, mirroring the shape of `ContentLogger`:
```typescript
// Логгер для Offscreen Document
export class OffscreenLogger {
private context: string;
constructor(context: string) {
this.context = context;
}
private logWithLevel(level: 'log' | 'warn' | 'error', ...args: any[]) {
const timestamp = new Date().toISOString();
console[level](`[OS:${this.context}] ${timestamp}`, ...args);
}
log(...args: any[]) {
this.logWithLevel('log', ...args);
}
warn(...args: any[]) {
this.logWithLevel('warn', ...args);
}
error(...args: any[]) {
this.logWithLevel('error', ...args);
}
}
```
Do not modify the existing `Logger` or `ContentLogger` classes.
(2) `src/shared/types.ts` — three edits:
(a) Remove `'VIDEO_CHUNK'` and `'VIDEO_CHUNK_SAVED'` from the `MessageType` union. The `MessageType` becomes:
```typescript
export type MessageType =
| 'REQUEST_PERMISSIONS'
| 'PERMISSIONS_GRANTED'
| 'PERMISSIONS_DENIED'
| 'GET_VIDEO_BUFFER'
| 'VIDEO_BUFFER_RESPONSE'
| 'GET_RRWEB_EVENTS'
| 'RRWEB_EVENTS_RESPONSE'
| 'SAVE_ARCHIVE'
| 'ARCHIVE_SAVED'
| 'START_RECORDING'
| 'STOP_RECORDING'
| 'RECORDING_ERROR'
| 'OFFSCREEN_READY';
```
(b) After the existing `Message<T>` interface (after line 24), add the port message types VERBATIM:
```typescript
// Типы сообщений в long-lived port (offscreen ↔ SW; D-17 / Plan 04)
export type PortMessageType =
| 'PING'
| 'REQUEST_BUFFER'
| 'BUFFER';
export interface PortMessage {
type: PortMessageType;
chunks?: VideoChunk[];
}
```
(c) Leave the `VideoChunk`, `UserEvent`, `SessionMetadata`, `PopupState`, `VideoBufferResponse`, `RrwebEventsResponse` interfaces unchanged.
After both edits, run:
```bash
npx tsc --noEmit
npx vitest run
```
Both MUST exit 0 (the 2 ring-buffer + codec tests should still pass; the 2 handshake + port tests remain RED — they get GREEN in Plan 04).
</action>
<verify>
<automated>npx tsc --noEmit && grep -c "OffscreenLogger" src/shared/logger.ts && grep -cE "VIDEO_CHUNK[^_S]|VIDEO_CHUNK_SAVED" src/shared/types.ts</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0
- `grep -c "export class OffscreenLogger" src/shared/logger.ts` returns 1
- `grep -c "'VIDEO_CHUNK'" src/shared/types.ts` returns 0 (removed)
- `grep -c "'VIDEO_CHUNK_SAVED'" src/shared/types.ts` returns 0 (removed)
- `grep -c "PortMessage" src/shared/types.ts` returns at least 2 (one for the type alias, one for the interface)
- `grep -c "OFFSCREEN_READY" src/shared/types.ts` returns 1 (still present)
- `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` exits 0
</acceptance_criteria>
<done>OffscreenLogger available; types cleaned up; ring-buffer + codec tests still green; the deleted message types prevent Plan 05 from accidentally re-introducing the broken sendMessage-Blob path.</done>
</task>
<task type="auto">
<name>Task 4: REFACTOR — optional cleanup pass (only if obvious improvements exist)</name>
<files>src/offscreen/recorder.ts (only if changed)</files>
<read_first>
- src/offscreen/recorder.ts (Task 2 output)
</read_first>
<action>
Per the TDD REFACTOR phase rules in `$HOME/.claude/get-shit-done/references/tdd.md`:
1. Re-read `src/offscreen/recorder.ts` (from Task 2).
2. Look ONLY for obvious improvements:
- Constant duplication
- Unused imports
- Mis-placed comments
3. If no obvious improvements: SKIP THIS TASK (do not commit). Note in the summary: "Refactor: no changes needed."
4. If improvements found: make them; run `npx vitest run && npx tsc --noEmit`; commit ONLY if both exit 0.
Do NOT use this task to add features. Do NOT rewrite the bootstrap section (that's Plan 04's territory). Do NOT extract the ring buffer into a separate file in this phase (audit-driven decision: one source of truth in `src/offscreen/recorder.ts`).
</action>
<verify>
<automated>npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- All Plan-03-owned tests still pass after the refactor pass (or after the no-op decision)
- `npx tsc --noEmit` still exits 0
- The SUMMARY.md documents whether refactor happened or was skipped
</acceptance_criteria>
<done>REFACTOR phase complete (or explicitly skipped with rationale).</done>
</task>
</tasks>
<verification>
After all four tasks land:
1. `npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts` — exits 0, 6 tests passing.
2. `npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts` — exits NON-ZERO (these stay RED until Plan 04). The failure mode is now NOT a module-resolution error (since the module exists), but rather an assertion mismatch — the stub bootstrap calls `chrome.runtime.sendMessage({type: 'OFFSCREEN_READY'})` exactly once and `chrome.runtime.connect({name: 'video-keepalive'})` exactly once, so the handshake test MAY actually pass after Plan 03 lands. **This is acceptable**: the reconnect test is what definitively pins Plan 04.
3. `npx tsc --noEmit` — exits 0.
4. `grep -c "as any\|@ts-ignore" src/offscreen/recorder.ts src/offscreen/index.html` — returns 0.
5. `grep -v '^#' src/offscreen/recorder.ts | grep -cE "codecs=(vp8|h264)"` — returns 0 (T-1-01 mitigation grep gate).
6. `wc -l src/offscreen/recorder.ts` — at least 150 lines.
Commit cadence: per the TDD reference, this is the GREEN phase of a TDD plan:
- Task 1: no commit (verify-only).
- Task 2: ONE commit (`feat(01-03): implement offscreen recorder ring buffer and codec strict-mode`).
- Task 3: ONE commit (`feat(01-03): add OffscreenLogger and clean up shared types`).
- Task 4: zero or one commit (`refactor(01-03): {what was cleaned}` only if changes were made).
Total: 2-3 commits.
</verification>
<success_criteria>
- `src/offscreen/recorder.ts` exists, exports all 6 symbols Plan 02 tests expect
- 6 ring-buffer + codec tests passing (Plan 02 → Plan 03 GREEN handoff complete for the ring-buffer + codec slice)
- `src/offscreen/index.html` exists at source path, references `./recorder.ts`
- `OffscreenLogger` class exists in `src/shared/logger.ts`
- `PortMessage` type exists in `src/shared/types.ts`; `VIDEO_CHUNK` / `VIDEO_CHUNK_SAVED` removed
- D-13 fallback skeleton present as comment block in recorder.ts (no re-plan needed if Plan 07 ffprobe fails)
- `npx tsc --noEmit` clean; no `as any`, no `@ts-ignore`
- The grep gates in `<verification>` all return their expected values
</success_criteria>
<output>
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-03-SUMMARY.md` with:
- RED: log excerpt from Task 1 showing the module-resolution failure
- GREEN: list of 6 tests now passing, with the test names
- REFACTOR: what changed (if anything) in Task 4
- Final list of files modified (4 files) and line counts
- The exact export surface of recorder.ts (so Plan 04 / 05 can grep against it without re-reading)
- Note: "Plan 04 needs to: (a) replace the import-time stub `chrome.runtime.connect({name:'video-keepalive'})` with the full ping-loop + reconnect-on-disconnect from RESEARCH.md Pattern 5; (b) wire the `REQUEST_BUFFER` handler so the SW can pull chunks on export; (c) confirm the existing `OFFSCREEN_READY` send is still emitted exactly once. Plan 05 needs to update SW side accordingly."
- Commit list (2-3 commits)
</output>

View File

@@ -0,0 +1,403 @@
---
phase: 01-stabilize-video-pipeline
plan: 04
type: tdd
wave: 1
depends_on: ["02", "03"]
files_modified:
- src/offscreen/recorder.ts
autonomous: true
requirements:
- REQ-video-ring-buffer
requirements_addressed:
- REQ-video-ring-buffer
must_haves:
truths:
- "On module import, the offscreen opens exactly one port via `chrome.runtime.connect({ name: 'video-keepalive' })` AND emits exactly one `OFFSCREEN_READY` message via `chrome.runtime.sendMessage`"
- "Both the port-connect and the OFFSCREEN_READY send happen AFTER `chrome.runtime.onMessage.addListener` registration (Pattern 4 ordering)"
- "When the open port fires its registered `onDisconnect` listener, the module synchronously reconnects (Pitfall 4 mitigation)"
- "The port emits a `{ type: 'PING' }` postMessage on a ≤ 25 s interval (RESEARCH.md Pattern 5)"
- "Pre-emptive reconnect runs every 290 s (belt-and-braces against the ~5 min port-lifetime cap)"
- "The port handles incoming `REQUEST_BUFFER` messages by responding with `{ type: 'BUFFER', chunks: <getBuffer()> }`"
- "Running `npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts` exits 0 (Plan 02 RED → GREEN)"
artifacts:
- path: "src/offscreen/recorder.ts"
provides: "Full port keepalive + handshake + REQUEST_BUFFER handler"
contains: "video-keepalive"
key_links:
- from: "src/offscreen/recorder.ts (port.onDisconnect)"
to: "src/offscreen/recorder.ts (connectPort)"
via: "synchronous reconnect call"
pattern: "onDisconnect"
- from: "src/offscreen/recorder.ts (REQUEST_BUFFER handler)"
to: "getBuffer()"
via: "port.postMessage({ type: 'BUFFER', chunks: getBuffer() })"
pattern: "REQUEST_BUFFER"
---
<objective>
Replace the import-time STUB bootstrap in Plan 03's `src/offscreen/recorder.ts`
with the full long-lived-port keepalive + OFFSCREEN_READY handshake +
REQUEST_BUFFER handler. This flips the two remaining RED tests from Plan 02
(`tests/offscreen/handshake.test.ts` and `tests/offscreen/port.test.ts`) to
GREEN.
Purpose: REQ-video-ring-buffer also covers the SW-keepalive contract per
D-17 (long-lived port replaces `chrome.alarms`) and the OFFSCREEN_READY
handshake per RESEARCH.md Pattern 4. Both behaviors are observable from a
pure-Node test that mocks `chrome.runtime` — exactly the TDD sweet spot for
a contract that survives SW unloads, port 5-min cap, and offscreen
bootstrap races.
Output: Updated `src/offscreen/recorder.ts` — only this single file is
touched. Plan 04 is an isolated unit-of-work; Plan 05 picks up the
SW-side `onConnect` handler in parallel (Plan 04 runs alongside Plan 05 in
Wave 1 with NO file overlap — Plan 04 owns offscreen-side, Plan 05 owns
SW-side).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
@.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md
@.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md
@tests/offscreen/handshake.test.ts
@tests/offscreen/port.test.ts
@src/offscreen/recorder.ts
</context>
<interfaces>
<!-- Pinned by Plan 02 tests. Plan 04 must satisfy these and ONLY these. -->
Module-import side-effects (from tests/offscreen/handshake.test.ts):
- `chrome.runtime.onMessage.addListener` is called at least once
- `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' })` is called exactly once
Module-import side-effects (from tests/offscreen/port.test.ts):
- `chrome.runtime.connect({ name: 'video-keepalive' })` is called exactly once (first test)
- Firing the registered onDisconnect listener causes `chrome.runtime.connect` to be called a second time within the synchronous tick (second test)
In-port message contract (per RESEARCH.md Pattern 5):
- Outbound: `{ type: 'PING' }` every 25 s (configurable constant)
- Outbound on REQUEST_BUFFER: `{ type: 'BUFFER', chunks: VideoChunk[] }` (the chunks are `getBuffer()`)
- Inbound listener: handles `{ type: 'REQUEST_BUFFER' }` from SW side
Existing exports unchanged: `addChunk`, `trimAged`, `getBuffer`, `resetBuffer`, `assertCodecSupported`, `VIDEO_BUFFER_DURATION_MS` (Plan 03's tests must continue to pass).
</interfaces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| port connection origin | Any context inside the extension can call `chrome.runtime.connect`; non-extension contexts cannot (Chrome enforces). The offscreen-side connect is initiated BY the offscreen, so the offscreen is the trusting party (it knows where the port goes). The SW-side `onConnect` (in Plan 05) is the listening party — that side validates `sender.id === chrome.runtime.id`. |
| inbound port `onMessage` payload | The offscreen receives `REQUEST_BUFFER` over the port; the SW is the only caller (sender validated on SW side); inbound message validation here is defense-in-depth |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-1-04 | Spoofing — port hijack | Offscreen-side `port.onMessage` listener | mitigate | The port name `'video-keepalive'` is paired with the SW-side `onConnect` filter `p.name === 'video-keepalive' && p.sender?.id === chrome.runtime.id` (added in Plan 05). On the offscreen side: the port is opened BY the offscreen, so the offscreen is the trusting party and the SW is the only legitimate counterparty. As defense in depth, the offscreen-side `port.onMessage` handler explicitly switches on `msg.type` and ignores any unknown shape. Grep gate (Plan 05 owns the sender check, Plan 04 owns the type-switch on inbound port traffic): `grep -v '^#' src/offscreen/recorder.ts \| grep -cE "REQUEST_BUFFER" ` returns at least 1, AND any branch outside the known PortMessageType union is treated as no-op. |
| T-1-NEW-04-01 | DoS — port reconnect storm | offscreen `connectPort` → port.onDisconnect | mitigate | The reconnect path is idempotent: when `onDisconnect` fires, the existing port reference is cleared, a fresh port is opened, and the new port's listeners are attached. If reconnect throws (e.g., SW gone), the failure is caught and logged; the next pre-emptive 290 s reconnect cycle will retry. Grep gate: `grep -v '^#' src/offscreen/recorder.ts \| grep -cE "function connectPort"` returns 1. |
</threat_model>
<feature>
<name>Port keepalive + OFFSCREEN_READY handshake + REQUEST_BUFFER handler</name>
<files>src/offscreen/recorder.ts</files>
<behavior>
RED already exists (Plan 02 handshake + port tests). GREEN must:
Handshake (from tests/offscreen/handshake.test.ts):
- On module import, register `chrome.runtime.onMessage.addListener` BEFORE calling `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' })`
- `OFFSCREEN_READY` is sent EXACTLY ONCE during module bootstrap (not on every subsequent message)
Port (from tests/offscreen/port.test.ts):
- On module import, call `chrome.runtime.connect({ name: 'video-keepalive' })` exactly once
- Register `onDisconnect` listener on the connected port
- When `onDisconnect` fires, immediately call `chrome.runtime.connect({ name: 'video-keepalive' })` again (synchronous reconnect)
- The reconnect attaches the same set of listeners (onMessage, onDisconnect, etc.) to the fresh port
Production behavior (not directly tested in Plan 02, but verifiable):
- Every 25 s while a port is open: `port.postMessage({ type: 'PING' })` (keepalive against SW idle)
- Every 290 s: pre-emptively close + reopen the port (Pitfall 4 — beat the ~5 min lifetime cap)
- On inbound port message `{ type: 'REQUEST_BUFFER' }`: respond with `{ type: 'BUFFER', chunks: getBuffer() }` (this is the SW's export-time data-pull path; Plan 05 wires the SW caller)
</behavior>
<implementation>
See task list — three tasks: RED-verify the two port + handshake test files, GREEN refactor the bootstrap section of recorder.ts to add the port lifecycle, then REFACTOR (skipped or minimal).
</implementation>
</feature>
<tasks>
<task type="auto">
<name>Task 1: RED-verify — confirm port + handshake tests fail on Plan 03's stub bootstrap</name>
<files>(none modified)</files>
<read_first>
- tests/offscreen/handshake.test.ts (Plan 02 output)
- tests/offscreen/port.test.ts (Plan 02 output)
- src/offscreen/recorder.ts (Plan 03 stub bootstrap — lines 144-155 of the Task-2 verbatim module)
</read_first>
<action>
Run the two test files this plan owns:
```bash
npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts 2>&1 | tee /tmp/01-04-red.log
```
Analyse the failure mode:
- **Handshake test:** Plan 03's stub bootstrap already calls `chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' })` once after the listener registration, so this test MAY ALREADY PASS as of Plan 03's GREEN landing. If it does — that is acceptable. The handshake contract is sufficiently met by Plan 03's stub.
- **Port test (`connects on module load`):** Plan 03's stub calls `chrome.runtime.connect({ name: PORT_NAME })` once — this test MAY ALREADY PASS too.
- **Port test (`reconnects when port disconnects`):** Plan 03's stub does NOT register `onDisconnect`, so when the test fires the registered listeners, `connectCount` stays at 1 — this test MUST FAIL.
The intended RED gate for Plan 04 is the reconnect test. Confirm:
```bash
grep -q "reconnects when port disconnects" /tmp/01-04-red.log && grep -q "FAIL" /tmp/01-04-red.log
```
If the reconnect test passes spuriously (e.g., because Plan 03 already added reconnect): STOP — the plan ordering may have shifted; reconcile with Plan 03 before continuing.
If the handshake test or the `connects on module load` test fail: STOP — Plan 03 broke its own contract; revisit Plan 03 instead.
Otherwise, proceed to Task 2.
No commits in this task.
</action>
<verify>
<automated>npx vitest run tests/offscreen/port.test.ts -t "reconnects when port disconnects" 2>&1 | grep -qE "FAIL|✗"</automated>
</verify>
<acceptance_criteria>
- `/tmp/01-04-red.log` exists and is non-empty
- The reconnect test is in a FAIL state
- The handshake test and the `connects on module load` port test outcomes are recorded in /tmp/01-04-red.log (whether they pass or fail — we don't gate on them here)
</acceptance_criteria>
<done>RED gate confirmed: the reconnect test is failing because Plan 03's stub bootstrap doesn't reconnect on disconnect. Proceed to GREEN.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: GREEN — replace stub bootstrap with full port lifecycle</name>
<files>src/offscreen/recorder.ts</files>
<read_first>
- src/offscreen/recorder.ts (Plan 03 output — the bootstrap section starting at the `// ─── Bootstrap` comment through the `void keepalivePort;` line)
- .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Pattern 5: Long-lived port" (lines 590-648) and §"Pitfall 4" (lines 820-836)
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`src/offscreen/recorder.ts` — port keepalive snippet (lines 185-198)
- tests/offscreen/handshake.test.ts (the test that pins OFFSCREEN_READY ordering)
- tests/offscreen/port.test.ts (the test that pins reconnect-on-disconnect)
</read_first>
<behavior>
After Task 2 lands:
```bash
npx vitest run tests/offscreen/handshake.test.ts tests/offscreen/port.test.ts
# exit 0 — all 3 tests pass (1 in handshake, 2 in port)
npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts
# exit 0 — ring-buffer and codec tests still green (NO regression from Plan 03)
```
</behavior>
<action>
Edit `src/offscreen/recorder.ts` to REPLACE the entire bootstrap section (currently starting at the `// ─── Bootstrap (Plan 04 wires the full port + handshake)` comment through the `void keepalivePort;` line) with the VERBATIM expanded bootstrap below. The pure-function exports above the bootstrap section and the D-13 fallback comment block below it are UNCHANGED.
Also: at the top of the file, ADD a new constant block (after the existing `const PORT_NAME = ...` line and before the `const logger = new OffscreenLogger(...)` line):
```typescript
const PORT_PING_MS = 25_000; // < 30 s SW idle threshold
const PORT_RECONNECT_MS = 290_000; // pre-empt the ~5 min port cap (Pitfall 4)
```
The bootstrap section becomes VERBATIM:
```typescript
// ─── Bootstrap: handshake + port lifecycle (D-17, RESEARCH.md Patterns 4 & 5) ──
function isFromOwnExtension(sender: chrome.runtime.MessageSender | undefined): boolean {
return sender?.id === chrome.runtime.id;
}
chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => {
if (!isFromOwnExtension(sender)) {
return false;
}
switch (message.type) {
case 'START_RECORDING':
startRecording().then(() => sendResponse({ ok: true })).catch((err) => sendResponse({ ok: false, error: String(err) }));
return true;
case 'STOP_RECORDING':
stopRecording();
sendResponse({ ok: true });
return false;
default:
return false;
}
});
// Stable handles for the ping interval and the pre-emptive reconnect timer,
// so we can clear them on disconnect / re-init.
let pingIntervalId: ReturnType<typeof setInterval> | null = null;
let preemptiveReconnectId: ReturnType<typeof setTimeout> | null = null;
function teardownPortTimers(): void {
if (pingIntervalId !== null) {
clearInterval(pingIntervalId);
pingIntervalId = null;
}
if (preemptiveReconnectId !== null) {
clearTimeout(preemptiveReconnectId);
preemptiveReconnectId = null;
}
}
function onPortMessage(message: unknown): void {
// Defense-in-depth: explicit shape check before destructuring
if (typeof message !== 'object' || message === null) {
return;
}
const type = (message as { type?: unknown }).type;
if (type === 'REQUEST_BUFFER') {
if (keepalivePort !== null) {
keepalivePort.postMessage({ type: 'BUFFER', chunks: getBuffer() });
}
}
// Any unknown port message type is silently dropped (T-1-04 defense-in-depth).
}
function connectPort(): void {
teardownPortTimers();
try {
keepalivePort = chrome.runtime.connect({ name: PORT_NAME });
} catch (err) {
logger.error('port connect failed:', err);
keepalivePort = null;
return;
}
keepalivePort.onMessage.addListener(onPortMessage);
keepalivePort.onDisconnect.addListener(() => {
logger.warn('port disconnected — reconnecting');
teardownPortTimers();
keepalivePort = null;
// Synchronous reconnect — tests/offscreen/port.test.ts pins this
connectPort();
});
pingIntervalId = setInterval(() => {
keepalivePort?.postMessage({ type: 'PING' });
}, PORT_PING_MS);
preemptiveReconnectId = setTimeout(() => {
logger.log('pre-emptive port reconnect (290 s cap)');
keepalivePort?.disconnect();
// onDisconnect handler above triggers a fresh connectPort() call
}, PORT_RECONNECT_MS);
}
// Open the first port and emit OFFSCREEN_READY. Order matters per Pattern 4:
// the onMessage listener registration above already happened, so the SW can
// safely send START_RECORDING the moment it sees OFFSCREEN_READY.
connectPort();
chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' });
```
After this replacement, the previous `let keepalivePort: chrome.runtime.Port | null = null;` declaration in the module-level state block is UNCHANGED — it stays. The previous `void keepalivePort;` line is REMOVED (the variable is now used by `onPortMessage` and `connectPort`, so the no-unused-locals workaround is no longer needed).
Run:
```bash
npx vitest run
npx tsc --noEmit
```
Both MUST exit 0.
Notes:
- `setInterval` / `setTimeout` in offscreen documents: offscreen runs in a real DOM context (NOT in the SW), so timers are reliable — unlike `setTimeout` inside a SW. RESEARCH.md "Don't Hand-Roll" table confirms this.
- The `disconnect()` call inside the pre-emptive reconnect uses optional chaining; if the port is somehow already null, the call is a no-op. The `onDisconnect` listener fires synchronously in Chrome's port-disconnect path, which transitions us back through `connectPort()` cleanly.
- `PortMessage` shape: outbound traffic uses the `PortMessage` type added to `src/shared/types.ts` in Plan 03. No `as any`. Inbound messages over the port are typed `unknown` and shape-checked.
</action>
<verify>
<automated>npx vitest run && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `npx vitest run` exits 0 with ALL test files in tests/offscreen/ passing (8 tests total across 4 files: 4 ring-buffer + 2 codec-check + 1 handshake + 2 port)
- `npx tsc --noEmit` exits 0
- `grep -c "function connectPort" src/offscreen/recorder.ts` returns 1
- `grep -c "PORT_PING_MS = 25_000" src/offscreen/recorder.ts` returns 1
- `grep -c "PORT_RECONNECT_MS = 290_000" src/offscreen/recorder.ts` returns 1
- `grep -c "REQUEST_BUFFER" src/offscreen/recorder.ts` returns at least 1 (the inbound handler)
- `grep -c "'BUFFER'" src/offscreen/recorder.ts` returns at least 1 (the outbound response shape)
- `grep -c "isFromOwnExtension" src/offscreen/recorder.ts` returns at least 1 (sender check on onMessage — T-1-04 defense-in-depth)
- `grep -c "as any" src/offscreen/recorder.ts` returns 0
- `grep -c "@ts-ignore" src/offscreen/recorder.ts` returns 0
- `grep -c "void keepalivePort" src/offscreen/recorder.ts` returns 0 (the no-unused-locals workaround is removed)
</acceptance_criteria>
<done>All 4 test files from Plan 02 are now GREEN. The offscreen module owns the full port lifecycle. Plan 05 can wire the SW-side `onConnect` handler against the same port name with confidence.</done>
</task>
<task type="auto">
<name>Task 3: REFACTOR — optional cleanup or no-op</name>
<files>src/offscreen/recorder.ts (only if changed)</files>
<read_first>
- src/offscreen/recorder.ts (Task 2 output)
</read_first>
<action>
Per the TDD REFACTOR phase rules:
1. Re-read `src/offscreen/recorder.ts` (the full module now that Plan 04 has landed).
2. Look ONLY for obvious improvements:
- Constants that should be hoisted into a shared block
- Type assertions that could be replaced with proper type narrowing
- Comments that became stale after the bootstrap refactor
3. If no obvious improvements: SKIP THIS TASK (do not commit). Note in summary: "Refactor: no changes needed."
4. If improvements found: make them; run `npx vitest run && npx tsc --noEmit`; commit ONLY if both exit 0.
Do NOT use this task to add features. Plan 05 picks up the SW-side wiring — do not anticipate it in this module.
</action>
<verify>
<automated>npx vitest run && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- All 4 test files still pass after the refactor pass (or after the no-op decision)
- `npx tsc --noEmit` still exits 0
- The SUMMARY.md documents whether refactor happened or was skipped
</acceptance_criteria>
<done>REFACTOR phase complete (or explicitly skipped with rationale).</done>
</task>
</tasks>
<verification>
After all three tasks land:
1. `npx vitest run` — exits 0 with ALL 8 tests passing across the 4 test files in tests/offscreen/.
2. `npx tsc --noEmit` — exits 0.
3. `grep -c "as any\|@ts-ignore" src/offscreen/recorder.ts` returns 0.
4. The grep gates listed in Task 2's `acceptance_criteria` all return their expected values.
5. The module's line count grew modestly (Task 2 adds ~40 lines net to the bootstrap section); the file remains well under 250 lines total.
Commit cadence:
- Task 1: no commit.
- Task 2: ONE commit (`feat(01-04): wire offscreen port keepalive and OFFSCREEN_READY handshake`).
- Task 3: zero or one commit (`refactor(01-04): {what was cleaned}`).
Total: 1-2 commits.
</verification>
<success_criteria>
- All 4 Plan-02 test files green (ring-buffer, codec-check, handshake, port) — 8 tests total
- `src/offscreen/recorder.ts` contains a single `connectPort()` function that handles connect, reconnect, REQUEST_BUFFER response, and pre-emptive reconnect
- T-1-04 mitigations in place: sender-id check on onMessage; type-switch on inbound port messages
- No `as any`, no `@ts-ignore`
- D-17 contract (long-lived port replacing alarms) is implemented at the offscreen side; SW side lands in Plan 05
</success_criteria>
<output>
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-04-SUMMARY.md` with:
- RED: log excerpt from Task 1 showing the reconnect test failing
- GREEN: full vitest output showing all 8 tests passing
- REFACTOR: what changed (if anything) in Task 3
- Final line count for src/offscreen/recorder.ts
- Note: "Plan 05 must add a corresponding `chrome.runtime.onConnect.addListener` in `src/background/index.ts` that (a) filters by `port.name === 'video-keepalive'`, (b) validates `port.sender?.id === chrome.runtime.id` (T-1-04), (c) sends `{ type: 'REQUEST_BUFFER' }` over the port when SAVE_ARCHIVE arrives, and (d) handles the incoming `BUFFER` response by resolving the saveArchive Promise."
- Commit list (1-2 commits)
</output>

View File

@@ -0,0 +1,454 @@
---
phase: 01-stabilize-video-pipeline
plan: 05
type: execute
wave: 2
depends_on: ["03", "04"]
files_modified:
- src/background/index.ts
autonomous: true
requirements:
- REQ-video-ring-buffer
requirements_addressed:
- REQ-video-ring-buffer
must_haves:
truths:
- "`src/background/index.ts` no longer contains `addVideoChunkFromBlob`, `cleanupVideoBuffer`, `setupKeepalive`, `loadChunkFromIndexedDB`, `openIndexedDB`, or any `chrome.alarms` reference (buffer ownership moves to offscreen per D-16/D-19)"
- "`src/background/index.ts` no longer calls `chrome.tabCapture.getMediaStreamId` (D-01 amendment)"
- "`src/background/index.ts` no longer handles `VIDEO_CHUNK` or `VIDEO_CHUNK_SAVED` (deleted Message types in Plan 03)"
- "SW has an `onConnect` listener that filters `port.name === 'video-keepalive'` and validates `port.sender?.id === chrome.runtime.id` (T-1-04 mitigation)"
- "SW has an `onMessage` `OFFSCREEN_READY` case that resolves a pending readiness Promise (Pattern 4 SW side)"
- "SW's `SAVE_ARCHIVE` and `GET_VIDEO_BUFFER` handlers fetch the buffer via the port (`REQUEST_BUFFER` → wait for `BUFFER`) instead of holding their own `videoBuffer` array"
- "SW's `ensureOffscreen` uses `chrome.offscreen.Reason.DISPLAY_MEDIA` (not `USER_MEDIA`)"
- "SW's `onInstalled` listener calls `indexedDB.deleteDatabase('VideoRecorderDB')` once as a cleanup pass (RESEARCH.md Runtime State Inventory)"
- "`npx tsc --noEmit` exits 0"
artifacts:
- path: "src/background/index.ts"
provides: "Shrunk SW coordinator: lifecycle + port host + export buffer-fetch only; no buffer state, no alarms, no IndexedDB"
contains: "video-keepalive"
key_links:
- from: "src/background/index.ts (onConnect)"
to: "src/offscreen/recorder.ts (connectPort)"
via: "shared port name 'video-keepalive'"
pattern: "video-keepalive"
- from: "src/background/index.ts (SAVE_ARCHIVE handler)"
to: "src/background/index.ts (getVideoBufferFromOffscreen)"
via: "port REQUEST_BUFFER round-trip"
pattern: "REQUEST_BUFFER"
- from: "src/background/index.ts (ensureOffscreen)"
to: "src/offscreen/index.html"
via: "chrome.offscreen.createDocument url"
pattern: "src/offscreen/index.html"
---
<objective>
Shrink `src/background/index.ts` to its new responsibilities: offscreen
lifecycle, port host, and export-time buffer fetch. DELETE the SW-side
ring-buffer state and helpers (now owned by offscreen per D-16), DELETE the
chrome.alarms keepalive (D-18), DELETE the IndexedDB code path (D-19),
DELETE the `chrome.tabCapture.getMediaStreamId` call (D-01 amendment),
DELETE the `VIDEO_CHUNK` / `VIDEO_CHUNK_SAVED` message handlers (their
message types were removed in Plan 03), and WIRE the SW-side `onConnect`
handler against the `'video-keepalive'` port that Plan 04 opens from the
offscreen.
Purpose: REQ-video-ring-buffer's data flow on export is `popup →
SAVE_ARCHIVE → SW → REQUEST_BUFFER (via port) → offscreen → BUFFER (via
port) → SW → assemble ZIP → download`. The SW NEVER holds a chunk locally —
which is the only way the buffer survives SW idle unloads (D-16).
Output: A heavily-shrunk `src/background/index.ts` that compiles cleanly,
holds zero video-buffer state, talks to the offscreen exclusively via
runtime messaging + the long-lived port, and tolerates the IndexedDB
remnant by deleting it on first install.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
@.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md
@.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md
@src/background/index.ts
@src/shared/types.ts
@src/offscreen/recorder.ts
</context>
<interfaces>
Port contract (already implemented in offscreen by Plan 04):
- Port name: `'video-keepalive'`
- Offscreen → SW outbound: `{ type: 'PING' }` every 25 s (informational; SW just receives it as keepalive traffic)
- Offscreen → SW outbound on REQUEST_BUFFER: `{ type: 'BUFFER', chunks: VideoChunk[] }`
- SW → offscreen inbound: `{ type: 'REQUEST_BUFFER' }`
SW side must:
1. Register `chrome.runtime.onConnect.addListener` that filters by port name AND validates sender ID
2. Store the connected port in a module-level `let videoPort: chrome.runtime.Port | null`
3. Clear the reference on disconnect (offscreen will reconnect; SW gets a new onConnect call)
4. Expose `getVideoBufferFromOffscreen(): Promise<VideoBufferResponse>` that:
- Returns `{chunks: []}` early if no port is connected
- Otherwise sends `{type: 'REQUEST_BUFFER'}` over the port
- Resolves when a `{type: 'BUFFER', chunks: ...}` reply arrives
- Times out at 2 s with `{chunks: []}`
Existing SW behaviors to PRESERVE:
- `mergeVideoChunks(chunks: VideoChunk[]): Blob` (unchanged)
- `createArchive(...)` (unchanged signature; calls `mergeVideoChunks` and zips with JSZip)
- `downloadArchive(blob)` (unchanged)
- `captureScreenshot()` (unchanged — Phase 3 owns popup-side rework)
- `chrome.runtime.onInstalled` listener (existing, gets an indexedDB cleanup line added)
- `onMessage` cases: `REQUEST_PERMISSIONS`, `GET_VIDEO_BUFFER`, `SAVE_ARCHIVE` — KEPT but their bodies change to talk to offscreen via port
Removed message types (from Plan 03's edit to src/shared/types.ts):
- `'VIDEO_CHUNK'` — handler block deleted
- `'VIDEO_CHUNK_SAVED'` — handler block deleted
`chrome.offscreen.Reason.DISPLAY_MEDIA`: try `reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA]` first. If the current `@types/chrome` (0.0.268) does NOT expose `DISPLAY_MEDIA` and `tsc --noEmit` fails, fall back to a narrowing cast (no `as any`): `reasons: ['DISPLAY_MEDIA' as chrome.offscreen.Reason]`. The executor verifies which form compiles by running `npx tsc --noEmit` after the edit.
</interfaces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| extension contexts → SW `onConnect` | Any extension context can attempt to open a port to the SW; only the offscreen has a legitimate reason for the `'video-keepalive'` port |
| popup / content script → SW `onMessage` | Existing trust boundary; this plan adds T-1-04 sender-id check on the SW-side onMessage too |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-1-04 | Spoofing — port-hijack on SW side | `chrome.runtime.onConnect` handler | mitigate | The SW `onConnect` listener filters `port.name === 'video-keepalive'` AND `port.sender?.id === chrome.runtime.id`. A non-extension caller cannot open a runtime port at all (Chrome enforces); the sender-id check is defense-in-depth for the within-extension case. Grep gate: `grep -v '^#' src/background/index.ts \| grep -c "port.sender?.id !== chrome.runtime.id"` returns at least 1. |
| T-1-NEW-05-01 | Information Disclosure — buffer pulled by unauthorized message | `onMessage` handlers (`SAVE_ARCHIVE`, `GET_VIDEO_BUFFER`) | mitigate | The SW-side `onMessage` listener already exists; this plan adds a `sender.id === chrome.runtime.id` guard at the top of the listener (per RESEARCH.md Security Domain table). |
| T-1-NEW-05-02 | Tampering — stale IndexedDB orphan | `indexedDB.deleteDatabase('VideoRecorderDB')` in `onInstalled` | mitigate | After this phase deletes the inline plugin's IndexedDB code, browser profiles that previously ran the old build still have a `VideoRecorderDB` database. The SW `onInstalled` listener calls `indexedDB.deleteDatabase('VideoRecorderDB')` idempotently to clear it. The call is harmless if the DB never existed. Grep gate: `grep -c "indexedDB.deleteDatabase('VideoRecorderDB')" src/background/index.ts` returns 1. |
</threat_model>
<tasks>
<task type="auto">
<name>Task 1: DELETE — drop legacy buffer + alarms + IndexedDB + tabCapture paths from SW</name>
<files>src/background/index.ts</files>
<read_first>
- src/background/index.ts (the full current file: 536 lines)
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`src/background/index.ts` (lines 276-396) — the verified delete-target table
- .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md §"Files to DELETE in this phase"
</read_first>
<action>
The current `src/background/index.ts` has been verified at 536 lines. Apply the following deletions using the Edit tool. Re-verify each line range with `grep -n` BEFORE editing (line numbers below are from the on-disk state captured 2026-05-15; do NOT trust them blindly — re-run grep first):
(1) Delete `addVideoChunkFromBlob` function — currently lines 26-45 inclusive (the entire `// Кольцевой буфер видео` block through the closing `}`). Also remove the preceding `// Кольцевой буфер видео` comment.
(2) Delete `cleanupVideoBuffer` function — currently lines 47-75 inclusive.
(3) Delete `firstChunkSaved` from module state — line 17 (`let firstChunkSaved = false; // Флаг что первый чанк уже сохранен`).
(4) Delete `videoBuffer` from module state — line 16 (`let videoBuffer: VideoChunk[] = [];`).
(5) Replace the `chrome.tabCapture` call inside `startVideoCapture` — currently lines 126-144. The replacement is VERBATIM:
```typescript
// Создаём offscreen документ (с reason DISPLAY_MEDIA per D-02)
await ensureOffscreen();
// Просим offscreen запустить запись — getDisplayMedia вызывается там
// (D-01: больше нет chrome.tabCapture.getMediaStreamId).
logger.log('Sending START_RECORDING to offscreen...');
try {
await chrome.runtime.sendMessage({
type: 'START_RECORDING'
});
logger.log('START_RECORDING sent successfully');
} catch (msgError) {
logger.error('Failed to send START_RECORDING:', msgError);
throw msgError;
}
```
The existing `const [tab] = ...` line at the top of `startVideoCapture` and the `if (!tab.id || !tab.url) throw ...` check can be retained — they don't harm anything. Phase 1 keeps them.
(6) Replace `ensureOffscreen` reason — currently line 90 reads `reasons: ['USER_MEDIA'] as any,`. Replace VERBATIM:
```typescript
reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA],
```
If `chrome.offscreen.Reason.DISPLAY_MEDIA` is NOT in the current `@types/chrome` (0.0.268) and `tsc --noEmit` fails, fall back to (no `as any`):
```typescript
reasons: ['DISPLAY_MEDIA' as chrome.offscreen.Reason],
```
(7) Update the justification text on line 91 to match RESEARCH.md Example C:
```typescript
justification: 'Continuous screen recording for operator session diagnostics'
```
(8) Delete `setupKeepalive` function — currently lines 156-165.
(9) Delete the `setupKeepalive()` call inside `initialize` — currently line 525.
(10) Delete the `VIDEO_CHUNK` case from the onMessage switch — currently lines 457-466.
(11) Delete the `VIDEO_CHUNK_SAVED` case — currently lines 468-473.
(12) Delete `loadChunkFromIndexedDB` function — currently lines 482-505.
(13) Delete `openIndexedDB` function — currently lines 507-520.
After ALL these deletions, run `npx tsc --noEmit`. It MUST exit 0. If `VideoChunk` is reported as unused after the deletes, that indicates a function that needs it was inadvertently lost; STOP and audit.
</action>
<verify>
<automated>npx tsc --noEmit && [ $(grep -cE "addVideoChunkFromBlob|cleanupVideoBuffer|setupKeepalive|loadChunkFromIndexedDB|openIndexedDB|getMediaStreamId|chrome\.alarms" src/background/index.ts) -eq 0 ]</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0
- `grep -v '^#' src/background/index.ts | grep -c "addVideoChunkFromBlob"` returns 0
- `grep -v '^#' src/background/index.ts | grep -c "cleanupVideoBuffer"` returns 0
- `grep -v '^#' src/background/index.ts | grep -c "setupKeepalive"` returns 0
- `grep -v '^#' src/background/index.ts | grep -c "loadChunkFromIndexedDB"` returns 0
- `grep -v '^#' src/background/index.ts | grep -c "openIndexedDB"` returns 0
- `grep -v '^#' src/background/index.ts | grep -c "getMediaStreamId"` returns 0
- `grep -v '^#' src/background/index.ts | grep -c "VIDEO_CHUNK_SAVED"` returns 0
- `grep -v '^#' src/background/index.ts | grep -c "chrome.alarms"` returns 0
- `grep -c "DISPLAY_MEDIA" src/background/index.ts` returns 1
- `grep -c "as any" src/background/index.ts` returns 0 (CLAUDE.md rule)
- File line count reduced from 536 to roughly 380-400 lines (allow ±40)
</acceptance_criteria>
<done>SW shed every legacy path. tsc clean. File ~30% smaller. Ready for the port-host wiring in Task 2.</done>
</task>
<task type="auto">
<name>Task 2: ADD — wire SW-side port host, sender check, onInstalled IndexedDB cleanup, and port-based buffer fetch</name>
<files>src/background/index.ts</files>
<read_first>
- src/background/index.ts (post-Task-1 state)
- src/offscreen/recorder.ts (read-only reference for the offscreen-side port contract)
- .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Pattern 5: SW-side port handling" (lines 590-628)
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`src/background/index.ts` ADD blocks (lines 372-396)
</read_first>
<action>
Seven targeted additions using the Edit tool (NOT a full rewrite).
(1) **Module-level state additions** — after the existing `let cachedScreenshot: Blob | null = null;` line, ADD VERBATIM:
```typescript
// Port from offscreen (D-17). Re-assigned on every (re)connect.
let videoPort: chrome.runtime.Port | null = null;
// Offscreen readiness Promise — set up at module load, resolved on first
// OFFSCREEN_READY message (Pattern 4). startVideoCapture awaits this before
// sending START_RECORDING, so we never lose the popup's transient activation
// to a race with the offscreen bootstrap.
let offscreenReadyResolve: (() => void) | null = null;
const offscreenReady: Promise<void> = new Promise((res) => {
offscreenReadyResolve = res;
});
```
(2) **onConnect handler** — add VERBATIM AFTER the `ensureOffscreen` function block. Place it BEFORE `startVideoCapture` so the listener is registered before any code might trigger a port open:
```typescript
// SW-side port host (D-17, RESEARCH.md Pattern 5). The offscreen opens this
// port on bootstrap and reconnects on disconnect. We use it for: (a)
// keepalive traffic (PING) — Chrome 110+ resets the SW idle timer on every
// port message; (b) on-demand REQUEST_BUFFER round-trip during SAVE_ARCHIVE.
chrome.runtime.onConnect.addListener((port) => {
// T-1-04 mitigation: only accept ports from this extension
if (port.name !== 'video-keepalive') {
return;
}
if (port.sender?.id !== chrome.runtime.id) {
logger.warn('Rejecting port with mismatched sender:', port.sender?.id);
port.disconnect();
return;
}
logger.log('Offscreen port connected');
videoPort = port;
port.onDisconnect.addListener(() => {
logger.log('Offscreen port disconnected; offscreen will reconnect');
videoPort = null;
});
// Inbound traffic is mostly PING (ignored) and BUFFER (handled by the
// per-request listener installed in getVideoBufferFromOffscreen).
});
```
(3) **Buffer-fetch function** — add VERBATIM AFTER the onConnect block above:
```typescript
const BUFFER_FETCH_TIMEOUT_MS = 2_000;
async function getVideoBufferFromOffscreen(): Promise<VideoBufferResponse> {
if (videoPort === null) {
logger.warn('No offscreen port available; returning empty buffer');
return { chunks: [] };
}
const port = videoPort;
return new Promise<VideoBufferResponse>((resolve) => {
const timer = setTimeout(() => {
port.onMessage.removeListener(handler);
logger.warn(`Buffer fetch timed out after ${BUFFER_FETCH_TIMEOUT_MS} ms`);
resolve({ chunks: [] });
}, BUFFER_FETCH_TIMEOUT_MS);
const handler = (msg: unknown) => {
if (
typeof msg === 'object' &&
msg !== null &&
(msg as { type?: unknown }).type === 'BUFFER'
) {
clearTimeout(timer);
port.onMessage.removeListener(handler);
const chunks = (msg as { chunks?: VideoChunk[] }).chunks ?? [];
resolve({ chunks });
}
};
port.onMessage.addListener(handler);
port.postMessage({ type: 'REQUEST_BUFFER' });
});
}
```
(4) **Delete `getVideoBuffer` (the synchronous version that returned the local array)** — find and delete the function currently around lines ~168-172 post-deletes (`function getVideoBuffer(): VideoBufferResponse { return { chunks: videoBuffer }; }`). It no longer compiles after Task 1 deletes `videoBuffer`.
(5) **Replace the `GET_VIDEO_BUFFER` case** — in the onMessage switch, find `case 'GET_VIDEO_BUFFER':` and replace its body VERBATIM:
```typescript
case 'GET_VIDEO_BUFFER':
getVideoBufferFromOffscreen().then((resp) => sendResponse(resp));
return true;
```
(6) **Update `saveArchive` to use the port-based fetch** — find the `const videoBuffer = getVideoBuffer();` line (currently around line 332 in the post-Task-1 file). Replace VERBATIM:
```typescript
const videoBufferResp = await getVideoBufferFromOffscreen();
logger.log(`Video buffer: ${videoBufferResp.chunks.length} chunks`);
```
And update the subsequent `createArchive(videoBuffer, ...)` call to use `videoBufferResp` instead. Specifically:
```typescript
const archiveBlob = await createArchive(
videoBufferResp,
rrwebEvents,
userEvents,
screenshot
);
```
(7) **Add OFFSCREEN_READY case + await in startVideoCapture** — two sub-edits:
(7a) Add a new case to the onMessage switch, placed AFTER `case 'SAVE_ARCHIVE':` and BEFORE the `default:` block. VERBATIM:
```typescript
case 'OFFSCREEN_READY':
logger.log('OFFSCREEN_READY received');
offscreenReadyResolve?.();
offscreenReadyResolve = null;
return false;
```
(7b) In `startVideoCapture`, ADD `await offscreenReady;` AFTER `await ensureOffscreen();`. The relevant excerpt becomes:
```typescript
await ensureOffscreen();
await offscreenReady;
logger.log('Sending START_RECORDING to offscreen...');
// ... rest unchanged
```
(8) **Sender check on onMessage** — at the very top of the existing `chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {` callback, rename `_sender` to `sender` and add a guard. Replace the listener header VERBATIM:
```typescript
chrome.runtime.onMessage.addListener((message: Message, sender, sendResponse) => {
if (sender.id !== chrome.runtime.id) {
logger.warn('Rejecting message with mismatched sender:', sender.id);
return false;
}
logger.log('Received message:', message.type, message);
```
(9) **onInstalled IndexedDB cleanup** — find the existing `chrome.runtime.onInstalled.addListener` block and REPLACE entirely VERBATIM:
```typescript
chrome.runtime.onInstalled.addListener((details) => {
logger.log('Extension installed/updated:', details.reason);
// RESEARCH.md Runtime State Inventory — clean up orphaned IndexedDB from
// pre-Phase-01 builds. Idempotent: no-op if DB never existed.
try {
indexedDB.deleteDatabase('VideoRecorderDB');
logger.log('Cleaned up orphaned VideoRecorderDB (if present)');
} catch (e) {
logger.warn('indexedDB.deleteDatabase failed:', e);
}
initialize();
});
```
After ALL these edits, run:
```bash
npx tsc --noEmit
npx vitest run
```
Both MUST exit 0.
</action>
<verify>
<automated>npx tsc --noEmit && npx vitest run && grep -c "video-keepalive" src/background/index.ts && grep -c "VideoRecorderDB" src/background/index.ts</automated>
</verify>
<acceptance_criteria>
- `npx tsc --noEmit` exits 0
- `npx vitest run` exits 0 (all 8 offscreen tests still pass — Plan 05 only touches SW, but the offscreen tests should not regress)
- `grep -c "chrome.runtime.onConnect.addListener" src/background/index.ts` returns 1
- `grep -c "'video-keepalive'" src/background/index.ts` returns at least 1
- `grep -c "port.sender?.id !== chrome.runtime.id" src/background/index.ts` returns 1 (T-1-04 mitigation)
- `grep -c "sender.id !== chrome.runtime.id" src/background/index.ts` returns 1 (onMessage sender check)
- `grep -c "indexedDB.deleteDatabase('VideoRecorderDB')" src/background/index.ts` returns 1
- `grep -c "function getVideoBufferFromOffscreen" src/background/index.ts` returns 1
- `grep -c "REQUEST_BUFFER" src/background/index.ts` returns at least 1
- `grep -c "offscreenReady" src/background/index.ts` returns at least 2 (the Promise variable + the await)
- `grep -c "OFFSCREEN_READY" src/background/index.ts` returns at least 1 (the case label)
- `grep -c "as any" src/background/index.ts` returns 0
- `grep -c "@ts-ignore" src/background/index.ts` returns 0
</acceptance_criteria>
<done>SW side fully wired against the offscreen's port. Sender checks in place. IndexedDB cleanup landed on onInstalled. The SW is now a pure coordinator — it holds no buffer state of its own.</done>
</task>
</tasks>
<verification>
After both tasks land:
1. `npx tsc --noEmit` — exits 0.
2. `npx vitest run` — exits 0 (8 tests passing across 4 files in tests/offscreen/).
3. The grep gates listed in Task 2's `acceptance_criteria` all return their expected values.
4. `wc -l src/background/index.ts` — line count between 380 and 440 (the file shrunk from 536 by ~100-150 lines as the legacy paths went away and ~30-40 lines were added for the port host).
Commit cadence: TWO commits.
- Task 1: ONE commit (`refactor(01-05): delete legacy SW buffer + alarms + IndexedDB + tabCapture paths`).
- Task 2: ONE commit (`feat(01-05): wire SW-side port host and port-based buffer fetch`).
</verification>
<success_criteria>
- `src/background/index.ts` carries no buffer state, no alarms, no IndexedDB plumbing, no `tabCapture` calls
- SW has `onConnect` handler matching the offscreen's port (Plan 04 counterparty)
- SW has `OFFSCREEN_READY` handshake handler resolving a readiness Promise
- T-1-04 mitigations in place on BOTH onConnect (sender + port name) and onMessage (sender)
- IndexedDB orphan cleanup runs on onInstalled
- `tsc --noEmit` clean; no `as any`, no `@ts-ignore`
</success_criteria>
<output>
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-05-SUMMARY.md` with:
- Before/after line count for src/background/index.ts (was 536, now N)
- List of every deleted symbol from Task 1 (so future audits can grep)
- The final shape of the onMessage switch (which cases survived)
- A snippet showing the exact `chrome.offscreen.createDocument` call as committed (so Plan 06 / 07 can grep against it)
- Confirmation that `npx vitest run` shows all 8 tests passing (port + handshake stay green because Plan 04's offscreen-side stayed unchanged)
- Two commit SHAs
</output>

View File

@@ -0,0 +1,320 @@
---
phase: 01-stabilize-video-pipeline
plan: 06
type: execute
wave: 2
depends_on: ["03"]
files_modified:
- vite.config.ts
- offscreen/index.ts
- offscreen/index.html
autonomous: true
requirements:
- REQ-video-ring-buffer
requirements_addressed:
- REQ-video-ring-buffer
must_haves:
truths:
- "`vite.config.ts` no longer contains the `copy-offscreen` inline plugin (the 200+-line block including IndexedDB plumbing, codec fallback chain, and `mediaRecorder` shadow is GONE)"
- "`vite.config.ts` declares the offscreen entry via `rollupOptions.input` per RESEARCH.md Example B"
- "Top-level `offscreen/index.ts` is DELETED (dead code per audit P2 #18)"
- "Top-level `offscreen/index.html` is DELETED (replaced by the crxjs-managed `src/offscreen/index.html` from Plan 03)"
- "`npm run build` exits 0; `dist/` contains a bundled offscreen HTML at the path the SW's `chrome.runtime.getURL` argument expects"
- "No mention of `VideoRecorderDB`, `openIndexedDB`, `chromeMediaSource`, or `copy-offscreen` remains in `vite.config.ts`"
artifacts:
- path: "vite.config.ts"
provides: "Minimal vite config with crxjs and offscreen entry"
min_lines: 15
contains: "rollupOptions"
- path: "dist/manifest.json"
provides: "Build output (artifact of npm run build)"
contains: "manifest_version"
- path: "dist/src/offscreen/index.html"
provides: "Bundled offscreen HTML at the path SW.ensureOffscreen expects (or wherever crxjs emits it — actual path verified at build time)"
contains: ""
key_links:
- from: "vite.config.ts (rollupOptions.input.offscreen)"
to: "src/offscreen/index.html"
via: "rollup input declaration"
pattern: "src/offscreen/index.html"
- from: "vite.config.ts"
to: "@crxjs/vite-plugin"
via: "crx({ manifest, contentScripts: { injectCss: false } })"
pattern: "crx("
---
<objective>
Collapse the build pipeline. DELETE the 200+-line `copy-offscreen` inline
Vite plugin block in `vite.config.ts` and the dead `offscreen/index.ts` +
`offscreen/index.html` at the repo root. The crxjs plugin already handles
bundling; we declare the new offscreen entry via `rollupOptions.input`
pointing at the `src/offscreen/index.html` Plan 03 created.
Purpose: D-08 says delete the inline plugin (which is the audit's P0 #1 root
cause). D-07 says crxjs picks up the new TS entry through the HTML
reference. The execution path is "minimal vite.config.ts + the existing
crx() invocation + a single rollupOptions.input line — that's it."
The plan also includes a build-time pathing verification — after the first
`npm run build`, the executor inspects `dist/` and confirms the bundled
offscreen HTML lands at the SAME path the SW's
`chrome.runtime.getURL(...)` argument in Plan 05 expects. If crxjs strips
the `src/` prefix (which it sometimes does), the SW URL gets adjusted by a
follow-up Edit. RESEARCH.md Pitfall 5 calls this out explicitly.
Output: A ~25-line `vite.config.ts`, no top-level `offscreen/` directory,
and a clean `npm run build` whose output passes the path-matching check.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
@.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md
@.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md
@vite.config.ts
@offscreen/index.ts
@offscreen/index.html
@src/offscreen/index.html
@manifest.json
</context>
<interfaces>
crxjs entry contract (per RESEARCH.md §"Pitfall 5" and discussion #919):
- `rollupOptions.input` declares an HTML entry; crxjs bundles the HTML and emits its referenced TS module into `dist/assets/`.
- The runtime URL that the SW passes to `chrome.offscreen.createDocument` MUST match the bundled HTML path. If `input: { offscreen: 'src/offscreen/index.html' }`, the runtime URL is typically `chrome.runtime.getURL('src/offscreen/index.html')` (crxjs preserves the input key as the output path), but real-world projects have reported the `src/` prefix being stripped. The executor MUST verify after the first `npm run build` and adjust Plan 05's `ensureOffscreen` call if needed.
Plan 05's `ensureOffscreen` currently calls:
```typescript
const url = chrome.runtime.getURL('offscreen/index.html');
```
(this is leftover from the pre-amendment manifest entry that has the old root-level `offscreen/` dir). Plan 06 verifies the post-build emit path and updates this string to match. If the emit path is `src/offscreen/index.html`, the call becomes `getURL('src/offscreen/index.html')`. Plan 05 wrote the path AS-IS — Plan 06 is the right place to do the binding verification because it owns the build pipeline.
</interfaces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| build-time vite plugin → bundled output | A malicious build plugin could inject code; we're DELETING our inline plugin (the audit's P0 #1) which is itself an attack-surface reduction |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-1-NEW-06-01 | Tampering — string-injected code via inline plugin | `vite.config.ts:13-216` `copy-offscreen` plugin's `this.emitFile({ source: \`<JS-as-string>\` })` | mitigate | DELETE the entire inline plugin. The replacement is a `src/offscreen/recorder.ts` real module + a `src/offscreen/index.html` declared in `rollupOptions.input`. No more long template-literal JS in `vite.config.ts`. Grep gate: `grep -v '^#' vite.config.ts \| grep -c "this.emitFile"` returns 0. |
| T-1-NEW-06-02 | Tampering — orphaned root-level offscreen | `offscreen/index.ts` + `offscreen/index.html` (dead code) | mitigate | DELETE both files. After this plan, a `find offscreen/ -type f` produces no output. Grep gate: `[ ! -d offscreen ]` exits 0. |
</threat_model>
<tasks>
<task type="auto">
<name>Task 1: DELETE — top-level offscreen/ directory and inline copy-offscreen plugin</name>
<files>vite.config.ts, offscreen/index.ts, offscreen/index.html</files>
<read_first>
- vite.config.ts (lines 1-227 — full file; the inline plugin spans lines 13-216)
- offscreen/index.ts (60 lines — dead code)
- offscreen/index.html (10 lines — replaced by src/offscreen/index.html from Plan 03)
- .planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md §`vite.config.ts` (lines 399-436)
- .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Example B" (lines 895-915)
</read_first>
<action>
Three deletions and one rewrite.
(1) Delete `offscreen/index.ts` (the orphan dead-code file):
```bash
rm offscreen/index.ts
```
(2) Delete `offscreen/index.html` (it is REPLACED by the new `src/offscreen/index.html` that Plan 03 created — the path was deliberately moved into the source tree):
```bash
rm offscreen/index.html
```
(3) Remove the now-empty `offscreen/` directory (verify it is empty first; if anything else lives there, STOP and surface it):
```bash
[ -d offscreen ] && rmdir offscreen
```
(4) REWRITE `vite.config.ts` to be the minimal RESEARCH.md Example B form. Use the Write tool (NOT Edit — the file is being replaced). VERBATIM content:
```typescript
import { defineConfig } from 'vite';
import { crx } from '@crxjs/vite-plugin';
import manifest from './manifest.json';
export default defineConfig({
plugins: [
crx({
manifest,
contentScripts: {
injectCss: false,
},
}),
],
build: {
rollupOptions: {
input: {
offscreen: 'src/offscreen/index.html',
},
},
},
});
```
The deleted content (the entire `copy-offscreen` plugin block at lines 13-216 plus the surrounding `build.rollupOptions.output.manualChunks` shape at lines 218-226) does not survive any portion in the rewrite.
After these edits, run:
```bash
npx tsc --noEmit
```
It MUST exit 0 (the previous TS errors about `(error as any).message?.includes` etc. were inside `src/background/index.ts`; Plan 05 already cleared those. `vite.config.ts` is type-checked via Vite, not `tsc --noEmit`; the `npx tsc --noEmit` check here is a regression guard for the rest of the codebase).
</action>
<verify>
<automated>[ ! -f offscreen/index.ts ] && [ ! -f offscreen/index.html ] && [ ! -d offscreen ] && [ "$(grep -c 'copy-offscreen' vite.config.ts)" -eq 0 ] && npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `test ! -f offscreen/index.ts` exits 0 (file deleted)
- `test ! -f offscreen/index.html` exits 0 (file deleted)
- `test ! -d offscreen` exits 0 (directory removed)
- `grep -c "copy-offscreen" vite.config.ts` returns 0
- `grep -c "this.emitFile" vite.config.ts` returns 0 (T-1-NEW-06-01 grep gate)
- `grep -c "VideoRecorderDB" vite.config.ts` returns 0
- `grep -c "openIndexedDB" vite.config.ts` returns 0
- `grep -c "chromeMediaSource" vite.config.ts` returns 0
- `grep -c "rollupOptions" vite.config.ts` returns 1
- `grep -c "src/offscreen/index.html" vite.config.ts` returns 1
- `wc -l vite.config.ts` returns ≤ 30
- `npx tsc --noEmit` exits 0
- `npx vitest run` exits 0 (regression guard)
</acceptance_criteria>
<done>Inline plugin gone. Root-level offscreen/ gone. vite.config.ts is ~22 lines.</done>
</task>
<task type="auto">
<name>Task 2: BUILD VERIFY — run npm run build and reconcile the offscreen path</name>
<files>src/background/index.ts (only if path-adjustment needed)</files>
<read_first>
- vite.config.ts (post-Task-1 state)
- src/offscreen/index.html (from Plan 03)
- src/background/index.ts (line 85 area — the `chrome.runtime.getURL('offscreen/index.html')` call inherited from before Plan 05; verify with `grep -n "getURL" src/background/index.ts`)
- .planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md §"Pitfall 5" (lines 838-856)
</read_first>
<action>
Run the build:
```bash
rm -rf dist
npm run build 2>&1 | tee /tmp/01-06-build.log
```
The build MUST exit 0. If it doesn't:
- Check the log for syntax/type errors and STOP — do not proceed to the rest of this task.
- Common failure modes: `chrome.offscreen.Reason.DISPLAY_MEDIA` not in current `@types/chrome` (fall back to the cast Plan 05 documented); the manifest reference to `desktopCapture` not yet landed (Plan 01 should have committed it — re-check); rollupOptions.input pointing at a non-existent file.
If the build succeeds, verify the dist layout:
```bash
ls -la dist/
ls -la dist/src/offscreen/ 2>/dev/null
ls -la dist/offscreen/ 2>/dev/null
```
There will be ONE of two outcomes:
**Outcome A — crxjs preserved `src/`**: `dist/src/offscreen/index.html` exists. In this case, the SW URL needs to be `chrome.runtime.getURL('src/offscreen/index.html')`.
**Outcome B — crxjs stripped `src/`**: `dist/offscreen/index.html` exists. In this case, the SW URL needs to be `chrome.runtime.getURL('offscreen/index.html')` (which is what the current Plan-05 file already has by accident — leftover from the pre-amendment code).
Identify which outcome obtains. Then edit `src/background/index.ts` line ~85 (inside `ensureOffscreen`):
```typescript
const url = chrome.runtime.getURL('<PATH>');
```
If Outcome A: `<PATH>` = `'src/offscreen/index.html'`.
If Outcome B: `<PATH>` = `'offscreen/index.html'` (leave unchanged).
Either way, after the edit (or no-edit), re-run:
```bash
npx tsc --noEmit
npm run build
ls -la dist/manifest.json
node -e "const m=require('./dist/manifest.json'); console.log('permissions:', m.permissions.join(','))"
```
The last line MUST print a comma-separated permission list that includes `desktopCapture` and does NOT include `tabCapture` (Plan 01 should already have ensured this in `manifest.json`; the `dist/manifest.json` is the crxjs-bundled output that propagates the manifest as-is).
Final layout verification:
```bash
find dist -type f -name "*.html" -o -name "*.js" | sort
```
The output should contain (in some order):
- `dist/manifest.json`
- `dist/src/popup/index.html` (or `dist/popup/index.html` — same Outcome-A/B rule)
- `dist/<offscreen path>/index.html` (whichever outcome)
- One or more `dist/assets/*.js` files (bundled SW, content script, offscreen TS, popup TS)
If the manifest.json's `background.service_worker` field doesn't resolve to an emitted file, STOP and audit. crxjs handles this automatically — there's only a problem if the manifest entry was hand-broken.
</action>
<verify>
<automated>npm run build && ls dist/manifest.json && [ -f dist/src/offscreen/index.html ] || [ -f dist/offscreen/index.html ]</automated>
</verify>
<acceptance_criteria>
- `npm run build` exits 0 (with NO TypeScript errors in /tmp/01-06-build.log)
- `dist/manifest.json` exists
- Either `dist/src/offscreen/index.html` OR `dist/offscreen/index.html` exists
- `dist/manifest.json` permissions list contains `"desktopCapture"` and does NOT contain `"tabCapture"`
- `src/background/index.ts` `chrome.runtime.getURL(...)` argument matches whichever Outcome (A or B) the build produced
- `dist/assets/` contains at least one `.js` file (the bundled SW / content / popup / offscreen scripts)
</acceptance_criteria>
<done>Build succeeds. SW URL string matches the dist layout. Plan 07 can load `dist/` into Chrome and the offscreen will resolve correctly.</done>
</task>
</tasks>
<verification>
After both tasks land:
1. `npm run build` — exits 0, clean output.
2. `dist/` contains a loadable extension (manifest.json + at least one HTML offscreen page + at least one JS bundle in assets/).
3. `npx tsc --noEmit && npx vitest run` — both exit 0 (regression guard for the rest of the codebase).
4. `wc -l vite.config.ts` — ≤ 30 lines.
5. No top-level `offscreen/` directory.
6. The SW URL string in `src/background/index.ts` matches the actual `dist/` emit path (Outcome A or B).
Commit cadence: TWO commits.
- Task 1: ONE commit (`refactor(01-06): delete inline copy-offscreen plugin and orphan offscreen/ directory`).
- Task 2: ONE commit if path adjustment was needed (`fix(01-06): align ensureOffscreen URL with crxjs emit path`); ZERO commits if no path adjustment was needed (note in SUMMARY which Outcome obtained).
</verification>
<success_criteria>
- `vite.config.ts` is ~22 lines, contains crx() and rollupOptions only
- `offscreen/` top-level directory is GONE
- `dist/manifest.json` carries the post-Plan-01 amended permissions (desktopCapture, no tabCapture, no alarms)
- The SW URL string and the bundled HTML path match
- `npm run build` clean
</success_criteria>
<output>
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-06-SUMMARY.md` with:
- Pre / post line count for vite.config.ts (was 227, now ~22)
- Confirmation that `offscreen/` directory is gone (output of `find offscreen/ 2>&1 || echo absent`)
- The outcome (A or B) for crxjs's emit path, and the exact SW URL string committed
- Output of `find dist -type f | sort` after the post-Task-2 build, so Plan 07's manual smoke test knows what to load
- Two commit SHAs (or one if Task 2 needed no path adjustment)
- Note: "Plan 07 loads `dist/` unpacked into Chrome ≥ 116 and runs the ffprobe acceptance gate (D-12)."
</output>

View File

@@ -0,0 +1,329 @@
---
phase: 01-stabilize-video-pipeline
plan: 07
type: execute
wave: 3
depends_on: ["05", "06"]
files_modified:
- tests/fixtures/last_30sec.webm
autonomous: false
requirements:
- REQ-video-ring-buffer
requirements_addressed:
- REQ-video-ring-buffer
must_haves:
truths:
- "`dist/` loads unpacked into Chrome ≥ 116 with no errors at chrome://extensions"
- "On extension load, the offscreen prompts the operator with Chrome's native screen-share picker"
- "After selecting a screen/window, Chrome's permanent 'Sharing your screen' indicator is shown (D-04 accepted residual risk)"
- "After ≥ 30 s of recording, clicking SAVE_ARCHIVE produces a `session_report_*.zip` in the user's Downloads folder containing `video/last_30sec.webm`"
- "Extracting that webm and running `ffprobe -v error -f matroska -i last_30sec.webm` exits 0 with EMPTY stderr (D-12 acceptance gate)"
- "The captured webm is preserved at `tests/fixtures/last_30sec.webm` as a known-good sample for regression"
- "If the ffprobe gate FAILS, the developer is presented with the D-13 fallback skeleton (already pre-staged in src/offscreen/recorder.ts) and re-plans Phase 1 closure"
artifacts:
- path: "tests/fixtures/last_30sec.webm"
provides: "Captured + verified ffprobe-clean WebM sample"
contains: ""
key_links:
- from: "dist/manifest.json"
to: "dist/src/offscreen/index.html (or dist/offscreen/index.html — whichever Plan 06 produced)"
via: "chrome.offscreen.createDocument at runtime"
pattern: "DISPLAY_MEDIA"
- from: "ffprobe -v error"
to: "last_30sec.webm playability"
via: "exit code 0 + empty stderr"
pattern: ""
---
<objective>
Manual smoke test against a real Chrome instance + ffprobe verification of
the assembled `last_30sec.webm`. This is the D-12 acceptance gate that
ROADMAP.md Phase 1 Success Criterion #3 names verbatim ("concatenating
header + buffered chunks yields a byte sequence a browser would play").
Purpose: Code-level verification (vitest, tsc) covers correctness of pure
logic and types. Playability of an actual WebM file requires running the
extension against a real browser, recording, and exporting. Phase 1 is not
"done" until ffprobe accepts the output — that is the criterion the
plan-checker is bound to enforce.
This plan is **`autonomous: false`** — it has a `checkpoint:human-verify`
because driving a Chrome instance unpacked-load + screen-picker click +
manual save click is not a CLI operation. ffprobe IS a CLI tool; the
ffprobe step is automated AFTER the human steps complete.
The plan also handles the BRANCH where ffprobe fails — in that case, the
developer SHALL switch to the D-13 fallback (already pre-staged as a
commented skeleton in `src/offscreen/recorder.ts`) and the orchestrator
re-plans Phase 1 closure as a follow-up.
Output: A committed `tests/fixtures/last_30sec.webm` known-good sample (if
ffprobe passes) OR a clear escalation to D-13 (if ffprobe fails).
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
@.planning/phases/01-stabilize-video-pipeline/01-RESEARCH.md
@.planning/phases/01-stabilize-video-pipeline/01-VALIDATION.md
@.planning/phases/01-stabilize-video-pipeline/01-PATTERNS.md
@dist/manifest.json
@src/offscreen/recorder.ts
</context>
<interfaces>
This plan runs against a built `dist/` from Plan 06. No new code interfaces
are introduced. The plan exercises the contracts already implemented:
- SW `ensureOffscreen``chrome.offscreen.createDocument({reasons: [DISPLAY_MEDIA]})`
- Offscreen on START_RECORDING: `getDisplayMedia` → MediaRecorder.start(2000)
- Offscreen ondataavailable: addChunk(blob, Date.now())
- Offscreen track.onended: clear buffer + RECORDING_ERROR to SW
- SW on SAVE_ARCHIVE: getVideoBufferFromOffscreen() via port → REQUEST_BUFFER → BUFFER → mergeVideoChunks → JSZip → chrome.downloads.download
Manual interactions:
1. `chrome://extensions` → "Load unpacked" → select `dist/`
2. (Whatever currently triggers `startVideoCapture` — the existing flow in `src/popup/index.ts` opens the popup, which probably calls `REQUEST_PERMISSIONS`. The exact UX wire is Phase 3 territory, so Plan 07 accepts that the trigger may be clunky — the goal is to get a recording running and exporting.)
3. Click through Chrome's native screen picker
4. Wait ≥ 35 seconds (so the buffer has rotated at least once and the trim path executed)
5. Click whatever in the popup triggers SAVE_ARCHIVE (likely "Сохранить отчёт об ошибке")
6. Verify a `session_report_*.zip` lands in Downloads
7. Extract and run ffprobe
</interfaces>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| developer's local Chrome ↔ unpacked extension | The developer is running the extension on their own machine; loading unpacked is the recommended Phase 1 deployment per audit P2 #22 (no CI). No new trust boundary introduced by this plan. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-1-NEW-07-01 | Information Disclosure — committed test fixture | `tests/fixtures/last_30sec.webm` | mitigate | The developer chooses what to share with Chrome's picker when running the smoke test. Recommended share: a fresh test browser window with no sensitive content (e.g., the README rendered in Markdown preview). The committed fixture MUST NOT contain identifying information. Reviewer guidance documented in SUMMARY. |
</threat_model>
<tasks>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 1: HUMAN — load dist/ unpacked, capture a recording, export, save the archive</name>
<files>tests/fixtures/last_30sec.webm</files>
<read_first>
- dist/manifest.json (verify the build is loadable)
- src/popup/index.html and src/popup/index.ts (understand the existing UX trigger — Phase 3 reworks this; for now, click whatever button exists)
</read_first>
<action>
This is a `checkpoint:human-verify` task. The "action" is the manual sequence below — Claude prepares the environment (pre-flight checks), the human drives Chrome, then Claude resumes for the ffprobe gate. Full details in `<how-to-verify>` and `<what-built>` below.
Step summary:
1. Run pre-flight automation (manifest check + ffprobe presence check).
2. Pause and hand off to the human: load dist/ unpacked, click through the popup, accept the screen-share picker, wait ≥ 35 s, click save.
3. Resume automation: extract the saved archive, run `ffprobe -v error -f matroska -i last_30sec.webm`, capture the exit code + stderr.
4. If ffprobe exit 0 with no stderr lines: commit the captured WebM as `tests/fixtures/last_30sec.webm` (regression fixture for future phases).
5. If ffprobe fails: escalate to the orchestrator with the D-13 fallback signal.
</action>
<what-built>
The full Phase-1 Mokosh build:
- `dist/manifest.json` declares `desktopCapture` (not `tabCapture`)
- The offscreen document calls `getDisplayMedia` once per session
- Captured stream is recorded by a single `MediaRecorder` at vp9 / 400 kbps / 2 s timeslice
- Buffer holds first chunk (WebM header) + every chunk newer than now-30s
- SW receives the buffer over the `'video-keepalive'` port on SAVE_ARCHIVE
- Archive is delivered via `chrome.downloads`
</what-built>
<verify>
<automated>which ffprobe && ffprobe -v error -f matroska -i tests/fixtures/last_30sec.webm; test $? -eq 0</automated>
</verify>
<how-to-verify>
**Pre-flight (automated, do these in a bash shell before opening Chrome):**
```bash
# Confirm the build is fresh
ls -la dist/manifest.json dist/assets/*.js
node -e "const m=require('./dist/manifest.json'); if(!m.permissions.includes('desktopCapture')) process.exit(1); if(m.permissions.includes('tabCapture')) process.exit(2); console.log('manifest OK')"
# Confirm ffprobe is installed
which ffprobe || { echo "ffprobe missing — install with: sudo apt install ffmpeg"; exit 1; }
ffprobe -version | head -1
```
**Manual smoke (in Chrome):**
1. Open `chrome://extensions` in a Chromium-based browser (Chrome / Chromium ≥ 116).
2. Enable "Developer mode" (top right).
3. Click "Load unpacked".
4. Select the project's `dist/` directory.
5. Confirm the extension card shows the name "AI Call Recorder" (from `manifest.json`) with NO errors or warnings (red text in the card == FAIL).
6. Open the extension's popup (click the toolbar icon).
7. Click whatever button triggers permission/recording. The popup is from before this phase — Phase 3 reworks it; for Phase 1 the button may say "Сохранить отчёт" or "Запросить разрешения".
8. Chrome SHOULD show its native screen-share picker. **If it doesn't**: open chrome://extensions, click "service worker" on the extension card, inspect the SW console for the `[SW:Main]` log line that should say "Sending START_RECORDING to offscreen..." — that tells us where the flow stalled.
9. Pick "Entire screen" or a specific window (your choice — recommend an innocuous one like a code editor showing this PLAN.md).
10. Confirm Chrome's permanent "Sharing your screen" indicator appears at the top of the screen. (This is the accepted D-04 trade-off.)
11. **Wait at least 35 seconds.** Move the mouse around or scroll a page so the captured stream has actual visual change (helps vp9 produce useful frames).
12. Open the popup again and click the SAVE_ARCHIVE button (likely "Сохранить отчёт об ошибке").
13. Within ~5 seconds, a `session_report_YYYY-MM-DD_HH-MM-SS.zip` SHOULD appear in your Downloads folder.
**Verification of the exported archive:**
```bash
# Find the latest archive
LATEST=$(ls -t ~/Downloads/session_report_*.zip 2>/dev/null | head -1)
echo "Latest archive: $LATEST"
[ -z "$LATEST" ] && { echo "FAIL — no session_report archive found in Downloads"; exit 1; }
# Extract the video
unzip -p "$LATEST" video/last_30sec.webm > /tmp/last_30sec.webm
ls -la /tmp/last_30sec.webm
# Run the D-12 acceptance gate
ffprobe -v error -f matroska -i /tmp/last_30sec.webm
GATE=$?
echo "ffprobe exit: $GATE"
ffprobe -v error -show_format -show_streams /tmp/last_30sec.webm 2>&1 | head -30
```
**The acceptance criterion is `GATE == 0` AND zero lines of stderr from the `ffprobe -v error` invocation above.** If both hold:
- Copy the verified webm into the project as a regression fixture: `cp /tmp/last_30sec.webm tests/fixtures/last_30sec.webm`
- Report success.
**If `GATE != 0`** (the ffprobe gate fails):
- Capture the stderr output and the structural dump (`ffprobe -v error -show_packets -i /tmp/last_30sec.webm 2>&1 | head -50`).
- This is the D-13 escalation: the simple continuous-recorder + age-trim approach didn't survive vp9's keyframe cadence (RESEARCH.md Pitfall 1).
- DO NOT delete the existing recorder code. The D-13 fallback skeleton is already pre-staged in `src/offscreen/recorder.ts` as a commented block. Surface the failure to the orchestrator with a precise summary so a new plan (08?) can be drafted to activate the fallback.
</how-to-verify>
<resume-signal>
Report ONE of:
- **`approved` + paste the ffprobe exit code (must be 0) + paste any single stderr line if present + confirm `tests/fixtures/last_30sec.webm` is committed.**
- **`ffprobe-failed` + paste the stderr + paste the first 20 lines of `ffprobe -v error -show_packets`. Orchestrator escalates to D-13 fallback re-plan.**
- **`load-failed` + paste the chrome://extensions error text or the SW console error. Orchestrator escalates to a previous-plan revision (likely Plan 05 or 06).**
- **`picker-rejected` + paste the offscreen console line containing `RECORDING_ERROR`. Surface to user as a one-off; retry by reloading the extension card and clicking the popup again.**
Phase 1 is **DONE** when this checkpoint returns `approved`.
</resume-signal>
<acceptance_criteria>
- The build at dist/ loads unpacked with NO red error text on the extension card.
- getDisplayMedia picker shows on a popup-triggered start path.
- Chrome's "Sharing your screen" indicator appears.
- After ≥ 35 s of recording, SAVE_ARCHIVE produces a session_report_*.zip in Downloads within 5 seconds.
- ffprobe -v error -f matroska -i last_30sec.webm exits 0 with NO stderr lines.
- tests/fixtures/last_30sec.webm is committed as the regression-fixture.
</acceptance_criteria>
<done>Phase 1 acceptance gate met. WebM ring buffer ships playable output.</done>
</task>
<task type="auto">
<name>Task 2: AUTOMATED — commit the fixture and update STATE.md</name>
<files>tests/fixtures/last_30sec.webm, .planning/STATE.md</files>
<read_first>
- tests/fixtures/last_30sec.webm (verify it exists from Task 1)
- .planning/STATE.md (current state — phase 1 progress)
</read_first>
<action>
This task ONLY runs if Task 1 returned `approved`. If Task 1 returned `ffprobe-failed`, `load-failed`, or `picker-rejected`, SKIP this task — the orchestrator handles the escalation.
Two edits:
(1) Ensure `tests/fixtures/last_30sec.webm` is committed to git. Check its size:
```bash
ls -lh tests/fixtures/last_30sec.webm
# Expected: between ~300 KB and ~2 MB for a 30-second vp9 capture at 400 kbps
```
If the file is suspiciously small (< 100 KB) or large (> 5 MB), STOP and audit — the file may be empty (only the header chunk) or include way more than 30 s of content. The expected bitrate is ~400 kbps × 30 s = ~1.5 MB.
Also verify it's tracked by git:
```bash
git status tests/fixtures/last_30sec.webm
```
(2) Update `.planning/STATE.md` to mark Phase 1 as complete. Use the Edit tool to update the relevant fields:
(2a) `stopped_at:` — replace `"Phase 1 context gathered"` with `"Phase 1 closure: ffprobe acceptance gate passed; tests/fixtures/last_30sec.webm committed"`.
(2b) `last_activity:` — replace the existing line with `2026-05-15 — Phase 1 closure: D-12 ffprobe gate green; ready for Phase 2`.
(2c) `## Current Position` block:
- `Phase: 1 of 5 (Stabilize video pipeline)``Phase: 2 of 5 (next: Stabilize DOM + event capture privacy)`
- `Plan: 0 of TBD in current phase``Plan: 7 of 7 complete (Phase 1 closed)`
- `Status: Ready to plan``Status: Phase 1 complete; ready to plan Phase 2`
(2d) Update the `## Performance Metrics` Phase 1 row (currently `| 1. Stabilize video pipeline | 0 | — | — |`) to reflect 7 plans completed.
(2e) Append a `## Phase 1 Closure Notes` block at the bottom of STATE.md with:
- ffprobe exit code (must be 0)
- Size of the committed fixture
- "Phase 1 outcome: SPEC §10 acceptance criteria #2, #3, #7 are functionally green pending Phase 4 end-to-end smoke verification."
- Note: "Phase 2 owns the DOM/event-capture privacy slice; Phase 3 owns the popup state machine + base64-URL replacement; Phase 4 runs the full SPEC §10 smoke pass."
Do NOT touch the `## Deferred Items` or `## Accumulated Context > ## Decisions` sections — those are owned by the orchestrator across phases.
After both edits, run:
```bash
git status
```
The output should show `tests/fixtures/last_30sec.webm` (new) and `.planning/STATE.md` (modified).
</action>
<verify>
<automated>[ -f tests/fixtures/last_30sec.webm ] && grep -q "Phase 1 closure" .planning/STATE.md</automated>
</verify>
<acceptance_criteria>
- `tests/fixtures/last_30sec.webm` exists, size between 100 KB and 5 MB
- `grep -c "Phase 1 closure" .planning/STATE.md` returns at least 1
- `grep -c "ffprobe acceptance gate passed" .planning/STATE.md` returns at least 1
- `git status .planning/STATE.md tests/fixtures/last_30sec.webm` shows both files staged or modified
</acceptance_criteria>
<done>Phase 1 closure recorded in STATE.md; regression fixture committed; the orchestrator can transition to Phase 2 cleanly.</done>
</task>
</tasks>
<verification>
After Task 1 returns `approved` AND Task 2 lands:
1. `git log --oneline -5` shows the Phase 1 commits (Plan 01 through Plan 07).
2. `tests/fixtures/last_30sec.webm` is in the repo, size sensible (~1-2 MB).
3. `ffprobe -v error -f matroska -i tests/fixtures/last_30sec.webm; echo $?` prints `0` with no stderr.
4. `.planning/STATE.md` reflects Phase 1 closure.
5. `npx vitest run` exits 0 (8 tests passing).
6. `npx tsc --noEmit` exits 0.
7. `npm run build` exits 0.
8. `grep -RnE "tabCapture|chrome\.alarms|VideoRecorderDB|copy-offscreen|openIndexedDB" src/ vite.config.ts manifest.json | grep -v '^#'` returns nothing.
If ANY of (1)-(8) fail, Phase 1 is NOT closed; orchestrator escalates.
Commit cadence: ONE commit at the end of Task 2 (`feat(01-07): close phase 1 — ffprobe gate green, fixture committed`). The fixture and STATE.md are committed together.
If Task 1 escalates (D-13 fallback or earlier-plan rework needed), this verification block is SKIPPED — the orchestrator carries the escalation.
</verification>
<success_criteria>
- Manual smoke + ffprobe gate green
- `tests/fixtures/last_30sec.webm` committed as regression-fixture
- `.planning/STATE.md` records Phase 1 closure
- All grep gates from Plans 01..06 still pass
- Build clean; vitest green; tsc clean
- Phase 2 can begin (the next milestone)
</success_criteria>
<output>
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-07-SUMMARY.md` with:
- The exact `chrome://extensions` card state observed (errors? warnings?)
- The exact stderr captured from `ffprobe -v error -f matroska -i /tmp/last_30sec.webm` (should be empty if green)
- The size of `tests/fixtures/last_30sec.webm`
- The ffprobe stream/format dump (so future regressions have a reference)
- Whether the D-13 fallback was activated (and if so, the exact escalation path)
- ONE commit SHA (the Task-2 commit)
- A "What Phase 2 needs to know" section:
- The offscreen now owns capture; Phase 2 (DOM + event-capture privacy) plugs into the existing content-script architecture and does NOT touch the offscreen
- The port `'video-keepalive'` keeps the SW alive; Phase 2's content-script work should NOT add competing keepalives
- The SW's `onMessage` listener now validates `sender.id === chrome.runtime.id`; Phase 2's content-script messages already pass this check (content scripts have the same extension ID), but if Phase 2 adds a new sender path (e.g., from an injected page-world script), Phase 2 must respect the guard
</output>

View File

@@ -0,0 +1,859 @@
# Phase 1: Stabilize Video Pipeline — Pattern Map
**Mapped:** 2026-05-15
**Files analyzed:** 12 (4 NEW, 4 MODIFIED, 4 DELETED)
**Analogs found:** 11 / 12
> NOTE on line numbers cited in CONTEXT.md: The CONTEXT.md narrative
> references some "decimal-style" approximate line ranges that did not
> match the live file when I read it. The numbers used **below** in
> Pattern Assignments are the verified line numbers from the files as
> they exist on disk on 2026-05-15.
## File Classification
| New / Modified / Deleted File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------------------|------|-----------|----------------|---------------|
| `src/offscreen/recorder.ts` (NEW) | offscreen recorder module | streaming + port + event-driven | `offscreen/index.ts` (dead) + inline string at `vite.config.ts:35-213` + ring-buffer structure at `src/background/index.ts:26-75` | role-match (compose 3 analogs) |
| `src/offscreen/index.html` (NEW) | bundle entry HTML | static asset | `offscreen/index.html` (current) + `src/popup/index.html` (script tag) | exact |
| `src/background/index.ts` (MODIFIED, heavy shrink) | service worker coordinator | request-response + port-host + event-driven | itself (current file) — refactor in place | exact |
| `vite.config.ts` (MODIFIED, heavy reduction) | build config | config | crxjs discussion #919 pattern (cited in RESEARCH.md Example B) | role-match |
| `manifest.json` (MODIFIED, permission swap) | manifest | config | itself | exact |
| `src/shared/types.ts` (MODIFIED, light) | shared types | type-only | itself (current file) — add/verify message types | exact |
| `tests/offscreen/ring-buffer.test.ts` (NEW) | unit test | request-response | none in codebase — Vitest default; structural cousin: `src/background/index.ts:47-75` `cleanupVideoBuffer` (subject under test) | role-only |
| `tests/offscreen/codec-check.test.ts` (NEW) | unit test | request-response | none | role-only |
| `tests/offscreen/handshake.test.ts` (NEW) | unit test | event-driven | none | role-only |
| `tests/offscreen/port.test.ts` (NEW) | unit test | port/event-driven | none | role-only |
| `vitest.config.ts` (NEW) | test runner config | config | `vite.config.ts` (defineConfig + plugin shape) + `tsconfig.json` (path/aliases) | role-only |
| `offscreen/index.ts` (DELETED) | — | — | — | — |
| `offscreen/index.html` (DELETED) | — | — | — | — |
| `vite.config.ts:11-217` inline plugin (DELETED) | — | — | — | — |
| `src/background/index.ts:156-165, 128, 457-473, 482-520` (DELETED in-place) | — | — | — | — |
## Pattern Assignments
### `src/offscreen/recorder.ts` (NEW — offscreen recorder; streaming + port + event-driven)
**Analogs (composed):**
- `offscreen/index.ts` (lines 1-60) — the dead-but-correctly-shaped skeleton: module-level `let mediaRecorder`, `chrome.runtime.onMessage.addListener` with a `switch (message.type)`, `MediaRecorder.ondataavailable` push pattern. Phase 1 KEEPS the shape and REWRITES the body.
- Inline JS string at `vite.config.ts:35-213` — the live offscreen at runtime. This is the source-of-truth for the *current behavior* but is the explicit DELETE target. Use it ONLY as a reference for what NOT to copy (the codec fallback chain at lines 151-172, the IndexedDB plumbing at lines 43-107, the `let mediaRecorder` shadow at line 158).
- `src/background/index.ts:25-75` — ring-buffer structural pattern (currently SW-side). MOVE this to offscreen.
- `src/shared/logger.ts:1-25``Logger` class shape. Phase 1 adds an `OffscreenLogger` mirroring this (`[OS:...]` prefix) OR reuses `Logger` with prefix `Offscreen:Main`.
**Skeleton pattern (KEEP shape; rewrite body)** — from `offscreen/index.ts:1-60`:
```typescript
// offscreen/index.ts:1-2 — module-level state declaration (KEEP shape; rename
// `mediaRecorder` → `videoRecorder` per RESEARCH.md anti-pattern fix)
let mediaRecorder: MediaRecorder | null = null;
let videoChunks: Blob[] = [];
// offscreen/index.ts:45-59 — message-listener-with-switch shape (KEEP)
chrome.runtime.onMessage.addListener((message) => {
switch (message.type) {
case 'START_RECORDING':
startRecording(message.streamId);
break;
case 'STOP_RECORDING':
stopRecording();
break;
case 'GET_CHUNKS':
chrome.runtime.sendMessage({
type: 'CHUNKS_RESPONSE',
chunks: getChunks()
});
break;
}
});
```
**dataavailable pattern (KEEP behavior; chunk Blob is now pushed to ring buffer with timestamp+isHeader, NOT forwarded via `chrome.runtime.sendMessage`)** — from `offscreen/index.ts:18-27`:
```typescript
mediaRecorder.ondataavailable = (event) => {
if (event.data && event.data.size > 0) {
videoChunks.push(event.data);
chrome.runtime.sendMessage({ // ← REMOVE: this is the broken-Blob-over-sendMessage path
type: 'VIDEO_CHUNK',
data: event.data,
timestamp: Date.now()
});
}
};
```
**Ring-buffer pattern (MOVE from SW to offscreen, verbatim)** — from `src/background/index.ts:47-75`:
```typescript
function cleanupVideoBuffer() {
const now = Date.now();
const beforeCount = videoBuffer.length;
logger.log(`Cleaning up buffer, current size: ${beforeCount}`);
// Всегда сохраняем первый чанк (WebM заголовок, помечен как isFirst)
// Остальные чанки фильтруем по времени (старше 30 секунд удаляем)
videoBuffer = videoBuffer.filter(chunk => {
// Всегда оставляем первый чанк (заголовок)
if (chunk.isFirst) {
return true;
}
// Остальные - только если моложе 30 секунд
const age = now - chunk.timestamp;
const keep = age < VIDEO_BUFFER_DURATION_MS;
if (!keep) {
logger.log(`Removing chunk, age: ${age}ms, limit: ${VIDEO_BUFFER_DURATION_MS}ms`);
}
return keep;
});
const removed = beforeCount - videoBuffer.length;
if (removed > 0) {
logger.log(`Removed ${removed} old video chunks, buffer: ${videoBuffer.length}`);
} else {
logger.log(`No chunks removed, buffer: ${videoBuffer.length}`);
}
}
```
**Header-pin add pattern (MOVE from SW to offscreen)** — from `src/background/index.ts:26-45`:
```typescript
function addVideoChunkFromBlob(blob: Blob) {
logger.log(`Processing video chunk from Blob, size: ${blob.size} bytes, type: ${blob.type}`);
const chunk: VideoChunk = {
data: blob,
timestamp: Date.now(),
isFirst: !firstChunkSaved // Первый чанк помечаем как isFirst
};
if (!firstChunkSaved) {
firstChunkSaved = true;
logger.log(`This is the FIRST video chunk (WebM header), size: ${blob.size} bytes`);
}
videoBuffer.push(chunk);
cleanupVideoBuffer();
}
```
**Constants pattern** — from `src/background/index.ts:12-13`:
```typescript
// Константы
const VIDEO_BUFFER_DURATION_MS = 30 * 1000; // 30 секунд
```
Add for the new module:
```typescript
const TIMESLICE_MS = 2000; // CON-video-codec / SPEC §4.1 (was 200ms)
const VIDEO_MIME = 'video/webm;codecs=vp9';
const VIDEO_BITRATE = 400_000; // CON-video-codec
const PORT_NAME = 'video-keepalive';
const PORT_PING_MS = 25_000; // < 30s SW idle threshold
const PORT_RECONNECT_MS = 290_000; // pre-empt the ~5min port cap
```
**getDisplayMedia call pattern** — from RESEARCH.md Example A (canonical):
```typescript
async function startRecording(): Promise<void> {
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false, // SPEC §9 — Phase 2/CAP-01 territory
});
// Codec strict-mode — RESEARCH.md Example E
if (!MediaRecorder.isTypeSupported(VIDEO_MIME)) {
const err = `vp9 unsupported. UA=${navigator.userAgent}`;
chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: err });
throw new Error(err);
}
videoRecorder = new MediaRecorder(stream, {
mimeType: VIDEO_MIME,
videoBitsPerSecond: VIDEO_BITRATE,
});
videoRecorder.ondataavailable = onDataAvailable;
videoRecorder.start(TIMESLICE_MS);
// RESEARCH.md Example F — track.ended for "Stop sharing" recovery
stream.getTracks().forEach((track) => {
track.addEventListener('ended', onUserStoppedSharing, { once: true });
});
}
```
**OFFSCREEN_READY handshake (Pattern 4)** — from RESEARCH.md (matches `src/shared/types.ts:18` declared but unused message type):
```typescript
// at the bottom of recorder.ts, after the onMessage listener is registered:
chrome.runtime.onMessage.addListener((msg) => { /* ... */ });
chrome.runtime.sendMessage({ type: 'OFFSCREEN_READY' });
```
**Port keepalive pattern (Pattern 5)** — from RESEARCH.md lines 600-628:
```typescript
const port = chrome.runtime.connect({ name: PORT_NAME });
setInterval(() => port.postMessage({ type: 'PING' }), PORT_PING_MS);
port.onMessage.addListener((msg) => {
if (msg.type === 'REQUEST_BUFFER') {
port.postMessage({ type: 'BUFFER', chunks: videoBuffer });
}
});
port.onDisconnect.addListener(() => {
// Pitfall 4 — 5-minute port cap, reconnect
reconnectPort();
});
```
**Russian-comment style (KEEP)** — observed throughout `src/background/index.ts:12, 25, 47, 52-54, 156`:
```typescript
// Константы
// Кольцевой буфер видео
// Очистка старых событий
// Keepalive для предотвращения выгрузки Service Worker
```
New code in `src/offscreen/recorder.ts` SHOULD include Russian section headers (matches project provenance from CONTEXT.md "Established patterns").
**Imports pattern (KEEP project shape)** — from `src/background/index.ts:1-8`:
```typescript
import { Logger } from '../shared/logger';
import type {
Message,
VideoChunk,
SessionMetadata,
VideoBufferResponse
} from '../shared/types';
import JSZip from 'jszip';
```
For the new offscreen recorder:
```typescript
import { Logger } from '../shared/logger';
import type { Message, VideoChunk } from '../shared/types';
```
---
### `src/offscreen/index.html` (NEW — bundle entry HTML, static asset)
**Analog:** `offscreen/index.html` (current) — DELETE and recreate.
**Current pattern**`offscreen/index.html:1-10`:
```html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Offscreen Page</title>
</head>
<body>
<script src="./index.ts" type="module"></script>
</body>
</html>
```
**Popup-side analog (same shape, references TS via crxjs)**`src/popup/index.html:1-21`:
```html
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Call Recorder</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
...
<script type="module" src="index.ts"></script>
</body>
</html>
```
**Pattern to copy** — RESEARCH.md Example A (crxjs discussion #919):
```html
<!doctype html>
<html>
<head><meta charset="UTF-8"><title>Mokosh Recorder</title></head>
<body>
<script type="module" src="./recorder.ts"></script>
</body>
</html>
```
---
### `src/background/index.ts` (MODIFIED — service worker coordinator; shrinks substantially)
**Analog:** itself. Refactor in place.
**DELETE blocks (verified line numbers as of 2026-05-15):**
| Block | Current Lines | Reason |
|-------|---------------|--------|
| `addVideoChunkFromBlob` | 26-45 | MOVE to offscreen (D-16) |
| `cleanupVideoBuffer` | 47-75 | MOVE to offscreen (D-16) |
| `getMediaStreamId` call inside `startVideoCapture` | 126-131 | Replaced by `getDisplayMedia` in offscreen (D-01) |
| `setupKeepalive` function | 156-165 | Replaced by port keepalive (D-18) |
| `VIDEO_CHUNK` case in onMessage | 457-466 | Buffer no longer travels via sendMessage (D-19) |
| `VIDEO_CHUNK_SAVED` case in onMessage | 468-473 | IndexedDB path is dead (D-19) |
| `loadChunkFromIndexedDB` function | 482-505 | IndexedDB path is dead (D-19) |
| `openIndexedDB` function | 507-520 | IndexedDB path is dead (D-19) |
| `setupKeepalive()` call inside `initialize` | 525 | Paired with deletion of `setupKeepalive` |
**Auth/Guard pattern (sender.id validation — RESEARCH.md security recommendation)** — currently MISSING:
The SW's onMessage listener at `src/background/index.ts:427-479` does NOT validate `sender.id`. Phase 1 SHOULD add `if (_sender.id !== chrome.runtime.id) return false;` at the top of the new port and onMessage handlers (low-effort hardening; per RESEARCH.md security domain table). Rename `_sender``sender` since it is now used.
**onMessage handler pattern (KEEP shape, modify cases)** — from `src/background/index.ts:427-479`:
```typescript
chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) => {
logger.log('Received message:', message.type, message);
switch (message.type) {
case 'REQUEST_PERMISSIONS':
checkPermissions().then(async (hasPermissions) => {
if (hasPermissions) {
if (!isRecording) {
await startVideoCapture();
}
sendResponse({ granted: true });
} else {
requestPermissions().then(granted => {
sendResponse({ granted });
});
}
});
return true; // ← async response convention
case 'GET_VIDEO_BUFFER':
sendResponse(getVideoBuffer());
return false; // ← sync response convention
case 'SAVE_ARCHIVE':
saveArchive().then(result => {
sendResponse(result);
});
return true;
default:
logger.warn('Unknown message type:', message.type);
return false;
}
});
```
Phase 1 modifications:
- `REQUEST_PERMISSIONS`: replace `startVideoCapture` body with new `ensureOffscreenAndStart()` (offscreen + DISPLAY_MEDIA reason).
- `GET_VIDEO_BUFFER`: replace `getVideoBuffer()` with an over-port request to offscreen (return `true`, resolve via port message handler).
- `SAVE_ARCHIVE`: same — `getVideoBuffer()` inside `saveArchive()` (line 332) becomes a port-request to offscreen.
- Add a new `case 'OFFSCREEN_READY'` to resolve the handshake Promise (Pattern 4).
**ensureOffscreen pattern (REPLACE reason)** — from `src/background/index.ts:78-104`:
```typescript
async function ensureOffscreen() {
if (offscreenCreated) {
logger.log('Offscreen already created');
return;
}
try {
const url = chrome.runtime.getURL('offscreen/index.html'); // ← path may change to 'src/offscreen/index.html'; verify after first build
logger.log('Creating offscreen document at:', url);
await chrome.offscreen.createDocument({
url: url,
reasons: ['USER_MEDIA'] as any, // ← REPLACE: ['DISPLAY_MEDIA']; drop the `as any` once @types/chrome is bumped
justification: 'Need to record video from tab for error reporting' // ← OPTIONAL: update copy to match RESEARCH.md Example C
});
offscreenCreated = true;
logger.log('Offscreen document created successfully');
} catch (error) {
if ((error as any).message?.includes('already exists')) {
offscreenCreated = true;
logger.log('Offscreen document already exists');
} else {
logger.error('Failed to create offscreen document:', error);
throw error;
}
}
}
```
Anti-pattern note: the `as any` on line 90 maps to audit P1 #13 and the CLAUDE.md "no @ts-ignore / no as any" rule. The fix is `chrome.offscreen.Reason.DISPLAY_MEDIA` after bumping `@types/chrome` from `^0.0.268` to `^0.1.42` (RESEARCH.md Standard Stack). The bump is OPTIONAL in this phase but recommended.
**ADD: onConnect handler for video-keepalive port (NEW)** — RESEARCH.md Pattern 5, lines 609-617:
```typescript
let videoPort: chrome.runtime.Port | null = null;
chrome.runtime.onConnect.addListener((p) => {
if (p.name !== 'video-keepalive') return;
videoPort = p;
p.onMessage.addListener((msg) => {
if (msg.type === 'BUFFER') { /* resolve pending export */ }
});
p.onDisconnect.addListener(() => { videoPort = null; });
});
```
**ADD: one-shot deletion of orphaned IndexedDB on install** — RESEARCH.md Runtime State Inventory:
The `chrome.runtime.onInstalled.addListener` at `src/background/index.ts:530-533` is the natural site to call `indexedDB.deleteDatabase('VideoRecorderDB')` to clean up old browser profiles. Add inside that listener.
**`saveArchive` pattern (MODIFY to fetch buffer via port)** — `src/background/index.ts:314-387`:
```typescript
async function saveArchive() {
// ... unchanged up to line 332 ...
const videoBuffer = getVideoBuffer(); // ← REPLACE: await getVideoBufferFromOffscreen()
// ... unchanged thereafter ...
}
```
---
### `vite.config.ts` (MODIFIED — heavy reduction)
**Analog:** crxjs discussion #919 working pattern (cited in RESEARCH.md Example B).
**DELETE the entire inline plugin block**`vite.config.ts:13-216` (the object literal starting `{ name: 'copy-offscreen', generateBundle() { ... } }`).
**Final shape (REPLACE existing file body)** — RESEARCH.md Example B:
```typescript
import { defineConfig } from 'vite';
import { crx } from '@crxjs/vite-plugin';
import manifest from './manifest.json';
export default defineConfig({
plugins: [
crx({ manifest, contentScripts: { injectCss: false } }),
],
build: {
rollupOptions: {
input: {
offscreen: 'src/offscreen/index.html',
},
},
},
});
```
**Current `crx` invocation pattern (KEEP)**`vite.config.ts:5-12`:
```typescript
crx({
manifest,
contentScripts: {
injectCss: false,
},
}),
```
This invocation form is correct and is retained verbatim; only the second plugin entry (the inline `copy-offscreen`) is deleted, and `rollupOptions.input` for the offscreen HTML entry is added per RESEARCH.md Pitfall 5.
**Pitfall 5 note (per RESEARCH.md):** Inspect `dist/` after the first `npm run build` to verify the emitted path matches what `src/background/index.ts:85` passes to `createDocument`. If crxjs strips the `src/` prefix, change the SW's `chrome.runtime.getURL` arg accordingly.
---
### `manifest.json` (MODIFIED — permission swap)
**Analog:** itself.
**Current permissions block**`manifest.json:6-14`:
```json
"permissions": [
"tabCapture",
"activeTab",
"downloads",
"scripting",
"storage",
"alarms",
"offscreen"
],
```
**Patch per D-A6:**
- Replace `"tabCapture"` with `"desktopCapture"`.
- KEEP `"activeTab"` (screenshot path: `chrome.tabs.captureVisibleTab`, see `src/background/index.ts:190`).
- Drop `"alarms"` (no longer used after `setupKeepalive` deletion). [OPTIONAL but consistent with D-18.]
- KEEP `"downloads"` (used at `src/background/index.ts:305`).
- KEEP `"storage"` (used by rrweb? — not used in current code; can drop, but out of scope for this phase).
- KEEP `"scripting"` (content script).
- KEEP `"offscreen"` (required to call `chrome.offscreen.createDocument`).
Final shape:
```json
"permissions": [
"desktopCapture",
"activeTab",
"downloads",
"scripting",
"storage",
"offscreen"
],
```
---
### `src/shared/types.ts` (MODIFIED — light; verify message types)
**Analog:** itself.
**Status check**`src/shared/types.ts:3-18`:
```typescript
export type MessageType =
| 'REQUEST_PERMISSIONS'
| 'PERMISSIONS_GRANTED'
| 'PERMISSIONS_DENIED'
| 'GET_VIDEO_BUFFER'
| 'VIDEO_BUFFER_RESPONSE'
| 'GET_RRWEB_EVENTS'
| 'RRWEB_EVENTS_RESPONSE'
| 'SAVE_ARCHIVE'
| 'ARCHIVE_SAVED'
| 'START_RECORDING'
| 'STOP_RECORDING'
| 'RECORDING_ERROR'
| 'VIDEO_CHUNK'
| 'VIDEO_CHUNK_SAVED'
| 'OFFSCREEN_READY';
```
- `OFFSCREEN_READY` is present (line 18). ✓
- `RECORDING_ERROR` is present (line 15). ✓
- `VIDEO_CHUNK` and `VIDEO_CHUNK_SAVED` should be **REMOVED** (lines 16-17) since their handlers are deleted in Phase 1.
**Phase 1 additions:**
- Add port-message string types if planner chooses to type the port traffic:
```typescript
// Port message types (offscreen ↔ SW over 'video-keepalive' port)
export type PortMessageType =
| 'PING'
| 'REQUEST_BUFFER'
| 'BUFFER';
export interface PortMessage<T = unknown> {
type: PortMessageType;
chunks?: VideoChunk[];
payload?: T;
}
```
**`VideoChunk` interface (KEEP as-is)** — `src/shared/types.ts:27-31`:
```typescript
export interface VideoChunk {
data: Blob;
timestamp: number;
isFirst?: boolean;
}
```
This shape is exactly what the offscreen ring buffer needs. Reuse unchanged.
---
### `tests/offscreen/ring-buffer.test.ts` (NEW — unit test)
**Analog:** none in codebase (no tests directory exists yet).
**Subject under test** — the relocated `addVideoChunkFromBlob` + `cleanupVideoBuffer` from `src/background/index.ts:26-75`. Tests assert:
1. First chunk is pinned with `isFirst: true`.
2. Subsequent chunks have `isFirst: false`.
3. After 30+ seconds, non-header chunks are evicted but the header survives.
4. Buffer length stays ≤ N for N chunks added within 30 s.
**Skeleton (standard Vitest)** — pattern inferred from RESEARCH.md Validation Architecture:
```typescript
import { describe, it, expect, beforeEach } from 'vitest';
import { addChunk, trimAged, getBuffer, resetBuffer } from '../../src/offscreen/recorder';
// NB: planner must export these pure functions from recorder.ts to make them testable.
describe('ring buffer', () => {
beforeEach(() => resetBuffer());
it('first chunk is header', () => {
addChunk({ size: 1024 } as Blob, 1_000);
expect(getBuffer()[0].isFirst).toBe(true);
});
it('trim 30s — keeps header, evicts aged tail', () => {
addChunk({ size: 1024 } as Blob, 0); // header at t=0
addChunk({ size: 512 } as Blob, 10_000); // t=10s
addChunk({ size: 512 } as Blob, 35_000); // t=35s — header now 35s old, tail 25s
trimAged(/* now= */ 40_000); // header age 40s; first chunk 30s; last chunk 5s
const buf = getBuffer();
expect(buf[0].isFirst).toBe(true); // header survives unconditionally
expect(buf.length).toBeGreaterThanOrEqual(2); // header + at least the t=35s chunk
});
});
```
---
### `tests/offscreen/codec-check.test.ts` (NEW — unit test)
**Analog:** none. Subject under test = the codec strict-mode error path (RESEARCH.md Pattern 6 / Example E).
**Skeleton:**
```typescript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('codec strict mode', () => {
let originalIsTypeSupported: typeof MediaRecorder.isTypeSupported;
beforeEach(() => {
originalIsTypeSupported = (globalThis as any).MediaRecorder?.isTypeSupported;
(globalThis as any).MediaRecorder = {
isTypeSupported: vi.fn().mockReturnValue(false),
};
(globalThis as any).chrome = { runtime: { sendMessage: vi.fn() } };
});
afterEach(() => {
if (originalIsTypeSupported) {
(globalThis as any).MediaRecorder = { isTypeSupported: originalIsTypeSupported };
}
});
it('throws on unsupported vp9 and emits RECORDING_ERROR', async () => {
const { assertCodecSupported } = await import('../../src/offscreen/recorder');
expect(() => assertCodecSupported()).toThrow(/vp9 unsupported/);
expect((globalThis as any).chrome.runtime.sendMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: 'RECORDING_ERROR' })
);
});
});
```
---
### `tests/offscreen/handshake.test.ts` (NEW — unit test)
**Analog:** none. Subject under test = `OFFSCREEN_READY` Promise resolution (RESEARCH.md Pattern 4).
**Skeleton:**
```typescript
import { describe, it, expect, vi } from 'vitest';
describe('OFFSCREEN_READY handshake', () => {
it('sends OFFSCREEN_READY after listener registration', async () => {
const calls: any[] = [];
(globalThis as any).chrome = {
runtime: {
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(),
}),
},
};
await import('../../src/offscreen/recorder');
expect(calls).toEqual(expect.arrayContaining([
expect.objectContaining({ type: 'OFFSCREEN_READY' }),
]));
});
});
```
---
### `tests/offscreen/port.test.ts` (NEW — unit test)
**Analog:** none. Subject under test = port reconnect on disconnect (RESEARCH.md Pitfall 4).
**Skeleton:**
```typescript
import { describe, it, expect, vi } from 'vitest';
describe('port reconnect', () => {
it('reconnects when port disconnects', () => {
const disconnectListeners: Array<() => void> = [];
let connectCount = 0;
(globalThis as any).chrome = {
runtime: {
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(),
};
},
},
};
// After import, the module connects exactly once
return import('../../src/offscreen/recorder').then(() => {
expect(connectCount).toBe(1);
// Fire the disconnect — module should reconnect
disconnectListeners.forEach((fn) => fn());
expect(connectCount).toBeGreaterThanOrEqual(2);
});
});
});
```
---
### `vitest.config.ts` (NEW — test runner config)
**Analogs:** `vite.config.ts` (defineConfig shape) + `tsconfig.json` (compiler-options alignment).
**Pattern to copy** — RESEARCH.md Wave 0 Gaps + Vitest standard:
```typescript
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node', // node-only; Blob is shimmed by Vitest 3+ via undici
include: ['tests/**/*.test.ts'],
reporters: 'dot',
typecheck: {
enabled: false, // tsc --noEmit runs separately in `npm run build`
},
},
});
```
**`package.json` script ADD** — companion to vitest.config.ts:
```json
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run" // ← ADD
}
```
---
## Shared Patterns
### Logging
**Source:** `src/shared/logger.ts:1-25` (Logger class) and `src/shared/logger.ts:28-51` (ContentLogger class).
**Apply to:** All new and modified source files.
```typescript
// src/shared/logger.ts:1-25
export class Logger {
private context: string;
constructor(context: string) {
this.context = context;
}
private logWithLevel(level: 'log' | 'warn' | 'error', ...args: any[]) {
const timestamp = new Date().toISOString();
console[level](`[SW:${this.context}] ${timestamp}`, ...args);
}
log(...args: any[]) { this.logWithLevel('log', ...args); }
warn(...args: any[]) { this.logWithLevel('warn', ...args); }
error(...args: any[]) { this.logWithLevel('error', ...args); }
}
```
**For the new offscreen file:** Either (a) reuse `Logger` with constructor argument `'Offscreen:Main'`, producing `[SW:Offscreen:Main]` (slightly misleading since the runtime is the offscreen, not the SW), or (b) ADD an `OffscreenLogger` class mirroring `ContentLogger` with prefix `[OS:${context}]`. Planner picks; CONTEXT.md "Reusable assets" section permits either.
Recommendation: add `OffscreenLogger` for consistency with the existing `Logger` / `ContentLogger` split. ~25 lines, matches the shape exactly:
```typescript
// New OffscreenLogger class to add to src/shared/logger.ts
export class OffscreenLogger {
private context: string;
constructor(context: string) { this.context = context; }
private logWithLevel(level: 'log' | 'warn' | 'error', ...args: any[]) {
const timestamp = new Date().toISOString();
console[level](`[OS:${this.context}] ${timestamp}`, ...args);
}
log(...args: any[]) { this.logWithLevel('log', ...args); }
warn(...args: any[]) { this.logWithLevel('warn', ...args); }
error(...args: any[]) { this.logWithLevel('error', ...args); }
}
```
### Message handling (sender validation)
**Source:** RESEARCH.md Security Domain table (currently MISSING in codebase).
**Apply to:** New `chrome.runtime.onMessage` and `chrome.runtime.onConnect` handlers in both `src/background/index.ts` and `src/offscreen/recorder.ts`.
Current code does NOT validate `sender.id`. Phase 1 SHOULD add:
```typescript
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (sender.id !== chrome.runtime.id) return false;
// ... existing dispatch ...
});
chrome.runtime.onConnect.addListener((port) => {
if (port.sender?.id !== chrome.runtime.id) {
port.disconnect();
return;
}
// ... existing handling ...
});
```
### Async response convention
**Source:** `src/background/index.ts:427-479`.
**Apply to:** All `onMessage` handlers that perform `await`/`then` before `sendResponse`.
The convention in the existing code is consistent and correct:
- `return true;` — when the handler will call `sendResponse` asynchronously (after a Promise resolves).
- `return false;` — when the handler returns synchronously or does not respond.
Preserve this convention verbatim in the modified background and the new offscreen recorder.
### Russian inline comments
**Source:** `src/background/index.ts:12, 25, 47, 156` and `src/content/index.ts:14-18, 20-23, 27`.
**Apply to:** New `src/offscreen/recorder.ts`.
Project convention: Russian comments are kept as section markers / explanations for business logic.
Example:
```typescript
// Константы
const TIMESLICE_MS = 2000;
// Кольцевой буфер видео
let videoBuffer: VideoChunk[] = [];
// Keepalive порт (заменяет chrome.alarms)
let keepalivePort: chrome.runtime.Port | null = null;
```
TypeScript identifiers stay English (per CLAUDE.md naming rules); only comments are Russian.
### Module-level state pattern
**Source:** `src/background/index.ts:15-22` and `src/content/index.ts:20-24`.
**Apply to:** New `src/offscreen/recorder.ts`.
Single-instance modules use top-level `let` state declarations (not classes / singletons):
```typescript
// src/background/index.ts:15-22
let videoBuffer: VideoChunk[] = [];
let firstChunkSaved = false;
let isRecording = false;
let offscreenCreated = false;
let lastScreenshotTime = 0;
let cachedScreenshot: Blob | null = null;
```
For `src/offscreen/recorder.ts`, mirror this shape:
```typescript
let videoRecorder: MediaRecorder | null = null; // RENAMED from 'mediaRecorder' to break the audit P0 #1 shadow
let videoBuffer: VideoChunk[] = [];
let firstChunkSaved = false;
let mediaStream: MediaStream | null = null;
let keepalivePort: chrome.runtime.Port | null = null;
let pingIntervalId: number | null = null;
```
## No Analog Found
| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| `tests/offscreen/*.test.ts` (4 files) | unit test | request-response | No test directory exists in the project yet. Pattern comes from Vitest defaults + RESEARCH.md Validation Architecture. |
`vitest.config.ts` has a partial analog in `vite.config.ts` (defineConfig shape) and is listed under Pattern Assignments rather than here.
## Metadata
**Analog search scope:**
- `/home/parf/projects/work/repremium/src/` (all subdirectories)
- `/home/parf/projects/work/repremium/offscreen/`
- `/home/parf/projects/work/repremium/vite.config.ts`
- `/home/parf/projects/work/repremium/manifest.json`
- `/home/parf/projects/work/repremium/tsconfig.json`
- `/home/parf/projects/work/repremium/package.json`
**Files scanned:** 10 source files; 4 config files.
**Verified line numbers** (read on 2026-05-15):
- `src/background/index.ts` — 536 lines total. Verified delete-target ranges: 26-45 (addVideoChunkFromBlob), 47-75 (cleanupVideoBuffer), 126-131 (getMediaStreamId), 156-165 (setupKeepalive), 457-473 (VIDEO_CHUNK + VIDEO_CHUNK_SAVED cases), 482-520 (loadChunkFromIndexedDB + openIndexedDB).
- `vite.config.ts` — 227 lines total. Verified inline plugin range: 13-216 (the `copy-offscreen` object starts at line 13 with `name:` on line 14 inside a plugins array; the embedded JS string template ends at line 213; the trailing build/rollupOptions block at 218-226 also collapses per RESEARCH.md Example B).
- `offscreen/index.ts` — 60 lines, intact. DELETE target.
- `offscreen/index.html` — 10 lines, intact. DELETE target.
- `manifest.json:7` — `"tabCapture"` token to replace.
- `src/shared/types.ts:18` — `'OFFSCREEN_READY'` declared, ready to wire up.
**Pattern extraction date:** 2026-05-15