---
slug: 01-09-notification-start-no-active-tab
status: awaiting_human_verify
goal: find_and_fix
trigger: "Operator UAT 2026-05-20: clicking onStartup notification ('Mokosh ready. Click to start a recording.') logs `Failed to start video capture: Error: No active tab found` and `notification-triggered start failed: Error: No active tab found` from the SW chunk (La function). Notification path silently fails; toolbar path unaffected."
created: 2026-05-20
updated: 2026-05-20
phase: 01-stabilize-video-pipeline
plan: 01-09
orchestrator_diagnosed: true
red_test: tests/background/start-video-capture-no-tab.test.ts
---

# Debug session 01-09-notification-start-no-active-tab — startVideoCapture must not depend on an active tab (D-01 cleanup gap)

## Problem statement

After Plan 01-10 + the 2026-05-20 commit `4bba679` split the onStartup
notification CTA text to "Mokosh ready. Click to start a recording.",
operator UAT exercised the notification click path for the first time and
observed silent failure: the SW devtools console logged

```
[SW:Main] 2026-05-20T08:56:43.336Z Failed to start video capture: Error: No active tab found
    at La (index.ts-CKI4Evvn.js:17:95466)
[SW:Main] 2026-05-20T08:56:43.336Z notification-triggered start failed: Error: No active tab found
    at La (index.ts-CKI4Evvn.js:17:95466)
```

Badge stayed IDLE; no Chrome "Sharing your screen" banner; no recording
began. Toolbar click path remained functional.

## Root cause

`src/background/index.ts:514-521` (pre-fix) inside `startVideoCapture()`:

```typescript
// Получаем активную вкладку
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });

if (!tab.id || !tab.url) {
  throw new Error('No active tab found');
}

logger.log(`Starting video capture for tab ${tab.id}: ${tab.url}`);
```

This block was load-bearing in the pre-D-01 `chrome.tabCapture` era —
the capture stream-id was bound to the active tab. Since Plan 01-09's
D-01 conversion (`chrome.tabCapture` → `getDisplayMedia` whole-desktop in
offscreen), the tab reference has been functionally dead: every line
after 521 (`ensureOffscreen()`, `await offscreenReady`,
`chrome.runtime.sendMessage({ type: 'START_RECORDING' })`, `isRecording =
true`, `setRecordingMode()`, error handling) makes no reference to `tab`
or to any tab property.

The two callers diverge because of the chrome permission model:

* `chrome.action.onClicked` (toolbar) — Chrome grants `activeTab` on
  the click gesture; `tab.url` is readable; the validation at line 517
  passes; the dead log line executes; the function proceeds normally.
* `chrome.notifications.onClicked` — NOT a user gesture toward a tab.
  `activeTab` is not granted, and the extension manifest declares no
  `tabs` permission (per `.planning/intel/01-11-SUMMARY.md` follow-up
  backlog: "tabs permission gap"). `chrome.tabs.query({ active: true,
  currentWindow: true })` resolves to a tab whose `url` is undefined
  (or `[]` depending on Chrome version + window state); `!tab.url`
  evaluates true; the function throws "No active tab found" before
  `ensureOffscreen()` is reached.

The bug was latent because Plan 01-09's smoke validation only
exercised toolbar.onClicked. The new Plan 01-10 + debug-2-of-3
notification CTA text (commit `4bba679`) explicitly invites operators
down the previously-untested notification.onClicked path. Operators
hit this on every browser-restart-after-install.

## Fix

Surgical removal of the dead tab query + validation + tab-dependent
log from `startVideoCapture` (`src/background/index.ts:514-521`).
Replace with a tab-independent log message that documents WHY the
removal happened (D-01 cleanup gap + this debug session).

```typescript
// Plan 01-09 D-01 cleanup gap (debug session
// `01-09-notification-start-no-active-tab`, 2026-05-20):
// The legacy chrome.tabs.query({ active: true, currentWindow: true })
// here was load-bearing in the pre-D-01 chrome.tabCapture era but
// is functionally dead post-D-01 — capture is whole-desktop via
// getDisplayMedia in offscreen and the SW-side start path needs
// no tab reference. The query also failed for chrome.notifications
// .onClicked callers (no activeTab grant + no `tabs` permission →
// tab.url undefined → "No active tab found" throw) so the onStartup
// notification CTA was silently broken. captureScreenshot +
// saveArchive retain their own genuine tab queries (tab.windowId
// for captureVisibleTab, tab.id for content-script sendMessage).
logger.log('Starting video capture (whole-desktop via getDisplayMedia in offscreen per D-01)');
```

### Out of scope (NOT touched — genuine tab dependencies)

* `captureScreenshot()` `src/background/index.ts:568-591` — passes
  `tab.windowId` to `chrome.tabs.captureVisibleTab(tab.windowId, ...)`.
  Genuine dependency on the active tab's window.
* `saveArchive()` `src/background/index.ts:741+` — uses `tab.id` for
  `chrome.tabs.sendMessage(tab.id, ...)` to query the rrweb content
  script for events, and uses the tab's window for the embedded
  screenshot. Genuine dependency.

These remain because (a) they only fire under operator gestures that
DO grant activeTab (toolbar click → popup → SAVE), and (b) they have
real downstream API consumers of tab fields. A separate Phase 5
"adopt `tabs` permission" decision is the appropriate forum for
hardening; not in scope for this surgical bug fix.

## TDD evidence

### RED test (new file)

`tests/background/start-video-capture-no-tab.test.ts` — 3 tests:

* **A:** `tabs.query` mocked to `[]` (no-activeTab notifications.onClicked
  context) → assert `chrome.runtime.sendMessage({type:'START_RECORDING'})`
  was dispatched (was failing pre-fix with "expected 0 to be greater than
  or equal to 1" — startVideoCapture threw before reaching sendMessage).
* **B:** `tabs.query` mocked to `[{id: 1}]` (url-less tab) → same
  assertion. Was failing pre-fix for the identical reason.
* **C:** REGRESSION GUARD — `tabs.query` mocked to a fully-populated
  tab (action.onClicked / activeTab-granted context) → same assertion.
  Was passing pre-fix; MUST stay passing after the fix so we don't
  over-trim and break the toolbar path.

Pre-fix run:
```
 Test Files  1 failed (1)
      Tests  2 failed | 1 passed (3)
```
Post-fix run:
```
 Test Files  1 passed (1)
      Tests  3 passed (3)
```

### Acceptance gates (all GREEN)

| Gate | Pre-fix baseline | Post-fix | Notes |
|------|------------------|----------|-------|
| `npm test` | 150/150 | **153/153** | +3 from new test file; 27 → 28 test files |
| `npm run test:uat` | 24/24 | **24/24** | No UAT changes; harness unaffected |
| `npx tsc --noEmit` | exit 0 | **exit 0** | No type-system surface change |
| `npm run build` | clean | **clean** | Built in 23.36s |

Note: A single ffprobe-driven test (`tests/background/webm-remux.test.ts
> ffprobe -count_frames`) showed an intermittent 5000ms timeout in one
of the multi-suite runs but passed in isolation (5/5) and on the second
full-suite run (153/153). This is a pre-existing flake under load
(ffprobe child-process spawn time), entirely unrelated to this fix —
no source files this fix touches are consumed by webm-remux tests.

### Pre-checkpoint bundle gates (per saved memory `feedback-pre-checkpoint-bundle-gates.md`)

Inspected the production SW chunk `dist/assets/index.ts-DBpA3-1k.js`
(referenced by `dist/service-worker-loader.js`):

* **Hook-string Tier-1 grep:** 0 matches (TEST_HOOK / MOKOSH_TEST_HOOK
  / `__test__` / `window.mokosh` / `globalThis.mokosh` etc).
* **SW CSP safety (eval / new Function):** 1 match — vendor library
  internal templater (rrweb-adjacent), guarded by `typeof` checks;
  pre-existing in baseline build; unrelated to this fix (no new
  vendor imports).
* **Node-globals (require / process / Buffer / __dirname / global):** 2
  matches — all inside vendor library `ArrayBuffer`/`Buffer` runtime
  feature-detect paths; pre-existing; not introduced by this fix.
* **DOM-globals (window / document / localStorage / alert / navigator):**
  3 matches — all inside vendor library `typeof window<"u"&&...`
  feature-detect patterns (firebug-shim, ArrayBuffer detect, JSDOM
  detect); evaluate false in SW context at runtime; pre-existing;
  not introduced by this fix.
* **Manifest validation:** unchanged — same permissions, same
  service_worker entry, same web_accessible_resources.

The diff is purely subtractive in the SW source (8 lines removed, 14
lines of code-comment + 1 new logger.log line added; no new imports,
no new APIs, no new vendor dependencies). It cannot have introduced
any of the pre-existing vendor matches.

## Why this was latent until 2026-05-20

* D-01 (`chrome.tabCapture` → `getDisplayMedia` in offscreen) shipped
  in Plan 01-09; the tab dependency was orphaned at that point.
* Plan 01-09 smoke validation only exercised the toolbar
  `action.onClicked` path, which grants activeTab and made the dead
  code transparent.
* The original `notifStartup` CTA copy wording was conservative
  ("Mokosh is ready"), not action-inviting; operators tended to
  dismiss the notification rather than click it.
* Commit `4bba679` (2026-05-20) split `notifStartup` into
  `notifStartupCta` ("Mokosh ready. Click to start a recording.")
  + `notifRecordingStarted`. The new CTA copy explicitly invites
  the click — operators began exercising the path on browser
  restarts. The latent bug surfaced immediately.

## Files modified

* `src/background/index.ts` — startVideoCapture: removed lines
  514-521 dead tab query + validation + tab-dependent log; replaced
  with a multi-line WHY comment + a tab-independent
  `logger.log('Starting video capture (whole-desktop via
  getDisplayMedia in offscreen per D-01)')`.
* `tests/background/start-video-capture-no-tab.test.ts` (NEW) —
  3 tests pinning the new contract.

## Forward-looking notes

* `captureScreenshot()` and `saveArchive()` still query the active
  tab; this is correct because they are reached only through gestures
  that grant activeTab (toolbar → popup → SAVE button). Do NOT
  generalise this fix to those functions — they have real downstream
  consumers of tab fields.
* The "add `tabs` permission to manifest" question is a separate
  scope-expansion decision (Phase 5 hardening candidate). It would
  let notifications.onClicked + future no-gesture paths read tab
  metadata, but it widens the permission surface. Not required for
  the current bug because the start-recording path doesn't need tab
  data.
* Defensive UX feedback (e.g., showing an error notification when
  startVideoCapture fails) is out of scope here. After this fix
  the failure mode that motivated such UX is gone; if a future
  failure path emerges, address it then.

## Operator UAT closure

Orchestrator re-spawns the operator UAT post-merge to confirm:

1. Install built extension to fresh Chrome profile.
2. Restart Chrome → `onStartup` fires → mokosh-startup-* notification
   appears with CTA "Mokosh ready. Click to start a recording."
3. Click the notification → badge transitions to REC → Chrome shows
   "Sharing your screen" banner → SW devtools console shows
   `Starting video capture (whole-desktop via getDisplayMedia in
   offscreen per D-01)` + `START_RECORDING sent successfully` (NO
   "No active tab found" anywhere).
4. Toolbar click while not recording still starts a new session
   (regression guard against over-trimming).
