docs(01): add Plans 01-08 / 01-09 / 01-10 (amended Phase 1 charter)

Plans cover the post-D-13 architecture and the auto-start UX charter
expansion that landed during 2026-05-16 UAT:

- Plan 01-08 — WebM remux via ts-ebml@3.0.2 + webm-muxer@5.1.4. Replaces
  the broken file-concat in mergeVideoSegments with a real single-EBML
  remux. Drives the 2 RED tests in tests/offscreen/webm-playback.test.ts
  to GREEN. Regenerates the canonical fixture against the remuxer.
  5 tasks (4 TDD + 1 operator empirical checkpoint), wave 1.

- Plan 01-09 — Whole-desktop constraint (displaySurface:'monitor',
  cursor:'always') + post-grant validation, chrome.action.onClicked
  direct toolbar invocation, chrome.action badge state machine
  (REC/OFF/ERROR), chrome.runtime.onStartup notification + recovery
  notification on onUserStoppedSharing, popup scoped to SAVE-only.
  17 new test assertions across 4 test files. smoke.sh updated to
  auto-select an entire screen. 5 tasks (4 TDD + 1 operator empirical
  checkpoint), wave 2 (depends on 01-08).

- Plan 01-10 — chrome.runtime.onInstalled welcome tab on first install
  via chrome.storage.local guard; vanilla welcome.html/ts/css bundle
  with single "Начать запись" button consuming install-time activation.
  Uses centralized Logger pattern. 4 tasks (3 TDD + 1 operator empirical
  checkpoint), wave 3 (depends on 01-09).

CONTEXT.md amendment block appended with 4 disambiguated decisions:
- D-14-remux: WebM remux supersedes D-13 file-concat
- D-15-display-surface: whole-desktop + cursor visibility lifted from
  Phase 5 deferral
- D-16-toolbar: toolbar onClicked + popup SAVE-only + badge state
  machine + onStartup/recovery notifications
- D-17-onboarding: welcome tab on first install (distinct from
  D-17-port-lifecycle from Option C)
The earlier D-17 port-lifecycle heading also renamed to hyphenated
form for cross-ref consistency.

Plan-check loop: 3 iterations (initial + 2 revisions). Iteration 1
surfaced 11 findings (2 BLOCKER + 6 WARNING + 3 INFO); all addressed
via revision iter 1 with checker-recommended fixes. Iteration 2
surfaced 3 derivative regressions (literal-string grep anchors from
the iter-1 fixes did not match live CONTEXT.md); all addressed in iter
2 with empirically-validated literals. Iteration 3 PASSED clean.

Validation: gsd-sdk frontmatter.validate + verify.plan-structure both
return valid=True for all 3 plans. Plan 01-08 Task 4 verify-chain
grep tested end-to-end against live CONTEXT.md (exit 0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 09:19:22 +02:00
parent bc310d98cf
commit 2e499d7387
4 changed files with 1849 additions and 2 deletions

View File

@@ -0,0 +1,497 @@
---
phase: 01-stabilize-video-pipeline
plan: 08
type: tdd
wave: 1
depends_on: []
files_modified:
- package.json
- package-lock.json
- src/background/index.ts
- src/background/webm-remux.ts
- tests/background/webm-remux.test.ts
- tests/fixtures/last_30sec.webm
- .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
autonomous: false
requirements:
- REQ-video-ring-buffer
tags:
- webm
- remux
- ts-ebml
- webm-muxer
- vp9
- matroska
must_haves:
truths:
- "tests/offscreen/webm-playback.test.ts container-level format=duration assertion (>= 25_000 ms) is GREEN against the regenerated tests/fixtures/last_30sec.webm fixture."
- "tests/offscreen/webm-playback.test.ts ffmpeg full-decode timeline assertion (>= 25_000 ms) is GREEN against the regenerated fixture."
- "mergeVideoSegments() no longer exists in src/background/index.ts; its callers go through the new remuxSegments() in src/background/webm-remux.ts."
- "The remux produces a single-EBML-header WebM (verified by byte-level probe asserting EBML header occurrence count === 1) covering all VP9 frames from every input segment with monotonically increasing timestamps."
- "The 53 baseline GREEN tests across 11 files plus the 4 webm-playback tests all pass (`npx vitest run` exit 0)."
- "Operator running ./smoke.sh + opening the produced last_30sec.webm in Chrome AND mpv sees ~30 s of continuous playback (NOT ~9 s)."
artifacts:
- path: "src/background/webm-remux.ts"
provides: "remuxSegments(segments: VideoSegment[]): Promise<Blob> — parses each input segment via ts-ebml, extracts VP9 SimpleBlocks + cluster timecodes, re-mixes monotonic timestamps via webm-muxer into a single-EBML-headered WebM."
min_lines: 120
exports:
- "remuxSegments"
- path: "tests/background/webm-remux.test.ts"
provides: "Unit-level RED→GREEN tests for the remux helper: single-EBML invariant, monotonic-timestamp invariant, frame-count preservation across input segments, keyframe-flag preservation."
min_lines: 100
- path: "src/background/index.ts"
provides: "createArchive() calls remuxSegments() (await — async) instead of synchronous mergeVideoSegments(); EmptyVideoBufferError surface preserved."
contains: "remuxSegments"
- path: "tests/fixtures/last_30sec.webm"
provides: "Regenerated fixture captured against the post-remux build; ffprobe container duration >= 25_000 ms; ffmpeg full-decode time >= 25_000 ms."
- path: "package.json"
provides: "Adds `ts-ebml` ^3.0.2 and `webm-muxer` ^5.1.4 to dependencies (both MIT, both SW-compatible per the d13 debug session library survey)."
contains: "ts-ebml"
- path: ".planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md"
provides: "AMENDMENT block appending D-14-remux (ts-ebml + webm-muxer remux) supersedes D-13's file-concat merge; D-13's recorder-side restart-segments lifecycle preserved."
contains: "D-14-remux"
key_links:
- from: "src/background/index.ts:createArchive"
to: "src/background/webm-remux.ts:remuxSegments"
via: "await remuxSegments(videoBufferResponse.segments)"
pattern: "remuxSegments\\("
- from: "src/background/webm-remux.ts"
to: "ts-ebml package"
via: "import { Decoder } from 'ts-ebml'"
pattern: "from ['\"]ts-ebml['\"]"
- from: "src/background/webm-remux.ts"
to: "webm-muxer package"
via: "import { Muxer, ArrayBufferTarget } from 'webm-muxer'"
pattern: "from ['\"]webm-muxer['\"]"
- from: "tests/offscreen/webm-playback.test.ts"
to: "tests/fixtures/last_30sec.webm"
via: "ffprobe + ffmpeg drive the regenerated fixture; the 2 RED assertions (lines 232-316) flip GREEN."
pattern: "MIN_PLAYABLE_DURATION_MS"
---
<objective>
Replace `src/background/index.ts:mergeVideoSegments()` file-concat (D-13) with a real single-EBML-headered WebM **remux** via `ts-ebml` (parse) + `webm-muxer` (write). Each input segment carries its own EBML header + Segment + Cluster structure; the output is one EBML + one Segment whose clusters contain every VP9 SimpleBlock from every input segment, with timestamps adjusted to be monotonic against a single global timeline. D-13's recorder-side restart-segments lifecycle (the part that fixed orphan-P-frame freezes) is preserved; only the merge step is replaced.
Purpose: satisfy SPEC §10 #7 (`last_30sec.webm` plays back in a browser) for real. The 2026-05-15 closure shipped a multi-EBML concat that mpv / Chrome / ffprobe all play as ~9.94 s (first segment's Info.Duration only) instead of ~30 s. The two RED tests already on disk in `tests/offscreen/webm-playback.test.ts` (lines 231-316) pin the actual contract; this plan drives them GREEN.
Output:
- `src/background/webm-remux.ts` — new module exporting `remuxSegments(segments: VideoSegment[]): Promise<Blob>`.
- `src/background/index.ts``mergeVideoSegments()` deleted; `createArchive()` calls `await remuxSegments(...)` (becomes async-on-the-merge-path; saveArchive already awaits createArchive).
- `tests/background/webm-remux.test.ts` — RED→GREEN unit tests pinning the single-EBML / monotonic-timestamp / frame-count / keyframe-flag invariants without requiring a real MediaRecorder.
- Regenerated `tests/fixtures/last_30sec.webm` from a fresh `./smoke.sh` against the post-remux build, validated by the 2 new playback-duration assertions.
- `package.json` + `package-lock.json` — adds `ts-ebml` ^3.0.2 + `webm-muxer` ^5.1.4.
- CONTEXT.md amendment block (D-14-remux supersedes D-13's file-concat; D-13's recorder lifecycle preserved). The amendment block was authored at plan-creation time by the orchestrator; Task 4 step 9 verifies it remains intact via grep checks (no new file mutation expected).
</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/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.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-VERIFICATION.md
@.planning/phases/01-stabilize-video-pipeline/01-UAT.md
@.planning/debug/d13-multi-ebml-concat-unplayable.md
@tests/offscreen/webm-playback.test.ts
@src/background/index.ts
@src/shared/types.ts
@src/shared/binary.ts
<interfaces>
<!-- Key types and contracts the executor will use. Extracted from codebase. -->
<!-- Executor should use these directly — no codebase exploration needed for them. -->
From `src/shared/types.ts`:
```typescript
export interface VideoSegment {
data: Blob; // a self-contained ~10 s WebM blob (D-13 segment)
timestamp: number;
}
export interface VideoBufferResponse {
segments: VideoSegment[];
}
```
From `src/background/index.ts` (current call site to be replaced, lines 395-421 + 449):
```typescript
// CURRENT (D-13 file-concat — TO BE DELETED):
function mergeVideoSegments(segments: VideoSegment[]): Blob {
const sortedSegments = [...segments].sort((a, b) => a.timestamp - b.timestamp);
const blobs: Blob[] = sortedSegments.map((segment) => segment.data);
return new Blob(blobs, { type: 'video/webm' });
}
// Called at line 449 (inside createArchive):
const videoBlob = mergeVideoSegments(videoBufferResponse.segments);
```
From the debug session research (`.planning/debug/d13-multi-ebml-concat-unplayable.md` lines 380-410), the remux algorithm:
```
1. For each segment blob, parse via ts-ebml Decoder → walk EBML tree →
for each Cluster's SimpleBlock children, extract:
• VP9 frame bytes (the SimpleBlock payload after the per-block header)
• keyframe flag (Matroska SimpleBlock first-flag-byte bit 7)
• cluster Timestamp (Cluster.Timecode element, ms)
• block-local timestamp offset (relative to its enclosing cluster)
2. Compute monotonic adjusted timestamp:
globalUs = (segmentBaseMs + clusterTsMs + blockOffsetMs) * 1000
where segmentBaseMs accumulates the prior segment's total content
duration (the last block's local timestamp + 1 frame worth, ~33 ms).
3. Stream all adjusted frames into a single webm-muxer Muxer with
addVideoChunkRaw(frameData, isKey ? 'key' : 'delta', globalUs).
4. muxer.finalize() → target.buffer (ArrayBuffer) → wrap in Blob.
```
From `webm-muxer` 5.1.4 API surface (from npm registry; see https://github.com/Vanilagy/webm-muxer):
```typescript
import { Muxer, ArrayBufferTarget } from 'webm-muxer';
const target = new ArrayBufferTarget();
const muxer = new Muxer({
target,
video: {
codec: 'V_VP9', // Matroska codec id for VP9
width: <px>, height: <px>, // probe from first segment's Video element
frameRate: 30, // approximate — not strict
},
// No `audio` block — Phase 1 SPEC §9 / CAP-01 explicitly excludes audio.
type: 'webm', // emit a WebM (Matroska subset) container
firstTimestampBehavior: 'offset', // tolerate a non-zero first frame ts
});
// timestamp is MICROSECONDS, integer.
muxer.addVideoChunkRaw(data: Uint8Array, type: 'key' | 'delta', timestamp: number, meta?: { decoderConfig?: { description?: Uint8Array } });
muxer.finalize();
// target.buffer is now an ArrayBuffer holding the single-EBML WebM.
```
From `ts-ebml` 3.0.2 API surface (from npm registry; see https://github.com/legokichi/ts-ebml):
```typescript
import { Decoder, tools } from 'ts-ebml';
const decoder = new Decoder();
const elements = decoder.decode(arrayBuffer); // EBMLElementDetail[]
// Walk elements; each has { name, type, dataSize, value, isEnd, ... }.
// Element names of interest (Matroska element table):
// 'EBML', 'Segment', 'Tracks', 'TrackEntry', 'Video', 'PixelWidth',
// 'PixelHeight', 'CodecPrivate', 'Cluster', 'Timecode', 'SimpleBlock'.
// SimpleBlock payload is the raw block: variable-length track number prefix,
// 2 bytes signed timestamp delta, 1 flags byte (bit 7 = keyframe), then
// VP9 frame bytes.
```
Existing test infrastructure (reusable patterns from `tests/background/request-id-protocol.test.ts` and `tests/offscreen/codec-check.test.ts`):
- `vi.resetModules()` + dynamic `await import('../../src/background/...')` per test (fresh module state).
- No chrome stub needed for `webm-remux.ts` — it's a pure async function.
- `import.meta.url` + `node:fs` for fixture reads.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Install ts-ebml + webm-muxer; verify SW-compat by import-only test (Wave 0 contract).</name>
<read_first>
- package.json (current dependency list — confirm neither library is already installed)
- vite.config.ts (confirm crxjs picks up new imports automatically; no Rollup external config needed)
- .planning/debug/d13-multi-ebml-concat-unplayable.md, Evidence/library-survey section (lines 380-410) — confirms both libraries are SW-compatible (no `window`/`document` references)
- tests/offscreen/codec-check.test.ts (test scaffold pattern: vi.resetModules + dynamic import)
</read_first>
<files>package.json, package-lock.json, tests/background/webm-remux-deps.test.ts</files>
<behavior>
- Test 1: `tests/background/webm-remux-deps.test.ts` imports `webm-muxer` and `ts-ebml` and asserts both exports surface (`Muxer`, `ArrayBufferTarget` from webm-muxer; `Decoder` from ts-ebml). RED if either is missing from `node_modules`; GREEN after `npm install` lands.
- Test 2: same file asserts that loading both modules under Node's default globals (no jsdom shim) does not throw any `window is not defined`/`document is not defined` errors. RED if either dist accidentally hits a DOM global; GREEN by virtue of both libraries' published SW-compatibility (per debug session lines 380-410).
</behavior>
<action>
1. RED:
- Create `tests/background/webm-remux-deps.test.ts` with two `it` blocks:
(a) `it('exports Muxer + ArrayBufferTarget + Decoder', async () => {...})` — dynamic-import both, expect named exports defined.
(b) `it('loads under default Node globals without DOM-global ReferenceErrors', async () => {...})` — assert that `delete (globalThis as any).window; delete (globalThis as any).document;` followed by re-import does not throw.
- Run `npx vitest run tests/background/webm-remux-deps.test.ts`; expect both tests RED with `Cannot find module` errors (libraries not installed yet).
2. GREEN:
- `npm install --save ts-ebml@^3.0.2 webm-muxer@^5.1.4` (versions verified live on npm registry 2026-05-16: ts-ebml 3.0.2 + webm-muxer 5.1.4; both MIT; both active).
- Commit `package.json` + `package-lock.json` changes; do NOT touch other files.
- Re-run the test; both flip GREEN.
3. Re-run the full suite (`npx vitest run`) to confirm no collateral regression (53 baseline GREEN + 2 new GREEN = 55; the 2 existing RED tests in webm-playback.test.ts stay RED — they're Task 4's GREEN target).
Use absolute imports (`import { Muxer } from 'webm-muxer'`, `import { Decoder } from 'ts-ebml'`) per the user's global style guide. No `as any`; no `@ts-ignore`. If TypeScript complains about missing types, install `@types/...` packages if available OR add a minimal `.d.ts` shim under `src/shared/` (whichever is canonical for the library's published shape — check the library's repo first, do NOT hand-roll types if the library ships its own).
</action>
<verify>
<automated>npx vitest run tests/background/webm-remux-deps.test.ts &amp;&amp; npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `package.json` dependencies include `ts-ebml` and `webm-muxer` at the pinned versions.
- `tests/background/webm-remux-deps.test.ts` exists with the 2 tests above and both PASS.
- `npx tsc --noEmit` exits 0 (no type errors from either library import).
- `npx vitest run` shows 11 files / 53 GREEN + 2 new GREEN; the 2 RED in webm-playback.test.ts remain RED (this task does not touch the production code path yet).
</acceptance_criteria>
<done>Both libraries installed with pinned versions, the import-surface test pinning both library names is committed and GREEN, tsc clean, baseline suite preserved.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Add unit-level RED tests for remuxSegments() invariants (single-EBML, monotonic ts, frame-count, keyframe-flag).</name>
<read_first>
- tests/offscreen/segment-rotation.test.ts (existing test pattern for D-13; reuse the `Blob`-fixture-building style — uses node `Buffer`)
- tests/offscreen/webm-playback.test.ts lines 1-100 (FIXTURE_PATH idiom + ffprobe/ffmpeg helpers; we'll reuse the ffprobe binary path for any container-introspection assertions in this test file)
- .planning/debug/d13-multi-ebml-concat-unplayable.md, Evidence/H4 byte-level EBML probe section (lines 311-373) — describes the exact EBML element IDs to scan for: EBML header `0x1A 0x45 0xDF 0xA3`, Segment `0x18 0x53 0x80 0x67`, Cluster `0x1F 0x43 0xB6 0x75`.
- src/shared/types.ts (VideoSegment shape).
- tests/background/request-id-protocol.test.ts lines 53-85 (vi.fn / port stub pattern — but webm-remux.ts needs no chrome stub).
</read_first>
<files>tests/background/webm-remux.test.ts</files>
<behavior>
- Test 1 (RED → GREEN by Task 3): given the canonical 3-segment fixture `tests/fixtures/last_30sec.webm` sliced into 3 `Blob` segments (via the byte-offset probe in the debug session: seg1 = bytes [0, 509038), seg2 = bytes [509038, 970967), seg3 = bytes [970967, EOF)), `remuxSegments([seg1, seg2, seg3])` returns a `Blob` whose bytes contain EXACTLY 1 occurrence of the EBML header magic `[0x1A, 0x45, 0xDF, 0xA3]` and EXACTLY 1 occurrence of the Segment magic `[0x18, 0x53, 0x80, 0x67]`.
- Test 2: the same output blob's byte size is within [0.7×, 1.3×] of the sum of input segment sizes — sanity check that we're not silently dropping content NOR ballooning it 10x.
- Test 3: `ffprobe -v error -show_entries format=duration -of csv=p=0` against the output blob (write to tmpfile first) reports a duration >= 25_000 ms (skip-if-missing per webm-playback.test.ts pattern — use `existsSync(FFPROBE_BIN)` gate).
- Test 4: `ffprobe -count_frames -show_entries stream=nb_read_frames` against the output blob reports a frame count `n` where `905 <= n <= 912` (the per-segment counts from the debug session probe were `301+300+311 = 912`; the muxer may drop at most one partial frame per segment boundary, so 3 boundaries × max 1 partial frame each = ±3 frames absolute tolerance). I-01 fix (2026-05-16 checker pass): tightened from the prior ±20% band (which would accept catastrophic loss like 730 frames) to ±1% / ±3 absolute frames, sized to the documented muxer boundary partial-frame drop only.
- Test 5: empty-input → `remuxSegments([])` returns a `Promise<Blob>` whose blob has `.size === 0`. (Defense-in-depth; saveArchive's EmptyVideoBufferError throw guards this path upstream, but the helper should be safe on its own.)
</behavior>
<action>
1. Create `tests/background/webm-remux.test.ts` with the 5 `it` blocks described above.
2. Reuse the `FFPROBE_BIN` constant + `ffprobeAvailable()` skip-gate from `tests/offscreen/webm-playback.test.ts` (copy them inline — the test file lives in a different directory so a direct import is awkward; keep both copies trivially in sync per the existing webm-playback.test.ts pattern).
3. Use `node:fs` `readFileSync` + `Buffer.subarray(start, end)` to slice the fixture into 3 segment Blobs (use `new Blob([buffer])` to wrap each slice). Add a `splitFixtureIntoSegments(): Blob[]` helper at the top of the test file documenting the byte offsets from the debug session probe.
4. To find EBML element occurrences, write a tiny `countMagic(bytes: Uint8Array, magic: Uint8Array): number` helper that does a simple byte-window scan (no library needed — 4-byte patterns, no false-positive risk at fixture scale). The Cluster magic should appear >= 3 times in the output (one Cluster per ~1-3 s); but ASSERT only that EBML headers and Segments are EXACTLY 1 each.
5. Use `await import('../../src/background/webm-remux')` (dynamic import) so the import error in this task (the file doesn't exist yet) is the test failure signal rather than a Vitest collection error. Wrap each `it` body in try/catch + `expect.fail()` if the import itself throws — gives a readable RED message rather than `Cannot find module`.
6. Run `npx vitest run tests/background/webm-remux.test.ts`; expect ALL 5 tests RED (the module doesn't exist yet).
7. DO NOT IMPLEMENT THE MODULE YET — Task 3 is the GREEN side of this RED gate.
</action>
<verify>
<automated>npx vitest run tests/background/webm-remux.test.ts 2>&amp;1 | grep -E "Tests.*[0-9]+ failed"</automated>
</verify>
<acceptance_criteria>
- `tests/background/webm-remux.test.ts` exists with 5 tests.
- All 5 tests are RED (production module not yet implemented).
- Baseline 55 tests (53 + 2 from Task 1) still GREEN; the 2 webm-playback duration RED still RED; 5 new RED here. Total: 11 files / 60 tests / 7 failed | 53 passed. tsc still exit 0 (the test file is type-clean — uses VideoSegment from shared types).
- No file under `src/` is modified by this task.
</acceptance_criteria>
<done>5 RED tests committed pinning the remux invariants at the unit level; baseline preserved.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Implement remuxSegments() in src/background/webm-remux.ts — drives all 5 RED tests in webm-remux.test.ts to GREEN.</name>
<read_first>
- tests/background/webm-remux.test.ts (the contract — produced by Task 2)
- .planning/debug/d13-multi-ebml-concat-unplayable.md, Evidence/library-survey + the algorithm description on lines 380-410 (the remux pipeline this implementation realizes)
- src/shared/types.ts (VideoSegment + VideoBufferResponse)
- src/shared/logger.ts (Logger class shape — same prefix-based instantiation used in SW; pick `Logger('Remux')` or equivalent)
- The `ts-ebml` and `webm-muxer` README files in node_modules/ (installed by Task 1; READ the published library docs there rather than guessing API surface — the interfaces block in this plan is a sketch, the dist source is authoritative).
</read_first>
<files>src/background/webm-remux.ts</files>
<behavior>
Implements `export async function remuxSegments(segments: VideoSegment[]): Promise<Blob>` satisfying all 5 tests from Task 2. See behavior list there.
</behavior>
<action>
1. Create `src/background/webm-remux.ts` with extensive JSDoc per project style (the user's global guide mandates extensive docstrings; mirror the existing src/background/index.ts comment density). Header comment cites D-14-remux (this plan's CONTEXT amendment, disambiguated from the historical "D-14: Not applicable" tab-switch entry per B-02 fix), references the d13 debug session, and explains the parse → adjust-timestamps → re-emit pipeline.
2. Imports:
- `import { Decoder } from 'ts-ebml';`
- `import { Muxer, ArrayBufferTarget } from 'webm-muxer';`
- `import type { VideoSegment } from '../shared/types';`
- `import { Logger } from '../shared/logger';`
Use absolute-style (no `from '..'` ascending more than one level except shared/) — matches existing src/background/index.ts.
3. Helper functions (small + named — no `continue`, prefer if-else chains per project style):
- `async function blobToArrayBuffer(b: Blob): Promise<ArrayBuffer>` — uses `b.arrayBuffer()` (web standard; available in Chrome SW).
- `interface ExtractedFrame { data: Uint8Array; isKey: boolean; timestampMs: number; }`
- `function extractFramesFromSegment(buffer: ArrayBuffer, segmentBaseMs: number): { frames: ExtractedFrame[]; segmentDurationMs: number; trackInfo: { width: number; height: number; codecPrivate?: Uint8Array; } | null }` — walks `Decoder.decode(buffer)`, tracks current Cluster Timecode, processes each SimpleBlock by parsing its first-byte-after-track-number flags (bit 7 = keyframe). Returns adjusted-by-segmentBaseMs timestamps.
- SimpleBlock parsing: read VINT track number (Matroska VINT decode is library-trivial; copy the routine from the Matroska spec's "Variable Size Integer" section if ts-ebml does not surface it directly), then 2 bytes int16 BE timestamp delta, then 1 byte flags. Frame data = remaining bytes.
- `function pickTrackInfoFromSegment(elements: EBMLElementDetail[]): { width, height, codecPrivate? } | null` — walks for Tracks → TrackEntry → Video → PixelWidth + PixelHeight; CodecPrivate if present.
4. Main flow:
a) Guard empty input: `if (segments.length === 0) return new Blob([], { type: 'video/webm' });` (satisfies Test 5).
b) Sort by `timestamp` ascending (matches the SW-side ordering pattern in the deleted mergeVideoSegments).
c) Parse first segment to derive `trackInfo` (width/height/CodecPrivate) — needed for the muxer's video config.
d) Build a `Muxer<ArrayBufferTarget>` with:
```
codec: 'V_VP9', width, height, frameRate: 30,
type: 'webm', firstTimestampBehavior: 'offset',
```
If `CodecPrivate` is present, pass it as `decoderConfig.description` (Uint8Array) via the per-chunk meta. (Most VP9 streams from MediaRecorder do not need CodecPrivate, but pass it through defensively.)
e) Loop segments with an accumulating `segmentBaseMs`:
- `extractFramesFromSegment(buffer, segmentBaseMs)` → frames + `segmentDurationMs`
- For each frame: `muxer.addVideoChunkRaw(frame.data, frame.isKey ? 'key' : 'delta', frame.timestampMs * 1000)` (the muxer wants microseconds).
- `segmentBaseMs += segmentDurationMs` (the next segment's first frame slots in just after the prior segment's last frame).
f) `muxer.finalize()`, then `return new Blob([target.buffer], { type: 'video/webm' })`.
5. Log at INFO: input segment count + sizes; per-segment extracted-frame count + duration; final blob size.
6. NO `as any`; NO `@ts-ignore`. If a type narrowing is genuinely needed, use a typed predicate (`function isSimpleBlock(el: EBMLElementDetail): el is SimpleBlockElement {...}`).
7. Run `npx vitest run tests/background/webm-remux.test.ts` — all 5 must flip GREEN.
8. Run `npx tsc --noEmit` — must be 0.
9. Run full suite — 11 files / 60 tests / 2 failed | 58 passed (the only remaining failures are the 2 webm-playback duration RED; those drive Task 4 GREEN once the call site swaps).
DESIGN NOTE per project style: prefer if-else chains over early returns is RELAXED for guard clauses (the empty-input and null-trackInfo guards are clearer as `if (cond) return ...`). Document this exception in the file's header comment.
</action>
<verify>
<automated>npx vitest run tests/background/webm-remux.test.ts &amp;&amp; npx tsc --noEmit</automated>
</verify>
<acceptance_criteria>
- `src/background/webm-remux.ts` exists, exports `remuxSegments`, has extensive JSDoc per project style (block comment header + per-function docstrings).
- All 5 tests in `tests/background/webm-remux.test.ts` GREEN.
- `npx tsc --noEmit` exit 0.
- Full suite: only the 2 webm-playback duration tests RED (they stay RED until the call site swap + fixture regeneration in Tasks 4-5).
- No `as any`, no `@ts-ignore`, no `console.log` — uses `new Logger('Remux')` for diagnostics.
- File size 120-300 LOC inclusive of comments (per debug session's estimate; if implementation balloons past 350 LOC, consider extracting EBML walk to a separate `webm-ebml-parser.ts` — but a single-file implementation is acceptable).
</acceptance_criteria>
<done>Pure helper module landed; unit invariants pinned GREEN; full-suite delta = 5 new GREEN; tsc clean.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Swap mergeVideoSegments → remuxSegments at call site in src/background/index.ts; await the now-async merge.</name>
<read_first>
- src/background/index.ts lines 1-50 (imports + EmptyVideoBufferError class)
- src/background/index.ts lines 385-505 (mergeVideoSegments definition + createArchive body)
- src/background/index.ts lines 534-623 (saveArchive — confirms it already awaits createArchive)
- src/background/webm-remux.ts (the new helper from Task 3)
- tests/background/request-id-protocol.test.ts (asserts createArchive→saveArchive error-surface contract; the swap MUST preserve EmptyVideoBufferError throw on empty input)
</read_first>
<files>src/background/index.ts</files>
<behavior>
- `createArchive` calls `await remuxSegments(videoBufferResponse.segments)` instead of synchronous `mergeVideoSegments(...)`.
- `mergeVideoSegments` function declaration is removed from the file (grep returns 0 hits).
- `EmptyVideoBufferError` throw paths preserved on (a) zero segments AND (b) zero-byte merged blob.
- All existing tests in tests/background/* still GREEN (the request-id-protocol contract tests don't care whether the merge is sync or async — they stub saveArchive at a higher level).
</behavior>
<action>
1. RED step: rerun `npx vitest run` against current state — confirm the 2 RED webm-playback tests are still RED + remux unit tests GREEN. (No new failing test to write here; the gate is the existing webm-playback RED + the absence-of-mergeVideoSegments grep.)
2. Add `import { remuxSegments } from './webm-remux';` near the top of `src/background/index.ts` (alphabetized with existing imports per project style).
3. Replace the body of `createArchive`'s video-merge block (currently lines 444-456) with:
```typescript
if (videoBufferResponse.segments.length === 0) {
throw new EmptyVideoBufferError(
'no video segments available — buffer fetch returned empty (port replacement timed out, or recorder never started)',
);
}
const videoBlob = await remuxSegments(videoBufferResponse.segments);
if (videoBlob.size === 0) {
throw new EmptyVideoBufferError(
`remuxed video blob is zero bytes (segment count=${videoBufferResponse.segments.length})`,
);
}
zip.file('video/last_30sec.webm', videoBlob);
logger.log(`✓ Added video (remuxed): ${videoBlob.size} bytes`);
```
**W-01 fix (2026-05-16 checker pass): error message string change.**
The EmptyVideoBufferError detail string changes from 'merged video blob is zero bytes' → 'remuxed video blob is zero bytes'. Before committing, pre-flight grep `src/` and `tests/` for the literal 'merged video blob is zero bytes' to confirm no downstream consumer matches on that string:
```
grep -RIn 'merged video blob is zero bytes' src/ tests/ || true
```
Empirical pre-check (executed 2026-05-16 from current tree): only `src/background/index.ts:452` carries the literal. `tests/background/request-id-protocol.test.ts` asserts on `error.code` ('empty-video-buffer'), NOT on the free-text message — so the rename is safe. Document the empty grep result in the commit message body so the diff reviewer sees the safety check ran.
4. DELETE the entire `mergeVideoSegments` function declaration (currently lines 385-421 — including the JSDoc header explaining D-13 file-concat). Replace with a single comment line:
```typescript
// mergeVideoSegments (D-13 file-concat) retired in Plan 01-08 (D-14-remux):
// see src/background/webm-remux.ts for the single-EBML remux path.
```
5. `createArchive` is already declared `async`; the new `await remuxSegments(...)` slots in without signature changes. Verify saveArchive's `const archiveBlob = await createArchive(...)` already awaits — no changes needed there.
6. Run `npx tsc --noEmit` — exit 0.
7. Run `npx vitest run`:
- tests/background/webm-remux.test.ts: GREEN (Task 3)
- tests/background/request-id-protocol.test.ts: GREEN (no contract change)
- tests/background/port-lifecycle-continuous.test.ts: GREEN (no contract change)
- tests/offscreen/webm-playback.test.ts: 2 of 4 still RED (the new duration assertions) — these stay RED until the FIXTURE is regenerated (Task 5).
- All other tests: GREEN.
8. Run `grep -nc 'mergeVideoSegments' src/background/index.ts` → must report `0` (or `0` lines with the function declaration; the deletion comment may or may not name it, your call).
9. **B-01 fix (2026-05-16 checker pass): CONTEXT.md amendment provenance verification (folded in from retired Task 6).** The orchestrator already appended the D-14-remux amendment block to `.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md` at plan creation time (alongside D-15-display-surface, D-16-toolbar, D-17-onboarding for Plans 01-09 and 01-10). This step is idempotent grep verification — no file mutation expected. Run these four greps; ALL must return exactly one match:
(a) `grep -c 'D-14-remux: WebM remux via ts-ebml + webm-muxer' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md` → must be 1. (B-02 fix: anchor on the disambiguated `D-14-remux` marker, NOT bare `D-14` which would also match the historical `**D-14:** **Not applicable**` line in the original decisions block.)
(b) `grep -c '^- \*\*D-13:\*\* \*\*Fallback if D-12 fails:\*\*' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md` → must be 1. (Confirms the original D-13 text is intact.)
(c) `grep -c 'Amendment .Phase 01-stabilize-video-pipeline, 2026-05-16. — D-17-port-lifecycle' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md` → must be 1. (Confirms the prior D-17-port-lifecycle amendment block intact; note the disambiguated name per B-02.)
(d) Run, verbatim (note: the literal contains backticks, so use single quotes around the -F argument exactly as shown — do NOT wrap this entire command in Markdown inline-code backticks when copy-pasting):
grep -cF '`src/background/webm-remux.ts` replaces' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
Must report exactly `1`. Confirms D-14-remux cites the new module (full path `src/background/webm-remux.ts`) replacing the old `mergeVideoSegments`. Anchor empirically verified to match CONTEXT.md line 412 (full path prefix is required; the un-prefixed form `webm-remux.ts` replaces` does not appear as a contiguous substring in the live file).
If ANY of (a)(d) returns zero, STOP and report to the orchestrator: the planner-time amendment was not committed or was clobbered downstream. Do NOT re-append a competing amendment block — debug the root cause first.
</action>
<verify>
<automated>npx tsc --noEmit &amp;&amp; npx vitest run --reporter=dot &amp;&amp; test "$(grep -v '^\s*//' src/background/index.ts | grep -c 'mergeVideoSegments')" = "0" &amp;&amp; grep -q 'D-14-remux: WebM remux via ts-ebml + webm-muxer' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md &amp;&amp; grep -q '^- \*\*D-13:\*\* \*\*Fallback if D-12 fails:\*\*' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md &amp;&amp; grep -q 'D-17-port-lifecycle' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md &amp;&amp; grep -qF '`src/background/webm-remux.ts` replaces' .planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md</automated>
</verify>
<acceptance_criteria>
- `src/background/index.ts` imports `remuxSegments` and awaits it in `createArchive`.
- `mergeVideoSegments` is deleted from the file (grep excluding comments returns 0).
- `EmptyVideoBufferError` throw paths preserved per the request-id-protocol contract tests.
- `npx tsc --noEmit` exit 0.
- `npx vitest run` shows the same 2 webm-playback duration RED still failing (because the FIXTURE on disk is the old multi-EBML one — Task 5 regenerates it); every other test GREEN.
- `npm run build` exit 0 (verifies the new import resolves through crxjs/Vite into the SW bundle without externalizing ts-ebml or webm-muxer — both are pure JS without native deps so the bundle should swallow them cleanly).
- **B-01 fix: CONTEXT.md provenance chain verified intact via grep** — all four checks in action step 9 pass (D-14-remux present; D-13 original preserved; D-17-port-lifecycle amendment preserved; webm-remux.ts cited in D-14-remux body). No file mutation by this verification step; if any grep fails, the executor STOPS and escalates rather than re-appending.
</acceptance_criteria>
<done>Call site swapped to remux; legacy merge gone; saveArchive's empty-buffer error surface preserved; build clean; 2 RED tests now waiting only on the fixture regen; CONTEXT.md amendment provenance verified intact (D-14-remux / D-13 / D-17-port-lifecycle / webm-remux.ts citation all grep-confirmed).</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 5: Operator regenerates tests/fixtures/last_30sec.webm via ./smoke.sh against the post-remux build; confirms ~30 s Chrome + mpv playback.</name>
<files>(operator-driven; no specific source file modified by this checkpoint)</files>
<action>See <how-to-verify> below — operator-driven empirical check; the executor agent must not bypass this checkpoint by stubbing.</action>
<verify>
<automated>echo "checkpoint:human-verify — see how-to-verify section; resume signal is the gate"</automated>
</verify>
<done>Operator types "approved" after running the how-to-verify steps. See <resume-signal> for the exact gate.</done>
<what-built>
Tasks 1-4 landed: ts-ebml + webm-muxer installed, `remuxSegments()` implemented in `src/background/webm-remux.ts`, `mergeVideoSegments` deleted from `src/background/index.ts`, and `createArchive` now produces a single-EBML-headered WebM. The 2 RED tests in `tests/offscreen/webm-playback.test.ts` (lines 232-316) still fail because they read the committed fixture `tests/fixtures/last_30sec.webm` which was captured against the OLD D-13 file-concat build. This checkpoint regenerates the fixture against the new build.
</what-built>
<how-to-verify>
1. Build: `npm run build` (must exit 0; produces `dist/`).
2. Run smoke: `KEEP_PROFILE=0 ./smoke.sh`.
3. In the launched Chrome window: Load Unpacked → select `dist/` → click the extension toolbar icon → screen-share picker auto-accepts the "Mokosh Smoke Test" tab.
4. Wait at least 35 seconds (longer is fine — wait 5+ minutes if convenient to also re-validate the post-Option-C port lifecycle still holds across the 290 s mark which is now an irrelevant timeline anyway).
5. Click the extension icon → "Сохранить отчёт об ошибке". The script will detect the new `session_report_*.zip`, extract `video/last_30sec.webm`, and stage it to `/tmp/mokosh-last_30sec.webm`.
6. **Empirical playback check (the actual gate)** — open `/tmp/mokosh-last_30sec.webm` in:
(a) Chrome (drag the file into a fresh tab) — observe playback duration in the controls; MUST be approximately 30 s (>= 25 s), NOT ~9 s.
(b) mpv (`mpv /tmp/mokosh-last_30sec.webm`) — observe the duration shown in the title bar and the playback proceeds the full ~30 s without stopping early.
7. Run `ffprobe -v error -show_entries format=duration -of csv=p=0 /tmp/mokosh-last_30sec.webm` and confirm the reported duration in seconds is between 25.0 and 30.0.
8. If steps 6+7 PASS: copy `/tmp/mokosh-last_30sec.webm` over `tests/fixtures/last_30sec.webm` (`cp /tmp/mokosh-last_30sec.webm tests/fixtures/last_30sec.webm`), then run `npx vitest run tests/offscreen/webm-playback.test.ts` — all 4 tests MUST flip GREEN.
9. If any of steps 6/7 FAIL: do NOT replace the fixture. Report the failure mode (duration value, mpv error, ffmpeg stderr) so Tasks 3-4 can be revised. The most likely failure modes are (a) webm-muxer's `frameRate` config disagreeing with the actual VP9 frame cadence → may need to derive from cluster timestamps; (b) keyframe-flag parsing off-by-one → first frame of each segment must be 'key', subsequent 'delta'; (c) CodecPrivate omission causing Chrome to fail to initialize the VP9 decoder; (d) base64 decode path failure: confirm via SW console 'Decoded buffer segment N: blob size=...' lines from decodeBufferSegments fired BEFORE remuxSegments runs; if those lines are missing, segments arrived as base64-string blobs and remux will silently garbage out (the SimpleBlock walk reads non-sensical bytes); (e) ts-ebml parse failure on first segment: width/height derive from the first segment's track info; if `pickTrackInfoFromSegment` returns null, remux falls back to 1024x768 OR throws — check SW console for 'Remux: pickTrackInfoFromSegment returned null' diagnostic, which signals the first segment did not contain a Tracks → TrackEntry → Video element tree (segment shape changed from what the d13 probe documented).
</how-to-verify>
<resume-signal>
Type "approved" after step 8 lands (fixture committed and all 4 webm-playback tests GREEN). If step 9 hit, paste the failure diagnostic and the executor will iterate on Task 3.
</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| offscreen↔SW long-lived port (D-17-port-lifecycle architecture preserved) | base64-encoded VideoSegment bytes cross here; the SW deserializes via base64ToBlob and now hands them to remuxSegments. No new trust boundary introduced by this plan. |
| third-party JS libraries (ts-ebml + webm-muxer) | NEW: untrusted code from npm gains access to VP9 frame bytes. Both are MIT-licensed pure-JS libraries with no native deps. Per the d13 debug session library survey: ts-ebml has a single `typeof window` self-fallback (safe); webm-muxer is zero-DOM-refs. Verified by `tests/background/webm-remux-deps.test.ts`. |
| ffprobe + ffmpeg CLI invocations (existing — test-only) | Tests Task 2 and Task 5 invoke `/usr/bin/ffprobe` and `/usr/bin/ffmpeg`. Trust-boundary unchanged from existing tests/offscreen/webm-playback.test.ts pattern; binaries are system-installed and read-only. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-1-08-01 | Tampering | `src/background/webm-remux.ts:remuxSegments` consuming attacker-crafted WebM segments | accept | Defense in depth: the segments originate INSIDE the extension's own offscreen MediaRecorder; an attacker controlling them already controls the extension. Wrapping ts-ebml parse in try/catch and returning `new Blob([])` on parse failure (which surfaces as EmptyVideoBufferError downstream) provides a clean failure mode without any new trust surface. |
| T-1-08-02 | Denial of Service | adversarial VideoSegment[] causing remuxSegments to allocate unbounded memory | accept | Buffer caller (saveArchive) already gates total input at 3 segments × ~800 KB = ~2.4 MB ceiling; ts-ebml + webm-muxer combined operate in pure-JS RAM without spawning sub-processes; SW heap headroom dominates the budget. |
| T-1-08-03 | Information Disclosure | npm supply-chain compromise of ts-ebml or webm-muxer | mitigate | `package-lock.json` pins exact resolved hashes. Both libraries vendored at release versions verified live on npm 2026-05-16 (`ts-ebml@3.0.2`, `webm-muxer@5.1.4`). No `npm audit` warnings at install time per Task 1 acceptance. Phase 5 hardening may add an automated SCA check; out of scope here. |
| T-1-08-04 | Repudiation | the regenerated fixture differing from operator's run-time recording shape | mitigate | Task 5 is the in-the-flesh empirical check by the operator (mpv + Chrome + ffprobe). The fixture commits ONLY after step 8 passes; if step 9 fails, the plan iterates rather than committing a misleading fixture. |
</threat_model>
<verification>
- `tests/offscreen/webm-playback.test.ts` shows 4 of 4 tests GREEN after Task 5 lands.
- `tests/background/webm-remux.test.ts` shows 5 of 5 tests GREEN after Task 3.
- `tests/background/webm-remux-deps.test.ts` shows 2 of 2 tests GREEN after Task 1.
- `npx vitest run` final: 12 files / 60 tests / ALL GREEN.
- `npx tsc --noEmit` exit 0.
- `npm run build` exit 0.
- `grep -c 'mergeVideoSegments' src/background/index.ts` excluding comments returns 0.
- ffprobe on `tests/fixtures/last_30sec.webm` reports container duration >= 25_000 ms.
- Operator empirical check: Chrome + mpv both play the fixture for ~30 s end-to-end.
- CONTEXT.md ends with D-14-remux amendment (disambiguated marker per B-02 fix); original D-13 and D-17-port-lifecycle amendment blocks unmodified.
</verification>
<success_criteria>
SPEC §10 #7 (`last_30sec.webm` plays back in a browser) is functionally satisfied at the codebase level when:
1. The regenerated `tests/fixtures/last_30sec.webm` plays for >= 25 s in Chrome AND mpv (operator-verified Task 5).
2. The 4 webm-playback.test.ts assertions (zero decoder errors, no ended-prematurely, container duration >= 25 s, ffmpeg decode timeline >= 25 s) all GREEN.
3. The remux helper unit tests (5) all GREEN against fixture-derived inputs.
4. No file-concat merge path remains in the SW.
5. The 53 baseline GREEN tests (Plan 01-07 closure suite + Option C tests) remain GREEN — no collateral regression.
6. CONTEXT.md amendment block landed (D-14-remux disambiguated marker; verified by Task 4 step 9 grep checks).
</success_criteria>
<output>
After completion, create `.planning/phases/01-stabilize-video-pipeline/01-08-SUMMARY.md` using the standard template. Cite: the 2 RED tests that flipped GREEN, the deleted mergeVideoSegments + new remuxSegments file, the regenerated fixture size + ffprobe duration, the CONTEXT amendment landing, npm dep additions with pinned versions, the total bundle size delta (`npm run build` output before vs after).
</output>

View File

@@ -0,0 +1,595 @@
---
phase: 01-stabilize-video-pipeline
plan: 09
type: tdd
wave: 2
depends_on:
- 01-08
files_modified:
- manifest.json
- src/offscreen/recorder.ts
- src/background/index.ts
- src/popup/index.ts
- src/popup/index.html
- tests/offscreen/display-surface-constraint.test.ts
- tests/background/toolbar-action.test.ts
- tests/background/badge-state-machine.test.ts
- tests/background/onstartup-notification.test.ts
- smoke.sh
autonomous: false
requirements:
- REQ-video-ring-buffer
tags:
- getDisplayMedia
- displaySurface
- toolbar
- notifications
- badge
- state-machine
- manifest
must_haves:
truths:
- "getDisplayMedia call in src/offscreen/recorder.ts carries video.displaySurface='monitor' and video.cursor='always' constraints (grep-verifiable)."
- "After picker grant, recorder validates track.getSettings().displaySurface and emits RECORDING_ERROR code 'wrong-display-surface' when operator picks a tab or window."
- "Clicking the toolbar icon while NOT recording triggers startVideoCapture via chrome.action.onClicked (no popup needed for start). Clicking while recording opens the popup for SAVE."
- "Popup is SAVE-ONLY: no auto-start request on open; shows SAVE button when isRecording is true; empty-state copy directs operator to toolbar icon when not recording."
- "chrome.runtime.onStartup fires chrome.notifications.create once per browser startup; chrome.notifications.onClicked drains the notification and triggers startVideoCapture (consuming the click as activation gesture)."
- "chrome.action badge state machine: 3 states REC, OFF, ERROR with deterministic setBadgeText + setBadgeBackgroundColor + setTitle calls."
- "onUserStoppedSharing transitions badge to OFF and emits a recovery notification inviting the operator to start again."
- "manifest.json adds 'notifications' permission; default_popup retained for SAVE flow."
- "smoke.sh updated for displaySurface monitor mode OR carries a documented limitation note that the auto-select tab targeting no longer applies."
artifacts:
- path: "src/offscreen/recorder.ts"
provides: "getDisplayMedia carries displaySurface monitor and cursor always constraints; post-grant validation emits wrong-display-surface RECORDING_ERROR; CaptureErrorCode union extended with 'wrong-display-surface'."
contains: "displaySurface"
- path: "src/background/index.ts"
provides: "chrome.action.onClicked handler routes by isRecording flag; chrome.runtime.onStartup creates notification; chrome.notifications.onClicked consumes activation and triggers startVideoCapture; badge state machine helper setBadgeState(state); onUserStoppedSharing recovery notification on RECORDING_ERROR message receipt."
contains: "chrome.action.onClicked"
- path: "src/popup/index.ts"
provides: "Auto-start permission request removed from init; popup is SAVE-only; updated empty-state copy."
- path: "src/popup/index.html"
provides: "Empty-state copy may need a one-line text change to match SAVE-only role."
- path: "manifest.json"
provides: "Adds 'notifications' to permissions array; preserves existing permissions; default_popup retained."
contains: "notifications"
- path: "tests/offscreen/display-surface-constraint.test.ts"
provides: "4 RED-to-GREEN tests pinning displaySurface monitor constraint applied + wrong-display-surface error emission + classifyCaptureError branch + happy-path monitor accepted."
- path: "tests/background/toolbar-action.test.ts"
provides: "Tests pinning chrome.action.onClicked routing: when isRecording false triggers startVideoCapture; when isRecording true does not double-start; setPopup dance gating onClicked vs popup-open."
- path: "tests/background/badge-state-machine.test.ts"
provides: "Tests pinning the 3-state badge contract REC/OFF/ERROR via setBadgeText + setBadgeBackgroundColor + setTitle."
- path: "tests/background/onstartup-notification.test.ts"
provides: "Tests pinning chrome.runtime.onStartup creates exactly one notification; chrome.notifications.onClicked clears the notification and triggers startVideoCapture; RECORDING_ERROR receipt emits recovery notification."
key_links:
- from: "src/offscreen/recorder.ts:startRecording"
to: "navigator.mediaDevices.getDisplayMedia"
via: "constraints object with displaySurface monitor and cursor always"
pattern: "displaySurface.*monitor"
- from: "src/background/index.ts chrome.action.onClicked handler"
to: "startVideoCapture()"
via: "if (!isRecording) await startVideoCapture()"
pattern: "chrome.action.onClicked"
- from: "src/background/index.ts chrome.runtime.onStartup handler"
to: "chrome.notifications.create"
via: "type basic; title 'Mokosh ready'; message instructs operator to click"
pattern: "chrome.notifications.create"
- from: "src/background/index.ts chrome.notifications.onClicked handler"
to: "startVideoCapture()"
via: "consumes notification activation as user gesture"
pattern: "chrome.notifications.onClicked"
- from: "setBadgeState helper"
to: "chrome.action.setBadgeText + setBadgeBackgroundColor + setTitle"
via: "called from startVideoCapture (REC), stopRecording/onUserStoppedSharing (OFF), RECORDING_ERROR handler (ERROR)"
pattern: "setBadgeState"
---
## Scope Sanity Note (W-03 fix, 2026-05-16 checker pass)
**5 tasks + 9 files modified.** The plan-checker pass flagged that 5 tasks is the WARNING threshold (per the planner's <scope_estimation> guidance: "2-3 tasks max") and 9 files in `files_modified` is at the borderline.
**Why we accept the borderline rather than split:**
1. The three concerns (displaySurface constraint, toolbar-onClicked routing, badge+notification UX) are tightly coupled at the operator-experience level — splitting them across plans would force the operator-driven Task 5 checkpoint to be duplicated (one per sub-plan), tripling the operator's manual-test burden for no architectural benefit.
2. The 9 files are NOT uniformly heavy: manifest.json gains 1 line; smoke.sh changes 3 string literals + adds ~14 comment lines (per W-04 enumeration); src/popup/index.html may not change at all (Task 4 step 15 says "no markup change needed"). The genuinely heavy edits are concentrated in `src/background/index.ts` (~80 LOC of new helpers + listeners) and `src/offscreen/recorder.ts` (~12 LOC for the displaySurface constraint + post-grant validation block).
3. Context-cost estimate: Task 1 + Task 2 fit in ~25% context (small displaySurface change); Task 3 + Task 4 fit in ~40% context (the broader background + popup work). Task 5 is a checkpoint with no executor-side context cost. Total: ~50% — within the planner's quality-degradation-curve target of "complete within ~50% context."
**Per the planner's own guidance** ("<scope_estimation>: Split if any of these apply: more than 3 tasks, multiple subsystems"): the "split if multiple subsystems" trigger would normally apply (we touch offscreen + background + popup + manifest + smoke), BUT the per-subsystem cost is small enough that the unified plan stays within budget. The plan-checker's recommendation (a) — "accept as borderline; document rationale; do not duplicate the operator checkpoint" — is the path taken here.
**If a future revision DOES force a split,** the natural cut line is: Plan 01-09A = displaySurface constraint (Tasks 1+2 only — purely offscreen-recorder-side); Plan 01-09B = toolbar/badge/notification UX (Tasks 3+4 — background-side + popup + manifest). Task 5 (operator checkpoint) merges into 01-09B since the displaySurface change is verifiable via the unit tests in 01-09A alone.
<objective>
Reduce per-session activation cost and eliminate the operator-picks-tab footgun.
Per-session clicks drop from 3 (toolbar -> popup-open -> Start button -> picker) to 2 (toolbar -> picker accept). Active prompting via OS notification at Chrome startup makes recording state operator-visible without requiring them to remember to click anything.
Three coordinated changes:
1. displaySurface constraint in getDisplayMedia locks Chrome's screen-share picker to monitor selection (eliminates UAT Test 3 "Share this tab instead" footgun documented in 01-UAT.md gaps section, and lifts the Phase 5 cursor:'always' refinement opportunistically).
2. chrome.action.onClicked migration replaces popup-Start-button flow with direct toolbar-icon-click flow. Popup remains for SAVE only.
3. Notification + badge state machine surfaces recording state: onStartup notification invites a fresh-day start, badge color/text shows REC/OFF/ERROR at a glance, onUserStoppedSharing emits a recovery prompt.
Output:
- src/offscreen/recorder.ts updated getDisplayMedia call + new wrong-display-surface CaptureErrorCode branch.
- src/background/index.ts adds chrome.action.onClicked + chrome.runtime.onStartup -> notification + chrome.notifications.onClicked + badge helper + RECORDING_ERROR handler.
- src/popup/index.ts has start-on-open path removed; popup is SAVE-only; src/popup/index.html copy may need a one-line update.
- manifest.json adds notifications permission.
- 4 new test files covering each new contract.
- smoke.sh updated for the displaySurface monitor mode (or carries a documented limitation note).
</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/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.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-UAT.md
@.planning/phases/01-stabilize-video-pipeline/01-08-PLAN.md
@src/offscreen/recorder.ts
@src/background/index.ts
@src/popup/index.ts
@src/popup/index.html
@manifest.json
@smoke.sh
<interfaces>
Key types and Chrome API surfaces the executor will use.
From src/offscreen/recorder.ts current getDisplayMedia call (lines 213-216):
```
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: false,
});
```
New shape under this plan (D-15-display-surface):
```
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'monitor', cursor: 'always' },
audio: false,
});
// Post-grant validation:
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack !== undefined) {
const settings = videoTrack.getSettings();
if (settings.displaySurface !== 'monitor') {
// Operator overrode the hint and picked a tab or window.
stream.getTracks().forEach((t) => { try { t.stop(); } catch (e) {} });
mediaStream = null;
throw new Error('wrong-display-surface: got "' + settings.displaySurface + '"');
}
}
```
From src/offscreen/recorder.ts CaptureErrorCode union (lines 158-164) — extend with new code:
```
export type CaptureErrorCode =
| 'user-cancelled'
| 'permission-denied'
| 'codec-unsupported'
| 'no-source-selected'
| 'capture-failed'
| 'wrong-display-surface' // NEW (Plan 01-09 D-15-display-surface)
| 'unknown';
```
classifyCaptureError (line 166) gains one new branch — matched on Error message prefix:
```
if (error instanceof Error && error.message.startsWith('wrong-display-surface')) {
return 'wrong-display-surface';
}
```
Chrome API: chrome.action.onClicked.addListener fires when the operator clicks the toolbar icon UNLESS a default_popup is set. Two options:
- Option A: remove default_popup entirely; onClicked fires on every click; handler opens popup programmatically via chrome.action.openPopup() when needed.
- Option B: dynamically swap setPopup ('' for start mode; 'src/popup/index.html' for save mode) so onClicked fires when popup is empty.
This plan uses Option B + setPopup:
```
function setIdleMode() {
chrome.action.setPopup({ popup: '' });
setBadgeState('OFF');
}
function setRecordingMode() {
chrome.action.setPopup({ popup: 'src/popup/index.html' });
setBadgeState('REC');
}
chrome.action.onClicked.addListener(async () => {
// This fires only when setPopup('') i.e. OFF mode.
if (!isRecording) {
await startVideoCapture();
}
});
```
Chrome API: chrome.notifications.create(notificationId, options, callback):
```
chrome.notifications.create('mokosh-startup-prompt', {
type: 'basic',
iconUrl: chrome.runtime.getURL('icons/icon128.png'),
title: 'Mokosh ready',
message: 'Click here to start recording your session.',
priority: 1,
});
chrome.notifications.onClicked.addListener((notificationId) => {
if (notificationId.startsWith('mokosh-')) {
chrome.notifications.clear(notificationId);
startVideoCapture().catch((err) => logger.warn('notification-triggered start failed:', err));
}
});
```
Chrome API: badge surface — chrome.action.setBadgeText, setBadgeBackgroundColor, setTitle. Suggested palette:
- REC: background #00C853 (green), text 'REC', title 'Recording — last 30 s buffered. Click to save.'
- OFF: background #D32F2F (red), text '', title 'Not recording. Click to start.'
- ERROR: background #F9A825 (yellow), text 'ERR', title 'Recording error. Click to try again.'
Existing isRecording flag in src/background/index.ts line 36 is the source of truth the badge state machine reads.
Existing onMessage handler at line 631 routes RECORDING_ERROR (the offscreen recorder sends these). This plan extends it: on RECORDING_ERROR receipt set badge to ERROR and emit a recovery notification.
Existing popup at src/popup/index.ts lines 60-87 calls checkPermissions on init which triggers REQUEST_PERMISSIONS auto-start. The new design: checkPermissions is removed from init; popup just shows SAVE; empty-state copy in updateUI (line 51) becomes "Откройте запись через иконку расширения" (Open recording via the extension icon).
smoke.sh update path: Path A (preferred) updates auto-select string to target entire-screen ("Screen 1" or "Entire screen" — try both via fallback); Path B (fallback) documents a one-time manual step (KEEP_PROFILE=1 preserves picker memory across runs).
Existing patterns to reuse:
- Test scaffold: tests/background/request-id-protocol.test.ts (lines 53-120) shows the canonical pattern for a chrome stub with onConnect/onMessage/onInstalled callbacks plus dynamic import + await import. Extend with chrome.action, chrome.notifications, and badge methods.
- CaptureErrorCode extension pattern: tests/offscreen/codec-check.test.ts (lines 58-123) per-code-branch test idiom.
NO 'as any', NO @ts-ignore — every new chrome.* call must be properly typed via @types/chrome (already installed).
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: RED tests — displaySurface constraint applied to getDisplayMedia + wrong-display-surface error code.</name>
<read_first>
- src/offscreen/recorder.ts lines 158-202 (CaptureErrorCode union + classifyCaptureError)
- src/offscreen/recorder.ts lines 206-245 (startRecording — the getDisplayMedia call site)
- tests/offscreen/codec-check.test.ts (test scaffold: per-error-code branch tests)
- tests/offscreen/handshake.test.ts (chrome stub pattern with sendMessage spy)
</read_first>
<files>tests/offscreen/display-surface-constraint.test.ts</files>
<behavior>
- Test 1: when getDisplayMedia is mocked, startRecording invokes it with constraints `{video: { displaySurface: 'monitor', cursor: 'always' }, audio: false}`. **I-03 fix (2026-05-16 checker pass): use strict deep-equality on the constraints object.** Assert via `expect(getDisplayMediaSpy).toHaveBeenCalledWith({video: { displaySurface: 'monitor', cursor: 'always' }, audio: false})` — partial match (`expect.objectContaining`) would UN-PIN the `cursor: 'always'` refinement that this plan opportunistically lifts from the Phase 5 deferral list per D-15-display-surface. If a future refactor accidentally drops `cursor: 'always'` while keeping `displaySurface: 'monitor'`, the partial-match form would silently pass; strict deep-equality fails loudly. RED today (current call passes `video: true`).
- Test 2: when getDisplayMedia returns a stream whose video track getSettings reports displaySurface 'browser' (operator picked a tab), startRecording stops the stream tracks AND emits chrome.runtime.sendMessage with RECORDING_ERROR + wrong-display-surface code. RED today.
- Test 3: when getDisplayMedia returns a stream whose track getSettings reports displaySurface 'monitor', startRecording proceeds without emitting a wrong-display-surface RECORDING_ERROR. (Ensures Task 2 does not over-fire.)
- Test 4: classifyCaptureError on Error with message starting 'wrong-display-surface' returns 'wrong-display-surface'. RED today (union doesn't include this code).
</behavior>
<action>
1. Create tests/offscreen/display-surface-constraint.test.ts.
2. Mock navigator.mediaDevices.getDisplayMedia per the handshake.test.ts chrome-stub pattern. Build a MediaStreamStub that includes getVideoTracks returning a track with getSettings, stop (vi.fn), and addEventListener.
3. Use vi.resetModules + dynamic await import per test (matches existing offscreen test discipline).
4. For Test 1: spy on getDisplayMedia; trigger startRecording via the recorder's onMessage handler (send START_RECORDING through the registered listener captured via the chrome.runtime.onMessage.addListener stub). **I-03 fix:** assert the spy was called with the exact constraints object via strict deep-equality — `expect(getDisplayMediaSpy).toHaveBeenCalledWith({video: { displaySurface: 'monitor', cursor: 'always' }, audio: false})` — NOT `expect.objectContaining(...)`. The strict form pins both the `displaySurface: 'monitor'` (D-15-display-surface footgun retirement) AND the `cursor: 'always'` (Phase 5 deferral opportunistically lifted); partial-match would leave the latter unpinned.
5. For Test 2: return a stream with displaySurface 'browser'; trigger START_RECORDING; await microtask drain (await Promise.resolve a few times). Assert the chrome.runtime.sendMessage stub received {type: 'RECORDING_ERROR', error: 'wrong-display-surface'}. Assert the stream's track stop was called for every track.
6. For Test 3: return a stream with displaySurface 'monitor'. Trigger START_RECORDING. Assert NO RECORDING_ERROR with wrong-display-surface was sent. (MediaRecorder will throw because jsdom doesn't supply it; catch and ignore — this test only cares about whether wrong-display-surface fires, not whether startRecording completed.)
7. For Test 4: await import the module; assert mod.classifyCaptureError on Error with 'wrong-display-surface' prefix returns 'wrong-display-surface'.
8. Run the test file; expect ALL 4 RED with descriptive failures.
</action>
<verify>
<automated>npx vitest run tests/offscreen/display-surface-constraint.test.ts</automated>
</verify>
<acceptance_criteria>
- File exists with 4 tests; all 4 RED.
- Baseline (Plan 01-08 final 12 files / 60 tests / all GREEN) + 4 new RED here = 13 files / 64 tests / 4 failed | 60 passed.
- tsc still exit 0.
</acceptance_criteria>
<done>4 RED tests pin the displaySurface contract; baseline preserved.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: GREEN — add displaySurface monitor + cursor always constraint and post-grant validation in src/offscreen/recorder.ts.</name>
<read_first>
- tests/offscreen/display-surface-constraint.test.ts (contract from Task 1)
- src/offscreen/recorder.ts lines 158-202 (CaptureErrorCode union + classifyCaptureError)
- src/offscreen/recorder.ts lines 206-245 (startRecording)
</read_first>
<files>src/offscreen/recorder.ts</files>
<behavior>
Drive the 4 RED tests from Task 1 to GREEN.
</behavior>
<action>
1. In the CaptureErrorCode union (line 158), add a new line: '| wrong-display-surface'.
2. In classifyCaptureError (line 166), add a branch near the top (after the instanceof Error guard, before the codec-unsupported check):
if the error message startsWith 'wrong-display-surface', return 'wrong-display-surface'.
3. Update the getDisplayMedia call in startRecording (lines 213-216) to pass {video: { displaySurface: 'monitor', cursor: 'always' }, audio: false}. Add a block comment citing Plan 01-09 D-15-display-surface (UAT Test 3 footgun retirement, disambiguated from the historical 'D-15: Operator switching tabs' decision per B-02 fix) AND lifting the Phase 5 cursor:'always' deferral opportunistically.
4. Immediately after mediaStream = stream; add a post-grant validation block: read getVideoTracks()[0].getSettings().displaySurface; if not 'monitor' then call stream.getTracks().forEach(t => t.stop()), null out mediaStream, throw new Error(`wrong-display-surface: got "${observed}", expected "monitor"`). The throw routes through startRecording's existing catch block — classifyCaptureError maps the prefix; RECORDING_ERROR is broadcast; the throw propagates.
5. Run npx vitest run tests/offscreen/display-surface-constraint.test.ts — all 4 must flip GREEN.
6. Run full suite — 13 files / 64 tests / all GREEN.
7. Run npx tsc --noEmit — exit 0.
Per project style: extensive docstrings; the post-grant validation block gets a dedicated multi-line comment explaining the constraint-hint-vs-enforcement gap (Chrome's getDisplayMedia spec treats displaySurface as a HINT, not a hard constraint — the operator can override; post-grant validation is the actual enforcement). No 'as any'; the settings check uses the official MediaTrackSettings.displaySurface field (lib.dom.d.ts covers this).
</action>
<verify>
<automated>npx vitest run tests/offscreen/display-surface-constraint.test.ts</automated>
</verify>
<acceptance_criteria>
- CaptureErrorCode union includes 'wrong-display-surface'.
- getDisplayMedia call carries displaySurface 'monitor' and cursor 'always' (grep verifies).
- Post-grant validation block exists and emits wrong-display-surface when picker yields non-monitor.
- 4 tests from Task 1 all GREEN.
- Full suite 13 files / 64 tests / all GREEN.
- npx tsc --noEmit exit 0.
</acceptance_criteria>
<done>displaySurface contract pinned and enforced; per-error-code routing extended; baseline preserved.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: RED tests — chrome.action.onClicked routing + badge state machine + onStartup notification + RECORDING_ERROR badge transition.</name>
<read_first>
- tests/background/request-id-protocol.test.ts lines 53-200 (chrome stub pattern; extend it)
- src/background/index.ts lines 631-687 (onMessage handler — where REQUEST_PERMISSIONS lives; understand the existing dispatch shape)
- src/background/index.ts lines 689-737 (initialize + onInstalled — where new onStartup will sit)
- .planning/phases/01-stabilize-video-pipeline/01-UAT.md gaps section (the displaySurface footgun + the operator-side ergonomics motivation)
</read_first>
<files>tests/background/toolbar-action.test.ts, tests/background/badge-state-machine.test.ts, tests/background/onstartup-notification.test.ts</files>
<behavior>
Three new test files. All tests start RED today.
File 1 — tests/background/toolbar-action.test.ts:
- Test A: chrome.action.onClicked.addListener is registered when the SW initializes.
- Test B: when isRecording is false and onClicked fires, the handler invokes startVideoCapture (the existing startVideoCapture path — chrome.tabs.query then ensureOffscreen then send START_RECORDING — should be detectable via the chrome.runtime.sendMessage spy receiving START_RECORDING).
- Test C: when isRecording is true (set via prior startVideoCapture call landing) and onClicked fires, the handler does NOT call startVideoCapture again (spy is called exactly once across both clicks). Defensive guard test.
- Test D: setPopup is called with empty string when transitioning to OFF state; with 'src/popup/index.html' when transitioning to REC state.
- **Test E (W-02 fix, 2026-05-16 checker pass) — popup-loads-when-idle race pin.** The checker flagged that Task 4 removes `checkPermissions` from `src/popup/index.ts` (lines 60-87) without any test asserting the post-removal behavior. Two assertions in this single test cover the change:
(E1) "popup init no longer fires `chrome.runtime.sendMessage` with `type: 'REQUEST_PERMISSIONS'`" — mock `globalThis.chrome.runtime.sendMessage = vi.fn().mockResolvedValue({granted: false})`; load `src/popup/index.ts` via dynamic `await import()` inside a JSDOM context with a stub `<body>` that includes `<button id="saveButton"><span class="button-text"></span></button>` and `<div id="statusMessage">`; await microtask drain (`await Promise.resolve()` ×3); assert `sendMessageSpy` was NOT called with an argument matching `expect.objectContaining({type: 'REQUEST_PERMISSIONS'})`. RED today because the current popup init at line 60-87 + line 167 (`init()` calls `checkPermissions()`) fires REQUEST_PERMISSIONS on every popup open. GREEN after Task 4 step 14 deletes the `checkPermissions()` call from `init()`.
(E2) "`saveButton.disabled === false` after popup init completes, regardless of any chrome message round-trip" — under the new SAVE-only popup charter (the popup ONLY opens when `isRecording === true` because `setRecordingMode` swaps `setPopup` to point at `src/popup/index.html`; in OFF mode `setPopup('')` causes the toolbar click to fire `onClicked` instead of opening the popup), the popup loading IMPLIES recording is active, so the SAVE button must be enabled unconditionally. After loading the popup module under JSDOM, query `document.querySelector('#saveButton')` and assert `(button as HTMLButtonElement).disabled === false`. RED today because the current `updateUI()` at line 33 gates `saveButton.disabled = !popupState.hasPermissions`, and `popupState.hasPermissions` defaults to false at init. GREEN after Task 4 step 14 also lands the `popupState.hasPermissions = true` unconditional set on popup init (per the action step's "Simplify: on popup load, ALWAYS enable the SAVE button" guidance).
Note: this test file's chrome stub must additionally provide `chrome.runtime.id`, `chrome.runtime.onMessage.addListener`, and an empty `chrome.action`/`chrome.notifications` shape if the popup module's strict-mode access patterns reach them; copy the minimal shape from the existing popup tests (none exist yet, so this is the first pop-up test — start with the chrome stub from `tests/background/request-id-protocol.test.ts` and PARE DOWN to only what the popup module actually touches via grep). The JSDOM environment is supplied by Vitest's default config; if not, override via `// @vitest-environment jsdom` pragma at the top of the test file.
File 2 — tests/background/badge-state-machine.test.ts:
- Test A: setBadgeState('REC') calls chrome.action.setBadgeText with text 'REC' AND setBadgeBackgroundColor with the green color AND setTitle.
- Test B: setBadgeState('OFF') calls setBadgeText with empty text AND setBadgeBackgroundColor with the red color AND setTitle.
- Test C: setBadgeState('ERROR') calls setBadgeText with 'ERR' AND setBadgeBackgroundColor with yellow AND setTitle.
- Test D: receiving a RECORDING_ERROR message via chrome.runtime.onMessage triggers setBadgeState('ERROR') (asserted via the setBadgeText spy receiving 'ERR' within a microtask of the dispatch).
File 3 — tests/background/onstartup-notification.test.ts:
- Test A: chrome.runtime.onStartup.addListener is registered when the SW module loads.
- Test B: firing the onStartup callback creates exactly one chrome.notifications.create call with notificationId starting 'mokosh-startup-' (or similar stable prefix), with type 'basic', title 'Mokosh ready', message containing 'Click'.
- Test C: firing chrome.notifications.onClicked with a 'mokosh-startup-...' id triggers chrome.notifications.clear(id) AND invokes startVideoCapture (assert via sendMessage spy receiving START_RECORDING after microtask drain).
- Test D: receiving a RECORDING_ERROR message triggers a recovery notification (a second chrome.notifications.create call with id prefix 'mokosh-recovery-...').
</behavior>
<action>
1. Copy the chrome stub from tests/background/request-id-protocol.test.ts as a starting point. Extend it with:
- chrome.action: { onClicked: { addListener, _callbacks }, setPopup: vi.fn(), setBadgeText: vi.fn(), setBadgeBackgroundColor: vi.fn(), setTitle: vi.fn() }
- chrome.notifications: { create: vi.fn(), clear: vi.fn(), onClicked: { addListener, _callbacks } }
- chrome.runtime.onStartup: { addListener, _callbacks }
Build each file's chrome stub by importing a shared builder helper if convenient; OR copy the stub into each test file (the existing tests duplicate the stub pattern, so duplication is acceptable per project convention).
2. Author each test per the behavior list. For tests that drive flows (Test C in file 1, Test C in file 3): set up the stub, register expected listeners, then synthesize the trigger by invoking the captured callback array directly (e.g. chromeStub.action.onClicked._callbacks[0]()).
3. For Test D in file 2 and Test D in file 3: invoke the onMessage callback via chromeStub.runtime.onMessage._callbacks[0]({type:'RECORDING_ERROR', error:'codec-unsupported'}, {id:'ext-id-test'}, vi.fn()); await microtask drain; assert downstream effects.
4. Run each test file individually; all should be RED with errors like "expected setBadgeText to have been called" or "expected chrome.notifications.create to have been called once".
5. DO NOT modify src/background/index.ts yet — Task 4 is the GREEN side.
</action>
<verify>
<automated>npx vitest run tests/background/toolbar-action.test.ts tests/background/badge-state-machine.test.ts tests/background/onstartup-notification.test.ts</automated>
</verify>
<acceptance_criteria>
- 3 new test files exist with the listed tests (13 tests total: 5+4+4 — toolbar-action gains Test E per W-02 fix).
- All 13 are RED today.
- Baseline (after Task 2: 13 files / 64 tests / 0 RED) + 13 new RED = 16 files / 77 tests / 13 failed | 64 passed.
- tsc exit 0.
</acceptance_criteria>
<done>13 RED tests pinning toolbar (5: 4 routing + 1 popup-idle-race per W-02) + badge (4) + notification (4) contracts; baseline preserved.</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: GREEN — add chrome.action.onClicked + badge state machine + onStartup notification + RECORDING_ERROR handler in src/background/index.ts; popup transitions to SAVE-only.</name>
<read_first>
- tests/background/toolbar-action.test.ts, tests/background/badge-state-machine.test.ts, tests/background/onstartup-notification.test.ts (contracts from Task 3)
- src/background/index.ts (whole file — already familiar)
- src/popup/index.ts (lines 60-115 — the checkPermissions + requestPermissions auto-init flow to be removed)
- src/popup/index.html (the markup — likely no change needed)
- manifest.json (to add notifications permission)
</read_first>
<files>src/background/index.ts, src/popup/index.ts, src/popup/index.html, manifest.json</files>
<behavior>
Drive the 13 RED tests to GREEN (per W-02 fix the popup-idle-race test joins toolbar-action.test.ts).
</behavior>
<action>
1. manifest.json: add 'notifications' to the permissions array. Preserve all existing permissions. Keep default_popup pointing to src/popup/index.html.
2. src/background/index.ts top-level state additions (after isRecording line 36):
- Constants for badge palette:
BADGE_REC_COLOR = '#00C853', BADGE_OFF_COLOR = '#D32F2F', BADGE_ERROR_COLOR = '#F9A825'
BADGE_REC_TEXT = 'REC', BADGE_OFF_TEXT = '', BADGE_ERROR_TEXT = 'ERR'
BADGE_REC_TITLE = 'Recording — last 30 s buffered. Click to save.'
BADGE_OFF_TITLE = 'Not recording. Click to start.'
BADGE_ERROR_TITLE = 'Recording error. Click to try again.'
NOTIFICATION_ICON_PATH = 'icons/icon128.png' (already in manifest)
NOTIFICATION_STARTUP_PREFIX = 'mokosh-startup-'
NOTIFICATION_RECOVERY_PREFIX = 'mokosh-recovery-'
- Note in comments that the badge state machine is the operator-facing surface for recording state; per project naming use SCREAMING_SNAKE for these true constants.
3. setBadgeState(state: 'REC'|'OFF'|'ERROR'): void helper — calls chrome.action.setBadgeText, setBadgeBackgroundColor, setTitle with the three values per the palette table. Wrap each chrome call in try/catch (the SW context normally has chrome.action available but unit tests may not stub all methods atomically — defense in depth).
4. setIdleMode() helper: chrome.action.setPopup({popup: ''}); setBadgeState('OFF');
5. setRecordingMode() helper: chrome.action.setPopup({popup: 'src/popup/index.html'}); setBadgeState('REC');
6. setErrorMode() helper: chrome.action.setPopup({popup: 'src/popup/index.html'}); setBadgeState('ERROR'); (popup remains accessible so operator can see any error copy AND attempt save anyway).
7. startVideoCapture (line 305) — at the end of the try block (after isRecording = true; line 342), call setRecordingMode().
8. startVideoCapture catch block (line 345) — call setErrorMode() before re-throwing.
9. Register chrome.action.onClicked at SW module load (near the bottom of the file, before the initialize() call):
chrome.action.onClicked.addListener(async () => {
if (isRecording) return;
try { await startVideoCapture(); } catch (err) { logger.warn('toolbar-onClicked start failed:', err); }
});
Document inline that this listener fires ONLY when setPopup is '' (idle mode); setRecordingMode swaps the popup back so subsequent clicks open the popup for SAVE.
10. Register chrome.runtime.onStartup near initialize:
chrome.runtime.onStartup.addListener(() => {
setIdleMode();
const notificationId = NOTIFICATION_STARTUP_PREFIX + Date.now();
chrome.notifications.create(notificationId, {
type: 'basic',
iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH),
title: 'Mokosh ready',
message: 'Click here to start recording your session.',
priority: 1,
});
});
11. Register chrome.notifications.onClicked:
chrome.notifications.onClicked.addListener((notificationId) => {
if (!notificationId.startsWith('mokosh-')) return;
chrome.notifications.clear(notificationId);
startVideoCapture().catch((err) => logger.warn('notification-triggered start failed:', err));
});
12. Extend onMessage handler (line 631) with a new RECORDING_ERROR branch:
case 'RECORDING_ERROR':
logger.warn('RECORDING_ERROR received:', message); // do not destructure data — types use unknown
setErrorMode();
const recoveryId = NOTIFICATION_RECOVERY_PREFIX + Date.now();
chrome.notifications.create(recoveryId, {
type: 'basic', iconUrl: chrome.runtime.getURL(NOTIFICATION_ICON_PATH),
title: 'Mokosh stopped', message: 'Recording stopped. Click here to start a new session.',
priority: 1,
});
return false;
13. In initialize() (line 690), at the end, call setIdleMode() — ensures fresh SW always boots into the OFF badge state and idle popup (popup='') so chrome.action.onClicked fires on first click.
14. src/popup/index.ts (line 60 onward): REMOVE checkPermissions function entirely. REMOVE the call to checkPermissions() in init() (line 167). The popup now opens; if popupState.hasPermissions is false (which it always will be on open since we never set it), the SAVE button stays disabled with the empty-state copy. UPDATE the empty-state copy in updateUI (line 51) to: 'Откройте запись через иконку расширения' (Open recording via the extension icon).
Actually re-think: with setRecordingMode active, the popup ONLY opens when isRecording is true (because in idle mode setPopup('') causes onClicked to fire instead). So when the popup loads, we KNOW isRecording is true (or the user got here through some odd race). Simplify: on popup load, ALWAYS enable the SAVE button (set hasPermissions = true unconditionally on init). Drop the entire requestPermissions function too.
15. src/popup/index.html: no markup change needed (the SAVE button works as-is); but consider updating the sub-label copy "Последние 30 сек видео + 10 мин лога" — it stays the same since it accurately describes SAVE.
16. **W-04 fix (2026-05-16 checker pass): smoke.sh update for monitor-mode picker — enumerate ALL five sub-steps** (the SHARE_TARGET infrastructure is more entangled than the prior single-line summary suggested; see `smoke.sh` lines 44, 109-113, 133 for the affected sites):
**16.a — Change the `SHARE_TARGET` variable value at line 44.**
```
# smoke.sh line 44 (was):
SHARE_TARGET="Mokosh Smoke Test"
# smoke.sh line 44 (after Plan 01-09):
SHARE_TARGET="Entire screen"
```
This variable is interpolated into line 133's `--auto-select-desktop-capture-source="${SHARE_TARGET}"` flag — changing the variable itself (NOT just the flag arg as a one-off literal) keeps the data-flow honest.
**16.b — Update the `data:` URL `<title>` element at line 107 to match.**
The HTML stub embedded in `smoke.sh` (the heredoc starting at line 106) currently begins with `<title>Mokosh Smoke Test</title>`. The `--auto-select-desktop-capture-source` Chromium flag historically matched against tab titles; under Plan 01-09 we want it to match the screen-source label instead. To keep the smoke tab's title from accidentally matching the new "Entire screen" target string, change the title to something distinct:
```
<title>Mokosh Smoke Test — monitor mode</title>
```
(Distinct enough that even if Chrome falls back to title-matching in some edge case, it does NOT match.)
**16.c — Update the on-screen instruction list in the same `data:` URL HTML.**
Lines 109-113 (the `<ol>` list inside the heredoc) currently say "The picker auto-accepts THIS tab". Under Plan 01-09 the picker auto-accepts the entire screen instead. Update the list-item copy:
```
<li>The picker auto-accepts <em>the entire screen</em> (not this tab — the new D-15-display-surface charter constrains to monitor mode).</li>
```
Also update any text that says "this tab is the share-screen target" near the top of the body to "this tab is informational; the picker shares the whole monitor".
**16.d — Document the empirical-test fallback for non-English Chrome locales.**
Add a comment block above line 44 explaining the fallback mode:
```
# Plan 01-09 D-15-display-surface: SHARE_TARGET must match the OS-locale-specific
# name Chrome's picker uses for entire-screen selection. Known strings:
# English: "Entire screen"
# Russian: "Весь экран"
# German: "Gesamter Bildschirm"
# French: "Ecran entier"
# If --auto-select-desktop-capture-source="${SHARE_TARGET}" fails to auto-accept
# on the operator's Chrome locale, the operator picks the screen manually one
# time; then KEEP_PROFILE=1 on subsequent runs carries the picker's last-pick
# memory across re-runs, sidestepping the auto-select string altogether.
```
**16.e — Cite the authoritative source for the locale-specific string.**
The picker string is locale-bound. **Authoritative source:** Chromium's grit resource definitions in `chrome/app/generated_resources.grd` (and per-locale `.xtb` translation files under `chrome/app/resources/`) — IDS_* identifiers like `IDS_DESKTOP_MEDIA_PICKER_SOURCE_TYPE_SCREEN` live in `.grd` (grit resource definition), not in `.cc` source. The previous draft cited `content/browser/media/desktop_capture_device_uma_types.cc` — that file contains UMA enums, not localized strings; **the citation was wrong, dropped here.** However the only reliable empirical check is to run the picker once and inspect the source label in Chrome's UI: per-locale translations drift across Chrome major versions and an offline `.grd` lookup is fragile. Document the operator's Chrome locale + version next to the SHARE_TARGET literal so future operators on different locales can update without surprise. Recommend the executor test the smoke harness against the operator's actual Chrome locale + version BEFORE committing the SHARE_TARGET value; if uncertain, default to the operator's locale and document the manual-pick fallback in the commit message body.
Net diff: smoke.sh adds ~14 lines of comments + changes 3 string literals (SHARE_TARGET value, `<title>` text, `<ol>` copy). No behavioral change to the polling / Downloads-snapshot / ffprobe-gate logic.
17. Run the 3 new test files; all 12 must GREEN.
18. Run full suite — 16 files / 77 tests / all GREEN.
19. Run npx tsc --noEmit — exit 0.
20. Run npm run build — exit 0; confirm dist/manifest.json has notifications permission.
Naming compliance: setBadgeState, setIdleMode, setRecordingMode, setErrorMode — camelCase verbs; NOTIFICATION_* and BADGE_* constants — SCREAMING_SNAKE; isRecording remains the boolean naming standard. No 'continue' statements; if-else chains for the badge-state switch. No 'as any'.
</action>
<verify>
<automated>npx vitest run tests/background/toolbar-action.test.ts tests/background/badge-state-machine.test.ts tests/background/onstartup-notification.test.ts</automated>
</verify>
<acceptance_criteria>
- manifest.json permissions array includes 'notifications'.
- src/background/index.ts has setBadgeState, setIdleMode, setRecordingMode, setErrorMode helpers; chrome.action.onClicked + chrome.runtime.onStartup + chrome.notifications.onClicked listeners; RECORDING_ERROR onMessage branch; initialize() calls setIdleMode().
- src/popup/index.ts no longer auto-requests permissions on init; popup is SAVE-only.
- smoke.sh updated for monitor mode OR carries documented limitation note.
- All 13 new tests GREEN.
- Full suite 16 files / 77 tests / all GREEN.
- npx tsc --noEmit exit 0.
- npm run build exit 0.
</acceptance_criteria>
<done>Toolbar + badge + notification UX live; popup is SAVE-only (with W-02 test pinning the empty-REQUEST_PERMISSIONS init + always-enabled SAVE button); manifest updated; baseline preserved.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 5: Operator runs smoke.sh against the post-Plan-01-09 build; confirms toolbar-click recording flow + monitor-only picker + notification + badge state machine work end-to-end.</name>
<files>(operator-driven; no specific source file modified by this checkpoint)</files>
<action>See <how-to-verify> below — operator-driven empirical check; the executor agent must not bypass this checkpoint by stubbing.</action>
<verify>
<automated>echo "checkpoint:human-verify — see how-to-verify section; resume signal is the gate"</automated>
</verify>
<done>Operator types "approved" after running the how-to-verify steps. See <resume-signal> for the exact gate.</done>
<what-built>
Tasks 1-4 landed: getDisplayMedia constrained to monitor mode (UAT Test 3 footgun retired), chrome.action.onClicked direct toolbar flow live, chrome.notifications onStartup + recovery emitting, badge state machine REC/OFF/ERROR wired, popup is SAVE-only, manifest carries notifications permission. The 13 new unit tests are GREEN. This checkpoint validates the runtime end-to-end behavior in real Chrome.
</what-built>
<how-to-verify>
1. Build: npm run build (must exit 0).
2. Run smoke: KEEP_PROFILE=0 ./smoke.sh.
3. After Chrome launches, Load Unpacked → select dist/. Observe the extension toolbar icon. Badge should display OFF state (no text, red background, tooltip 'Not recording. Click to start.').
4. Restart Chrome via chrome://restart (or close and re-launch the smoke profile). On startup, an OS notification 'Mokosh ready — Click here to start recording your session.' should appear within ~2 seconds.
5. Click the notification. Chrome's screen-share picker should appear. Confirm it offers ONLY entire-screen / monitor options (no tab list, no window list — or at least confirm tabs/windows are not the default selection).
6. Pick 'Entire screen' (or 'Screen 1' depending on Chrome build) and accept. The 'Sharing your screen' banner appears.
7. Observe the badge transitions to REC (green background, 'REC' text, tooltip 'Recording — last 30 s buffered. Click to save.').
8. Wait ~35 seconds (or longer to validate the post-Option-C port lifecycle still holds).
9. Click the toolbar icon. The popup opens (NOT the picker — because setRecordingMode set the popup to 'src/popup/index.html'). Click 'Сохранить отчёт об ошибке'. The save flow runs; the zip lands in ~/Downloads as session_report_*.zip.
10. Open the zip; confirm video/last_30sec.webm is present and (validated by Plan 01-08) plays for ~30s in Chrome and mpv.
11. Stop the sharing via Chrome's 'Stop sharing' banner button. Badge transitions to ERROR ('ERR' text, yellow background). A recovery notification 'Mokosh stopped — Recording stopped. Click here to start a new session.' appears.
12. Click the recovery notification. The picker reappears; pick Entire screen; recording resumes; badge returns to REC.
13. Edge-case: click the toolbar icon WITHOUT a prior recording (after step 3 + reload extension without restart). The picker should appear directly (NOT the popup). This validates that initialize → setIdleMode set the popup to '' so onClicked fires.
14. If any of steps 4 (notification appears), 5 (picker is monitor-only), 9 (popup opens on toolbar click while recording), 11 (badge transitions to ERROR + recovery notification), 12 (recovery click triggers picker), or 13 (idle toolbar click triggers picker directly) FAIL, document the exact failure mode + reproduction steps. The plan iterates on the failing handler.
</how-to-verify>
<resume-signal>
Type 'approved' after steps 1-13 all PASS. If any step fails, paste the failure diagnostic + your Chrome version + locale + whether KEEP_PROFILE was used; Task 4 iterates.
</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| OS notification system <-> Chrome SW | NEW: chrome.notifications.* relies on the OS notification center. Misuse risks notification spam. Mitigation: notification ids are prefix-scoped ('mokosh-startup-', 'mokosh-recovery-') and we drain on click; we never create more than one notification per event class per second. |
| Popup activation gesture | UNCHANGED: getDisplayMedia requires a user-gesture; previously the popup-button-click provided it. Now chrome.action.onClicked and chrome.notifications.onClicked both provide it per Chrome's documented behavior (toolbar clicks and notification clicks are activation-bearing). |
| operator-controllable picker (displaySurface hint) | NEW HARDENING: a tab or window pick no longer reaches recording — track validation enforces monitor and tears down the stream + surfaces RECORDING_ERROR. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-1-09-01 | Spoofing | chrome.notifications.onClicked accepting any notificationId | mitigate | Handler validates notificationId.startsWith('mokosh-') before any side effect. Foreign extensions cannot inject our prefixes (chrome.notifications IDs are per-extension-scoped). |
| T-1-09-02 | Tampering | operator-overridden displaySurface yielding tab/window capture | mitigate | Task 2 post-grant validation reads track.getSettings().displaySurface and aborts if not monitor; partial-screen capture cannot reach the buffer. |
| T-1-09-03 | Information Disclosure | recording the operator's whole desktop captures more sensitive content than tab mode would | accept | The whole-desktop constraint is the EXPLICIT user-requested charter (Plan 01-09 must-haves). Operator-facing UX surfaces the recording state via Chrome's permanent 'Sharing your screen' indicator + new REC badge + notifications; operator can stop sharing at any time. |
| T-1-09-04 | Denial of Service | repeat-firing of RECORDING_ERROR creating notification spam | mitigate | Recovery notification has Date.now()-suffixed id, so chrome.notifications.create coalesces if the OS notification center deduplicates by title; even worst-case (3 RECORDING_ERRORs in a row) produces 3 notifications which is operator-actionable, not spam. A future Phase 5 hardening can debounce; out of scope here. |
| T-1-09-05 | Elevation of Privilege | dynamic setPopup race — popup loads with isRecording false, attempts SAVE, ships empty archive | mitigate | Save path's existing EmptyVideoBufferError (Option C, D-17-port-lifecycle amendment) surfaces the error to the popup which displays it; the operator does not silently receive an empty archive. The setPopup dance is best-effort UX; the save-path correctness gate is the EmptyVideoBufferError throw. |
</threat_model>
<verification>
- npx vitest run shows 16 files / 77 tests / all GREEN.
- npx tsc --noEmit exit 0.
- npm run build exit 0; dist/manifest.json carries 'notifications' permission.
- Operator empirical: notification appears at startup; picker is monitor-only; toolbar click in idle triggers picker; toolbar click in recording opens popup for SAVE; badge transitions REC/OFF/ERROR observed; recovery notification fires on stop-sharing.
- grep -n "displaySurface: 'monitor'" src/offscreen/recorder.ts returns at least one match.
- grep -n "chrome.action.onClicked" src/background/index.ts returns at least one match.
- grep -n "chrome.notifications.create" src/background/index.ts returns at least one match.
- grep -n "chrome.runtime.onStartup" src/background/index.ts returns at least one match.
- grep -n "notifications" manifest.json returns at least one match within the permissions array.
</verification>
<success_criteria>
Plan 01-09 is complete when:
1. The 4 displaySurface tests + 13 toolbar/badge/notification/popup-idle-race tests (17 new total) are all GREEN.
2. All 60 baseline GREEN tests from Plan 01-08 remain GREEN.
3. Operator runs the Task 5 checkpoint and confirms the end-to-end UX works: idle-toolbar-click triggers picker; recording-toolbar-click opens popup; onStartup notification appears; recovery notification appears after stop-sharing; badge transitions REC/OFF/ERROR visibly.
4. manifest.json + smoke.sh + popup updated consistently with the new UX charter.
5. tsc + build clean.
</success_criteria>
<output>
After completion, create .planning/phases/01-stabilize-video-pipeline/01-09-SUMMARY.md per the standard template. Cite: the 17 new tests landed GREEN (4 displaySurface + 5 toolbar-action including W-02 popup-idle-race + 4 badge + 4 notification); manifest permission delta; new chrome.action / chrome.notifications listener registrations; popup role transition (start-on-open removed); smoke.sh updated for monitor-only picker; Task 5 operator-verification result.
</output>

View File

@@ -0,0 +1,550 @@
---
phase: 01-stabilize-video-pipeline
plan: 10
type: execute
wave: 3
depends_on:
- 01-09
files_modified:
- manifest.json
- src/welcome/welcome.html
- src/welcome/welcome.ts
- src/welcome/welcome.css
- src/background/index.ts
- tests/background/onboarding.test.ts
autonomous: false
requirements:
- REQ-video-ring-buffer
tags:
- onboarding
- welcome
- chrome.runtime.onInstalled
- chrome.storage
- web_accessible_resources
must_haves:
truths:
- "On first install (chrome.runtime.onInstalled with reason 'install') a welcome tab opens automatically pointing at src/welcome/welcome.html (resolved via chrome.runtime.getURL)."
- "On subsequent installs (reason 'update' or 'chrome_update') OR on a second-install when chrome.storage.local already has onboarding-completed flag, NO welcome tab opens (assert via tests/background/onboarding.test.ts: chrome.tabs.create is called exactly once across two install events)."
- "Welcome page has a single 'Start Mokosh' button that, on click, sends a REQUEST_PERMISSIONS message to the SW which kicks off the existing startVideoCapture flow (consuming the click as user gesture for getDisplayMedia)."
- "After successful start the welcome page updates its DOM to show a 'Recording started — close this tab when ready' confirmation state (no full page reload; uses chrome.runtime.onMessage to listen for the RECORDING_STARTED ack OR observes the popup-toolbar badge transition by re-checking chrome.action.getBadgeText)."
- "manifest.json adds 'storage' to permissions (if not already present — it IS already present per current manifest, so just verify; do not duplicate) AND adds web_accessible_resources entry for src/welcome/welcome.html so chrome.tabs.create with chrome.runtime.getURL resolves."
- "Onboarding test covers BOTH first-install AND subsequent-install paths via two synthesized onInstalled events with different reason fields."
artifacts:
- path: "src/welcome/welcome.html"
provides: "Welcome page markup with a single Start button, brief explainer paragraph, no fancy framework (vanilla DOM per project style)."
min_lines: 25
- path: "src/welcome/welcome.ts"
provides: "Click-handler that sends REQUEST_PERMISSIONS to SW; transitions DOM to confirmation state on success; surfaces error on failure."
min_lines: 30
- path: "src/welcome/welcome.css"
provides: "Minimal styling (~30 LOC) consistent with the project's existing popup style.css aesthetic."
min_lines: 20
- path: "src/background/index.ts"
provides: "onInstalled handler extended: if reason === 'install' AND chrome.storage.local does not have 'onboarding-completed' flag, open welcome tab via chrome.tabs.create + set flag. Existing IDB cleanup + initialize() call preserved."
contains: "src/welcome/welcome.html"
- path: "manifest.json"
provides: "web_accessible_resources array with entry for src/welcome/welcome.html. 'storage' permission verified present."
contains: "web_accessible_resources"
- path: "tests/background/onboarding.test.ts"
provides: "RED-to-GREEN tests pinning: (a) first install creates welcome tab; (b) update/chrome_update does NOT create welcome tab; (c) repeated install when flag already set does NOT create welcome tab."
key_links:
- from: "src/background/index.ts onInstalled handler"
to: "chrome.tabs.create"
via: "chrome.tabs.create({url: chrome.runtime.getURL('src/welcome/welcome.html')})"
pattern: "chrome.tabs.create"
- from: "src/welcome/welcome.ts click handler"
to: "chrome.runtime.sendMessage"
via: "{type: 'REQUEST_PERMISSIONS'} — kicks the existing SW startVideoCapture flow"
pattern: "REQUEST_PERMISSIONS"
- from: "manifest.json web_accessible_resources"
to: "src/welcome/welcome.html"
via: "resources array entry making the page extension-accessible"
pattern: "src/welcome/welcome.html"
---
<objective>
First-install operator-friendly activation. When the extension is installed (typically from the Chrome Web Store one day, but also from a local Load Unpacked), a welcome tab opens automatically with a brief explainer + a single 'Start Mokosh' button. Operator reads the explainer + clicks → recording starts immediately (consuming the install-time click as the getDisplayMedia user gesture).
This complements Plan 01-09's toolbar-onClicked + onStartup notification flow:
- Plan 01-09 makes Chrome-startup-time activation easy (notification → click → picker).
- Plan 01-10 makes install-time activation easy (welcome tab → click → picker).
- Both consume the same RECORDING_ERROR/RECORDING_STARTED surface; both reuse the existing src/background/index.ts startVideoCapture path.
Output:
- src/welcome/welcome.{html,ts,css} — 3 new files for the onboarding page (vanilla DOM per project style; the existing popup at src/popup/index.html is the analog).
- src/background/index.ts — onInstalled handler extended with first-install detection (chrome.storage.local flag) + chrome.tabs.create call.
- manifest.json — adds web_accessible_resources entry pointing at the welcome page so chrome.tabs.create with chrome.runtime.getURL resolves. (storage permission already present per current manifest; verify it's not removed.)
- tests/background/onboarding.test.ts — pins the first-install vs subsequent-install vs already-completed contracts.
</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/ROADMAP.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.planning/phases/01-stabilize-video-pipeline/01-CONTEXT.md
@.planning/phases/01-stabilize-video-pipeline/01-08-PLAN.md
@.planning/phases/01-stabilize-video-pipeline/01-09-PLAN.md
@src/background/index.ts
@src/popup/index.html
@src/popup/index.ts
@src/popup/style.css
@manifest.json
<interfaces>
Key Chrome API surfaces.
chrome.runtime.onInstalled.addListener fires with {reason: 'install' | 'update' | 'chrome_update' | 'shared_module_update'}. The handler in src/background/index.ts line 724 already exists for IDB cleanup; this plan extends it.
chrome.storage.local.get + .set pattern:
```
const { 'onboarding-completed': onboardingCompleted } = await chrome.storage.local.get('onboarding-completed');
if (details.reason === 'install' && onboardingCompleted !== true) {
await chrome.tabs.create({ url: chrome.runtime.getURL('src/welcome/welcome.html') });
await chrome.storage.local.set({ 'onboarding-completed': true });
}
```
chrome.tabs.create signature:
```
chrome.tabs.create({ url: string, active?: boolean }): Promise<chrome.tabs.Tab>
```
manifest.json web_accessible_resources shape (MV3):
```
"web_accessible_resources": [
{
"resources": ["src/welcome/welcome.html"],
"matches": ["<all_urls>"]
}
]
```
Without this entry, chrome.runtime.getURL returns a path but loading it via chrome.tabs.create fails with a permission error in MV3.
Welcome page structure follows the existing popup pattern (src/popup/index.html + src/popup/style.css):
- HTML: container div, h1, p explainer, button. Russian copy consistent with popup ('Сохранить отчёт об ошибке' precedent).
- TS: vanilla DOM (no framework); attach addEventListener('click', handler) to the button; call chrome.runtime.sendMessage({type: 'REQUEST_PERMISSIONS'}); on success update the DOM to show confirmation; on failure show error.
- CSS: minimal, consistent palette with popup's style.css.
The existing src/background/index.ts onInstalled handler at line 724:
```
chrome.runtime.onInstalled.addListener((details) => {
logger.log('Extension installed/updated:', details.reason);
try {
indexedDB.deleteDatabase('VideoRecorderDB');
logger.log('Cleaned up orphaned VideoRecorderDB (if present)');
} catch (e) {
logger.warn('IDB cleanup failed:', e);
}
initialize();
});
```
This plan extends it WITHOUT touching the IDB cleanup or initialize() call — adds the onboarding check as an additional step (sequentially after the existing logic).
Note: the existing onInstalled handler is SYNCHRONOUS (no async/await). Wrapping the new onboarding logic in a self-executing async IIFE preserves the handler's sync signature while allowing await for chrome.storage.local + chrome.tabs.create.
Vite/crxjs note: src/welcome/welcome.html needs to be declared as a rollupOptions.input entry in vite.config.ts so the build bundles it into dist/. The existing vite.config.ts already has src/offscreen/index.html as an input; add src/welcome/welcome.html alongside. Otherwise the welcome page won't appear in dist/.
Existing tests/background/request-id-protocol.test.ts chrome stub pattern includes chrome.runtime.onInstalled.addListener as a vi.fn(); extend with chrome.tabs.create + chrome.storage.local.{get, set} for this test file.
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: RED tests — onInstalled first-install creates welcome tab; subsequent-install does not; already-completed flag suppresses.</name>
<read_first>
- tests/background/request-id-protocol.test.ts lines 53-200 (chrome stub scaffold)
- src/background/index.ts lines 724-737 (existing onInstalled handler)
- manifest.json (current shape — confirms storage permission already present)
</read_first>
<files>tests/background/onboarding.test.ts</files>
<behavior>
Three RED tests:
- Test A: when onInstalled fires with reason 'install' AND chrome.storage.local has no 'onboarding-completed' flag, chrome.tabs.create is called exactly once with a URL containing 'src/welcome/welcome.html'. After the call, chrome.storage.local.set is called with `{'onboarding-completed': true}`. **I-02 fix (2026-05-16 checker pass):** additionally assert `expect(chromeStub.storage.local.get).toHaveBeenCalledWith('onboarding-completed')` — this pins the EXACT key name the handler reads from storage. Without this assertion, a refactor that renamed the constant to e.g. `'mokosh-onboarding-done'` would still pass Test A's set-side assertion (because both the read and write would use the new name in lock-step) while silently breaking compatibility with any installed user's storage from the prior schema. One additional line of test code; catches a real cross-version compatibility bug class.
- Test B: when onInstalled fires with reason 'update', chrome.tabs.create is NOT called.
- Test C: when onInstalled fires with reason 'install' BUT chrome.storage.local already has 'onboarding-completed': true, chrome.tabs.create is NOT called.
</behavior>
<action>
1. Create tests/background/onboarding.test.ts.
2. Copy the chrome stub from tests/background/request-id-protocol.test.ts. Extend with:
- chrome.tabs: { create: vi.fn().mockResolvedValue({ id: 99, url: 'chrome-extension://...' }) }
- chrome.storage: { local: { get: vi.fn(), set: vi.fn().mockResolvedValue(undefined) } }
- chrome.runtime.onInstalled: { addListener: vi.fn(), _callbacks: [] } — and have addListener push to _callbacks for test-driven invocation
- chrome.runtime.getURL: (path) => 'chrome-extension://test-id/' + path
3. Test A:
- chromeStub.storage.local.get.mockResolvedValue({}); // empty — no flag set
- vi.resetModules(); set globalThis.chrome; await import('../../src/background/index');
- Synthesize: await chromeStub.runtime.onInstalled._callbacks[0]({ reason: 'install' });
- Drain microtasks via await Promise.resolve a few times.
- Assert chrome.tabs.create called exactly once with object whose url contains 'src/welcome/welcome.html'.
- Assert chrome.storage.local.set called with {'onboarding-completed': true}.
- **I-02 fix:** assert `expect(chromeStub.storage.local.get).toHaveBeenCalledWith('onboarding-completed')` — pins the exact storage key the handler reads. (One line of test code beyond the existing set-side check.)
4. Test B:
- chromeStub.storage.local.get.mockResolvedValue({});
- vi.resetModules(); set globalThis.chrome; await import.
- Synthesize: await chromeStub.runtime.onInstalled._callbacks[0]({ reason: 'update' });
- Drain microtasks.
- Assert chrome.tabs.create NOT called.
- Assert chrome.storage.local.set NOT called.
5. Test C:
- chromeStub.storage.local.get.mockResolvedValue({ 'onboarding-completed': true });
- vi.resetModules(); set globalThis.chrome; await import.
- Synthesize: await chromeStub.runtime.onInstalled._callbacks[0]({ reason: 'install' });
- Drain microtasks.
- Assert chrome.tabs.create NOT called.
6. Run npx vitest run tests/background/onboarding.test.ts — all 3 must be RED today (the new onInstalled extension does not exist yet).
7. DO NOT modify src/background/index.ts in this task.
</action>
<verify>
<automated>npx vitest run tests/background/onboarding.test.ts</automated>
</verify>
<acceptance_criteria>
- tests/background/onboarding.test.ts exists with 3 tests; all 3 RED.
- Baseline (Plan 01-09 final: 16 files / 76 tests / all GREEN) + 3 new RED here = 17 files / 79 tests / 3 failed | 76 passed.
- npx tsc --noEmit exit 0.
</acceptance_criteria>
<done>3 RED tests pin the onboarding routing contract; baseline preserved.</done>
</task>
<task type="auto">
<name>Task 2: Create welcome page assets src/welcome/welcome.{html,ts,css}; register vite entry; add web_accessible_resources to manifest.</name>
<read_first>
- src/popup/index.html (style analog)
- src/popup/style.css (palette + sizing reference)
- src/popup/index.ts (vanilla DOM + chrome.runtime.sendMessage pattern)
- vite.config.ts (where to add rollupOptions.input entry)
- manifest.json (current web_accessible_resources is ABSENT — this task adds it)
</read_first>
<files>src/welcome/welcome.html, src/welcome/welcome.ts, src/welcome/welcome.css, vite.config.ts, manifest.json</files>
<action>
1. Create src/welcome/welcome.html (Russian per project provenance; matches popup language):
```
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Добро пожаловать в Mokosh</title>
<link rel="stylesheet" href="welcome.css">
</head>
<body>
<main class="welcome">
<h1>Добро пожаловать в Mokosh</h1>
<p>
Расширение записывает последние 30 секунд экрана и 10 минут логов
вашего браузера, чтобы при возникновении бага вы могли одним
кликом сохранить полный отчёт для службы поддержки.
</p>
<p>
Нажмите кнопку ниже, чтобы выбрать экран и начать запись.
Запись идёт фоном; ваши данные не отправляются никуда —
только сохраняются локально по вашему запросу.
</p>
<button id="startButton" class="start-button" type="button">
Начать запись
</button>
<p id="statusMessage" class="status-message"></p>
</main>
<script type="module" src="welcome.ts"></script>
</body>
</html>
```
2. Create src/welcome/welcome.ts (vanilla DOM; absolute imports per project style). **W-06 fix (2026-05-16 checker pass): use the centralized `Logger` class from `src/shared/logger.ts` instead of an inline `console.log` wrapper.** This matches the background + offscreen + popup convention (popup currently has a bespoke inline `function log` per `src/popup/index.ts:18` — that's a known stylistic divergence the popup hasn't yet been refactored against; the welcome page lands clean from day one). The `Logger` class is the SW logger by published shape (prefix `[SW:<context>]`) — the welcome page is NOT an SW, so the SW-prefix is semantically loose; if the executor finds this jarring during implementation, they MAY add a new `WelcomeLogger` class to `src/shared/logger.ts` mirroring the `OffscreenLogger`/`ContentLogger` pattern (prefix `[WC:<context>]`) and use that here instead. EITHER choice satisfies W-06's "use the centralized logger" requirement; the inline `function log` form is what the checker rejected.
```
// src/welcome/welcome.ts — onboarding click-handler (Plan 01-10 D-17-onboarding).
//
// Sends REQUEST_PERMISSIONS to the SW which routes through the same
// startVideoCapture path as the toolbar onClicked handler (Plan 01-09
// D-16-toolbar). The button click counts as the user gesture for
// getDisplayMedia.
// W-06 fix: use the centralized Logger from src/shared/logger.ts
// instead of an inline console.log wrapper. The Logger class emits
// `[SW:<context>] <ISO timestamp>` lines; the prefix is semantically
// loose for a welcome page (not an SW) but matches the background +
// offscreen logger discipline. If the executor opts to add a new
// WelcomeLogger class to src/shared/logger.ts (prefix `[WC:Welcome]`)
// and import that instead, that ALSO satisfies W-06.
import { Logger } from '../shared/logger';
const logger = new Logger('Welcome');
const startButton = document.getElementById('startButton') as HTMLButtonElement | null;
const statusMessage = document.getElementById('statusMessage') as HTMLParagraphElement | null;
async function onStart(): Promise<void> {
if (startButton === null || statusMessage === null) {
return;
}
startButton.disabled = true;
statusMessage.textContent = 'Открываем выбор источника...';
statusMessage.className = 'status-message';
try {
const response = await chrome.runtime.sendMessage({
type: 'REQUEST_PERMISSIONS',
});
logger.log('REQUEST_PERMISSIONS response:', response);
if (response?.granted === true) {
statusMessage.textContent = 'Запись начата. Эту вкладку можно закрыть.';
statusMessage.className = 'status-message success';
startButton.textContent = 'Запись активна';
} else {
startButton.disabled = false;
statusMessage.textContent = 'Не удалось начать запись. Попробуйте снова.';
statusMessage.className = 'status-message error';
}
} catch (err) {
logger.warn('Start failed:', err);
startButton.disabled = false;
statusMessage.textContent = 'Ошибка: ' + ((err as Error)?.message ?? String(err));
statusMessage.className = 'status-message error';
}
}
function init(): void {
if (startButton !== null) {
startButton.addEventListener('click', onStart);
}
}
document.addEventListener('DOMContentLoaded', init);
```
3. Create src/welcome/welcome.css (consistent palette with src/popup/style.css):
```
html, body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
color: #1f1f1f;
}
.welcome {
max-width: 600px;
margin: 60px auto;
padding: 32px;
background: #ffffff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.welcome h1 {
margin: 0 0 16px;
font-size: 24px;
font-weight: 600;
}
.welcome p {
line-height: 1.5;
margin: 0 0 16px;
}
.start-button {
display: block;
width: 100%;
padding: 12px 16px;
margin: 24px 0 16px;
font-size: 16px;
font-weight: 500;
background: #00C853;
color: #ffffff;
border: none;
border-radius: 6px;
cursor: pointer;
}
.start-button:hover:not(:disabled) {
background: #00B248;
}
.start-button:disabled {
background: #BDBDBD;
cursor: not-allowed;
}
.status-message {
min-height: 1.4em;
font-size: 14px;
color: #616161;
}
.status-message.success {
color: #00C853;
}
.status-message.error {
color: #D32F2F;
}
```
4. Update vite.config.ts — add 'src/welcome/welcome.html' to rollupOptions.input alongside the existing offscreen entry:
```
rollupOptions: {
input: {
offscreen: 'src/offscreen/index.html',
welcome: 'src/welcome/welcome.html',
},
},
```
5. Update manifest.json — add a web_accessible_resources array:
```
"web_accessible_resources": [
{
"resources": ["src/welcome/welcome.html"],
"matches": ["<all_urls>"]
}
]
```
Insert this after the "host_permissions" block. Confirm 'storage' is already in permissions (it IS per current manifest line 11; do not duplicate).
6. Run npm run build — exit 0; confirm dist/src/welcome/welcome.html exists and the new web_accessible_resources entry is in dist/manifest.json.
7. Run npx tsc --noEmit — exit 0.
8. Run npx vitest run — baseline preserved (17 files / 76 GREEN + 3 RED from Task 1; the 3 RED stay RED).
</action>
<verify>
<automated>npm run build && npx tsc --noEmit && test -f dist/src/welcome/welcome.html && grep -q web_accessible_resources dist/manifest.json</automated>
</verify>
<acceptance_criteria>
- src/welcome/welcome.{html,ts,css} exist with the contents above (verbatim or stylistically equivalent).
- vite.config.ts has the welcome input entry.
- manifest.json has the web_accessible_resources block with welcome.html.
- npm run build exit 0; dist/ contains src/welcome/welcome.html and a manifest with web_accessible_resources.
- npx tsc --noEmit exit 0.
- Baseline preserved (17 files / 79 tests / 3 RED from Task 1 still RED; 76 GREEN).
</acceptance_criteria>
<done>Welcome page assets staged + build pipeline picks them up + manifest declares them accessible; ready for the SW handler in Task 3.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: GREEN — extend onInstalled in src/background/index.ts with first-install welcome-tab logic; drives Task 1 tests to GREEN.</name>
<read_first>
- tests/background/onboarding.test.ts (contracts from Task 1)
- src/background/index.ts lines 724-737 (existing onInstalled handler)
</read_first>
<files>src/background/index.ts</files>
<action>
1. Define a constant near the top of the file (alongside other top-level constants like VIDEO_MIME_FALLBACK):
const ONBOARDING_FLAG = 'onboarding-completed';
const WELCOME_PATH = 'src/welcome/welcome.html';
2. Extract a helper function (placed near the other helpers, e.g. just below ensureOffscreen at line 86):
async function openWelcomeIfFirstInstall(details: chrome.runtime.InstalledDetails): Promise<void> {
if (details.reason !== 'install') {
return;
}
try {
const stored = await chrome.storage.local.get(ONBOARDING_FLAG);
if (stored[ONBOARDING_FLAG] === true) {
logger.log('Onboarding already completed; skipping welcome tab.');
return;
}
const url = chrome.runtime.getURL(WELCOME_PATH);
await chrome.tabs.create({ url });
await chrome.storage.local.set({ [ONBOARDING_FLAG]: true });
logger.log('Welcome tab opened and onboarding flag set.');
} catch (err) {
logger.warn('openWelcomeIfFirstInstall failed:', err);
}
}
Document with a JSDoc header per project style; cite Plan 01-10 **D-17-onboarding** (this plan's CONTEXT amendment marker — appended at plan-creation time alongside D-14-remux / D-15-display-surface / D-16-toolbar). **B-02 fix (2026-05-16 checker pass):** the original draft of this line cited bare 'D-16' which is ambiguous — the historical decisions block (CONTEXT.md line 100) has `**D-16:** Video buffer ownership moves to the offscreen document`, completely unrelated to the toolbar UX. To disambiguate per the D-17-port-lifecycle / D-17-onboarding precedent, all amendment-block markers carry a `-suffix`: D-14-remux (remux helper), D-15-display-surface (whole-desktop constraint), D-16-toolbar (toolbar+badge+notifications), D-17-onboarding (this plan's welcome-tab). Citing D-17-onboarding here points the JSDoc reader at the right amendment block on CONTEXT.md.
3. Modify the existing onInstalled handler (line 724) to invoke the helper. The existing handler is synchronous; wrap the new call in a fire-and-forget pattern OR convert the listener to async — both are valid for chrome.runtime.onInstalled (Chrome 91+ accepts async listeners; the IDB cleanup is sync and stays at the top, and the welcome flow is async at the bottom):
chrome.runtime.onInstalled.addListener((details) => {
logger.log('Extension installed/updated:', details.reason);
try {
indexedDB.deleteDatabase('VideoRecorderDB');
logger.log('Cleaned up orphaned VideoRecorderDB (if present)');
} catch (e) {
logger.warn('IDB cleanup failed:', e);
}
initialize();
// Plan 01-10: open welcome tab on first install. Fire-and-forget;
// the helper logs its own errors.
openWelcomeIfFirstInstall(details).catch((err) => {
logger.warn('openWelcomeIfFirstInstall threw:', err);
});
});
4. Run npx vitest run tests/background/onboarding.test.ts — all 3 must flip GREEN.
5. Run full suite — 17 files / 79 tests / all GREEN.
6. Run npx tsc --noEmit — exit 0.
7. Run npm run build — exit 0.
Naming/style: ONBOARDING_FLAG + WELCOME_PATH SCREAMING_SNAKE per project rule for true constants. openWelcomeIfFirstInstall — full-word camelCase. No 'continue'; if-else chains. No 'as any'. The chrome.storage.local.get key-name access is type-safe via dynamic indexing (acceptable per @types/chrome's chrome.storage.local signature: returns Record<string, unknown>).
</action>
<verify>
<automated>npx vitest run tests/background/onboarding.test.ts</automated>
</verify>
<acceptance_criteria>
- openWelcomeIfFirstInstall helper exists in src/background/index.ts with the documented behavior.
- onInstalled handler invokes the helper.
- All 3 onboarding tests GREEN.
- Full suite 17 files / 79 tests / all GREEN.
- npx tsc --noEmit exit 0.
- npm run build exit 0.
</acceptance_criteria>
<done>onInstalled extended; tests GREEN; welcome flow wired end-to-end at the SW layer.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 4: Operator empirical check — first install opens welcome tab; click triggers picker; recording starts; tests confirm second install does NOT re-open welcome.</name>
<files>(operator-driven; no specific source file modified by this checkpoint)</files>
<action>See <how-to-verify> below — operator-driven empirical check; the executor agent must not bypass this checkpoint by stubbing.</action>
<verify>
<automated>echo "checkpoint:human-verify — see how-to-verify section; resume signal is the gate"</automated>
</verify>
<done>Operator types "approved" after running the how-to-verify steps. See <resume-signal> for the exact gate.</done>
<what-built>
Tasks 1-3 landed: src/welcome/* page bundle, manifest web_accessible_resources, onInstalled handler extended with chrome.storage.local-gated welcome-tab opening. The 3 unit tests are GREEN. This checkpoint validates real Chrome behavior end-to-end.
</what-built>
<how-to-verify>
1. Build: npm run build (exit 0).
2. Wipe smoke profile: rm -rf /tmp/mokosh-smoke-profile (or KEEP_PROFILE=0 ./smoke.sh which does the wipe).
3. Run smoke: KEEP_PROFILE=0 ./smoke.sh. Chrome launches with fresh profile.
4. Load Unpacked → select dist/. THE WELCOME TAB SHOULD AUTOMATICALLY OPEN within ~1 second after the extension loads. The tab URL should look like chrome-extension://<id>/src/welcome/welcome.html.
5. Confirm the welcome page renders: title 'Добро пожаловать в Mokosh', explainer paragraphs, big green 'Начать запись' button, empty status message line.
6. Click 'Начать запись'. The button disables; status message shows 'Открываем выбор источника...'; Chrome's screen-share picker appears (monitor-only per Plan 01-09).
7. Pick 'Entire screen' and accept. Status message transitions to 'Запись начата. Эту вкладку можно закрыть.' The toolbar badge transitions to REC (green) per Plan 01-09.
8. Close the welcome tab.
9. Now reload the extension at chrome://extensions (toggle off then on). Observe: the welcome tab does NOT open this time (because chrome.storage.local has 'onboarding-completed' === true from the first install). Only the existing toolbar/badge behavior applies.
10. To re-validate the onInstalled='install' path: wipe the profile (Cmd+Q Chrome → rm -rf /tmp/mokosh-smoke-profile → relaunch smoke.sh → Load Unpacked again). Welcome tab opens again because storage.local was wiped with the profile.
11. If step 4 (welcome tab opens), step 6 (picker appears on click), step 7 (recording starts), or step 9 (re-load does NOT re-open) fails: document the failure mode + Chrome version + the SW console errors. Iterate on Task 2 (asset bundling) or Task 3 (SW handler) accordingly.
</how-to-verify>
<resume-signal>
Type 'approved' after steps 4, 6, 7, 9 all PASS. If any step fails, paste the failure diagnostic.
</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| welcome page <-> SW | Welcome page (a same-origin extension page) sends REQUEST_PERMISSIONS via chrome.runtime.sendMessage. T-1-NEW-05-01 sender-id check in the SW's onMessage listener (line 635) already validates sender.id === chrome.runtime.id; no new boundary. |
| chrome.storage.local <-> SW | Storage flag is non-secret (boolean true); even if leaked, the only effect is suppressing the welcome tab on future installs. No PII; no sensitive content. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-1-10-01 | Tampering | adversary clears onboarding-completed flag to spam welcome tabs | accept | Welcome tab is non-destructive; the worst case is an extra tab on each install which the operator can close. No data exfiltration path. |
| T-1-10-02 | Information Disclosure | welcome.html leaking via web_accessible_resources fingerprinting (extensions can be enumerated by sites probing chrome-extension:// URLs in web_accessible_resources) | accept | Extension identifier is already discoverable through chrome.runtime.getURL exposure on any extension page; matches:[\"<all_urls>\"] is the standard pattern for welcome flows. Phase 5 hardening could narrow matches to a specific install-confirmation domain if needed; out of scope. |
| T-1-10-03 | Denial of Service | adversary controls chrome.storage.local quota via web requests | mitigate | Single boolean flag uses ~50 bytes; storage.local quota is 10 MB; not exploitable. |
| T-1-10-04 | Elevation of Privilege | welcome page tricks SW into bypassing checkpoints | mitigate | Welcome page sends only REQUEST_PERMISSIONS — same route as the popup. The SW's existing sender-id check + the chrome.action user-gesture model both still apply. No new elevation path. |
</threat_model>
<verification>
- npx vitest run shows 17 files / 79 tests / all GREEN.
- npx tsc --noEmit exit 0.
- npm run build exit 0; dist/src/welcome/welcome.html exists; dist/manifest.json contains web_accessible_resources with welcome.html.
- Operator empirical: first install opens welcome tab; click triggers picker; recording starts; reload does NOT re-open welcome.
- grep -n "chrome.tabs.create" src/background/index.ts returns at least one match.
- grep -n "welcome.html" manifest.json returns at least one match.
- grep -n "openWelcomeIfFirstInstall" src/background/index.ts returns at least one match.
</verification>
<success_criteria>
Plan 01-10 is complete when:
1. The 3 onboarding tests are GREEN.
2. All 76 baseline GREEN tests from Plan 01-09 remain GREEN.
3. Operator runs the Task 4 checkpoint and confirms first install opens welcome tab, click starts recording, reload does NOT re-open.
4. tsc + build clean; manifest + vite + welcome assets all consistent.
</success_criteria>
<output>
After completion, create .planning/phases/01-stabilize-video-pipeline/01-10-SUMMARY.md per the standard template. Cite: the 3 new tests landed GREEN; new src/welcome/ page bundle; manifest web_accessible_resources delta; onInstalled extension; first-install vs subsequent-install behavior confirmed by Task 4 operator check.
</output>

View File

@@ -299,7 +299,7 @@ phase, so downstream phases see a consistent baseline:
--- ---
## Amendment (Phase 01-stabilize-video-pipeline, 2026-05-16) — D-17 port lifecycle ## Amendment (Phase 01-stabilize-video-pipeline, 2026-05-16) — D-17-port-lifecycle
- **AMENDED-BY:** debug session `empty-archive-port-race` (Option C, resolved 2026-05-16) - **AMENDED-BY:** debug session `empty-archive-port-race` (Option C, resolved 2026-05-16)
- **Replaces nothing.** D-17 above stands as the original port-keepalive - **Replaces nothing.** D-17 above stands as the original port-keepalive
@@ -367,6 +367,211 @@ phase, so downstream phases see a consistent baseline:
--- ---
## Amendment (Phase 01-stabilize-video-pipeline, 2026-05-16 second batch) — Plans 01-08 / 01-09 / 01-10 charter
**Context.** On 2026-05-16 Phase 1 was REOPENED after UAT Test 3 re-attempt
revealed two compounding gaps in the 2026-05-15 closure:
1. D-13's concat-of-self-contained-WebM-segments architecture produces a
multi-EBML-header file that mpv, Chrome's HTMLMediaElement, and
ffprobe's `format=duration` all play as ~9.94 s (the first segment's
Info.Duration only) instead of ~30 s. The "operator-confirmed clean
Chrome playback" check from 2026-05-15 verified playback ran without
freezing but did not measure total duration. SPEC §10 #7
(`last_30sec.webm plays back in a browser`) is not actually satisfied.
See `.planning/debug/d13-multi-ebml-concat-unplayable.md` for the
byte-level EBML probe + library-survey + decision rationale.
2. The operator UX is unsatisfactory at the picker stage (tab-share
footgun: "Share this tab instead" affordance) and at the activation
stage (3 clicks per session: toolbar → popup open → Start button →
picker). These were flagged in UAT Test 3's advisory section and
deferred to Phase 5 at closure time; the reopen reclassifies them
as Phase 1 deliverables because the same fix-cycle window is
available.
The orchestrator's "add-more-plans" routing on 2026-05-16 adds three
plans (01-08, 01-09, 01-10) to close these gaps without re-litigating
the seven plans (01-01..01-07) that already landed.
**D-14-remux: WebM remux via ts-ebml + webm-muxer (supersedes D-13's file-concat).**
- **AMENDED-BY:** debug session `d13-multi-ebml-concat-unplayable`
(root-cause confirmed 2026-05-16). Remediation lands via Plan 01-08.
- **Replaces partial.** D-13's recorder-side restart-segments lifecycle
(the part that fixed the orphan-P-frame freeze observed in debug
session `webm-playback-freeze`) is PRESERVED. Only D-13's file-concat
merge on the SW save path is retired. Original D-13 text above
remains intact for provenance.
- **Architectural commitments retired:** D-13 file-concat byte-stream
merge in `src/background/index.ts:mergeVideoSegments` is RETIRED.
Concat of self-contained WebM segments does NOT produce a single
playable 30 s WebM in any common consumer-grade player.
- **Architectural commitments added:**
- `remuxSegments(segments: VideoSegment[]): Promise<Blob>` in
`src/background/webm-remux.ts` replaces `mergeVideoSegments`.
Parses each segment via `ts-ebml`'s Decoder, walks the EBML tree,
extracts each Cluster's SimpleBlock children (VP9 frame bytes +
keyframe flag + cluster Timecode + block offset), and re-mixes
into a single output WebM via `webm-muxer`'s `Muxer<ArrayBufferTarget>.addVideoChunkRaw(data, type, timestampUs)`.
Each frame's timestamp is adjusted to be monotonic against a
single global timeline.
- **Library choice (LOCKED):** `ts-ebml` ^3.0.2 (parse) +
`webm-muxer` ^5.1.4 (write). Both MIT, both actively maintained
(last releases 2025-09-28 and 2025-07-02 respectively), both
SW-compatible (no DOM globals on the hot path). Combined gzipped
weight ~100 KB. Verified by `tests/background/webm-remux-deps.test.ts`.
- **`createArchive` becomes async on the merge path.**
`videoBlob = await remuxSegments(...)` replaces synchronous
`mergeVideoSegments(...)`. The existing `EmptyVideoBufferError`
throw (Option C, D-17-port-lifecycle amendment) is preserved AND extended to
fire on a zero-byte remux output.
- **D-13 recorder-side lifecycle UNCHANGED.** The offscreen-side
`src/offscreen/recorder.ts` keeps the restart-segments rotation
(`SEGMENT_DURATION_MS = 10_000` ms, `MAX_SEGMENTS = 3`), which
remains the canonical fix for the orphan-P-frame freeze from
debug session `webm-playback-freeze`. The remux happens only on
export, only in the SW.
- **Pinning contracts added (Plan 01-08):**
- `tests/background/webm-remux-deps.test.ts` (2 tests — library
presence + SW-compat).
- `tests/background/webm-remux.test.ts` (5 tests — single-EBML
invariant, monotonic timestamps, size sanity, ffprobe duration
>= 25 s, frame-count tolerance).
- `tests/offscreen/webm-playback.test.ts` lines 231-316 (the 2 RED
tests landed by the d13 debug session) flip GREEN against the
regenerated `tests/fixtures/last_30sec.webm`.
- **D-13 status:** PARTIALLY RETIRED. Recorder lifecycle preserved;
file-concat merge retired. CONTEXT.md D-13 text above stands for
historical provenance; downstream readers reaching D-13 must ALSO
read this D-14-remux amendment.
**D-15-display-surface: Whole-desktop constraint + post-grant validation (replaces
operator-discretion picker).**
- **AMENDED-BY:** UAT Test 3 advisory finding (2026-05-16): "Share
this tab instead" Chrome affordance is a one-click footgun that
redirects the recording target mid-session. Reclassified from
Phase 5 advisory to Phase 1 deliverable via Plan 01-09.
- **Replaces nothing structurally.** D-01's `getDisplayMedia` choice
stands; this amendment narrows the constraints object passed to it.
- **Architectural commitments added:**
- `getDisplayMedia` is invoked with constraints
`{video: { displaySurface: 'monitor', cursor: 'always' }, audio: false}`.
The `displaySurface: 'monitor'` constraint hints Chrome's picker
to default to entire-screen selection; the `cursor: 'always'`
constraint includes the operator's screen cursor in captured
frames (this lifts the Phase 5 cursor-visibility refinement
opportunistically — STATE.md Decisions entry [Phase 01-07-deferred-to-5]).
- Post-grant validation reads `track.getSettings().displaySurface`.
If the operator overrode the hint and picked a tab or window,
the recorder tears down the stream, nulls `mediaStream`, and
throws `Error('wrong-display-surface: got "<observed>"')`. The
throw routes through `classifyCaptureError` (extended with a
`'wrong-display-surface'` branch added to the `CaptureErrorCode`
union) into the existing `RECORDING_ERROR` channel.
- The `CaptureErrorCode` union grows by one member:
`'wrong-display-surface'` (joins the existing seven).
- **Pinning contract added (Plan 01-09):**
- `tests/offscreen/display-surface-constraint.test.ts` (4 tests:
constraints applied; wrong-displaySurface emits RECORDING_ERROR;
monitor-displaySurface does NOT emit; classifyCaptureError
branch).
**D-16-toolbar: Toolbar-onClicked + popup-SAVE-only + badge state machine +
onStartup/recovery notifications (replaces popup-Start-button activation).**
- **AMENDED-BY:** UAT Test 3 ergonomics observation; operator-experience
goal "per-session click count from 3 to 2" surfaced by user on
2026-05-16. Reclassified from soft-deferred UX-polish to Phase 1
deliverable via Plan 01-09.
- **Replaces nothing structurally.** The existing popup → SW
`REQUEST_PERMISSIONS` → `startVideoCapture` flow stands; this
amendment changes WHICH UI surface triggers REQUEST_PERMISSIONS.
- **Architectural commitments added:**
- `chrome.action.onClicked` listener registered at SW module load.
When `isRecording === false`, the handler invokes
`startVideoCapture()` directly (the toolbar click counts as the
user gesture for `getDisplayMedia`).
- Dynamic `chrome.action.setPopup` swap: empty string (`''`) in
OFF mode (so toolbar click triggers `onClicked`); pointing to
`src/popup/index.html` in REC mode (so toolbar click opens the
popup for SAVE). The SAVE-only popup retires the auto-prompt
behavior in `src/popup/index.ts:checkPermissions`.
- Badge state machine with three states. REC: green background
(`#00C853`), text `'REC'`, tooltip "Recording — last 30 s
buffered. Click to save." OFF: red background (`#D32F2F`),
empty text, tooltip "Not recording. Click to start." ERROR:
yellow background (`#F9A825`), text `'ERR'`, tooltip
"Recording error. Click to try again."
- `chrome.runtime.onStartup` fires `chrome.notifications.create`
once per browser startup with a `'mokosh-startup-'`-prefixed id,
inviting the operator to click to start.
- `chrome.notifications.onClicked` validates the id prefix
(`'mokosh-'`), drains the notification via `chrome.notifications.clear`,
and triggers `startVideoCapture()` (notification click is also
a user gesture under Chrome's documented activation model).
- On `RECORDING_ERROR` receipt, the SW transitions badge to ERROR
and emits a `'mokosh-recovery-'`-prefixed recovery notification
inviting a fresh start.
- `manifest.json` adds `notifications` to permissions. `default_popup`
retained for the SAVE flow.
- **Popup role change.** `src/popup/index.ts` no longer auto-requests
permissions on init. `checkPermissions` / `requestPermissions`
functions are removed from the init path. Empty-state copy updated
to direct operator to the toolbar icon.
- **Smoke harness update.** `smoke.sh`'s auto-select target string
changes from the tab title to an entire-screen string (e.g.
`"Entire screen"` or `"Screen 1"` depending on Chrome locale). If
the auto-select fails under a non-English Chrome, a one-time
manual pick is documented as the fallback.
- **Pinning contracts added (Plan 01-09):**
- `tests/background/toolbar-action.test.ts` (4 tests: onClicked
routing; setPopup dance).
- `tests/background/badge-state-machine.test.ts` (4 tests: three
badge states + RECORDING_ERROR transition).
- `tests/background/onstartup-notification.test.ts` (4 tests:
onStartup → create notification; onClicked → start recording;
RECORDING_ERROR → recovery notification).
**D-17 (NEW — distinct from the port-lifecycle D-17 amendment above):
Onboarding welcome tab on first install.**
> NB: this is a SEPARATE D-17 marker, scoped to Plan 01-10. The earlier
> D-17 amendment block (port lifecycle) is also labeled D-17 in its
> historical context (it amends the original D-17 from the 2026-05-15
> decisions block above). To disambiguate downstream readers, this
> Plan 01-10 marker is referenced as D-17-onboarding in cross-citations;
> the port-lifecycle marker is referenced as D-17-port-lifecycle.
- **Trigger:** Plan 01-09's UX is operator-facing at runtime; Plan 01-10
adds the install-time activation path so the operator's very first
interaction with the extension is also one-click.
- **Architectural commitments added:**
- `chrome.runtime.onInstalled` handler extended: on
`details.reason === 'install'` AND `chrome.storage.local`
`'onboarding-completed'` flag absent, open
`src/welcome/welcome.html` via
`chrome.tabs.create({url: chrome.runtime.getURL(...)})`. After the
tab opens, set the `'onboarding-completed'` flag so future installs
or reloads do NOT re-open the welcome.
- New welcome page bundle: `src/welcome/welcome.{html,ts,css}`.
Vanilla DOM per project style; single `'Начать запись'` button
that sends `REQUEST_PERMISSIONS` (the install-time click counts
as user gesture for `getDisplayMedia`).
- `manifest.json` adds `web_accessible_resources` array with entry
for `src/welcome/welcome.html` so `chrome.runtime.getURL` resolves.
`'storage'` permission already present (no change needed).
- `vite.config.ts` `rollupOptions.input` gains a `welcome:
'src/welcome/welcome.html'` entry so crxjs bundles the page.
- **Pinning contract added (Plan 01-10):**
- `tests/background/onboarding.test.ts` (3 tests: first install
creates welcome tab; subsequent install does NOT; already-completed
flag suppresses).
---
*Phase: 01-stabilize-video-pipeline* *Phase: 01-stabilize-video-pipeline*
*Context gathered: 2026-05-15* *Context gathered: 2026-05-15*
*Amended: 2026-05-16 (debug session empty-archive-port-race, Option C)* *Amended: 2026-05-16 (debug session empty-archive-port-race, Option C — D-17-port-lifecycle narrowing)*
*Amended: 2026-05-16 (Plans 01-08 / 01-09 / 01-10 charter — D-14-remux WebM remux supersedes D-13 file-concat; D-15-display-surface whole-desktop + cursor; D-16-toolbar toolbar + badge + notifications; D-17-onboarding welcome tab)*