Files
mokosh/.planning/phases/01-stabilize-video-pipeline/01-05-PLAN.md

461 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 01-stabilize-video-pipeline
plan: 05
type: execute
wave: 4
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, and the alarms-driven keepalive is DELETED per D-18 (it never actually reset the SW idle timer; the long-lived port does)"
- "`src/background/index.ts` no longer calls `chrome.tabCapture.getMediaStreamId` (D-01 amendment); video acquisition is now `getDisplayMedia` invoked from the offscreen module"
- "`src/background/index.ts` no longer handles `VIDEO_CHUNK` or `VIDEO_CHUNK_SAVED` (deleted Message types in Plan 03)"
- "`src/background/index.ts` no longer contains any `chrome.tabs.onActivated` handler tied to the recording lifecycle (D-14: tab-switch re-attach is non-applicable under `getDisplayMedia`; D-15: operator tab-switching no longer interrupts recording, the buffer keeps filling regardless of active tab)"
- "SW has an `onConnect` listener that filters `port.name === 'video-keepalive'` and validates `port.sender?.id === chrome.runtime.id` (T-1-04 mitigation; this is the SW-side counterparty of the long-lived port keepalive per D-17)"
- "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`) per D-02"
- "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), DELETE any `chrome.tabs.onActivated`
re-attach plumbing (D-14: not applicable under the new capture API; D-15:
operator tab switches no longer interrupt the recording), 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.
(14) Verify no `chrome.tabs.onActivated` listener exists in the file (D-14 / D-15: tab-switch handling is non-applicable under the new capture API). Run `grep -n "chrome.tabs.onActivated" src/background/index.ts`. If the grep returns any hits, DELETE those lines (the entire listener callback block). If the grep returns nothing, log "D-14/D-15 satisfied: no tab-switch handler found in SW" in the task summary.
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|chrome\.tabs\.onActivated" 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 -v '^#' src/background/index.ts | grep -c "chrome.tabs.onActivated"` returns 0 (D-14/D-15 mitigation)
- `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, no `chrome.tabs.onActivated` re-attach plumbing
- 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>