Files
mokosh/.planning/phases/01-stabilize-video-pipeline/01-03-PLAN.md
Mark b8219af5b9 docs(01): revise plan 03 wave + OffscreenLogger strict-mode per checker iteration 1
Two changes:
1. wave: 1 → 2 (cascade after Plan 02 wave fix)
2. OffscreenLogger: ...args: any[] → ...args: unknown[] for strict-mode
   hygiene. Existing Logger / ContentLogger are left on the legacy any[]
   pattern (refactoring is out of Phase 1 scope) — divergence documented
   via style_divergence_note frontmatter field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:50:05 +02:00

32 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, style_divergence_note, requirements, requirements_addressed, must_haves
phase plan type wave depends_on files_modified autonomous style_divergence_note requirements requirements_addressed must_haves
01-stabilize-video-pipeline 03 tdd 2
02
src/offscreen/recorder.ts
src/offscreen/index.html
src/shared/logger.ts
src/shared/types.ts
true OffscreenLogger uses `...args: unknown[]` (strict-mode hygiene) where existing Logger / ContentLogger use `...args: any[]` (project convention). Refactoring the existing two for consistency is OUT OF SCOPE for Phase 1; they remain on the legacy pattern.
REQ-video-ring-buffer
REQ-video-ring-buffer
truths artifacts key_links
`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
path provides min_lines exports
src/offscreen/recorder.ts Ring buffer + getDisplayMedia + MediaRecorder + codec strict-mode + track-ended handler 150
addChunk
trimAged
getBuffer
resetBuffer
assertCodecSupported
VIDEO_BUFFER_DURATION_MS
path provides contains
src/offscreen/index.html crxjs-managed offscreen entry point ./recorder.ts
path provides contains
src/shared/logger.ts OffscreenLogger added alongside existing Logger / ContentLogger export class OffscreenLogger
path provides contains
src/shared/types.ts Port message types added; deleted VIDEO_CHUNK / VIDEO_CHUNK_SAVED variants PortMessage
from to via pattern
src/offscreen/recorder.ts src/shared/types.ts import type { Message, VideoChunk, PortMessage } from '../shared/types' from '../shared/types'
from to via pattern
src/offscreen/recorder.ts src/shared/logger.ts import { OffscreenLogger } from '../shared/logger' from '../shared/logger'
from to via pattern
src/offscreen/index.html src/offscreen/recorder.ts <script type="module" src="./recorder.ts"> ./recorder.ts
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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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

Required exports from src/offscreen/recorder.ts (verified against the RED tests):

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):

export interface VideoChunk {
  data: Blob;
  timestamp: number;
  isFirst?: boolean;
}

<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>
Offscreen recorder module — ring buffer + getDisplayMedia + codec strict-mode + track-ended cleanup src/offscreen/recorder.ts (new), src/offscreen/index.html (new), src/shared/logger.ts (modify), src/shared/types.ts (modify) 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)

See task list — three tasks: RED-verify, GREEN-implement, then types + logger ergonomics.

Task 1: RED-verify — confirm Plan 02 tests fail at module resolution (none modified) - tests/offscreen/ring-buffer.test.ts (Plan 02 output) - tests/offscreen/codec-check.test.ts (Plan 02 output) Run the two test files this plan owns and CAPTURE the failure mode:
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. 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" <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> RED gate confirmed. Plan 02 test files import a module that does not yet exist. Proceed to GREEN.

Task 2: GREEN — write src/offscreen/recorder.ts and src/offscreen/index.html src/offscreen/recorder.ts, src/offscreen/index.html - 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) After Task 2 ships, the following MUST hold:
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)
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"):
// 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:

<!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:

npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts --reporter=dot
npx tsc --noEmit

Both MUST exit 0. npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts && npx tsc --noEmit <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> Two tests files green; ring buffer and codec strict-mode behavior implemented; offscreen HTML entry exists; tsc clean.

Task 3: Logger + types ergonomics — add OffscreenLogger, clean up shared/types.ts src/shared/logger.ts, src/shared/types.ts - 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) Two edits.

(1) src/shared/logger.ts — append a new OffscreenLogger class at the END of the file, mirroring the shape of ContentLogger:


// Логгер для Offscreen Document
export class OffscreenLogger {
  private context: string;

  constructor(context: string) {
    this.context = context;
  }

  private logWithLevel(level: 'log' | 'warn' | 'error', ...args: unknown[]) {
    const timestamp = new Date().toISOString();
    console[level](`[OS:${this.context}] ${timestamp}`, ...args);
  }

  log(...args: unknown[]) {
    this.logWithLevel('log', ...args);
  }

  warn(...args: unknown[]) {
    this.logWithLevel('warn', ...args);
  }

  error(...args: unknown[]) {
    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:

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:


// Типы сообщений в 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:

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). npx tsc --noEmit && grep -c "OffscreenLogger" src/shared/logger.ts && grep -cE "VIDEO_CHUNK[^_S]|VIDEO_CHUNK_SAVED" src/shared/types.ts <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> 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.

Task 4: REFACTOR — optional cleanup pass (only if obvious improvements exist) src/offscreen/recorder.ts (only if changed) - src/offscreen/recorder.ts (Task 2 output) 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). npx vitest run tests/offscreen/ring-buffer.test.ts tests/offscreen/codec-check.test.ts && npx tsc --noEmit <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> REFACTOR phase complete (or explicitly skipped with rationale).

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.

<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>
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)