From 737c1bde6f67d114acfa4a22fc1fe24d71e36a58 Mon Sep 17 00:00:00 2001 From: scotej <134114466+scotej@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:33:33 +1000 Subject: [PATCH 1/6] fix(ai): start the sample loop on a fresh launch, and let the report say when AI didn't run (I79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #92 screenshotted a Windows session report with "Focused-time —", "No focus score was recorded", no ai_* timeline rows — and "No distractions detected. Nice work." right beside all of it. Root cause: useModelStore is never hydrated outside Settings → AI. hydrate() had exactly one caller, ModelPickerContainer's mount effect, and that component lives only in the Settings → AI pane. useSettingsStore is hydrated at boot by ThemeProvider, so aiFeaturesEnabled was correctly true while activeModelId sat at its null initial value — and activeModelId gates everything: SessionView's loop effect returns early on !activeModelId (so startSampleLoop never runs and its no_active_model toast can never fire), and Home's topic-submit skips the V2-P9 gesture-context screen pre-acquire on the same condition, which on WebView2 is separately fatal. Any launch without a Settings → AI visit ran a whole session with AI silently dead. Cross-platform, present at HEAD; also explains #94. Fixes, in the order a session hits them: - hydrate the model store in Home's boot effect - pre-acquire the screen stream from every real gesture that (re)boots the loop: topic submit, Rejoin, camera/mic "Try again"; a store mid-hydration counts as "maybe active" - toast once per session when AI is on, the store is ready, and no model is active — the gap onStartFail could never cover - onStalled: one notice per loop lifetime after 3 consecutive unproductive ticks, with a distinct reason and actionable copy per cause. Every path it covers was console.warn-only, and release builds have no devtools. Paused states are not stalls. - derive the per-tick timeout from the model's benchmarked p95 (3x, floored at 90s, capped at benchmark.ts's 300s). A model could benchmark successfully — the only thing that sets activeModelId — and then abort every live tick. - bound the screen acquire at 120s so an unanswered picker is a retryable error, not a permanent boot() wedge that also strands the sidecar - map InvalidStateError/InvalidAccessError to screen_capture_denied, whose overlay retry is itself the missing user gesture - migration 004 records sessions.ai_enabled, and aiCoverage() gives the report four honest states instead of one shrug: ran (keeps "Nice work"), noChecks (names the malfunction), off, unknown (pre-004). Shared with the text export. - Rust: resolve_runtime_dir falls back to the binary's own directory rather than None (None meant no CWD and no PATH prepend — the state I75 fixed), and the crash-loop give-up path carries the VC++ redist hint, which a child dying in the Windows loader never reached. Co-Authored-By: Claude Opus 5 --- ISSUES.md | 161 +++++++------- src-tauri/src/commands/sessions.rs | 5 + src-tauri/src/commands/sidecar.rs | 26 ++- src-tauri/src/db/migrations.rs | 55 ++++- .../src/db/migrations/004_ai_enabled.sql | 16 ++ src-tauri/src/db/migrations/MANIFEST.sha256 | 1 + src-tauri/src/db/sessions.rs | 24 ++- src/features/ai/captureScreen.ts | 13 ++ src/features/ai/focusStore.ts | 8 + src/features/ai/index.ts | 6 + src/features/ai/sampleLoop.ts | 168 ++++++++++++++- src/features/session/Report.tsx | 28 ++- src/features/session/SessionView.tsx | 52 ++++- src/features/session/lifecycle.ts | 1 + src/features/session/reportData.ts | 37 ++++ src/features/session/reportSerialize.ts | 29 ++- src/lib/db/sessions.ts | 7 + src/routes/Home.tsx | 40 +++- src/stories/Dashboard.stories.tsx | 1 + src/stories/FocusInsights.stories.tsx | 1 + src/stories/Report.stories.tsx | 62 +++++- src/strings.ts | 28 +++ tests/unit/ai-focus-store.test.ts | 38 ++++ tests/unit/ai-sample-loop.test.ts | 203 ++++++++++++++++++ tests/unit/file-export.test.ts | 1 + tests/unit/report-data.test.ts | 57 +++++ tests/unit/report-serialize.test.ts | 51 +++++ tests/unit/stats-data.test.ts | 1 + tests/unit/stats-insights.test.ts | 1 + 29 files changed, 1013 insertions(+), 108 deletions(-) create mode 100644 src-tauri/src/db/migrations/004_ai_enabled.sql diff --git a/ISSUES.md b/ISSUES.md index 429a2f59..8da366e0 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -10,83 +10,84 @@ Round 1 (`audit/sev1-sev2-fixes`, PR #29): every Sev1/Sev2 fixed. Round 2 (`audi **Improvement wave 3 (post-v1.6.0, `feat/improvements-wave3`).** A 12-subsystem multi-agent survey with per-finding adversarial verification, then a multi-lens review of the branch (the three low-severity findings it confirmed were fixed on-branch). Rows I51+ record the confirmed fixes; each shipped as its own commit with tests where the harness allows. Test-only and doc-only outcomes (rendezvous-derivation vectors, signed-hello gate coverage, inbox replay coverage, and the DESIGN-SYSTEM §4 / ARCHITECTURE §11–§12 / README true-ups) are not ledgered separately. -| ID | Sev | Location | Evidence | Status | -| --- | ---- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| I1 | Sev1 | `src/features/session/pomodoro.ts` | `stop()` sent no wire signal; receivers' 10 s silence timer resurrected the timer under a new broadcaster ~10 s after Stop. | **fixed** (R1) — explicit `stopped:true` message; receivers reset to idle. ARCHITECTURE §7 updated. | -| I2 | Sev2 | `src/features/friends/presence.ts` | Online state compared sender wall clock to receiver's; backward sender clock step wedged presence permanently. | **fixed** (R1) — stamp receiver-local time on receive. | -| I3 | Sev2 | `src/features/session/lifecycle.ts` + `sessionStore.ts` | Everyone-else-leaves auto-end lost `sessions.peer_pubkeys` + `markStudied` because `peerLeft` pruned `peers` first. | **fixed** (R1) — cumulative `seenPeerEdPubkeys` set. | -| I4 | Sev2 | `src/features/ai/benchmark.ts` | p95 included the cold-start warmup sample, inflating the sample floor 5–10× with no user recourse. | **fixed** (R1) — run + discard one warmup sample. | -| I5 | Sev2 | `src-tauri/src/commands/models.rs` | Resume fast-path hashed a multi-GB GGUF synchronously on the async runtime, stalling concurrent IPC. | **fixed** (R1) — moved to `spawn_blocking`. | -| I6 | Sev3 | `src/features/ai/sampleLoop.ts` | Battery-pause branch omitted the §8 "thermal-aware notice" and rescheduled at the sample interval, not 60 s. | **fixed** (R2) — `onBatteryPause`/`onBatteryResume` callbacks (fire once each) wired to SessionView toasts; paused branch now reschedules at `BATTERY_POLL_INTERVAL_MS`. Regression test added. | -| I7 | Sev4 | `src/features/session/invite.ts` | Auditor flagged idle-invite `hostSession()` as bypassing the topic gate. | **not a bug** — `Home.tsx` enforces `TopicGateModal` (sets `pendingInitialTopic`) before `inviteToCurrentSession`; no other caller. No change. | -| I8 | Sev3 | `src/features/session/SessionView.tsx` | Audit receive did not check `session_topic` (the ai-alert path does). | **fixed** (R2) — added `verified.session_topic !== sessionTopic` drop, mirroring `aiAlerts.ts`. | -| I9 | Sev3 | `src/features/session/pomodoro.ts` | Any peer sending a valid signed `pomodoro` msg is accepted as broadcaster, even mid-broadcast by another. | **deferred — conflicts with canonical doc.** ARCHITECTURE §14 explicitly: "Friend disables their own AI / fakes score — **Not defended. Social trust. Accepted.**" The "most recent sender becomes broadcaster" behavior is a deliberate, code-documented reconnection-robustness choice; hardening it would silently deviate from the accepted friends-only threat model and risk regressing the documented original-broadcaster-returns path. Surfaced per house rule; user can override to request the hardening explicitly. | -| I10 | Sev3 | `ARCHITECTURE.md §7` | `score_final` wire type has no producer/consumer. | **fixed (doc)** (R2) — §7 annotated: `score_final` is reserved/not-implemented in V2; the report is local-SQLite by V2-P8 design; type kept so a future phase avoids a breaking wire change. Not removed (removal would be a forward-compat break). | -| I11 | Sev3 | `src/features/ai/sampleLoop.ts` | Declared topic interpolated into the focus prompt without injection delimiters. | **fixed** (R2) — topic wrapped in `` + labelled as data; system-prompt rule added; `FOCUS_SYSTEM_PROMPT_VERSION` → 2; `tests/ai-eval/run.ts` kept byte-identical; ARCHITECTURE §8 prompt updated. | -| I12 | Sev3 | `src/features/ai/aiAgent.ts` | Total JSON-parse failure echoed ≤200 chars of raw model output into the dialog. | **fixed** (R2) — fixed safe string to the user; raw logged to console only. Test updated. | -| I13 | Sev3 | `src-tauri/capabilities/default.json` | `ai-dialog` window granted `notification`/`store`; §12 says permissions are main-window-scoped. | **fixed** (R2) — `default.json` restricted to `["main"]`; new `ai-dialog.json` capability scoped to the dialog window with `core:default` only (it uses only core event/window IPC). | -| I14 | Sev3 | `src-tauri/src/db/migrations.rs` + `001_initial.sql` | Bare `CREATE TABLE` + no single-instance ⇒ two simultaneous first-launches could panic the second. | **fixed** (R2) — `IMMEDIATE` transaction with the version read moved inside the tx (locks before reading); `IF NOT EXISTS` on 001's DDL; `INSERT OR IGNORE` on `schema_version`. Sequential-upgrade tests preserved. | -| I15 | Sev3 | `src/stores/identityStore.ts` / `identity.rs` | Identity commit (keychain then file) had no rollback; a failed file write + re-onboard overwrites the keychain entry. | **mitigated** (R2) — the file write is now atomic (see I16); residual is now only "rename succeeded but the keychain `set` itself fails", an OS-keychain fault recoverable via BIP39 (PLAN §7). Full two-store transactionality is out of scope for a Sev3. | -| I16 | Sev3 | `src-tauri/src/commands/identity.rs` | `fs::write` non-atomic; a crash mid-write truncates `identity.json`. | **fixed** (R2) — write to `*.json.tmp` then `fs::rename` over the target (atomic on same FS); temp cleaned on rename failure. | -| I17 | Sev3 | `src-tauri/src/db/sessions.rs` | `started_at`/`ended_at`/`total_minutes` overwritten while the comment claimed additive upserts. | **fixed (comment)** (R2) — comment rewritten to state these three are deliberately authoritative-overwrite (a re-summarize must be able to correct them; COALESCE would swallow it) while the report columns are additive. No behavior change, by design. | -| I18 | Sev4 | `pair.ts` / `lib/trystero/index.ts` / `sidecar.rs` | `verifyHello` didn't reject self-pubkey; stale `selfId` comment; `sidecar_start` trusts JS `model_path`. | **partially fixed** (R2). `verifyHello` now rejects a hello whose `ed_pubkey` equals the local identity (passed `ctx.edPubHex` from `runPair`). `trystero/index.ts` comment corrected to describe the actual module-global-`selfId` mechanism. The `sidecar_start` model-path sandbox is **deferred — conflicts with canonical doc**: PLAN §5 explicitly promises "Advanced users can point at any local GGUF", so constraining `model_path` to `data_dir/models` would break a documented feature. Surfaced per house rule. | -| I19 | Sev4 | `package.json` devDependencies | `npm audit` flags ~20 dev-chain advisories across critical/high/moderate (criticals: `concurrently@9` → `shell-quote`; highs/moderates span the `@storybook/*` and `esbuild`/`tsx` chains). Re-flagged on every scan. | **triaged — no runtime exposure** (2026-06-13). Every one is a **devDependency**; none reaches the installed desktop app — `npm audit --omit=dev` is clean (0) and no advisory package appears in `dependencies`. Bump `concurrently` and the `@storybook/*` chain when convenient; do **not** rush a major Storybook upgrade for a dev-only advisory. Recorded so the scan result isn't re-investigated each time. (Exact counts shift with the lockfile; the load-bearing fact is the clean prod audit.) | -| I20 | Sev1 | `src-tauri/src/db/mod.rs` | `is_definitely_corrupt` only treated a non-"ok" `integrity_check` verdict as corruption; a truncated file (SQLITE_CORRUPT) or damaged header (SQLITE_NOTADB) makes the pragma ERROR, so recovery never fired and the app bricked on every launch after a power-loss/force-kill. | **fixed** — classify by SQLite error code; also clean up `-journal`/`-wal`/`-shm` on rename. Corruption-signature tests added. | -| I21 | Sev2 | `src-tauri/capabilities/ai-dialog.json` | The scoped ai-dialog capability lacked `core:window:allow-close`, so the floating AI dialog's Esc/blur/X all silently failed (regression from the I13 scope-down). | **fixed** — grant `core:window:allow-close`. | -| I22 | Sev2 | `src/features/session/reportData.ts` + `stats/statsInsights.ts` | Report topic-timeline / top-distractions and cross-session insights walked every audit event; peers' broadcast `topic_set`/`ai_alert` (persisted locally) were misattributed to the local user. | **fixed** — thread the local ed_pubkey and filter to it (matches the self-only score gauge / trend). | -| I23 | Sev3 | `src/features/ai/sampleLoop.ts` | `onScreenTrackEnded` latched `captureDenied` and tore down ALL AI capture when ANY screen track ended; unplugging a secondary display in "All displays" mode killed focus detection with a misleading permission overlay. | **fixed** — discriminate: drop the dead display, latch only when the last live one ends. | -| I24 | Sev2 | `src-tauri/src/commands/models.rs` | No read/idle timeout on downloads; a mid-stream stall hung `bytes_stream().next()` forever, freezing the UI and permanently locking the `model_id`. | **fixed** — 60s `read_timeout`. | -| I25 | Sev2 | `src-tauri/src/commands/system.rs` | `system_relaunch_app`'s `app.restart()` skips `RunEvent::Exit` (the only sidecar kill), orphaning a running llama-server on Window-Style relaunch. | **fixed** — `kill_blocking` before restart. | -| I26 | Sev2 | `src-tauri/src/lib.rs` | A boot-time global-shortcut OS conflict propagated out of `setup()` into `build().expect()`, panicking before first paint. | **fixed** — register best-effort; a failed binding is inert until rebound in Settings. | -| I27 | Sev3 | `src/features/friends/invite.ts` + `inviteRetry.ts` | An invite retry queued after the 15s send timeout escaped `cancelAll` if the session ended during the window, later pulling the friend into a dead room. | **fixed** — injected `isSessionLive` guard: a retry never fires for a session that isn't the host's current live one. | -| I28 | Sev3 | `src/lib/fileExport.ts` | CSV export didn't neutralize spreadsheet formula injection; a peer-chosen display name beginning with `= + - @` executed on open (=HYPERLINK exfil / DDE). | **fixed** — quote-prefix string cells starting with a trigger; numeric cells untouched. | -| I29 | Sev2 | `src/features/friends/AddFriendDialog.tsx` | A programmatic close (contact-card deep link) during an in-flight legacy pairing never aborted it — Radix doesn't fire `onOpenChange` on a parent-driven close — leaking the trystero room + relay sockets. | **fixed** — tear down on any `open` transition. | -| I30 | Sev3 | `src/features/friends/inbox.ts` | No replay/dedup on the inbox receive path; a stranger on the pubkey-derived inbox topic could re-broadcast a captured envelope to re-fire the invite toast/notification. | **fixed** — dedup on `(from_ed_pubkey, box nonce)`, TTL-bounded. §14 row added. | -| I31 | Sev3 | `src/features/friends/AddFriendDialog.tsx` + `lib/relayDiagnostics.ts` | Pairing's "network trouble" hint read only the Nostr socket map, so it wrongly blamed the user's network in the exact MQTT-fallback case the v1.2.2 race was built to survive. | **fixed** — transport-aware `pairingRelaysUnreachable()` judging both socket maps; Nostr-only signal kept for the invite path. | -| I32 | Sev3 | `src/routes/Home.tsx` | `PairDeepLinkBoot` rendered only in the non-session tail, so a `studyvis://` link clicked mid-session reached zero listeners and was dropped. | **fixed** — render the full tail in the active-session branch. | -| I33 | Sev3 | `src/strings.ts` | In-app AI copy promised screen access is requested "when you start your first session", but enabling AI requests it immediately. | **fixed (copy)** — reword to match the shipped enable-time prompt. | -| I34 | Sev3 | `src-tauri/src/lib.rs` | With minimize-to-tray off, closing the main window while the AI dialog was open stranded the app: process alive, main window gone, tray "Open" a no-op. | **fixed** — destroy the AI dialog on that close path so the runtime exits. | -| I35 | Sev3 | `src-tauri/src/commands/sidecar.rs` | The crash-restart watcher could spawn a fresh llama-server after `kill_blocking` already ran at quit (during the backoff window), orphaning it. | **fixed** — `shutting_down` flag re-checked after backoff, before respawn. | -| I36 | Sev3 | `src/design/theme.tsx` | `ThemeProvider` wrote the pre-hydration fallback `dark` class, stripping the boot-cache `light` class and flashing dark for light/auto users. | **fixed** — defer to the boot script until an authoritative mode exists. | -| I37 | Sev4 | `src/features/friends/pair.ts` | The legacy pairing hello's (unsigned) `display_name` was stored/rendered raw, unlike the ContactCard path's cap + bidi/zero-width sanitize. | **fixed** — shared `normalizeUntrustedName` applied on both paths. | -| I38 | Sev3 | `src/features/ai/sidecar.ts` | `useSidecarStore.start()` unconditionally set `running` after its await, clobbering an interleaved `stop()` and leaving the store `running` on a killed process. | **fixed** — bail if a stop intervened. | -| I39 | Sev3 | `src/App.tsx` + `src/components/ErrorBoundary.tsx` | No React error boundary anywhere; any render throw blanked the whole window and killed the always-on inbox/presence + live session. | **fixed** — top-level `ErrorBoundary` around the routed content with a calm "Try again". | -| I40 | Sev4 | `src-tauri/src/commands/system.rs` | Changing one PTT shortcut when both shared a combo (hand-edited settings.json) unregistered the other. | **fixed** — only unregister the old combo when the other action isn't still using it. | -| I41 | Sev4 | `src/lib/encoding.ts` | `hexToBytes` used `parseInt` per byte, silently mis-decoding malformed hex ('1g'→0x01, '-a'→wraps) instead of rejecting. | **fixed** — validate the whole string against `/^[0-9a-fA-F]*$/` first. Adversarial-input tests added. | -| I42 | Sev3 | `src/features/session/SessionView.tsx` | The local session camera/mic stream had no `ended` listener, so a mid-session device loss (unplug / OS-revoke / another app grabbing the camera) left peers on a frozen tile and silently killed the AI face path. | **fixed** — attach an `ended` listener that surfaces the existing "Try again" recovery banner. | -| I43 | Sev3 | `.github/workflows/ci.yml` | CI compiled only aarch64-apple-darwin, so `#[cfg(target_os="windows")]` code first built inside `release.yml` AFTER the tag was pushed. | **fixed** — macOS + Windows Rust matrix on every push/PR. | -| I44 | Sev3 | `.github/workflows/release-prep.yml` | The one-click gate skipped `check-a11y` and all Rust compilation, so a release could be cut over an axe-core / clippy regression. | **fixed** — add the a11y gate; require the exact main SHA's CI run to be green before bump/tag/push. | -| I45 | Sev3 | `README.md` / `PLAN.md` / `ARCHITECTURE.md` / `CHANGELOG.md` | User-facing doc drift: first-run described the retired 12-word flow as primary; "one WebSocket" (really ~8 relays); "three tiers" (four models); "v1.2.0 is current" (v1.3.1); §6 "MQTT not yet wired" (raced since v1.2.2); changelog x86_64 DMG claim (aarch64-only). | **fixed** — brought each in line with the shipped code. | -| I46 | Sev3 | `src/features/friends/invite.ts` | `sendInviteEnvelope` treats any peer joining the recipient's inbox topic as delivery, so an eavesdropper on that shared pubkey-derived topic can appear as "delivered" or drop the invite. | **accepted — friends-only threat model.** Envelope is still NaCl-box-sealed to the recipient; worst case is a suppressed offline-retry (re-click Invite). Documented in §14. The flagged signed invite-ACK shipped in #47 C2 (new `invite-ack` action, v1.2.x-wire-compatible: no ACK within the window → honest "unconfirmed" copy) — for UX legibility, not as a defense; the eavesdropper acceptance above stands. | -| I47 | Sev3 | `src/features/friends/presence.ts` | Presence heartbeats/goodbyes are unauthenticated on a pubkey-derived topic, so a stranger with a friend's public pubkey can forge that friend's online/offline state. | **accepted — friends-only threat model.** Presence is soft UX state, not a data/session compromise. Signing would break cross-version presence (older peers send unsigned), so enforcement is deferred, not shipped. Documented in §14. | -| I48 | Sev3 | `src/features/friends/pair.ts` (upstream `@trystero-p2p/mqtt`) | Each pairing's MQTT room open→leave orphans ~4 broker connections: trystero-core sets `didInit=false` on last-room-leave but never `.end()`s the MQTT clients. | **deferred — upstream trystero bug.** Bounded (a handful of pairings per session, cleared on process exit) under the friends-only 4-peer model. Fix is upstream (or an app-side always-on MQTT room, which trades the leak for a persistent idle broker connection — not worth it). | -| I49 | Sev3 | `src/features/friends/InboxBoot.tsx` + `presence.ts` | The presence effect keys on the whole friend set, so adding/removing any friend tears down + rebuilds the own presence room, broadcasting a goodbye that flickers your presence offline→online on every other friend's screen (and can fire a spurious "came online" notification). | **fixed** (#47 C6, the recorded dedicated pass) — `startPresence` gained `updateFriends`: friend list edits diff rooms in place (join added / leave removed), the own room and heartbeat cadence never churn, and `leave()`'s tested goodbye semantics are untouched. InboxBoot keys the subscription on identity only and drives list edits through the diff; removed friends' notify baselines are pruned so a re-add starts fresh. Unit tests cover added/removed/no-op churn including a watcher asserting no goodbye flicker. | -| I50 | Sev4 | `src-tauri/tauri.conf.json` | Both webview windows ship with CSP disabled (defense-in-depth only — no reachable XSS sink today: React auto-escapes, no `innerHTML`/`eval`). | **deferred — needs a desktop CSP smoke-test.** A wrong CSP hard-breaks Tauri IPC/asset loading, which no static gate catches; landing a `script-src 'self'` policy safely requires running the built desktop app (not possible headless). Recommended policy: `default-src 'self'; script-src 'self'; object-src 'none'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' ws: wss: http://127.0.0.1:*`. | -| I51 | Sev2 | `src/routes/Home.tsx` | The `tail` fragment (InboxBoot + deep-link + import dialog + topic gate) rendered at a different unkeyed child index per view branch, so React reconciled by index and re-mounted the always-on presence/inbox room on every view switch — re-triggering the I49 goodbye flicker, blanking the friends list for up to a heartbeat, and dropping an invite that arrived in the teardown window. | **fixed** — `` pins the tail fiber across branches of differing child arity. The load-bearing key is documented at the site; `pairDeepLink.ts`'s stale "view switches re-mount the boot" comment corrected (the `launchConsumed` guard kept). Not statically checkable and not node-testable without RTL, so protected by the site comment. | -| I52 | Sev2 | `src/features/session/lifecycle.ts` + `stores/sessionStore.ts` | `total_minutes` was pure wall-clock `endedAt − startedAt`, counting OS-sleep/suspend as study time; a session slept on persisted the whole span (a free streak day and inflated totals). | **fixed** — elapsed is `min(wallMs, monoMs)` off a `performance.now()` origin captured at start, mirrored in the live footer. Not retroactive (old rows stand); degrades to prior behavior on a platform whose monotonic clock happens to include suspend, never undercounts. Unit-tested via an injectable `monotonicNow` seam (awake / slept-through / backward wall clock / no-mono fallback / slept-through rejoin). | -| I53 | Sev3 | `src/features/session/lifecycle.ts` + `SessionView.tsx` | A peer's deliberate `left` (signed, on the wire since V1-P9) still armed the 20 s reconnect grace and offered a Rejoin into a dead room. | **fixed** — mark departed peers, and skip the grace/Rejoin only when the room empties with no unexplained absence remaining, via a new `SessionEndReason` (`'peer'`). Unexplained-absent peers are tracked in a Set (not a single flag, per the review) so an intervening join by another peer can't strand a still-absent blipper; the mark clears per-peer on rejoin so a later blip still gets grace. ARCHITECTURE §13 updated. Grace unit tests extended. | -| I54 | Sev3 | `src/features/friends/InboxBoot.tsx` + `friendOnlineNotify.ts` | The friend-online baseline suppressed every friend's _first_ online resolution after mount (not just boot's initial sweep), so a genuine later arrival never notified — the one event the feature exists for. | **fixed** — per-friend watch-start map with a settle bound. The bound is a dedicated `NOTIFY_SETTLE_MS` (3 min, sized above realistic presence-handshake latency), not the 60 s heartbeat window: reusing the latter let a slow-connecting already-online friend re-read as an arrival (review finding). Only the settle window is suppressed. Unit-tested. | -| I55 | Sev3 | `src/features/session/hello.ts` | The signed session-hello `display_name` was stored/rendered without the cap + bidi/zero-width sanitize every other untrusted-name path applies; on `main` it was unbounded. | **fixed** — `normalizeUntrustedName(name, HELLO_NAME_CAP)`. Cap is 192 UTF-8 bytes — the worst case for the 64-UTF-16-unit `maxLength` our own inputs enforce — so a legitimate multibyte name (CJK/emoji) survives intact rather than being byte-truncated (review finding), while a hand-modified sender is still bounded. Unit-tested incl. multibyte + bidi. | -| I56 | Sev3 | `src/features/ai/sampleLoop.ts` | `onCaptureError` fired per tick (contract says once/lifetime) and the face-track guard never checked `readyState`, so a dead webcam threw `track_ended` every tick and toast-stormed the session over the MediaErrorBanner already saying the same thing. | **fixed** — the ended-track guard skips the tick without counting a sample; a `captureErrorReported` latch mirrors `sidecarErrorReported`, reporting once and clearing on the next successful verdict. Unit-tested. | -| I57 | Sev3 | `src/design/tokens.ts` + `src/design/index.css` | The focus ring (`accent.ring`, 40 % alpha) measured ~2.6:1 dark / ~1.8:1 light against the surfaces it is drawn on — below WCAG 1.4.11 — because the UA outline is globally reset; the gate missed it by measuring the opaque accent. `shadow.glow` had also drifted 3px/4px. | **fixed** — raised alpha (60 % dark / 80 % light), mirrored in both hand-kept files; `check-contrast` now measures the ring in the bg-stack at its real per-theme alpha; `shadow.glow` reconciled to the tokens.ts value (3px). The ring's inner edge on `bg-accent-default` buttons intentionally stays below 3:1 — the outer edge against the canvas carries identification. | -| I58 | Sev3 | `src/components/ui/dropdown-menu.tsx` | Menu items declared `focus:bg-bg-raised` on a `bg-bg-raised` surface — a 1.00:1 no-op — so keyboard/mouse navigation showed no highlight (worst in the in-session audio pickers, where two identically-named devices are indistinguishable). | **fixed** — an inset accent ring highlight (keeps `focus:` so Radix pointer-move still lights it). The byte-identical Button/Badge `secondary` hover was fixed the same way (`hover:bg-bg-surface`). | -| I59 | Sev3 | `src/components/AuditLogPanel.tsx` + `SessionNotesPanel.tsx` | The session-log and notes scroll containers had no focusable descendant and no `tabIndex`, so a keyboard-only user couldn't scroll them (WCAG 2.1.1). macOS/WKWebView only; Windows WebView2 auto-focuses scrollers. | **fixed** — `tabIndex={0}` + a focus-visible inset ring on both. Overflowing Storybook stories added so the axe `scrollable-region-focusable` gate has something to assert on. | -| I60 | Sev3 | `src/strings.ts` (`searchKeywords`) + `Settings.tsx` | v1.6.0 settings search routed "tray"/"minimize"/"capture displays"/"auto-update" to Advanced (which owns none of them) and left Advanced's own settings ("launch at login", "clear history", "onboarding") unfindable. | **fixed** — keywords moved to the panes that own each setting; Advanced keywords added; a `Record` guard in `Settings.tsx` pins the bucket↔pane mapping without a strings→features import cycle. | -| I61 | Sev3 | `src/stores/settingsStore.ts` + `ShortcutsCategory.tsx` | `resetShortcutsToDefaults` rethrew on the first setter's combo collision and never ran the second; the rejection was swallowed to `console.error`, so the button was a silent no-op. | **fixed** — reorder + per-call try/catch so both setters run; a residual collision surfaces a `toast.error` (copy in strings.ts). The Rust `is_registered` skip the original proposal suggested was dropped — it would re-open #47 B5. Stateful fake added to the keybindings test. | -| I62 | Sev3 | `src/features/updater/updaterStore.ts` + `AboutCategory.tsx` | Settings → About offered a live Restart-now / Check-now during a session (unguarded, unlike the update banner), and its help text asserted "you're on X, the latest" from the initial `idle` state and after a silent background-check failure. | **fixed** — session-active guards in `installAndRestart`/`checkNow` (the `userInitiated` exemption, made false by the in-session settings overlay, removed); About disables the buttons in-session and derives its help from an explicit `upToDate` branch rather than a fallthrough. Store tests flipped to assert deferral. | -| I63 | Sev3 | `src/features/identity/recoverLogic.ts` | A failed 24-word restore pointed at all 24 words equally, with no way to narrow a single typo on the highest-stakes screen in the app. | **fixed** — name the words that aren't in the wordlist (`unknownWords` on `MnemonicClass`, populated only on the 24-word path); copy in strings.ts. Kept in `recoverLogic.ts`, not the cross-version crypto module. Unit-tested. | -| I64 | Sev3 | `src/features/stats/FocusInsights.tsx` | The focus-over-time trend tooltip had no date, so a dip couldn't be anchored to a day. | **fixed** — carry each point's `startedAt`; the tooltip renders the `dayKey` day, byte-identical to the bar chart's day format. | -| I65 | Sev4 | `src/features/stats/statsData.ts` | The stats CSV omitted the two headline tiles (total sessions, streak, average) — the numbers the pane is built around. | **fixed (summary)** — prepend summary rows, preserving the null-average ("AI off" vs "scored 0") distinction. Per-session detail left out of scope. Test extended. | -| I66 | Sev3 | `src-tauri/src/commands/sidecar.rs` | `sidecar_start` spawned llama-server then opened the log file; an `open_log_file` failure after a successful spawn dropped the `CommandChild` without `kill()`, orphaning a multi-GB process past app exit (same class as I25/I35). | **fixed** — open the log before spawning, so no fallible `?` sits between the spawn and `guard.child`. Reviewed by reading (CI is the first Rust compiler on this dev box). | -| I67 | Sev3 | `src-tauri/src/commands/sidecar.rs` | The respawn budget was a 30 s sliding window, so any crash spaced >30 s reset the counter and the watcher respawned llama-server forever without ever setting `errored` — no recovery affordance surfaced and the D7 log cap was defeated. | **fixed** — the budget now counts consecutive respawns that each died before `MIN_HEALTHY_UPTIME` (120 s); a durable child resets the streak (`next_attempts` pure fn, unit-tested). Once the budget is exceeded `errored` is set as before. | -| I68 | Sev4 | `src-tauri/src/db/audit_events.rs` | The cross-session insights read shipped the entire `audit_events` table over IPC though only `ai_warning`/`ai_alert` rows are consumed. | **fixed** — `WHERE kind IN ('ai_warning','ai_alert')` narrows the query (~4× less JSON at 10k rows); `list_all` → `list_ai_distractions_all`, but the Tauri command name is unchanged so the IPC/TS contract is untouched. The SQL twin of TS `isDistraction` is commented at the query. | -| I69 | Sev3 | `src-tauri/src/lib.rs` | The corrupt-DB recovery dialog asserted re-pairing was required and never mentioned the friends-backup import — wrong at the exact moment a friend loses their list. | **fixed (copy)** — the dialog now names Settings → Identity → Import friends as the restore path if a backup exists, otherwise re-pair. | -| I70 | Sev4 | `.github/workflows/release.yml` | A half-built draft (one platform's artifact missing from `latest.json`) could be published, stranding every friend on the missing platform with no update path and a false "you're on the latest". | **fixed** — a job asserts both platforms are present in the draft's `latest.json` and, on failure, stamps the draft title "INCOMPLETE, DO NOT PUBLISH" (needs `contents: write` to read a draft). Not runnable on this box; validated by YAML parse + reading. | -| I71 | Sev2 | `src/features/updater/updaterStore.ts` + `src-tauri/src/commands/system.rs` | Issue #77: an app opened straight from the mounted `.dmg` runs under macOS App Translocation (read-only bundle), where `update.install()`'s rename-into-place can never succeed — every launch re-downloaded the installer, offered "Restart now", and failed with the generic install toast. The one documented install step (drag to Applications) is exactly the one this path skipped, and the updater had no idea. | **fixed** — new `system_install_context` command (translocation via exe-path component, read-only volume via `statfs`; fail-open) consulted after a check finds an update: an unswappable bundle sets a new process-permanent `blocked` status _before_ any bytes move, and the banner + Settings → About replace the doomed Restart with move-to-Applications guidance. Verified live: dev binary on a read-only DMG against the real v1.7.0 release showed the blocked row. Windows/NSIS unaffected (always updatable). | -| I72 | Sev1 | `src-tauri/src/commands/models.rs` | Every model download failed at the picker's preflight with "…The model manifest may be stale." for every catalog entry. `model_head_check` populated `content_length` from `reqwest::Response::content_length()`, which is the body's size hint — an HTTP/1.1 HEAD response body is always empty (hyper decodes it as zero-length regardless of headers), so every probe reported 0 bytes and the size gate rejected all six entries. The manifest itself is current: the raw `Content-Length` (and `x-linked-etag` = pinned sha256) at every pinned revision still matches. | **fixed** — read the `Content-Length` response header instead; in-module regression test against a local HEAD server; live-verified that all 10 catalog files (6 model + 4 mmproj — the three Gemma quants share one projector) report header sizes byte-identical to the manifest. Git history dates the break to the picker's birth: the size gate, the `content_length()` call, and the no-http2 reqwest dep all landed in one commit (af2987d, V2-P2) and never changed, and the zero-length HEAD decode is server-independent — so no catalog download has ever passed this preflight, and the downstream GET/verify/resume path has never run end-to-end in a shipped build (first real install is its true test). First user report 2026-07-26. | -| I73 | Sev1 | `src-tauri/src/commands/sidecar.rs` + `src-tauri/src/commands/engine.rs` | In-app llama-server spawn has never worked in any build. `shell().sidecar("binaries/llama-server")` resolves `/binaries/llama-server` (tauri-plugin-shell 2.3.5 joins the full configured string against the exe dir), but tauri-build (dev) and the bundler (release) both strip the directory prefix and the triple, placing the file at `/llama-server` — verified in `target/debug/` and in the installed `StudyVis.app/Contents/MacOS/`. Every `sidecar_start` failed with `spawn llama-server: No such file or directory`, surfaced as "AI failed to start:" / "AI model crashed". The plugin has been pinned at 2.3.5 since V1-P1, so this is a day-one bug, not a regression; it sat behind I72 (downloads never completed), which is why the first user report of both landed the same day (2026-07-26 — the on-disk `llama-server.log` from that attempt is a 0-byte file: the child never ran). | **fixed** — sidecar binaries now resolve to absolute paths and spawn via `shell().command()`: bundled probe at `/llama-server(.exe)` (size-gated), then a managed install under `data_dir/engine/-/`. When neither resolves, `sidecar_start` auto-installs the pinned llama.cpp b9095 release asset (SHA-256-verified; pins lockstep-tested against `scripts/fetch-llama-server.sh`; tar.gz/zip unpacked flattened + filtered), gated by the new `engine_auto_install` setting (default ON) with `engine_info`/`engine_install` commands and a Settings → AI "AI engine" row (status/progress/Reinstall). `build.rs` writes a debug-profile-only placeholder so fresh checkouts compile without the fetch script; release-profile builds still hard-fail. Windows spawn failures name the VC++ redistributable when `vcruntime140.dll` is absent. Verified live on macOS: the installed bundle's binary spawns via the exact fixed resolution (`--version`, Metal init, exit 0), the placeholder build compiles and launches, and the pinned archives download, hash-match, extract, and run on this machine. The in-app GUI walk (Settings row + session start) is user-walked — the dev binary's keychain prompt blocks machine-driving it. | -| I74 | Sev2 | `src/features/friends/presence.ts` + `presenceRelay.ts` + `src/lib/nostr/` | A mutually added friend showed permanently offline on BOTH ends whenever a STUN-only WebRTC datachannel could not form between the two networks (symmetric NAT / CGNAT / strict firewall — no TURN ships, ARCHITECTURE §4). Heartbeats only rode datachannels; trystero fires no callback on a failed ICE attempt (it silently re-offers forever), and offline ContactCard pairing (§5.1) removed the last step that ever proved the P2P path worked — so the failure was invisible end to end, with every relay reachable and both apps running. Presence, invites, and sessions all share the broken leg; presence was just the visible symptom. | **fixed** — relay-carried presence: sealed ephemeral Nostr events (kind 20001, new `studyvis:presence-relay:v1` tag/key derivations pinned in topics.test.ts) published every 30 s to the pinned relays over an owned reconnecting socket pool; no `since` filter and `limit: 0` (the #47 C1 clock-skew lesson). The datachannel leg stays and now stamps `lastP2pAt`, so `presenceState()` distinguishes direct-online from relay-only "limited" (120 s settle, I54 lesson) — surfaced in the friends list as an amber "Available · limited connection" row plus a one-line hint deep-linking Settings → Network (TURN). Goodbyes keep `lastSeenAt` for "seen … ago". Sessions/invites behind the same NAT still need TURN — the UI now says so instead of lying "Offline". Old builds interop unchanged (they never see this leg). ARCHITECTURE §4/§7/§11/§14 + PLAN §2 updated; `offchain.pub` dropped from the relay pin (now rejects anonymous publishes). | -| I75 | Sev1 | `src-tauri/src/commands/sidecar.rs` | After 1.8.0 shipped I73's spawn-path fix, on-device AI still failed to start on a real Windows install: `llama-server.exe` spawned, printed its banner (`Running without SSL`, `loading model`), then exited with `no backends are loaded` / `failed to load model` / `giving up after 4 restart attempts` (friend's `llama-server.log`, 2026-07-26 — the same day 1.8.0 shipped, the very next link in the same chain). Root cause: the pinned llama.cpp b9095 release assets are `GGML_BACKEND_DL` builds — 15 `ggml-cpu-*.dll` variants on Windows (haswell/zen4/sse42/…), `libggml-cpu.dylib`/`libggml-metal.dylib`/`libggml-blas.dylib` on macOS — that ggml `dlopen()`s at startup rather than linking. `ggml_backend_load_best` (`ggml/src/ggml-backend-reg.cpp`) globs exactly two places for those: the executable's own directory and the process's current working directory — never `PATH`/`DYLD_FALLBACK_LIBRARY_PATH`/`LD_LIBRARY_PATH`. I73's env-var prepend only satisfies the binary's _linked_ imports (`llama.dll`/`ggml-base.dll`/…), which is why the process starts at all; it never reaches the dlopen glob, so `ggml_backend_reg_count()` stays 0, `common_init_from_params` fails, and the crash-restart watcher gives up after `RESTART_BUDGET` (4) identical failures — on every bundled Windows and macOS install, not an edge case. Verified against the pinned llama.cpp b9095 source (`ggml-backend-reg.cpp:479-489`) and the actual release archives (`llama-b9095-bin-win-cpu-x64.zip`, `llama-b9095-bin-macos-arm64.tar.gz`). | **fixed** — `spawn_llama` now also sets the child's working directory to the same runtime dir already resolved for the `PATH`/`DYLD_FALLBACK_LIBRARY_PATH`/`LD_LIBRARY_PATH` prepend (`Command::current_dir`, tauri-plugin-shell 2.3.5), since `fs::current_path()` is in ggml's search list. One code path covers both engine sources (bundled, and the managed install where `runtime_dir` already equals the exe's own directory) and all three platforms. Not runnable on this box — no cargo/node toolchain and `src-tauri/binaries/` has no fetched engine on this Linux dev host; gated by CI and the `Release prep` workflow's gate job instead. | -| I76 | Sev1 | `src/features/ai/sampleLoop.ts` + `captureScreen.ts` + `src/routes/Home.tsx` + `AiCategory.tsx` + `SessionView.tsx` | User report: "AI capture error: getDisplayMedia must be called from a user gesture handler" firing on ordinary session starts with AI already enabled, and — because the fallout from this same failure kept killing the just-started sidecar — a separate, misleading "AI isn't running yet. Turn it on in Settings → AI" from the Ctrl+] chat dialog even though AI genuinely was on. Root cause: `sampleLoop.ts`'s `boot()` acquires the session's long-lived screen `MediaStream` via `navigator.mediaDevices.getDisplayMedia()`, but `boot()` runs from a React `useEffect` fired by state changes (session active + AI on + model chosen + camera up), never from inside a click handler. WebView2 (Windows) and WKWebView (macOS) require `getDisplayMedia()` to run inside live transient user activation on _every_ call, not just the first — the same reason the OS picker itself fires on every acquire (documented in `src/features/ai/README.md`'s "Acquire strategy", which is why V2-P9 already moved to one long-lived stream instead of a per-tick acquire) — so with no gesture in `boot()`'s call stack the call was rejected outright. Because the rejection's `DOMException` name fell outside `mapDisplayMediaError`'s handled set, it surfaced as the generic `screen_capture_unavailable` code and a raw toast instead of the intended `screen_capture_denied` recovery overlay, and `boot()`'s existing failure path tore down the sidecar it had just started. A second, compounding gap: `onCaptureError` never updated `AiStatusChip`'s runtime status, so the chip kept reading "active" after AI had silently died underneath it — matching the reporter's "I can't tell if it's on or if it's errored." | **fixed** — a gesture-context handoff: callers that DO have a real user gesture (`TopicGateModal`'s submit when starting a session with AI already enabled; `AiCategory`'s "enable AI" toggle when a session is already active; `SessionView`'s permission-overlay retry) call the new `preacquireScreenStream()` synchronously (no `await` before it), which starts `getDisplayMedia()` inside that click and stashes the in-flight promise; `sampleLoop.ts`'s default `acquireScreenStream` runtime hook consumes that stash instead of calling `getDisplayMedia()` itself outside gesture context. An unconsumed stash (a rapid re-toggle, or a session that never reaches `boot()`) is released via `discardPendingScreenStream()`, including on `SessionView` unmount, so it never leaks a live stream or leaves the OS recording indicator lit. Separately, `onCaptureError` now carries a `fatal` flag — true for a `boot()`-time acquire failure (the loop really did tear itself and the sidecar down) vs. false for a `tick()`-time transient one (the loop keeps running) — so `SessionView` only flips the status chip to "error" on the former. Unit-tested (pending-stream stash/discard, default-runtime consumption of the stash, the `fatal` flag on both call sites); `npm run build`/`lint`/`test` all green (878 tests). | -| I77 | Sev1 | `src/features/session/lifecycle.ts` + `SessionView.tsx` + `tests/integration/session.test.ts` | User report: "on my device I can't see the other person's camera but they can see mine" — a guest joining a friend's session never received the host's camera **or** mic, in either direction of the pair, while the host saw the guest fine. Root cause: `SessionView`'s media-acquire effect published the local `MediaStream` with a single untargeted `room.addStream(stream)`, and trystero 0.24 delivers a stream only to the peers that are active **at that instant** — `addStream` → `applyMediaOp` → `iterate` enumerates `keys(activePeerMap)` right then (`@trystero-p2p/core` `room.mjs:83`, `:494`) and queues nothing; peer activation (`room.mjs:306-314`) sets `activePeerMap` and fires `onPeerJoin` but replays no previously added local stream. The host is structurally guaranteed to lose that race: `hostSession()` derives a session topic from 32 fresh random bytes and `begin()`s the room **before** the invite is even sent, so the host's camera opens while it is provably alone and its one broadcast reaches nobody, forever. The guest normally wins it, because the session peer activates over trystero's already-open shared connection to that same friend in roughly one RTT — faster than a cold camera opens — so the guest's `addStream` lands and the host sees the guest. Two stale comments asserted the opposite of the library's actual behavior and are what preserved the bug: `SessionView.tsx` claimed `addStream` "forwards new tracks to all current peers **and to peers who join later**", and the stream-binding effect claimed "trystero replays existing peers when we register the stream callback" (`onPeerStream` is a bare assignment at `room.mjs:511`; only `onPeerJoin` sweeps, at `:506-509`, a replay our own `wrapRoom` consumes at construction). CI could not catch it: the integration bus mock hard-coded both false beliefs — its `addStream` ignored `targetPeers` and fanned out to every room, and its join + `onPeerStream` paths both replayed existing streams. Day-one defect; `trystero` has been pinned `^0.24.0` since the media path was introduced, so host→guest video has never worked in any shipped build. | **fixed** — publishing moved into `publishLocalStream(room, stream)` in `lifecycle.ts`, which broadcasts to the currently-active peers and, in the immediately adjacent statement, subscribes `onPeerJoin` to re-send the same stream targeted at each later joiner (the pattern trystero's own README prescribes). The two calls live in one function so the "no `await` in the seam" invariant is structural: the broadcast covers who is active now, the subscriber covers who arrives later, and JS's single thread means no peer is missed or served twice — a double-add would desync trystero's FIFO pairing of stream metadata to incoming tracks. `SessionView`'s effect cleanup unsubscribes **before** `stopTracks`, so a "Try again" re-acquire can't hand a later joiner a dead stream. Both false comments replaced with the verified semantics + `room.mjs` line refs. The integration bus mock now models `activePeerMap` honestly (targeted sends honored, no join replay, no `onPeerStream` replay), and `tests/unit/session-publish-stream.test.ts` pins the contract — 2 of its 4 cases fail against the pre-fix code. **Both friends must update:** a patched host reaches an unpatched guest, but a patched guest still receives nothing from an unpatched host. | -| I78 | Sev2 | `src-tauri/Cargo.toml` (`tauri 2.11.0`) | GHSA-7gmj-67g7-phm9 — "Tauri has an Origin Confusion Issue that Allows Remote Pages to Invoke Local-Only IPC Commands" (CVSS 8.8), affecting `tauri >= 2.0.0, <= 2.11.0`; fixed upstream in 2.11.1. StudyVis exposes a wide IPC surface (SQLite, keychain-backed identity, sidecar spawn, filesystem paths), so origin confusion is the class that matters most here rather than a theoretical one. Not found by `cargo deny`: the advisory is GitHub-Advisory-Database-only and RustSec does not carry it — it surfaced when OSV-Scanner was run over `Cargo.lock` while building the #102 supply-chain gates. | **fixed** — `cargo update -p tauri --precise 2.11.1` (lockfile-only; `Cargo.toml` already requires `"2"`, so no manifest change). Pulled tauri-build/codegen/macros/runtime/runtime-wry/utils forward with it. Verified: OSV over `Cargo.lock` no longer reports the advisory, and `cargo deny check advisories licenses bans sources` stays green. Shipped as its own PR rather than bundled into the #102 CI branch: a Tauri bump is a Rust change that this box cannot compile, so it wants its own PR and its own full CI run. The new `.github/dependabot.yml` opens the 2.11.0 → 2.11.1 bump automatically (cargo ecosystem; `tauri*` is excluded from the routine grouping precisely so it lands as its own reviewable PR), and `maintenance.yml`'s weekly OSV scan keeps reporting it until the bump lands. Nothing in the pinned-ignore list of `src-tauri/deny.toml` suppresses it. | +| ID | Sev | Location | Evidence | Status | +| --- | ---- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| I1 | Sev1 | `src/features/session/pomodoro.ts` | `stop()` sent no wire signal; receivers' 10 s silence timer resurrected the timer under a new broadcaster ~10 s after Stop. | **fixed** (R1) — explicit `stopped:true` message; receivers reset to idle. ARCHITECTURE §7 updated. | +| I2 | Sev2 | `src/features/friends/presence.ts` | Online state compared sender wall clock to receiver's; backward sender clock step wedged presence permanently. | **fixed** (R1) — stamp receiver-local time on receive. | +| I3 | Sev2 | `src/features/session/lifecycle.ts` + `sessionStore.ts` | Everyone-else-leaves auto-end lost `sessions.peer_pubkeys` + `markStudied` because `peerLeft` pruned `peers` first. | **fixed** (R1) — cumulative `seenPeerEdPubkeys` set. | +| I4 | Sev2 | `src/features/ai/benchmark.ts` | p95 included the cold-start warmup sample, inflating the sample floor 5–10× with no user recourse. | **fixed** (R1) — run + discard one warmup sample. | +| I5 | Sev2 | `src-tauri/src/commands/models.rs` | Resume fast-path hashed a multi-GB GGUF synchronously on the async runtime, stalling concurrent IPC. | **fixed** (R1) — moved to `spawn_blocking`. | +| I6 | Sev3 | `src/features/ai/sampleLoop.ts` | Battery-pause branch omitted the §8 "thermal-aware notice" and rescheduled at the sample interval, not 60 s. | **fixed** (R2) — `onBatteryPause`/`onBatteryResume` callbacks (fire once each) wired to SessionView toasts; paused branch now reschedules at `BATTERY_POLL_INTERVAL_MS`. Regression test added. | +| I7 | Sev4 | `src/features/session/invite.ts` | Auditor flagged idle-invite `hostSession()` as bypassing the topic gate. | **not a bug** — `Home.tsx` enforces `TopicGateModal` (sets `pendingInitialTopic`) before `inviteToCurrentSession`; no other caller. No change. | +| I8 | Sev3 | `src/features/session/SessionView.tsx` | Audit receive did not check `session_topic` (the ai-alert path does). | **fixed** (R2) — added `verified.session_topic !== sessionTopic` drop, mirroring `aiAlerts.ts`. | +| I9 | Sev3 | `src/features/session/pomodoro.ts` | Any peer sending a valid signed `pomodoro` msg is accepted as broadcaster, even mid-broadcast by another. | **deferred — conflicts with canonical doc.** ARCHITECTURE §14 explicitly: "Friend disables their own AI / fakes score — **Not defended. Social trust. Accepted.**" The "most recent sender becomes broadcaster" behavior is a deliberate, code-documented reconnection-robustness choice; hardening it would silently deviate from the accepted friends-only threat model and risk regressing the documented original-broadcaster-returns path. Surfaced per house rule; user can override to request the hardening explicitly. | +| I10 | Sev3 | `ARCHITECTURE.md §7` | `score_final` wire type has no producer/consumer. | **fixed (doc)** (R2) — §7 annotated: `score_final` is reserved/not-implemented in V2; the report is local-SQLite by V2-P8 design; type kept so a future phase avoids a breaking wire change. Not removed (removal would be a forward-compat break). | +| I11 | Sev3 | `src/features/ai/sampleLoop.ts` | Declared topic interpolated into the focus prompt without injection delimiters. | **fixed** (R2) — topic wrapped in `` + labelled as data; system-prompt rule added; `FOCUS_SYSTEM_PROMPT_VERSION` → 2; `tests/ai-eval/run.ts` kept byte-identical; ARCHITECTURE §8 prompt updated. | +| I12 | Sev3 | `src/features/ai/aiAgent.ts` | Total JSON-parse failure echoed ≤200 chars of raw model output into the dialog. | **fixed** (R2) — fixed safe string to the user; raw logged to console only. Test updated. | +| I13 | Sev3 | `src-tauri/capabilities/default.json` | `ai-dialog` window granted `notification`/`store`; §12 says permissions are main-window-scoped. | **fixed** (R2) — `default.json` restricted to `["main"]`; new `ai-dialog.json` capability scoped to the dialog window with `core:default` only (it uses only core event/window IPC). | +| I14 | Sev3 | `src-tauri/src/db/migrations.rs` + `001_initial.sql` | Bare `CREATE TABLE` + no single-instance ⇒ two simultaneous first-launches could panic the second. | **fixed** (R2) — `IMMEDIATE` transaction with the version read moved inside the tx (locks before reading); `IF NOT EXISTS` on 001's DDL; `INSERT OR IGNORE` on `schema_version`. Sequential-upgrade tests preserved. | +| I15 | Sev3 | `src/stores/identityStore.ts` / `identity.rs` | Identity commit (keychain then file) had no rollback; a failed file write + re-onboard overwrites the keychain entry. | **mitigated** (R2) — the file write is now atomic (see I16); residual is now only "rename succeeded but the keychain `set` itself fails", an OS-keychain fault recoverable via BIP39 (PLAN §7). Full two-store transactionality is out of scope for a Sev3. | +| I16 | Sev3 | `src-tauri/src/commands/identity.rs` | `fs::write` non-atomic; a crash mid-write truncates `identity.json`. | **fixed** (R2) — write to `*.json.tmp` then `fs::rename` over the target (atomic on same FS); temp cleaned on rename failure. | +| I17 | Sev3 | `src-tauri/src/db/sessions.rs` | `started_at`/`ended_at`/`total_minutes` overwritten while the comment claimed additive upserts. | **fixed (comment)** (R2) — comment rewritten to state these three are deliberately authoritative-overwrite (a re-summarize must be able to correct them; COALESCE would swallow it) while the report columns are additive. No behavior change, by design. | +| I18 | Sev4 | `pair.ts` / `lib/trystero/index.ts` / `sidecar.rs` | `verifyHello` didn't reject self-pubkey; stale `selfId` comment; `sidecar_start` trusts JS `model_path`. | **partially fixed** (R2). `verifyHello` now rejects a hello whose `ed_pubkey` equals the local identity (passed `ctx.edPubHex` from `runPair`). `trystero/index.ts` comment corrected to describe the actual module-global-`selfId` mechanism. The `sidecar_start` model-path sandbox is **deferred — conflicts with canonical doc**: PLAN §5 explicitly promises "Advanced users can point at any local GGUF", so constraining `model_path` to `data_dir/models` would break a documented feature. Surfaced per house rule. | +| I19 | Sev4 | `package.json` devDependencies | `npm audit` flags ~20 dev-chain advisories across critical/high/moderate (criticals: `concurrently@9` → `shell-quote`; highs/moderates span the `@storybook/*` and `esbuild`/`tsx` chains). Re-flagged on every scan. | **triaged — no runtime exposure** (2026-06-13). Every one is a **devDependency**; none reaches the installed desktop app — `npm audit --omit=dev` is clean (0) and no advisory package appears in `dependencies`. Bump `concurrently` and the `@storybook/*` chain when convenient; do **not** rush a major Storybook upgrade for a dev-only advisory. Recorded so the scan result isn't re-investigated each time. (Exact counts shift with the lockfile; the load-bearing fact is the clean prod audit.) | +| I20 | Sev1 | `src-tauri/src/db/mod.rs` | `is_definitely_corrupt` only treated a non-"ok" `integrity_check` verdict as corruption; a truncated file (SQLITE_CORRUPT) or damaged header (SQLITE_NOTADB) makes the pragma ERROR, so recovery never fired and the app bricked on every launch after a power-loss/force-kill. | **fixed** — classify by SQLite error code; also clean up `-journal`/`-wal`/`-shm` on rename. Corruption-signature tests added. | +| I21 | Sev2 | `src-tauri/capabilities/ai-dialog.json` | The scoped ai-dialog capability lacked `core:window:allow-close`, so the floating AI dialog's Esc/blur/X all silently failed (regression from the I13 scope-down). | **fixed** — grant `core:window:allow-close`. | +| I22 | Sev2 | `src/features/session/reportData.ts` + `stats/statsInsights.ts` | Report topic-timeline / top-distractions and cross-session insights walked every audit event; peers' broadcast `topic_set`/`ai_alert` (persisted locally) were misattributed to the local user. | **fixed** — thread the local ed_pubkey and filter to it (matches the self-only score gauge / trend). | +| I23 | Sev3 | `src/features/ai/sampleLoop.ts` | `onScreenTrackEnded` latched `captureDenied` and tore down ALL AI capture when ANY screen track ended; unplugging a secondary display in "All displays" mode killed focus detection with a misleading permission overlay. | **fixed** — discriminate: drop the dead display, latch only when the last live one ends. | +| I24 | Sev2 | `src-tauri/src/commands/models.rs` | No read/idle timeout on downloads; a mid-stream stall hung `bytes_stream().next()` forever, freezing the UI and permanently locking the `model_id`. | **fixed** — 60s `read_timeout`. | +| I25 | Sev2 | `src-tauri/src/commands/system.rs` | `system_relaunch_app`'s `app.restart()` skips `RunEvent::Exit` (the only sidecar kill), orphaning a running llama-server on Window-Style relaunch. | **fixed** — `kill_blocking` before restart. | +| I26 | Sev2 | `src-tauri/src/lib.rs` | A boot-time global-shortcut OS conflict propagated out of `setup()` into `build().expect()`, panicking before first paint. | **fixed** — register best-effort; a failed binding is inert until rebound in Settings. | +| I27 | Sev3 | `src/features/friends/invite.ts` + `inviteRetry.ts` | An invite retry queued after the 15s send timeout escaped `cancelAll` if the session ended during the window, later pulling the friend into a dead room. | **fixed** — injected `isSessionLive` guard: a retry never fires for a session that isn't the host's current live one. | +| I28 | Sev3 | `src/lib/fileExport.ts` | CSV export didn't neutralize spreadsheet formula injection; a peer-chosen display name beginning with `= + - @` executed on open (=HYPERLINK exfil / DDE). | **fixed** — quote-prefix string cells starting with a trigger; numeric cells untouched. | +| I29 | Sev2 | `src/features/friends/AddFriendDialog.tsx` | A programmatic close (contact-card deep link) during an in-flight legacy pairing never aborted it — Radix doesn't fire `onOpenChange` on a parent-driven close — leaking the trystero room + relay sockets. | **fixed** — tear down on any `open` transition. | +| I30 | Sev3 | `src/features/friends/inbox.ts` | No replay/dedup on the inbox receive path; a stranger on the pubkey-derived inbox topic could re-broadcast a captured envelope to re-fire the invite toast/notification. | **fixed** — dedup on `(from_ed_pubkey, box nonce)`, TTL-bounded. §14 row added. | +| I31 | Sev3 | `src/features/friends/AddFriendDialog.tsx` + `lib/relayDiagnostics.ts` | Pairing's "network trouble" hint read only the Nostr socket map, so it wrongly blamed the user's network in the exact MQTT-fallback case the v1.2.2 race was built to survive. | **fixed** — transport-aware `pairingRelaysUnreachable()` judging both socket maps; Nostr-only signal kept for the invite path. | +| I32 | Sev3 | `src/routes/Home.tsx` | `PairDeepLinkBoot` rendered only in the non-session tail, so a `studyvis://` link clicked mid-session reached zero listeners and was dropped. | **fixed** — render the full tail in the active-session branch. | +| I33 | Sev3 | `src/strings.ts` | In-app AI copy promised screen access is requested "when you start your first session", but enabling AI requests it immediately. | **fixed (copy)** — reword to match the shipped enable-time prompt. | +| I34 | Sev3 | `src-tauri/src/lib.rs` | With minimize-to-tray off, closing the main window while the AI dialog was open stranded the app: process alive, main window gone, tray "Open" a no-op. | **fixed** — destroy the AI dialog on that close path so the runtime exits. | +| I35 | Sev3 | `src-tauri/src/commands/sidecar.rs` | The crash-restart watcher could spawn a fresh llama-server after `kill_blocking` already ran at quit (during the backoff window), orphaning it. | **fixed** — `shutting_down` flag re-checked after backoff, before respawn. | +| I36 | Sev3 | `src/design/theme.tsx` | `ThemeProvider` wrote the pre-hydration fallback `dark` class, stripping the boot-cache `light` class and flashing dark for light/auto users. | **fixed** — defer to the boot script until an authoritative mode exists. | +| I37 | Sev4 | `src/features/friends/pair.ts` | The legacy pairing hello's (unsigned) `display_name` was stored/rendered raw, unlike the ContactCard path's cap + bidi/zero-width sanitize. | **fixed** — shared `normalizeUntrustedName` applied on both paths. | +| I38 | Sev3 | `src/features/ai/sidecar.ts` | `useSidecarStore.start()` unconditionally set `running` after its await, clobbering an interleaved `stop()` and leaving the store `running` on a killed process. | **fixed** — bail if a stop intervened. | +| I39 | Sev3 | `src/App.tsx` + `src/components/ErrorBoundary.tsx` | No React error boundary anywhere; any render throw blanked the whole window and killed the always-on inbox/presence + live session. | **fixed** — top-level `ErrorBoundary` around the routed content with a calm "Try again". | +| I40 | Sev4 | `src-tauri/src/commands/system.rs` | Changing one PTT shortcut when both shared a combo (hand-edited settings.json) unregistered the other. | **fixed** — only unregister the old combo when the other action isn't still using it. | +| I41 | Sev4 | `src/lib/encoding.ts` | `hexToBytes` used `parseInt` per byte, silently mis-decoding malformed hex ('1g'→0x01, '-a'→wraps) instead of rejecting. | **fixed** — validate the whole string against `/^[0-9a-fA-F]*$/` first. Adversarial-input tests added. | +| I42 | Sev3 | `src/features/session/SessionView.tsx` | The local session camera/mic stream had no `ended` listener, so a mid-session device loss (unplug / OS-revoke / another app grabbing the camera) left peers on a frozen tile and silently killed the AI face path. | **fixed** — attach an `ended` listener that surfaces the existing "Try again" recovery banner. | +| I43 | Sev3 | `.github/workflows/ci.yml` | CI compiled only aarch64-apple-darwin, so `#[cfg(target_os="windows")]` code first built inside `release.yml` AFTER the tag was pushed. | **fixed** — macOS + Windows Rust matrix on every push/PR. | +| I44 | Sev3 | `.github/workflows/release-prep.yml` | The one-click gate skipped `check-a11y` and all Rust compilation, so a release could be cut over an axe-core / clippy regression. | **fixed** — add the a11y gate; require the exact main SHA's CI run to be green before bump/tag/push. | +| I45 | Sev3 | `README.md` / `PLAN.md` / `ARCHITECTURE.md` / `CHANGELOG.md` | User-facing doc drift: first-run described the retired 12-word flow as primary; "one WebSocket" (really ~8 relays); "three tiers" (four models); "v1.2.0 is current" (v1.3.1); §6 "MQTT not yet wired" (raced since v1.2.2); changelog x86_64 DMG claim (aarch64-only). | **fixed** — brought each in line with the shipped code. | +| I46 | Sev3 | `src/features/friends/invite.ts` | `sendInviteEnvelope` treats any peer joining the recipient's inbox topic as delivery, so an eavesdropper on that shared pubkey-derived topic can appear as "delivered" or drop the invite. | **accepted — friends-only threat model.** Envelope is still NaCl-box-sealed to the recipient; worst case is a suppressed offline-retry (re-click Invite). Documented in §14. The flagged signed invite-ACK shipped in #47 C2 (new `invite-ack` action, v1.2.x-wire-compatible: no ACK within the window → honest "unconfirmed" copy) — for UX legibility, not as a defense; the eavesdropper acceptance above stands. | +| I47 | Sev3 | `src/features/friends/presence.ts` | Presence heartbeats/goodbyes are unauthenticated on a pubkey-derived topic, so a stranger with a friend's public pubkey can forge that friend's online/offline state. | **accepted — friends-only threat model.** Presence is soft UX state, not a data/session compromise. Signing would break cross-version presence (older peers send unsigned), so enforcement is deferred, not shipped. Documented in §14. | +| I48 | Sev3 | `src/features/friends/pair.ts` (upstream `@trystero-p2p/mqtt`) | Each pairing's MQTT room open→leave orphans ~4 broker connections: trystero-core sets `didInit=false` on last-room-leave but never `.end()`s the MQTT clients. | **deferred — upstream trystero bug.** Bounded (a handful of pairings per session, cleared on process exit) under the friends-only 4-peer model. Fix is upstream (or an app-side always-on MQTT room, which trades the leak for a persistent idle broker connection — not worth it). | +| I49 | Sev3 | `src/features/friends/InboxBoot.tsx` + `presence.ts` | The presence effect keys on the whole friend set, so adding/removing any friend tears down + rebuilds the own presence room, broadcasting a goodbye that flickers your presence offline→online on every other friend's screen (and can fire a spurious "came online" notification). | **fixed** (#47 C6, the recorded dedicated pass) — `startPresence` gained `updateFriends`: friend list edits diff rooms in place (join added / leave removed), the own room and heartbeat cadence never churn, and `leave()`'s tested goodbye semantics are untouched. InboxBoot keys the subscription on identity only and drives list edits through the diff; removed friends' notify baselines are pruned so a re-add starts fresh. Unit tests cover added/removed/no-op churn including a watcher asserting no goodbye flicker. | +| I50 | Sev4 | `src-tauri/tauri.conf.json` | Both webview windows ship with CSP disabled (defense-in-depth only — no reachable XSS sink today: React auto-escapes, no `innerHTML`/`eval`). | **deferred — needs a desktop CSP smoke-test.** A wrong CSP hard-breaks Tauri IPC/asset loading, which no static gate catches; landing a `script-src 'self'` policy safely requires running the built desktop app (not possible headless). Recommended policy: `default-src 'self'; script-src 'self'; object-src 'none'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' ws: wss: http://127.0.0.1:*`. | +| I51 | Sev2 | `src/routes/Home.tsx` | The `tail` fragment (InboxBoot + deep-link + import dialog + topic gate) rendered at a different unkeyed child index per view branch, so React reconciled by index and re-mounted the always-on presence/inbox room on every view switch — re-triggering the I49 goodbye flicker, blanking the friends list for up to a heartbeat, and dropping an invite that arrived in the teardown window. | **fixed** — `` pins the tail fiber across branches of differing child arity. The load-bearing key is documented at the site; `pairDeepLink.ts`'s stale "view switches re-mount the boot" comment corrected (the `launchConsumed` guard kept). Not statically checkable and not node-testable without RTL, so protected by the site comment. | +| I52 | Sev2 | `src/features/session/lifecycle.ts` + `stores/sessionStore.ts` | `total_minutes` was pure wall-clock `endedAt − startedAt`, counting OS-sleep/suspend as study time; a session slept on persisted the whole span (a free streak day and inflated totals). | **fixed** — elapsed is `min(wallMs, monoMs)` off a `performance.now()` origin captured at start, mirrored in the live footer. Not retroactive (old rows stand); degrades to prior behavior on a platform whose monotonic clock happens to include suspend, never undercounts. Unit-tested via an injectable `monotonicNow` seam (awake / slept-through / backward wall clock / no-mono fallback / slept-through rejoin). | +| I53 | Sev3 | `src/features/session/lifecycle.ts` + `SessionView.tsx` | A peer's deliberate `left` (signed, on the wire since V1-P9) still armed the 20 s reconnect grace and offered a Rejoin into a dead room. | **fixed** — mark departed peers, and skip the grace/Rejoin only when the room empties with no unexplained absence remaining, via a new `SessionEndReason` (`'peer'`). Unexplained-absent peers are tracked in a Set (not a single flag, per the review) so an intervening join by another peer can't strand a still-absent blipper; the mark clears per-peer on rejoin so a later blip still gets grace. ARCHITECTURE §13 updated. Grace unit tests extended. | +| I54 | Sev3 | `src/features/friends/InboxBoot.tsx` + `friendOnlineNotify.ts` | The friend-online baseline suppressed every friend's _first_ online resolution after mount (not just boot's initial sweep), so a genuine later arrival never notified — the one event the feature exists for. | **fixed** — per-friend watch-start map with a settle bound. The bound is a dedicated `NOTIFY_SETTLE_MS` (3 min, sized above realistic presence-handshake latency), not the 60 s heartbeat window: reusing the latter let a slow-connecting already-online friend re-read as an arrival (review finding). Only the settle window is suppressed. Unit-tested. | +| I55 | Sev3 | `src/features/session/hello.ts` | The signed session-hello `display_name` was stored/rendered without the cap + bidi/zero-width sanitize every other untrusted-name path applies; on `main` it was unbounded. | **fixed** — `normalizeUntrustedName(name, HELLO_NAME_CAP)`. Cap is 192 UTF-8 bytes — the worst case for the 64-UTF-16-unit `maxLength` our own inputs enforce — so a legitimate multibyte name (CJK/emoji) survives intact rather than being byte-truncated (review finding), while a hand-modified sender is still bounded. Unit-tested incl. multibyte + bidi. | +| I56 | Sev3 | `src/features/ai/sampleLoop.ts` | `onCaptureError` fired per tick (contract says once/lifetime) and the face-track guard never checked `readyState`, so a dead webcam threw `track_ended` every tick and toast-stormed the session over the MediaErrorBanner already saying the same thing. | **fixed** — the ended-track guard skips the tick without counting a sample; a `captureErrorReported` latch mirrors `sidecarErrorReported`, reporting once and clearing on the next successful verdict. Unit-tested. | +| I57 | Sev3 | `src/design/tokens.ts` + `src/design/index.css` | The focus ring (`accent.ring`, 40 % alpha) measured ~2.6:1 dark / ~1.8:1 light against the surfaces it is drawn on — below WCAG 1.4.11 — because the UA outline is globally reset; the gate missed it by measuring the opaque accent. `shadow.glow` had also drifted 3px/4px. | **fixed** — raised alpha (60 % dark / 80 % light), mirrored in both hand-kept files; `check-contrast` now measures the ring in the bg-stack at its real per-theme alpha; `shadow.glow` reconciled to the tokens.ts value (3px). The ring's inner edge on `bg-accent-default` buttons intentionally stays below 3:1 — the outer edge against the canvas carries identification. | +| I58 | Sev3 | `src/components/ui/dropdown-menu.tsx` | Menu items declared `focus:bg-bg-raised` on a `bg-bg-raised` surface — a 1.00:1 no-op — so keyboard/mouse navigation showed no highlight (worst in the in-session audio pickers, where two identically-named devices are indistinguishable). | **fixed** — an inset accent ring highlight (keeps `focus:` so Radix pointer-move still lights it). The byte-identical Button/Badge `secondary` hover was fixed the same way (`hover:bg-bg-surface`). | +| I59 | Sev3 | `src/components/AuditLogPanel.tsx` + `SessionNotesPanel.tsx` | The session-log and notes scroll containers had no focusable descendant and no `tabIndex`, so a keyboard-only user couldn't scroll them (WCAG 2.1.1). macOS/WKWebView only; Windows WebView2 auto-focuses scrollers. | **fixed** — `tabIndex={0}` + a focus-visible inset ring on both. Overflowing Storybook stories added so the axe `scrollable-region-focusable` gate has something to assert on. | +| I60 | Sev3 | `src/strings.ts` (`searchKeywords`) + `Settings.tsx` | v1.6.0 settings search routed "tray"/"minimize"/"capture displays"/"auto-update" to Advanced (which owns none of them) and left Advanced's own settings ("launch at login", "clear history", "onboarding") unfindable. | **fixed** — keywords moved to the panes that own each setting; Advanced keywords added; a `Record` guard in `Settings.tsx` pins the bucket↔pane mapping without a strings→features import cycle. | +| I61 | Sev3 | `src/stores/settingsStore.ts` + `ShortcutsCategory.tsx` | `resetShortcutsToDefaults` rethrew on the first setter's combo collision and never ran the second; the rejection was swallowed to `console.error`, so the button was a silent no-op. | **fixed** — reorder + per-call try/catch so both setters run; a residual collision surfaces a `toast.error` (copy in strings.ts). The Rust `is_registered` skip the original proposal suggested was dropped — it would re-open #47 B5. Stateful fake added to the keybindings test. | +| I62 | Sev3 | `src/features/updater/updaterStore.ts` + `AboutCategory.tsx` | Settings → About offered a live Restart-now / Check-now during a session (unguarded, unlike the update banner), and its help text asserted "you're on X, the latest" from the initial `idle` state and after a silent background-check failure. | **fixed** — session-active guards in `installAndRestart`/`checkNow` (the `userInitiated` exemption, made false by the in-session settings overlay, removed); About disables the buttons in-session and derives its help from an explicit `upToDate` branch rather than a fallthrough. Store tests flipped to assert deferral. | +| I63 | Sev3 | `src/features/identity/recoverLogic.ts` | A failed 24-word restore pointed at all 24 words equally, with no way to narrow a single typo on the highest-stakes screen in the app. | **fixed** — name the words that aren't in the wordlist (`unknownWords` on `MnemonicClass`, populated only on the 24-word path); copy in strings.ts. Kept in `recoverLogic.ts`, not the cross-version crypto module. Unit-tested. | +| I64 | Sev3 | `src/features/stats/FocusInsights.tsx` | The focus-over-time trend tooltip had no date, so a dip couldn't be anchored to a day. | **fixed** — carry each point's `startedAt`; the tooltip renders the `dayKey` day, byte-identical to the bar chart's day format. | +| I65 | Sev4 | `src/features/stats/statsData.ts` | The stats CSV omitted the two headline tiles (total sessions, streak, average) — the numbers the pane is built around. | **fixed (summary)** — prepend summary rows, preserving the null-average ("AI off" vs "scored 0") distinction. Per-session detail left out of scope. Test extended. | +| I66 | Sev3 | `src-tauri/src/commands/sidecar.rs` | `sidecar_start` spawned llama-server then opened the log file; an `open_log_file` failure after a successful spawn dropped the `CommandChild` without `kill()`, orphaning a multi-GB process past app exit (same class as I25/I35). | **fixed** — open the log before spawning, so no fallible `?` sits between the spawn and `guard.child`. Reviewed by reading (CI is the first Rust compiler on this dev box). | +| I67 | Sev3 | `src-tauri/src/commands/sidecar.rs` | The respawn budget was a 30 s sliding window, so any crash spaced >30 s reset the counter and the watcher respawned llama-server forever without ever setting `errored` — no recovery affordance surfaced and the D7 log cap was defeated. | **fixed** — the budget now counts consecutive respawns that each died before `MIN_HEALTHY_UPTIME` (120 s); a durable child resets the streak (`next_attempts` pure fn, unit-tested). Once the budget is exceeded `errored` is set as before. | +| I68 | Sev4 | `src-tauri/src/db/audit_events.rs` | The cross-session insights read shipped the entire `audit_events` table over IPC though only `ai_warning`/`ai_alert` rows are consumed. | **fixed** — `WHERE kind IN ('ai_warning','ai_alert')` narrows the query (~4× less JSON at 10k rows); `list_all` → `list_ai_distractions_all`, but the Tauri command name is unchanged so the IPC/TS contract is untouched. The SQL twin of TS `isDistraction` is commented at the query. | +| I69 | Sev3 | `src-tauri/src/lib.rs` | The corrupt-DB recovery dialog asserted re-pairing was required and never mentioned the friends-backup import — wrong at the exact moment a friend loses their list. | **fixed (copy)** — the dialog now names Settings → Identity → Import friends as the restore path if a backup exists, otherwise re-pair. | +| I70 | Sev4 | `.github/workflows/release.yml` | A half-built draft (one platform's artifact missing from `latest.json`) could be published, stranding every friend on the missing platform with no update path and a false "you're on the latest". | **fixed** — a job asserts both platforms are present in the draft's `latest.json` and, on failure, stamps the draft title "INCOMPLETE, DO NOT PUBLISH" (needs `contents: write` to read a draft). Not runnable on this box; validated by YAML parse + reading. | +| I71 | Sev2 | `src/features/updater/updaterStore.ts` + `src-tauri/src/commands/system.rs` | Issue #77: an app opened straight from the mounted `.dmg` runs under macOS App Translocation (read-only bundle), where `update.install()`'s rename-into-place can never succeed — every launch re-downloaded the installer, offered "Restart now", and failed with the generic install toast. The one documented install step (drag to Applications) is exactly the one this path skipped, and the updater had no idea. | **fixed** — new `system_install_context` command (translocation via exe-path component, read-only volume via `statfs`; fail-open) consulted after a check finds an update: an unswappable bundle sets a new process-permanent `blocked` status _before_ any bytes move, and the banner + Settings → About replace the doomed Restart with move-to-Applications guidance. Verified live: dev binary on a read-only DMG against the real v1.7.0 release showed the blocked row. Windows/NSIS unaffected (always updatable). | +| I72 | Sev1 | `src-tauri/src/commands/models.rs` | Every model download failed at the picker's preflight with "…The model manifest may be stale." for every catalog entry. `model_head_check` populated `content_length` from `reqwest::Response::content_length()`, which is the body's size hint — an HTTP/1.1 HEAD response body is always empty (hyper decodes it as zero-length regardless of headers), so every probe reported 0 bytes and the size gate rejected all six entries. The manifest itself is current: the raw `Content-Length` (and `x-linked-etag` = pinned sha256) at every pinned revision still matches. | **fixed** — read the `Content-Length` response header instead; in-module regression test against a local HEAD server; live-verified that all 10 catalog files (6 model + 4 mmproj — the three Gemma quants share one projector) report header sizes byte-identical to the manifest. Git history dates the break to the picker's birth: the size gate, the `content_length()` call, and the no-http2 reqwest dep all landed in one commit (af2987d, V2-P2) and never changed, and the zero-length HEAD decode is server-independent — so no catalog download has ever passed this preflight, and the downstream GET/verify/resume path has never run end-to-end in a shipped build (first real install is its true test). First user report 2026-07-26. | +| I73 | Sev1 | `src-tauri/src/commands/sidecar.rs` + `src-tauri/src/commands/engine.rs` | In-app llama-server spawn has never worked in any build. `shell().sidecar("binaries/llama-server")` resolves `/binaries/llama-server` (tauri-plugin-shell 2.3.5 joins the full configured string against the exe dir), but tauri-build (dev) and the bundler (release) both strip the directory prefix and the triple, placing the file at `/llama-server` — verified in `target/debug/` and in the installed `StudyVis.app/Contents/MacOS/`. Every `sidecar_start` failed with `spawn llama-server: No such file or directory`, surfaced as "AI failed to start:" / "AI model crashed". The plugin has been pinned at 2.3.5 since V1-P1, so this is a day-one bug, not a regression; it sat behind I72 (downloads never completed), which is why the first user report of both landed the same day (2026-07-26 — the on-disk `llama-server.log` from that attempt is a 0-byte file: the child never ran). | **fixed** — sidecar binaries now resolve to absolute paths and spawn via `shell().command()`: bundled probe at `/llama-server(.exe)` (size-gated), then a managed install under `data_dir/engine/-/`. When neither resolves, `sidecar_start` auto-installs the pinned llama.cpp b9095 release asset (SHA-256-verified; pins lockstep-tested against `scripts/fetch-llama-server.sh`; tar.gz/zip unpacked flattened + filtered), gated by the new `engine_auto_install` setting (default ON) with `engine_info`/`engine_install` commands and a Settings → AI "AI engine" row (status/progress/Reinstall). `build.rs` writes a debug-profile-only placeholder so fresh checkouts compile without the fetch script; release-profile builds still hard-fail. Windows spawn failures name the VC++ redistributable when `vcruntime140.dll` is absent. Verified live on macOS: the installed bundle's binary spawns via the exact fixed resolution (`--version`, Metal init, exit 0), the placeholder build compiles and launches, and the pinned archives download, hash-match, extract, and run on this machine. The in-app GUI walk (Settings row + session start) is user-walked — the dev binary's keychain prompt blocks machine-driving it. | +| I74 | Sev2 | `src/features/friends/presence.ts` + `presenceRelay.ts` + `src/lib/nostr/` | A mutually added friend showed permanently offline on BOTH ends whenever a STUN-only WebRTC datachannel could not form between the two networks (symmetric NAT / CGNAT / strict firewall — no TURN ships, ARCHITECTURE §4). Heartbeats only rode datachannels; trystero fires no callback on a failed ICE attempt (it silently re-offers forever), and offline ContactCard pairing (§5.1) removed the last step that ever proved the P2P path worked — so the failure was invisible end to end, with every relay reachable and both apps running. Presence, invites, and sessions all share the broken leg; presence was just the visible symptom. | **fixed** — relay-carried presence: sealed ephemeral Nostr events (kind 20001, new `studyvis:presence-relay:v1` tag/key derivations pinned in topics.test.ts) published every 30 s to the pinned relays over an owned reconnecting socket pool; no `since` filter and `limit: 0` (the #47 C1 clock-skew lesson). The datachannel leg stays and now stamps `lastP2pAt`, so `presenceState()` distinguishes direct-online from relay-only "limited" (120 s settle, I54 lesson) — surfaced in the friends list as an amber "Available · limited connection" row plus a one-line hint deep-linking Settings → Network (TURN). Goodbyes keep `lastSeenAt` for "seen … ago". Sessions/invites behind the same NAT still need TURN — the UI now says so instead of lying "Offline". Old builds interop unchanged (they never see this leg). ARCHITECTURE §4/§7/§11/§14 + PLAN §2 updated; `offchain.pub` dropped from the relay pin (now rejects anonymous publishes). | +| I75 | Sev1 | `src-tauri/src/commands/sidecar.rs` | After 1.8.0 shipped I73's spawn-path fix, on-device AI still failed to start on a real Windows install: `llama-server.exe` spawned, printed its banner (`Running without SSL`, `loading model`), then exited with `no backends are loaded` / `failed to load model` / `giving up after 4 restart attempts` (friend's `llama-server.log`, 2026-07-26 — the same day 1.8.0 shipped, the very next link in the same chain). Root cause: the pinned llama.cpp b9095 release assets are `GGML_BACKEND_DL` builds — 15 `ggml-cpu-*.dll` variants on Windows (haswell/zen4/sse42/…), `libggml-cpu.dylib`/`libggml-metal.dylib`/`libggml-blas.dylib` on macOS — that ggml `dlopen()`s at startup rather than linking. `ggml_backend_load_best` (`ggml/src/ggml-backend-reg.cpp`) globs exactly two places for those: the executable's own directory and the process's current working directory — never `PATH`/`DYLD_FALLBACK_LIBRARY_PATH`/`LD_LIBRARY_PATH`. I73's env-var prepend only satisfies the binary's _linked_ imports (`llama.dll`/`ggml-base.dll`/…), which is why the process starts at all; it never reaches the dlopen glob, so `ggml_backend_reg_count()` stays 0, `common_init_from_params` fails, and the crash-restart watcher gives up after `RESTART_BUDGET` (4) identical failures — on every bundled Windows and macOS install, not an edge case. Verified against the pinned llama.cpp b9095 source (`ggml-backend-reg.cpp:479-489`) and the actual release archives (`llama-b9095-bin-win-cpu-x64.zip`, `llama-b9095-bin-macos-arm64.tar.gz`). | **fixed** — `spawn_llama` now also sets the child's working directory to the same runtime dir already resolved for the `PATH`/`DYLD_FALLBACK_LIBRARY_PATH`/`LD_LIBRARY_PATH` prepend (`Command::current_dir`, tauri-plugin-shell 2.3.5), since `fs::current_path()` is in ggml's search list. One code path covers both engine sources (bundled, and the managed install where `runtime_dir` already equals the exe's own directory) and all three platforms. Not runnable on this box — no cargo/node toolchain and `src-tauri/binaries/` has no fetched engine on this Linux dev host; gated by CI and the `Release prep` workflow's gate job instead. | +| I76 | Sev1 | `src/features/ai/sampleLoop.ts` + `captureScreen.ts` + `src/routes/Home.tsx` + `AiCategory.tsx` + `SessionView.tsx` | User report: "AI capture error: getDisplayMedia must be called from a user gesture handler" firing on ordinary session starts with AI already enabled, and — because the fallout from this same failure kept killing the just-started sidecar — a separate, misleading "AI isn't running yet. Turn it on in Settings → AI" from the Ctrl+] chat dialog even though AI genuinely was on. Root cause: `sampleLoop.ts`'s `boot()` acquires the session's long-lived screen `MediaStream` via `navigator.mediaDevices.getDisplayMedia()`, but `boot()` runs from a React `useEffect` fired by state changes (session active + AI on + model chosen + camera up), never from inside a click handler. WebView2 (Windows) and WKWebView (macOS) require `getDisplayMedia()` to run inside live transient user activation on _every_ call, not just the first — the same reason the OS picker itself fires on every acquire (documented in `src/features/ai/README.md`'s "Acquire strategy", which is why V2-P9 already moved to one long-lived stream instead of a per-tick acquire) — so with no gesture in `boot()`'s call stack the call was rejected outright. Because the rejection's `DOMException` name fell outside `mapDisplayMediaError`'s handled set, it surfaced as the generic `screen_capture_unavailable` code and a raw toast instead of the intended `screen_capture_denied` recovery overlay, and `boot()`'s existing failure path tore down the sidecar it had just started. A second, compounding gap: `onCaptureError` never updated `AiStatusChip`'s runtime status, so the chip kept reading "active" after AI had silently died underneath it — matching the reporter's "I can't tell if it's on or if it's errored." | **fixed** — a gesture-context handoff: callers that DO have a real user gesture (`TopicGateModal`'s submit when starting a session with AI already enabled; `AiCategory`'s "enable AI" toggle when a session is already active; `SessionView`'s permission-overlay retry) call the new `preacquireScreenStream()` synchronously (no `await` before it), which starts `getDisplayMedia()` inside that click and stashes the in-flight promise; `sampleLoop.ts`'s default `acquireScreenStream` runtime hook consumes that stash instead of calling `getDisplayMedia()` itself outside gesture context. An unconsumed stash (a rapid re-toggle, or a session that never reaches `boot()`) is released via `discardPendingScreenStream()`, including on `SessionView` unmount, so it never leaks a live stream or leaves the OS recording indicator lit. Separately, `onCaptureError` now carries a `fatal` flag — true for a `boot()`-time acquire failure (the loop really did tear itself and the sidecar down) vs. false for a `tick()`-time transient one (the loop keeps running) — so `SessionView` only flips the status chip to "error" on the former. Unit-tested (pending-stream stash/discard, default-runtime consumption of the stash, the `fatal` flag on both call sites); `npm run build`/`lint`/`test` all green (878 tests). | +| I77 | Sev1 | `src/features/session/lifecycle.ts` + `SessionView.tsx` + `tests/integration/session.test.ts` | User report: "on my device I can't see the other person's camera but they can see mine" — a guest joining a friend's session never received the host's camera **or** mic, in either direction of the pair, while the host saw the guest fine. Root cause: `SessionView`'s media-acquire effect published the local `MediaStream` with a single untargeted `room.addStream(stream)`, and trystero 0.24 delivers a stream only to the peers that are active **at that instant** — `addStream` → `applyMediaOp` → `iterate` enumerates `keys(activePeerMap)` right then (`@trystero-p2p/core` `room.mjs:83`, `:494`) and queues nothing; peer activation (`room.mjs:306-314`) sets `activePeerMap` and fires `onPeerJoin` but replays no previously added local stream. The host is structurally guaranteed to lose that race: `hostSession()` derives a session topic from 32 fresh random bytes and `begin()`s the room **before** the invite is even sent, so the host's camera opens while it is provably alone and its one broadcast reaches nobody, forever. The guest normally wins it, because the session peer activates over trystero's already-open shared connection to that same friend in roughly one RTT — faster than a cold camera opens — so the guest's `addStream` lands and the host sees the guest. Two stale comments asserted the opposite of the library's actual behavior and are what preserved the bug: `SessionView.tsx` claimed `addStream` "forwards new tracks to all current peers **and to peers who join later**", and the stream-binding effect claimed "trystero replays existing peers when we register the stream callback" (`onPeerStream` is a bare assignment at `room.mjs:511`; only `onPeerJoin` sweeps, at `:506-509`, a replay our own `wrapRoom` consumes at construction). CI could not catch it: the integration bus mock hard-coded both false beliefs — its `addStream` ignored `targetPeers` and fanned out to every room, and its join + `onPeerStream` paths both replayed existing streams. Day-one defect; `trystero` has been pinned `^0.24.0` since the media path was introduced, so host→guest video has never worked in any shipped build. | **fixed** — publishing moved into `publishLocalStream(room, stream)` in `lifecycle.ts`, which broadcasts to the currently-active peers and, in the immediately adjacent statement, subscribes `onPeerJoin` to re-send the same stream targeted at each later joiner (the pattern trystero's own README prescribes). The two calls live in one function so the "no `await` in the seam" invariant is structural: the broadcast covers who is active now, the subscriber covers who arrives later, and JS's single thread means no peer is missed or served twice — a double-add would desync trystero's FIFO pairing of stream metadata to incoming tracks. `SessionView`'s effect cleanup unsubscribes **before** `stopTracks`, so a "Try again" re-acquire can't hand a later joiner a dead stream. Both false comments replaced with the verified semantics + `room.mjs` line refs. The integration bus mock now models `activePeerMap` honestly (targeted sends honored, no join replay, no `onPeerStream` replay), and `tests/unit/session-publish-stream.test.ts` pins the contract — 2 of its 4 cases fail against the pre-fix code. **Both friends must update:** a patched host reaches an unpatched guest, but a patched guest still receives nothing from an unpatched host. | +| I78 | Sev2 | `src-tauri/Cargo.toml` (`tauri 2.11.0`) | GHSA-7gmj-67g7-phm9 — "Tauri has an Origin Confusion Issue that Allows Remote Pages to Invoke Local-Only IPC Commands" (CVSS 8.8), affecting `tauri >= 2.0.0, <= 2.11.0`; fixed upstream in 2.11.1. StudyVis exposes a wide IPC surface (SQLite, keychain-backed identity, sidecar spawn, filesystem paths), so origin confusion is the class that matters most here rather than a theoretical one. Not found by `cargo deny`: the advisory is GitHub-Advisory-Database-only and RustSec does not carry it — it surfaced when OSV-Scanner was run over `Cargo.lock` while building the #102 supply-chain gates. | **fixed** — `cargo update -p tauri --precise 2.11.1` (lockfile-only; `Cargo.toml` already requires `"2"`, so no manifest change). Pulled tauri-build/codegen/macros/runtime/runtime-wry/utils forward with it. Verified: OSV over `Cargo.lock` no longer reports the advisory, and `cargo deny check advisories licenses bans sources` stays green. Shipped as its own PR rather than bundled into the #102 CI branch: a Tauri bump is a Rust change that this box cannot compile, so it wants its own PR and its own full CI run. The new `.github/dependabot.yml` opens the 2.11.0 → 2.11.1 bump automatically (cargo ecosystem; `tauri*` is excluded from the routine grouping precisely so it lands as its own reviewable PR), and `maintenance.yml`'s weekly OSV scan keeps reporting it until the bump lands. Nothing in the pinned-ignore list of `src-tauri/deny.toml` suppresses it. | +| I79 | Sev1 | `src/features/ai/modelStore.ts` + `src/routes/Home.tsx` + `src/features/session/SessionView.tsx` + `sampleLoop.ts` + `Report.tsx` | Issue #92: a real 10-minute two-person session on Windows rendered a report with `Focused-time —`, "No focus score was recorded for this session.", zero `ai_*` timeline rows — and, directly beside all that, "No distractions detected. Nice work." **Root cause: `useModelStore` is never hydrated outside Settings → AI.** `hydrate()` had exactly one caller, `ModelPickerContainer`'s mount effect (`ModelPickerContainer.tsx:85`), and that component mounts only inside the Settings → AI pane. `useSettingsStore` is hydrated at boot by `ThemeProvider` (`src/design/theme.tsx:52`), so `aiFeaturesEnabled` was correctly `true` while `activeModelId` sat at its `null` initial value — and `activeModelId` gates everything: `SessionView.tsx`'s sample-loop effect returns early on `if (!activeModelId)`, so `startSampleLoop` is never called and its `onStartFail('no_active_model')` toast — the one surface that names this — can never fire; `Home.tsx`'s `handleTopicSubmit` skips the V2-P9 gesture-context `preacquireScreenStream()` on the same condition, which on WebView2 is separately fatal. So any launch where the user didn't happen to open Settings → AI ran a whole session with AI silently dead: no loop, no toast, no audit row, no log line, and an unscored `sessions` row. Cross-platform and present at HEAD — it also explains #94 ("Ai does not work on macos when its enabled"). The report then made the silence permanent: `score`/`focused_pct`/`confident_samples`/`skipped_samples` all read NULL for an AI-off session, an AI-on-but-dead session, AND a pre-003 row, so no surface could tell a deliberate choice from a malfunction, and the distractions empty state asserted a clean measurement that never happened. Five further silent-death paths found alongside it: a sidecar that spawns but never reports healthy, an HTTP error from the sidecar, a per-tick abort, and any other tick throw were each `console.warn`-only (no devtools in release builds); the live 90 s per-tick timeout was 3.3× tighter than benchmark.ts's 300 s bound, so a model could benchmark successfully — the only thing that sets `activeModelId` — and then abort every live inference forever; an unanswered screen-share picker wedged `boot()` with no timeout, and `stop()` awaits `bootPromise`, so the sidecar was never killed; the Rejoin path and the camera/mic "Try again" path both re-`boot()` with no gesture pre-acquire; `mapDisplayMediaError` had no `InvalidStateError`/`InvalidAccessError` case, so a missing-transient-activation refusal was filed as `unavailable` (a dead-end toast) instead of reaching the recovery overlay whose retry button IS a gesture; `resolve_runtime_dir`'s `_ => Ok(None)` still degraded to a spawn with no CWD and no PATH prepend — the exact lethal-on-Windows state I75 fixed; and a child that dies in the Windows loader spawns Ok, so it crash-loops to the restart budget without ever reaching the VC++-redist hint. | **fixed** — (1) hydrate `useModelStore` in `Home.tsx`'s boot effect, so the persisted model is the truth from launch rather than from a Settings visit; (2) `handleTopicSubmit` + `handleRejoin` + `handleMediaRetry` all pre-acquire the screen stream inside their real user gesture, and a store still mid-hydration counts as "maybe active" (an unconsumed stream is discarded on unmount; a missed pre-acquire is fatal on WebView2); (3) a once-per-session toast when AI is on, the model store is `ready`, and no model is active — the gap where `onStartFail` could never fire; (4) `onStalled` fires once per loop lifetime after `STALL_TICKS` (3) consecutive unproductive ticks, with a distinct reason per cause (`engine_unavailable` / `engine_error` / `inference_timeout` / `unknown`) and actionable copy; paused states (break, camera off, pomodoro rest, battery) are deliberately not stalls; (5) the per-tick timeout is derived from the model's benchmarked p95 (`effectiveRequestTimeoutMs`: 3× p95, floored at 90 s, capped at benchmark.ts's 300 s); (6) `SCREEN_ACQUIRE_TIMEOUT_MS` (120 s) bounds the acquire so an unanswered picker becomes a visible retryable error instead of a permanent wedge, and a late-arriving stream is stopped rather than leaked; (7) `InvalidStateError` / `InvalidAccessError` → `screen_capture_denied`, routing to the overlay whose retry is itself the missing gesture; (8) migration **004** adds `sessions.ai_enabled` (1/0, NULL = pre-004), written from live settings at teardown, and the new `aiCoverage()` derivation gives the report four honest states — `ran` keeps the earned "Nice work", `noChecks` names the malfunction and points at Settings → AI, `off` says AI was off, `unknown` stays cause-neutral for pre-004 rows — shared by the rendered report and the text export so a pasted copy can never disagree; (9) Rust: `resolve_runtime_dir` falls back to the binary's own directory (one of the two places ggml globs anyway) instead of `None`, and the crash-loop give-up path now carries `append_windows_dll_hint`. Tests: `aiCoverage` (6 cases incl. the pre-003 scored row and the NULL-is-not-0 rule), serializer honesty (4), `snapshotFocusForReport.aiEnabled` (3), stall notice (4 incl. streak-reset and camera-off-is-not-a-stall), `effectiveRequestTimeoutMs` boundaries (4), and a Rust 003→004 upgrade test asserting old rows read NULL. Stories: `AiOnButNoChecks`, `AiOffForSession`. | diff --git a/src-tauri/src/commands/sessions.rs b/src-tauri/src/commands/sessions.rs index faff224d..878e2647 100644 --- a/src-tauri/src/commands/sessions.rs +++ b/src-tauri/src/commands/sessions.rs @@ -29,6 +29,10 @@ pub fn sessions_insert( generated_at: Option, confident_samples: Option, skipped_samples: Option, + // I79 — 1 = AI focus detection was on for this session, 0 = off, None = + // caller didn't say (older frontend). None coalesces, so an omitted value + // never overwrites a recorded one. + ai_enabled: Option, ) -> Result<(), String> { let conn = lock(&state)?; let row = sessions::SessionRow { @@ -43,6 +47,7 @@ pub fn sessions_insert( generated_at, confident_samples, skipped_samples, + ai_enabled, }; sessions::insert(&conn, &row).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/commands/sidecar.rs b/src-tauri/src/commands/sidecar.rs index 46104649..93f6667d 100644 --- a/src-tauri/src/commands/sidecar.rs +++ b/src-tauri/src/commands/sidecar.rs @@ -478,7 +478,21 @@ fn spawn_with_fallback( for (source, binary) in candidates { let runtime_dir = match source { // Companion dylibs/dlls that tauri bundles under Resources/. - super::engine::EngineSource::Bundled => resolve_runtime_dir(app).ok().flatten(), + // + // I79 — fall back to the binary's own directory when the resource + // path doesn't resolve. `resolve_runtime_dir` returns Ok(None) for + // any miss (wrong triple, bundler laid the companions out + // differently — I73 is precisely that having happened once), and a + // None used to mean spawning with NO working directory and NO PATH + // prepend: the exact state I75 fixed, still reachable, and fatal on + // Windows where the ggml backends are dlopen-only. The exe's own + // directory is one of the two places ggml_backend_load_best globs + // anyway, so this fallback is never worse than None and is right + // whenever the companions ship beside the binary. + super::engine::EngineSource::Bundled => resolve_runtime_dir(app) + .ok() + .flatten() + .or_else(|| binary.parent().map(Path::to_path_buf)), // The managed install keeps libraries next to the binary, where // @loader_path / $ORIGIN already resolve them; prepending the dir // anyway keeps both sources on one code path. @@ -681,9 +695,17 @@ async fn watch( restart_attempts = next_attempts(restart_attempts, child_started_at.elapsed()); if restart_attempts > RESTART_BUDGET { guard.errored = true; + // I79 — carry the Windows VC++ hint here too. A child that dies + // inside the loader spawns successfully (CreateProcess returns a + // handle before the DLL resolution that kills it), so it never + // reaches the spawn-failure path where this hint was applied — it + // crash-loops to the restart budget instead, and the toast the JS + // side raises from `last_error` named an exit code and nothing the + // user could act on. guard.last_error = last_exit .clone() - .or_else(|| Some(format!("restart budget exceeded ({RESTART_BUDGET})"))); + .or_else(|| Some(format!("restart budget exceeded ({RESTART_BUDGET})"))) + .map(append_windows_dll_hint); guard.port = None; let _ = writeln!( log, diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index 729ba1f2..f317b5a0 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -13,11 +13,13 @@ use rusqlite::{Connection, TransactionBehavior}; const MIGRATION_001_INITIAL: &str = include_str!("migrations/001_initial.sql"); const MIGRATION_002_V2: &str = include_str!("migrations/002_v2.sql"); const MIGRATION_003_SAMPLE_COUNTS: &str = include_str!("migrations/003_sample_counts.sql"); +const MIGRATION_004_AI_ENABLED: &str = include_str!("migrations/004_ai_enabled.sql"); const MIGRATIONS: &[(u32, &str)] = &[ (1, MIGRATION_001_INITIAL), (2, MIGRATION_002_V2), (3, MIGRATION_003_SAMPLE_COUNTS), + (4, MIGRATION_004_AI_ENABLED), ]; pub const MAX_KNOWN_VERSION: u32 = MIGRATIONS[MIGRATIONS.len() - 1].0; @@ -110,7 +112,7 @@ mod tests { .unwrap_or(0) } - const LATEST_VERSION: u32 = 3; + const LATEST_VERSION: u32 = 4; #[test] fn applies_full_schema_on_empty_db() { @@ -235,6 +237,53 @@ mod tests { assert_eq!(skipped, None); } + // I79 acceptance: 004 runs cleanly on a database already at schema_version + // 3 with a real session row, and that pre-migration row reads back a NULL + // `ai_enabled` — "unknown", never a fabricated 0 that would let the report + // claim AI was off in a session nobody recorded the setting for. + #[test] + fn upgrades_v3_db_to_v4_with_null_ai_enabled_on_old_rows() { + let mut conn = Connection::open_in_memory().expect("open in-memory"); + { + conn.execute( + "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)", + [], + ) + .expect("schema_version"); + let tx = conn.transaction().expect("tx"); + tx.execute_batch(MIGRATION_001_INITIAL).expect("apply 001"); + tx.execute_batch(MIGRATION_002_V2).expect("apply 002"); + tx.execute_batch(MIGRATION_003_SAMPLE_COUNTS) + .expect("apply 003"); + tx.execute( + "INSERT INTO schema_version (version) VALUES (1), (2), (3)", + [], + ) + .expect("record v3"); + tx.commit().expect("commit v3"); + } + conn.execute( + "INSERT INTO sessions (id, started_at, score, confident_samples) + VALUES ('s1', 1, 90, 24)", + [], + ) + .expect("insert session"); + assert_eq!(current_version(&conn), 3); + + let applied = run_migrations(&mut conn).expect("upgrade run"); + assert_eq!(applied, LATEST_VERSION); + + let (ai_enabled, confident): (Option, Option) = conn + .query_row( + "SELECT ai_enabled, confident_samples FROM sessions WHERE id = 's1'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .expect("read ai_enabled"); + assert_eq!(ai_enabled, None, "pre-004 rows must read as unknown"); + assert_eq!(confident, Some(24), "003 data must survive the 004 upgrade"); + } + #[test] fn refuses_db_created_by_newer_version() { let mut conn = Connection::open_in_memory().expect("open in-memory"); @@ -298,6 +347,10 @@ mod tests { 3, "a1ef24581336a04ecb9f9636afe3d0c574d9e47072f88ffccd1ff3c9aefffa42", ), + ( + 4, + "b083d1dfcf99b192a69ef21b6f1d1300dad999058feb5c9a6bf43853662d01cf", + ), ]; assert_eq!( MIGRATIONS.len(), diff --git a/src-tauri/src/db/migrations/004_ai_enabled.sql b/src-tauri/src/db/migrations/004_ai_enabled.sql new file mode 100644 index 00000000..f6fe55ac --- /dev/null +++ b/src-tauri/src/db/migrations/004_ai_enabled.sql @@ -0,0 +1,16 @@ +-- I79 migration. Chained behind 003 (never edit a shipped migration). +-- +-- Whether on-device AI focus detection was ENABLED for this session, recorded +-- at teardown from the live settings value. Without it, `score`/`focused_pct`/ +-- `confident_samples`/`skipped_samples` all reading NULL is ambiguous three +-- ways — AI deliberately off, AI on but never able to run a single check, or a +-- row written by a build older than the counters — and the post-session report +-- cannot tell the user which happened. Issue #92 is that ambiguity seen from +-- the outside: a Windows session where AI was on and silently dead rendered +-- identically to a clean AI-off session, down to "No distractions detected. +-- Nice work." +-- +-- 1 = AI features were on, 0 = off, NULL = unknown (any row written before +-- this migration). The report treats NULL as "unknown" and keeps its existing +-- cause-neutral copy, so old rows never gain a claim nobody recorded. +ALTER TABLE sessions ADD COLUMN ai_enabled INTEGER; diff --git a/src-tauri/src/db/migrations/MANIFEST.sha256 b/src-tauri/src/db/migrations/MANIFEST.sha256 index 29022516..945cf6dd 100644 --- a/src-tauri/src/db/migrations/MANIFEST.sha256 +++ b/src-tauri/src/db/migrations/MANIFEST.sha256 @@ -7,3 +7,4 @@ d19c380c48d5986806f36eedd72332f2d96e57390ce92ed839fbffe51bc8300e 001_initial.sql f01897d50e1d448a0995ace633c04c82b454c32c0e792a94964a81ae46031685 002_v2.sql a1ef24581336a04ecb9f9636afe3d0c574d9e47072f88ffccd1ff3c9aefffa42 003_sample_counts.sql +b083d1dfcf99b192a69ef21b6f1d1300dad999058feb5c9a6bf43853662d01cf 004_ai_enabled.sql diff --git a/src-tauri/src/db/sessions.rs b/src-tauri/src/db/sessions.rs index 2b8323ae..abe3ab04 100644 --- a/src-tauri/src/db/sessions.rs +++ b/src-tauri/src/db/sessions.rs @@ -33,13 +33,18 @@ pub struct SessionRow { // and AI-off sessions; the report treats NULL as "counts unknown". pub confident_samples: Option, pub skipped_samples: Option, + // I79 — whether AI focus detection was enabled for this session (004 + // migration). 1 = on, 0 = off, NULL = unknown (pre-004 row). Lets the + // report separate "AI was off" from "AI was on and recorded nothing", + // which every other column in this struct reads as NULL for both. + pub ai_enabled: Option, } pub fn list(conn: &Connection) -> Result> { let mut stmt = conn.prepare( "SELECT id, started_at, ended_at, total_minutes, peer_pubkeys, declared_topic, score, focused_pct, generated_at, - confident_samples, skipped_samples + confident_samples, skipped_samples, ai_enabled FROM sessions ORDER BY started_at DESC, id ASC", )?; @@ -56,6 +61,7 @@ pub fn list(conn: &Connection) -> Result> { generated_at: row.get(8)?, confident_samples: row.get(9)?, skipped_samples: row.get(10)?, + ai_enabled: row.get(11)?, }) })?; rows.collect() @@ -65,7 +71,7 @@ pub fn get(conn: &Connection, id: &str) -> Result> { let mut stmt = conn.prepare( "SELECT id, started_at, ended_at, total_minutes, peer_pubkeys, declared_topic, score, focused_pct, generated_at, - confident_samples, skipped_samples + confident_samples, skipped_samples, ai_enabled FROM sessions WHERE id = ?1", )?; @@ -82,6 +88,7 @@ pub fn get(conn: &Connection, id: &str) -> Result> { generated_at: row.get(8)?, confident_samples: row.get(9)?, skipped_samples: row.get(10)?, + ai_enabled: row.get(11)?, }) }) .optional() @@ -104,8 +111,8 @@ pub fn insert(conn: &Connection, row: &SessionRow) -> Result<()> { "INSERT INTO sessions (id, started_at, ended_at, total_minutes, peer_pubkeys, declared_topic, score, focused_pct, generated_at, - confident_samples, skipped_samples) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + confident_samples, skipped_samples, ai_enabled) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(id) DO UPDATE SET started_at = excluded.started_at, ended_at = excluded.ended_at, @@ -116,7 +123,8 @@ pub fn insert(conn: &Connection, row: &SessionRow) -> Result<()> { focused_pct = COALESCE(excluded.focused_pct, sessions.focused_pct), generated_at = COALESCE(excluded.generated_at, sessions.generated_at), confident_samples = COALESCE(excluded.confident_samples, sessions.confident_samples), - skipped_samples = COALESCE(excluded.skipped_samples, sessions.skipped_samples)", + skipped_samples = COALESCE(excluded.skipped_samples, sessions.skipped_samples), + ai_enabled = COALESCE(excluded.ai_enabled, sessions.ai_enabled)", params![ row.id, row.started_at, @@ -129,6 +137,7 @@ pub fn insert(conn: &Connection, row: &SessionRow) -> Result<()> { row.generated_at, row.confident_samples, row.skipped_samples, + row.ai_enabled, ], )?; Ok(()) @@ -220,6 +229,7 @@ pub fn synthesize_from_orphaned_audit_events( generated_at: None, confident_samples: None, skipped_samples: None, + ai_enabled: None, }, )?; adopted += 1; @@ -275,6 +285,7 @@ mod tests { generated_at: None, confident_samples: None, skipped_samples: None, + ai_enabled: None, } } @@ -362,6 +373,7 @@ mod tests { generated_at: None, confident_samples: None, skipped_samples: None, + ai_enabled: None, }; insert(&conn, &row).expect("insert 1"); let again = SessionRow { @@ -376,6 +388,7 @@ mod tests { generated_at: None, confident_samples: None, skipped_samples: None, + ai_enabled: None, }; insert(&conn, &again).expect("insert 2"); let read = get(&conn, "topic-hex").expect("get").expect("present"); @@ -403,6 +416,7 @@ mod tests { generated_at: Some(1_700_000_300_500), confident_samples: Some(24), skipped_samples: Some(2), + ai_enabled: None, }; insert(&conn, &report_row).expect("insert report"); let read = get(&conn, "topic-hex").expect("get").expect("present"); diff --git a/src/features/ai/captureScreen.ts b/src/features/ai/captureScreen.ts index 30bcbfe3..58570fb6 100644 --- a/src/features/ai/captureScreen.ts +++ b/src/features/ai/captureScreen.ts @@ -201,6 +201,19 @@ export function mapDisplayMediaError(err: unknown): CaptureError { case 'OverconstrainedError': code = 'screen_capture_no_video' break + case 'InvalidStateError': + case 'InvalidAccessError': + // I79 — "no transient activation" (the I76 failure). Chromium/WebView2 + // raises InvalidStateError, WebKit InvalidAccessError, and the default + // branch below used to file both under `screen_capture_unavailable`, + // whose only affordance is a toast carrying a raw DOMException string. + // `screen_capture_denied` mounts the recovery overlay instead — and its + // "Try again" button is itself the user gesture the call was missing, + // so the retry genuinely works rather than repeating the failure. Its + // non-mac steps ("if the prompt was dismissed, click Try again") read + // correctly for this case too. + code = 'screen_capture_denied' + break default: code = 'screen_capture_unavailable' } diff --git a/src/features/ai/focusStore.ts b/src/features/ai/focusStore.ts index 4405aa5a..a15c02d9 100644 --- a/src/features/ai/focusStore.ts +++ b/src/features/ai/focusStore.ts @@ -173,6 +173,13 @@ export type FocusSnapshot = { // doesn't render as "0 checks skipped". confidentSamples: number | null skippedSamples: number | null + // I79 — 1 when AI focus detection was enabled for this session, 0 when off. + // Read from settings rather than inferred from the tallies: every field above + // is null both for an AI-off session and for an AI-on session whose loop + // never ran a check, and issue #92 is what it costs a user not to be able to + // tell those apart. Never null from this function — only a row written by a + // build older than the 004 migration reads null. + aiEnabled: number } export function snapshotFocusForReport(): FocusSnapshot { @@ -184,5 +191,6 @@ export function snapshotFocusForReport(): FocusSnapshot { focusedPct: scored ? s.onTaskSamples / s.totalSamples : null, confidentSamples: ranAnyCheck ? s.totalSamples : null, skippedSamples: ranAnyCheck ? s.skippedSamples : null, + aiEnabled: useSettingsStore.getState().values.aiFeaturesEnabled ? 1 : 0, } } diff --git a/src/features/ai/index.ts b/src/features/ai/index.ts index b4d5809b..2a2ab093 100644 --- a/src/features/ai/index.ts +++ b/src/features/ai/index.ts @@ -286,6 +286,11 @@ export { __resetSampleLoopRuntime, getSampleLoopRuntime, REQUEST_TIMEOUT_MS, + MAX_REQUEST_TIMEOUT_MS, + REQUEST_TIMEOUT_P95_FACTOR, + SCREEN_ACQUIRE_TIMEOUT_MS, + STALL_TICKS, + effectiveRequestTimeoutMs, BATTERY_POLL_INTERVAL_MS, FALLBACK_SAMPLE_INTERVAL_SEC, MAX_SAMPLE_INTERVAL_SEC, @@ -302,5 +307,6 @@ export type { SampleLoopOptions, SampleLoopHandle, SampleLoopStartReason, + SampleLoopStallReason, BackoffState, } from './sampleLoop' diff --git a/src/features/ai/sampleLoop.ts b/src/features/ai/sampleLoop.ts index e6a9f429..21008a1e 100644 --- a/src/features/ai/sampleLoop.ts +++ b/src/features/ai/sampleLoop.ts @@ -79,6 +79,29 @@ import { DEFAULT_CTX_SIZE, useSidecarStore } from './sidecar' // steady-state path 60 s is generous. If the model takes longer the tick is // aborted, marked as a skip, and the next interval resumes. export const REQUEST_TIMEOUT_MS = 90_000 +// I79 — ceiling on how long boot() waits for the screen-capture acquire to +// settle. getDisplayMedia does not time out on its own: a picker the user never +// answers (alt-tabbed away, prompt behind the session window — WebView2 shows +// its own, and it is easy to miss) leaves the promise pending forever. boot() +// then never returns, `stop()` awaits `bootPromise` and so never returns +// either, and the sidecar it already started is never killed. Generous on +// purpose: a user genuinely reading the picker has two minutes, and the only +// cost of the ceiling is converting a permanent silent wedge into a visible, +// retryable error. +export const SCREEN_ACQUIRE_TIMEOUT_MS = 120_000 +// I79 — ceiling on the derived per-tick timeout below. Matches benchmark.ts's +// own 5-minute per-request bound, so a model the benchmark accepted can never +// be guaranteed to abort here. +export const MAX_REQUEST_TIMEOUT_MS = 300_000 +// I79 — multiple of the benchmark-measured p95 to allow a live inference before +// aborting it. The benchmark bounds a request at 5 minutes while this loop +// bounded it at 90 s, so a model that measured a p95 above ~90 s benchmarked +// "successfully" (that measurement is the ONLY thing that sets activeModelId) +// and then aborted every single live tick, forever, with nothing but a +// console.warn to show for it. Deriving the live bound from the same +// measurement closes that gap; 3× leaves room for the ordinary variance the +// cadence backoff is separately designed to absorb. +export const REQUEST_TIMEOUT_P95_FACTOR = 3 // How often we re-read battery state — once a minute matches ARCHITECTURE // §2's "polls this every 60 s". Cheap Tauri command so we could go faster, // but battery state isn't moving in the milliseconds. @@ -114,6 +137,19 @@ export function effectiveIntervalSec( return Math.max(floor, Math.min(MAX_SAMPLE_INTERVAL_SEC, userOverrideSec)) } +// I79 — per-tick HTTP timeout, derived from the model's benchmark-measured p95. +// `p95Sec` of 0 means "never benchmarked" (or a benchmark without a usable +// measurement), which falls back to the flat REQUEST_TIMEOUT_MS the loop always +// used. Pure so the unit tests can pin the boundaries. +export function effectiveRequestTimeoutMs(p95Sec: number): number { + if (!Number.isFinite(p95Sec) || p95Sec <= 0) return REQUEST_TIMEOUT_MS + const derived = p95Sec * REQUEST_TIMEOUT_P95_FACTOR * 1000 + return Math.min( + MAX_REQUEST_TIMEOUT_MS, + Math.max(REQUEST_TIMEOUT_MS, Math.round(derived)) + ) +} + // A6 — duration-based cadence backoff. ARCHITECTURE §8 promised a // "thermal-aware notice" but only on-battery+<20% paused sampling — which // never fires on AC, exactly where a fanless laptop throttles under @@ -321,6 +357,30 @@ export type SampleLoopStartReason = | 'model_files_missing' | 'sidecar_start_failed' +// I79 — why a running loop has produced no judgments. Distinct from +// `SampleLoopStartReason`: the loop DID start, and is still ticking. +export type SampleLoopStallReason = + // The sidecar never reached running+healthy. Covers the Rust watcher having + // exhausted its restarts (status 'errored' has its own callback) and the + // slower kind where /health simply never returns 2xx. + | 'engine_unavailable' + // The sidecar answered, with an HTTP error. A model that loaded without its + // vision projector answers exactly this way, every tick. + | 'engine_error' + // Every inference hit the per-tick timeout. The machine is too slow for the + // cadence, or the model is wedged. + | 'inference_timeout' + // Anything else thrown inside the tick — a fetch TypeError, a malformed + // response, an encode failure. + | 'unknown' + +// I79 — consecutive unproductive ticks before `onStalled` fires. Three is long +// enough that a cold-start warmup (the first tick or two commonly skip while +// llama-server loads the model into RAM) never trips it, and short enough that +// the user hears about a genuinely dead pipeline in well under a minute at the +// default cadence. +export const STALL_TICKS = 3 + export type SampleLoopOptions = { // Declared study topic. Read per-tick via callback so a mid-session // topic_change via the V2-P7 Ctrl+] dialog takes effect on the NEXT @@ -369,6 +429,19 @@ export type SampleLoopOptions = { // p95, i.e. the machine is throttling). SessionView wires a one-shot // in-voice toast. No payload: the notice is informational, not actionable. onThermalBackoff?: () => void + // I79 — fires ONCE per loop lifetime when the loop has been ticking without + // producing a single judgment for STALL_TICKS consecutive ticks. + // + // Every path this covers used to be a bare `console.warn` and a reschedule: + // a sidecar that spawns but never reports healthy, an HTTP error from a model + // whose vision projector failed to load, an inference that aborts on the + // per-tick timeout every time, a `fetch` TypeError. In release builds there + // is no devtools and llama-server.log holds none of it, so the entire + // session ran with the AI chip reading "watching" and the report recorded + // nothing — which is exactly what issue #92 looked like from the user's side. + // Paused states (break, camera off, pomodoro rest, battery) are deliberately + // NOT stalls: nothing is wrong and nothing is being hidden. + onStalled?: (reason: SampleLoopStallReason) => void // Fires once per resolved sample with the events the score machine // emitted for that sample plus the sample's verdict. V2-P6 wires the // peer-alert + self-warning dispatcher through this callback so the @@ -422,6 +495,11 @@ type InternalState = { // `justEngaged` and re-toast. This latch keeps the documented once-only // contract; mirrors `batteryNoticeShown` / `sidecarErrorReported`. thermalNoticeShown: boolean + // I79 — consecutive ticks that reached the inference path (not a paused or + // input-absent state) and still produced no judgment. Reset by any resolved + // sample. Drives the one-shot `onStalled` notice via `stallReported`. + unproductiveTicks: number + stallReported: boolean modelId: string | null ticks: number // The long-lived screen MediaStreams acquired in boot(). Empty until boot @@ -434,7 +512,10 @@ type InternalState = { export function startSampleLoop(opts: SampleLoopOptions): SampleLoopHandle { const runtime = activeRuntime - const requestTimeoutMs = opts.requestTimeoutMs ?? REQUEST_TIMEOUT_MS + // I79 — an explicit override wins verbatim (the unit tests drive short + // timeouts through it); otherwise the bound is derived per tick from the + // model's measured p95 via `effectiveRequestTimeoutMs`. + const requestTimeoutOverrideMs = opts.requestTimeoutMs ?? null const state: InternalState = { stopped: false, @@ -448,6 +529,8 @@ export function startSampleLoop(opts: SampleLoopOptions): SampleLoopHandle { modelP95Sec: 0, backoff: initialBackoffState(), thermalNoticeShown: false, + unproductiveTicks: 0, + stallReported: false, modelId: opts.modelId, ticks: 0, screenStreams: [], @@ -710,6 +793,18 @@ export function startSampleLoop(opts: SampleLoopOptions): SampleLoopHandle { }, delayMs) } + // I79 — count an unproductive tick and raise the one-shot stall notice once + // the streak reaches STALL_TICKS. Called only from paths that genuinely tried + // to produce a judgment; paused / input-absent paths call `resetStall` or + // neither, so a camera-off stretch never accuses the engine. + function noteUnproductiveTick(reason: SampleLoopStallReason): void { + state.unproductiveTicks += 1 + if (state.unproductiveTicks >= STALL_TICKS && !state.stallReported) { + state.stallReported = true + opts.onStalled?.(reason) + } + } + async function tick(): Promise { if (state.stopped) return state.ticks += 1 @@ -780,6 +875,10 @@ export function startSampleLoop(opts: SampleLoopOptions): SampleLoopHandle { } catch { // best-effort; we'll try again next tick } + // I79 — a sidecar that never becomes healthy is the single most common + // way AI runs a whole session recording nothing. Counted, so the user + // hears about it instead of reading "AI watching" over a dead engine. + noteUnproductiveTick('engine_unavailable') schedule(nextDelayMs()) return } @@ -812,9 +911,11 @@ export function startSampleLoop(opts: SampleLoopOptions): SampleLoopHandle { state.inFlight = true activeAbort = new AbortController() + const tickTimeoutMs = + requestTimeoutOverrideMs ?? effectiveRequestTimeoutMs(state.modelP95Sec) const timer = runtime.setTimeout(() => { activeAbort?.abort() - }, requestTimeoutMs) + }, tickTimeoutMs) try { const [face, screen] = await Promise.all([ @@ -834,6 +935,7 @@ export function startSampleLoop(opts: SampleLoopOptions): SampleLoopHandle { port == null || port !== gatedPort ) { + noteUnproductiveTick('engine_unavailable') return } const body = buildFocusRequest({ @@ -860,6 +962,7 @@ export function startSampleLoop(opts: SampleLoopOptions): SampleLoopHandle { `[sampleLoop] HTTP ${response.status} from sidecar`, errText.slice(0, 200) ) + noteUnproductiveTick('engine_error') return } const json = (await response.json()) as ChatCompletionResponse @@ -887,6 +990,11 @@ export function startSampleLoop(opts: SampleLoopOptions): SampleLoopHandle { opts.onThermalBackoff?.() } state.captureErrorReported = false + // I79 — a resolved sample (confident OR uncertain) means the pipeline is + // alive end to end, so the stall streak starts over. `stallReported` + // stays latched: the notice is once per loop lifetime, and a pipeline + // that recovers on its own doesn't need a second toast. + state.unproductiveTicks = 0 const events = useFocusStore .getState() .applyJudgment(verdict, runtime.now()) @@ -910,15 +1018,18 @@ export function startSampleLoop(opts: SampleLoopOptions): SampleLoopHandle { state.captureErrorReported = true opts.onCaptureError?.(err, false) } + noteUnproductiveTick('unknown') return } if (err instanceof DOMException && err.name === 'AbortError') { console.warn( - `[sampleLoop] inference aborted (timeout ${requestTimeoutMs} ms)` + `[sampleLoop] inference aborted (timeout ${tickTimeoutMs} ms)` ) + noteUnproductiveTick('inference_timeout') return } console.warn('[sampleLoop] tick failed:', err) + noteUnproductiveTick('unknown') } finally { runtime.clearTimeout(timer) activeAbort = null @@ -964,6 +1075,53 @@ export function startSampleLoop(opts: SampleLoopOptions): SampleLoopHandle { disposeScreenStream() } + // I79 — `runtime.acquireScreenStream()` with a deadline. A stream that + // arrives after the deadline is stopped rather than leaked: the caller has + // already moved on to its failure path, so nothing would ever release those + // tracks and the OS recording indicator would stay lit with no session + // behind it. + async function acquireScreenStreamBounded(): Promise { + let timer: ReturnType | null = null + let timedOut = false + const attempt = runtime.acquireScreenStream() + try { + return await Promise.race([ + attempt, + new Promise((_resolve, reject) => { + timer = runtime.setTimeout(() => { + timedOut = true + reject( + new CaptureError( + 'screen_capture_unavailable', + `screen capture was not granted within ${Math.round( + SCREEN_ACQUIRE_TIMEOUT_MS / 1000 + )}s (the screen-share prompt may be waiting behind another window)` + ) + ) + }, SCREEN_ACQUIRE_TIMEOUT_MS) + }), + ]) + } finally { + if (timer !== null) runtime.clearTimeout(timer) + if (timedOut) { + void attempt + .then((late) => { + for (const t of late.getTracks()) { + try { + t.stop() + } catch { + // already-stopped tracks throw on some platforms; ignore. + } + } + }) + .catch(() => { + // The acquire failed on its own after we gave up — nothing to + // release, and the timeout error is what the caller already saw. + }) + } + } + } + async function boot(): Promise { if (!opts.modelId) { opts.onStartFail?.('no_active_model') @@ -1049,7 +1207,7 @@ export function startSampleLoop(opts: SampleLoopOptions): SampleLoopHandle { // and the model just sees fewer screens. let firstStream: MediaStream try { - firstStream = await runtime.acquireScreenStream() + firstStream = await acquireScreenStreamBounded() } catch (err) { if (err instanceof CaptureError && err.code === 'screen_capture_denied') { state.captureDenied = true @@ -1115,7 +1273,7 @@ export function startSampleLoop(opts: SampleLoopOptions): SampleLoopHandle { for (let i = 1; i < acquireTargetCount; i += 1) { if (state.stopped) return try { - const stream = await runtime.acquireScreenStream() + const stream = await acquireScreenStreamBounded() const track = stream.getVideoTracks()[0] if (!track) { // Defensive — degraded into a no-track stream. Release and stop diff --git a/src/features/session/Report.tsx b/src/features/session/Report.tsx index 442dbfbe..d82a5e7f 100644 --- a/src/features/session/Report.tsx +++ b/src/features/session/Report.tsx @@ -55,6 +55,7 @@ import { SEVERITY_DEDUCTIONS } from '@/features/ai/scoreMachine' import type { Severity } from '@/features/ai/parseJudgment' import { formatBreakDuration } from './break' import { + aiCoverage, deriveBreaksSummary, deriveTopDistractions, deriveTopicTimeline, @@ -62,11 +63,13 @@ import { groupTimelineByWho, sampleQualitySummary, parseAuditDetail, + type AiCoverage, } from './reportData' import { describeRow, formatTopicHeading, labelFor, + noScoreBody, serializeReportToText, type ResolvedReportData, } from './reportSerialize' @@ -277,6 +280,11 @@ export function ReportView({ ) // #47 D5 — non-null only when a material share of AI checks were skipped. const sampleQuality = sampleQualitySummary(session) + // I79 — whether the AI measured anything, and if not, whether we know why. + // Drives the score card's body copy and the distractions empty state, which + // must agree: one claiming "no score recorded" beside the other saying "Nice + // work" is the contradiction issue #92 screenshotted. + const coverage = aiCoverage(session) const [copied, setCopied] = useState(false) const copyTimer = useRef | null>(null) @@ -460,7 +468,7 @@ export function ReportView({

{strings.report.privacy}

{score == null ? ( - + ) : ( )} @@ -514,7 +522,13 @@ export function ReportView({
{topDistractions.length === 0 ? ( - + ) : (
    {topDistractions.map((entry, i) => ( @@ -591,7 +605,11 @@ function Empty({ message }: { message: string }) { // recorded focus score (AI off / no confident samples). DESIGN-SYSTEM §10 // empty-state pattern: muted, no spinner, occupies the gauge's footprint so // the hero layout doesn't reflow. -function NoScore() { +// +// I79 — the body names the cause when the row recorded one; `noScoreBody` lives +// in reportSerialize.ts so the exported copy is shared with the text export and +// this file keeps only component exports (react-refresh). +function NoScore({ coverage }: { coverage: AiCoverage }) { return (
    {strings.report.noScore.heading} - - {strings.report.noScore.body} - + {noScoreBody(coverage)}
    ) } diff --git a/src/features/session/SessionView.tsx b/src/features/session/SessionView.tsx index 2f640a00..d04ead84 100644 --- a/src/features/session/SessionView.tsx +++ b/src/features/session/SessionView.tsx @@ -184,6 +184,9 @@ export function SessionView({ // when this session hasn't touched a peer's slider yet. const persistedPeerVolumes = useSettingsStore((s) => s.values.peerVolumes) const activeModelId = useModelStore((s) => s.activeModelId) + // I79 — distinguishes "no model picked" from "model store hasn't loaded + // yet". Only the former is worth telling the user about. + const modelStatus = useModelStore((s) => s.status) const selfWarning = useAlertsUiStore((s) => s.selfWarning) const alertedPeers = useAlertsUiStore((s) => s.alertedPeers) const onBreak = useBreakStore((s) => s.onBreak) @@ -880,6 +883,25 @@ export function SessionView({ } }, [status, startedAt]) + // I79 — AI on, session live, but no model to run: say so ONCE per session. + // + // The loop effect below returns early in this state, which means + // `startSampleLoop` never runs and its `onStartFail('no_active_model')` + // toast — the one surface that names this problem — can never fire. Issue + // #92 is what that silence looks like from the outside: a full session, an + // unscored report, and nothing anywhere saying AI sat out. Gated on + // modelStatus === 'ready' so a mid-hydration null never accuses a correctly + // configured install, and keyed on startedAt so it fires once per session + // rather than on every model/camera flap. + const noModelNoticeShownFor = useRef(null) + useEffect(() => { + if (status !== 'active' || !startedAt) return + if (!aiFeaturesEnabled || modelStatus !== 'ready' || activeModelId) return + if (noModelNoticeShownFor.current === startedAt) return + noModelNoticeShownFor.current = startedAt + toast.error(strings.session.errors.pickModel, aiSettingsToastAction()) + }, [status, startedAt, aiFeaturesEnabled, modelStatus, activeModelId]) + // V2-P5 AI sample loop: starts when AI features are on, an active model // exists, the session is running, and the local camera track is up. // Stops on any of those flipping. Topic defaults to "Studying" — V2-P9 @@ -985,6 +1007,24 @@ export function SessionView({ // A6 — one-shot per session; the loop fires this at most once. toast(strings.session.errors.aiSlowedDown) }, + // I79 — the loop is alive but has produced nothing for STALL_TICKS + // consecutive checks. One-shot per loop lifetime. The chip goes to + // 'error' alongside the toast so the state is still legible after the + // toast dismisses — a silently-dead pipeline reading "watching" for a + // whole session is what issue #92 recorded. + onStalled: (reason) => { + const copy = strings.session.errors.aiStalled + const message = + reason === 'engine_unavailable' + ? copy.engineUnavailable + : reason === 'engine_error' + ? copy.engineError + : reason === 'inference_timeout' + ? copy.inferenceTimeout + : copy.unknown + toast.error(message, aiSettingsToastAction()) + setAiRuntimeStatus('error') + }, }) return () => { const local = handle @@ -1345,10 +1385,20 @@ export function SessionView({ // "Try again" — clear the error and bump the nonce so the acquisition // effect (keyed on [room, mediaRetryNonce]) re-runs getUserMedia. + // I79 — a camera/mic retry re-acquires `localStream`, which is in the + // sample-loop effect's deps, so the loop tears down and boot()s again. That + // second boot() reaches getDisplayMedia with an empty gesture stash and, on + // WebView2, is refused outright — recovering the camera would silently cost + // the user AI for the rest of the session. This click is a real gesture; + // spend it on a screen pre-acquire too, under the same AI-is-actually-running + // condition the loop effect uses. const handleMediaRetry = useCallback(() => { + if (aiFeaturesEnabled && activeModelId && !captureDenied) { + void preacquireScreenStream() + } setMediaErrorName(null) setMediaRetryNonce((n) => n + 1) - }, []) + }, [aiFeaturesEnabled, activeModelId, captureDenied]) // Only offered for the permission-denied case. Jumps to the OS camera // privacy pane via the same Rust opener the onboarding step uses. macOS is diff --git a/src/features/session/lifecycle.ts b/src/features/session/lifecycle.ts index e9b02178..7b333c4a 100644 --- a/src/features/session/lifecycle.ts +++ b/src/features/session/lifecycle.ts @@ -309,6 +309,7 @@ export function buildLeaveHandler(args: { generatedAt: endedAt, confidentSamples: focusSnapshot.confidentSamples, skippedSamples: focusSnapshot.skippedSamples, + aiEnabled: focusSnapshot.aiEnabled, }) } catch (err) { console.error('sessions_insert failed:', err) diff --git a/src/features/session/reportData.ts b/src/features/session/reportData.ts index 9e425b8f..ebc46b5a 100644 --- a/src/features/session/reportData.ts +++ b/src/features/session/reportData.ts @@ -35,6 +35,43 @@ export function sampleQualitySummary(session: { return { skipped, totalChecks } } +// I79 — how much the AI actually saw of this session. Four states, because +// three of them used to render identically (issue #92: a Windows session where +// AI was on and silently dead was byte-identical to a clean AI-off one, down to +// "No distractions detected. Nice work." asserting a measurement that never +// happened). +// +// 'ran' at least one check completed — an empty distraction list is a +// real finding and the confident "Nice work" copy is earned. +// 'noChecks' AI was on and not one check completed. Nothing was measured; +// the report says so and points at Settings → AI. +// 'off' AI was deliberately off. Nothing was measured, and that is +// exactly what the user asked for. +// 'unknown' a row written before the 004 migration recorded `ai_enabled`. +// Nothing was measured as far as we know, and we don't claim why. +// +// `confident_samples` / `skipped_samples` are the check-ran evidence: both are +// non-null together whenever the loop completed a tick (snapshotFocusForReport), +// and null together otherwise. `score` is checked too so a pre-003 row that +// recorded a score still reads as 'ran' rather than losing its history. +export type AiCoverage = 'ran' | 'noChecks' | 'off' | 'unknown' + +export function aiCoverage(session: { + score: number | null + confident_samples: number | null + skipped_samples: number | null + ai_enabled: number | null +}): AiCoverage { + const ranAnyCheck = + session.confident_samples != null || + session.skipped_samples != null || + session.score != null + if (ranAnyCheck) return 'ran' + if (session.ai_enabled === 1) return 'noChecks' + if (session.ai_enabled === 0) return 'off' + return 'unknown' +} + export function parseAuditDetail(raw: string): Record { if (!raw) return {} try { diff --git a/src/features/session/reportSerialize.ts b/src/features/session/reportSerialize.ts index 59507aee..00fd6155 100644 --- a/src/features/session/reportSerialize.ts +++ b/src/features/session/reportSerialize.ts @@ -14,12 +14,14 @@ import type { SessionRecord } from '@/lib/db/sessions' import { strings } from '@/strings' import { formatBreakDuration } from './break' import { + aiCoverage, deriveBreaksSummary, deriveTopDistractions, deriveTopicTimeline, formatOffset, groupTimelineByWho, parseAuditDetail, + type AiCoverage, } from './reportData' export type ResolvedReportData = { @@ -69,6 +71,23 @@ export function describeRow( return label } +// I79 — body copy for the unscored-session card, and the matching one-liner in +// the text export. Shared so the rendered report and a pasted/saved copy can +// never disagree about what the AI did: an unscored session where AI was ON and +// produced nothing is a malfunction the user can act on, one where AI was off is +// not, and a row predating the 004 migration cannot say which it was. +export function noScoreBody(coverage: AiCoverage): string { + if (coverage === 'off') return strings.report.noScore.bodyOff + if (coverage === 'noChecks') return strings.report.noScore.bodyNoChecks + return strings.report.noScore.body +} + +export function noScoreCopyLine(coverage: AiCoverage): string { + if (coverage === 'off') return strings.report.noScore.copyLineOff + if (coverage === 'noChecks') return strings.report.noScore.copyLineNoChecks + return strings.report.noScore.copyLine +} + export function formatTopicHeading(topic: string | null): string { if (!topic || !topic.trim()) return strings.report.studiedFallback return strings.report.studiedWithTopic(topic) @@ -88,6 +107,7 @@ export function serializeReportToText(data: ResolvedReportData): string { const grouped = groupTimelineByWho(auditEvents) const distractions = deriveTopDistractions(auditEvents, myEdPubkeyHex) const breaks = deriveBreaksSummary(auditEvents) + const coverage = aiCoverage(session) const totalMinutes = session.total_minutes ?? 0 const focusedPctLabel = session.focused_pct == null @@ -101,8 +121,9 @@ export function serializeReportToText(data: ResolvedReportData): string { formatTopicHeading(session.declared_topic), `${strings.report.summaryPrefix}${strings.report.summaryMinutes(totalMinutes)}${strings.report.summaryMiddle}${focusedPctLabel}`, // R1 — never emit a fabricated 100 for an unscored (AI-off) session. + // I79 — and name the cause when the row recorded one. session.score == null - ? strings.report.noScore.copyLine + ? noScoreCopyLine(coverage) : strings.report.scoreLine(session.score), '', `## ${strings.report.sections.topic.heading}`, @@ -137,7 +158,11 @@ export function serializeReportToText(data: ResolvedReportData): string { // user just saw. The on-screen Distractions section precedes Breaks. lines.push('', `## ${strings.report.sections.distractions.heading}`) if (distractions.length === 0) { - lines.push(strings.report.sections.distractions.empty) + lines.push( + coverage === 'ran' + ? strings.report.sections.distractions.empty + : strings.report.sections.distractions.emptyNoChecks + ) } else { for (const d of distractions) { const ded = d.totalDeduction > 0 ? ` · −${d.totalDeduction}` : '' diff --git a/src/lib/db/sessions.ts b/src/lib/db/sessions.ts index 891c8b5f..8b4b7a43 100644 --- a/src/lib/db/sessions.ts +++ b/src/lib/db/sessions.ts @@ -26,6 +26,11 @@ export type SessionRow = { // (AI off, or a row written by an older build). confidentSamples?: number | null skippedSamples?: number | null + // I79 — was AI focus detection on for this session? (004 migration.) + // 1 = on, 0 = off, null = unknown. Every other AI column reads null both + // when AI was off and when it was on but never produced a check; this is + // what lets the report tell those two apart. + aiEnabled?: number | null } // Shape returned by `sessions_list` / `sessions_get`. Tauri auto-camelCases @@ -43,6 +48,7 @@ export type SessionRecord = { generated_at: number | null confident_samples: number | null skipped_samples: number | null + ai_enabled: number | null } export async function sessionsInsert(row: SessionRow): Promise { @@ -58,6 +64,7 @@ export async function sessionsInsert(row: SessionRow): Promise { generatedAt: row.generatedAt ?? null, confidentSamples: row.confidentSamples ?? null, skippedSamples: row.skippedSamples ?? null, + aiEnabled: row.aiEnabled ?? null, }) } diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index f02f48c4..466e3d87 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -119,6 +119,22 @@ export function Home() { } }, [status, friendsStatus, loadFriends]) + // I79 — hydrate the model store at boot, not at Settings → AI mount. + // + // `activeModelId` is the gate on the whole AI focus pipeline: SessionView + // starts no sample loop without it, and `handleTopicSubmit` below skips the + // gesture-context screen pre-acquire without it. Until this effect existed + // the ONLY caller of `hydrate()` was ModelPickerContainer, which mounts + // exclusively inside Settings → AI — so on every launch where the user + // didn't happen to visit that pane, a fully configured install ran a whole + // session with AI silently dead and persisted an unscored sessions row + // (issue #92: score/focused_pct NULL, no ai_* audit rows, no toast, no log). + // Hydrating here (Home is the main window's root view, mounted before any + // session can start) makes the persisted model the truth from boot. + useEffect(() => { + void useModelStore.getState().hydrate() + }, []) + const runHostInvite = useCallback( async (friend: Friend) => { if (!identity || !identity.display_name) return @@ -259,7 +275,19 @@ export function Home() { const handleRejoin = useCallback(() => { const s = useSessionStore.getState() if (!s.sessionTopic || !s.sessionPassword) return - if (aiOn()) s.setPendingInitialTopic(s.declaredStudyTopic) + // I79 — Rejoin skips the topic gate, so it also used to skip the only + // gesture-context screen pre-acquire (handleTopicSubmit's). This click IS + // a user gesture; spend it the same way, or the rejoined session's boot() + // calls getDisplayMedia gestureless and AI is dead for the second stint. + // Must precede any await, and the model-store gate matches + // handleTopicSubmit's. + if (aiOn()) { + const models = useModelStore.getState() + if (models.activeModelId || models.status === 'loading') { + void preacquireScreenStream() + } + s.setPendingInitialTopic(s.declaredStudyTopic) + } try { joinSession(s.sessionTopic, s.sessionPassword) } catch (err) { @@ -304,7 +332,15 @@ export function Home() { // bother when a model is actually active — otherwise boot() never // runs and nothing would consume the pre-acquired stream. Must be the // very first thing this handler does, before any `await`. - if (useModelStore.getState().activeModelId) { + // + // I79 — a store still mid-hydration counts as "maybe active". Reading a + // null `activeModelId` out of an unhydrated store used to skip the + // pre-acquire, and on WebView2 that is fatal rather than degraded: + // boot()'s gestureless getDisplayMedia is refused outright and the loop + // dies before its first tick. An unconsumed stream is the cheap side of + // this trade — SessionView discards it on unmount. + const models = useModelStore.getState() + if (models.activeModelId || models.status === 'loading') { void preacquireScreenStream() } // Seed the one-shot topic BEFORE the session flips to active so diff --git a/src/stories/Dashboard.stories.tsx b/src/stories/Dashboard.stories.tsx index 78ac5680..8743db6e 100644 --- a/src/stories/Dashboard.stories.tsx +++ b/src/stories/Dashboard.stories.tsx @@ -45,6 +45,7 @@ function session(over: Partial = {}): SessionRecord { generated_at: null, confident_samples: null, skipped_samples: null, + ai_enabled: null, ...over, } } diff --git a/src/stories/FocusInsights.stories.tsx b/src/stories/FocusInsights.stories.tsx index 204b3b87..891a44d2 100644 --- a/src/stories/FocusInsights.stories.tsx +++ b/src/stories/FocusInsights.stories.tsx @@ -42,6 +42,7 @@ function session(over: Partial = {}): SessionRecord { generated_at: null, confident_samples: null, skipped_samples: null, + ai_enabled: null, ...over, } } diff --git a/src/stories/Report.stories.tsx b/src/stories/Report.stories.tsx index 509ccbd3..f3bdbd56 100644 --- a/src/stories/Report.stories.tsx +++ b/src/stories/Report.stories.tsx @@ -50,6 +50,7 @@ function baseSession(overrides: Partial = {}): SessionRecord { generated_at: ENDED_AT, confident_samples: null, skipped_samples: null, + ai_enabled: null, ...overrides, } } @@ -198,10 +199,11 @@ export const MostlyOffTask: Story = { }, } -// No-AI baseline (R1): lifecycle events only. AI focus detection was off, so -// score AND focused_pct are null — the hero renders the calm "No focus score" -// placeholder instead of a fabricated 100/100 gauge, and the Top distractions -// section shows the "Nice work" empty state. +// No-AI baseline (R1): lifecycle events only, and a row with no `ai_enabled` +// recorded — i.e. written by a build older than the I79 004 migration. Score +// and focused_pct are null, so the hero renders the calm cause-neutral "No +// focus score" placeholder rather than a fabricated 100/100 gauge. This is the +// one remaining state where the report cannot say WHY nothing was measured. export const NoAiBaseline: Story = { args: { data: buildData( @@ -223,3 +225,55 @@ export const NoAiBaseline: Story = { onClose, }, } + +// I79 — AI was ON and produced nothing. This is the state issue #92 +// screenshotted from a real Windows session, and the state this PR exists to +// stop rendering as an all-clear: the score card names the malfunction and +// points at Settings → AI, and Top distractions says nothing was measured +// instead of "No distractions detected. Nice work." +export const AiOnButNoChecks: Story = { + args: { + data: buildData( + baseSession({ + score: null, + focused_pct: null, + confident_samples: null, + skipped_samples: null, + ai_enabled: 1, + declared_topic: 'latin', + }), + [ + event(ME, 'joined', 0), + event(ME, 'topic_set', 0, { topic: 'latin' }), + event(ALICE, 'pomodoro_start', 163_000, { preset: '25/5' }), + event(ME, 'left', 638_000), + ] + ), + animateScore: false, + onClose, + }, +} + +// I79 — AI was deliberately OFF. Nothing was measured and nothing is wrong; +// the copy says so plainly rather than implying a malfunction or claiming a +// clean session the AI never watched. +export const AiOffForSession: Story = { + args: { + data: buildData( + baseSession({ + score: null, + focused_pct: null, + confident_samples: null, + skipped_samples: null, + ai_enabled: 0, + }), + [ + event(ME, 'joined', 0), + event(ALICE, 'joined', 800), + event(ME, 'left', 25 * 60_000), + ] + ), + animateScore: false, + onClose, + }, +} diff --git a/src/strings.ts b/src/strings.ts index 1e2bb238..2b0c0695 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -640,6 +640,20 @@ export const strings = { // the AI category (the copy above names it; the button honors it). openSettingsAction: 'Open settings', pickModel: 'Pick a model in Settings → AI.', + // I79 — the loop is running but has produced no judgment for several + // consecutive checks. Each reason names what to do about it; all four + // used to be a console.warn nobody in a release build can read, so the + // session simply recorded nothing and said nothing. + aiStalled: { + engineUnavailable: + "The AI engine isn't responding, so nothing is being checked. Try turning AI off and on in Settings → AI.", + engineError: + 'The AI model is loaded but rejecting checks. Re-download it in Settings → AI — its vision files may be incomplete.', + inferenceTimeout: + 'AI checks are timing out on this machine. A smaller model, or a slower sample interval, will fit better.', + unknown: + "AI checks aren't completing, so nothing is being recorded this session.", + }, modelFilesMissing: 'Model files are missing. Re-download them in Settings → AI.', aiFailedToStart: 'AI failed to start.', @@ -750,6 +764,11 @@ export const strings = { distractions: { heading: 'Top distractions', empty: 'No distractions detected. Nice work.', + // I79 — the same section when nothing was measured. "Nice work" is + // earned praise for a session the AI watched and found clean; on a + // session it never watched it was a fabricated all-clear, and it read + // as one right beside a score card admitting no score was recorded. + emptyNoChecks: 'No AI checks ran, so nothing was measured here.', }, breaks: { heading: 'Breaks', @@ -769,6 +788,15 @@ export const strings = { heading: 'No focus score', body: 'No focus score was recorded for this session.', copyLine: 'Score: not recorded', + // I79 — R1 kept this copy cause-neutral because the sessions row held no + // way to tell the causes apart. The 004 migration records `ai_enabled`, + // so two of the three cases can now be named honestly. `body` above + // stays the wording for rows that predate it. + bodyOff: 'AI focus detection was off for this session.', + bodyNoChecks: + 'AI was on but never ran a check, so nothing was measured. Check Settings → AI.', + copyLineOff: 'Score: not recorded (AI off)', + copyLineNoChecks: 'Score: not recorded (AI ran no checks)', }, copyCta: 'Copy report', copyAriaLabel: 'Copy session report to clipboard', diff --git a/tests/unit/ai-focus-store.test.ts b/tests/unit/ai-focus-store.test.ts index 0e6e172b..2eef5d77 100644 --- a/tests/unit/ai-focus-store.test.ts +++ b/tests/unit/ai-focus-store.test.ts @@ -15,6 +15,7 @@ import { } from '@/features/ai' import type { Judgment, SampleVerdict, UncertainVerdict } from '@/features/ai' import { snapshotFocusForReport } from '@/features/ai/focusStore' +import { useSettingsStore } from '@/stores/settingsStore' const UNCERTAIN: UncertainVerdict = { kind: 'uncertain', reason: 'parse fail' } @@ -283,3 +284,40 @@ describe('useFocusStore', () => { expect(useFocusStore.getState().skippedSamples).toBe(0) }) }) + +// I79 — the snapshot now also records WHETHER AI was on, because every other +// field it returns is null both for an AI-off session and for an AI-on session +// whose loop never ran a single check. Issue #92 is what that ambiguity costs: +// the report had no way to distinguish a deliberate choice from a malfunction. +describe('snapshotFocusForReport — aiEnabled (I79)', () => { + beforeEach(() => { + resetStore() + }) + afterEach(() => { + useSettingsStore.setState((s) => ({ + values: { ...s.values, aiFeaturesEnabled: false }, + })) + }) + + test('records 1 when AI features are enabled, even with zero samples', () => { + useSettingsStore.setState((s) => ({ + values: { ...s.values, aiFeaturesEnabled: true }, + })) + const snap = snapshotFocusForReport() + // The distinguishing case: nothing measured, but the user had it on. + expect(snap.score).toBeNull() + expect(snap.confidentSamples).toBeNull() + expect(snap.aiEnabled).toBe(1) + }) + + test('records 0 when AI features are off', () => { + useSettingsStore.setState((s) => ({ + values: { ...s.values, aiFeaturesEnabled: false }, + })) + expect(snapshotFocusForReport().aiEnabled).toBe(0) + }) + + test('is never null — only a pre-004 database row reads unknown', () => { + expect(snapshotFocusForReport().aiEnabled).not.toBeNull() + }) +}) diff --git a/tests/unit/ai-sample-loop.test.ts b/tests/unit/ai-sample-loop.test.ts index 2a9a41db..9f94cf4f 100644 --- a/tests/unit/ai-sample-loop.test.ts +++ b/tests/unit/ai-sample-loop.test.ts @@ -15,6 +15,11 @@ import { nextBackoffState, preacquireScreenStream, SLOW_TICK_FACTOR, + STALL_TICKS, + MAX_REQUEST_TIMEOUT_MS, + REQUEST_TIMEOUT_MS, + REQUEST_TIMEOUT_P95_FACTOR, + effectiveRequestTimeoutMs, __resetBatteryRuntime, __resetCaptureRuntime, __resetFocusStoreThresholdReader, @@ -874,6 +879,172 @@ describe('startSampleLoop — gating skip paths', () => { }) }) +// I79 — every one of these paths used to be a bare console.warn plus a +// reschedule. In a release build there is no devtools and llama-server.log +// carries none of it, so a session could run to completion with the AI chip +// reading "watching", the report recording nothing, and the user told nothing. +// Issue #92 is that state, screenshotted. +// I79 — the loop's effective cadence depends on the model floor and the user's +// Settings → AI override, so counting wall-clock advances is fragile. Drive off +// the loop's own tick accounting instead: advance until it has recorded exactly +// `target` unproductive ticks, and fail loudly rather than hang if it never does. +async function advanceToUnproductiveTicks( + handle: { __state: () => { unproductiveTicks: number } }, + clock: FakeClock, + target: number +): Promise { + for (let i = 0; i < 200; i += 1) { + if (handle.__state().unproductiveTicks >= target) return + await clock.advance(5000) + } + throw new Error( + `loop never reached ${target} unproductive ticks (stuck at ${handle.__state().unproductiveTicks})` + ) +} + +describe('startSampleLoop — stall notice (I79)', () => { + beforeEach(() => { + resetAllStores() + screenEncodeCalls = 0 + screenExtractImpl = null + __setCaptureRuntime(fakeCaptureRuntime) + __setScreenCaptureRuntime({ + getDisplayMedia: async () => makeFakeScreenStream(), + }) + }) + afterEach(() => { + __resetSampleLoopRuntime() + __resetCaptureRuntime() + __resetScreenCaptureRuntime() + __resetBatteryRuntime() + __resetFocusStoreThresholdReader() + discardPendingScreenStream() + }) + + test('a sidecar that never becomes healthy reports engine_unavailable once', async () => { + const clock = new FakeClock() + const fetchMock = vi.fn(async () => judgmentResponse('on_task')) + __setSampleLoopRuntime( + buildSampleLoopRuntime({ + clock, + fetch: fetchMock as never, + startSidecar: async () => { + // Spawns, never reaches running+healthy — the shape of a child that + // dies in the Windows loader and crash-loops under the watcher. + useSidecarStore.setState({ status: 'starting', healthy: false }) + return 9999 + }, + }) + ) + const onStalled = vi.fn() + const handle = startSampleLoop({ + getTopic: () => 't', + modelId: 'test-model', + getFaceTrack: () => makeFakeTrack(), + onStalled, + }) + await flushMicrotasks(10) + // One tick short of the threshold: still quiet, because a cold start + // legitimately skips the first tick or two while the model loads. + await advanceToUnproductiveTicks(handle, clock, STALL_TICKS - 1) + expect(onStalled).not.toHaveBeenCalled() + + await advanceToUnproductiveTicks(handle, clock, STALL_TICKS) + expect(onStalled).toHaveBeenCalledTimes(1) + expect(onStalled).toHaveBeenCalledWith('engine_unavailable') + + // One-shot for the loop's lifetime — not a toast every 5 seconds. + await advanceToUnproductiveTicks(handle, clock, STALL_TICKS + 3) + expect(onStalled).toHaveBeenCalledTimes(1) + await handle.stop() + }) + + test('an HTTP error from the sidecar reports engine_error', async () => { + // A model that loaded without its vision projector answers exactly this + // way, on every single tick. + const clock = new FakeClock() + const fetchMock = vi.fn(async () => ({ + ok: false, + status: 500, + text: async () => 'no multimodal support', + json: async () => ({}), + })) + __setSampleLoopRuntime( + buildSampleLoopRuntime({ clock, fetch: fetchMock as never }) + ) + const onStalled = vi.fn() + const handle = startSampleLoop({ + getTopic: () => 't', + modelId: 'test-model', + getFaceTrack: () => makeFakeTrack(), + onStalled, + }) + await flushMicrotasks(10) + await advanceToUnproductiveTicks(handle, clock, STALL_TICKS) + expect(onStalled).toHaveBeenCalledWith('engine_error') + expect(useFocusStore.getState().totalSamples).toBe(0) + await handle.stop() + }) + + test('a resolved sample clears the streak before it reaches the threshold', async () => { + // The guarantee that keeps this from crying wolf: an intermittent failure + // interleaved with real samples is not a stall. + const clock = new FakeClock() + let call = 0 + const fetchMock = vi.fn(async () => { + call += 1 + // fail, fail, succeed, fail, fail — never STALL_TICKS in a row. + if (call % 3 === 0) return judgmentResponse('on_task') + return { + ok: false, + status: 503, + text: async (): Promise => 'busy', + json: async (): Promise => ({}), + } + }) + __setSampleLoopRuntime( + buildSampleLoopRuntime({ clock, fetch: fetchMock as never }) + ) + const onStalled = vi.fn() + const handle = startSampleLoop({ + getTopic: () => 't', + modelId: 'test-model', + getFaceTrack: () => makeFakeTrack(), + onStalled, + }) + await flushMicrotasks(10) + // Nine ticks' worth of wall clock: enough for three fail/fail/succeed + // cycles, never STALL_TICKS failures in a row. + for (let i = 0; i < 40; i += 1) await clock.advance(5000) + expect(onStalled).not.toHaveBeenCalled() + expect(useFocusStore.getState().totalSamples).toBeGreaterThan(0) + await handle.stop() + }) + + test('a camera-off stretch is not a stall', async () => { + // isPaused covers camera-off and pomodoro rest. Nothing is wrong, nothing + // is being hidden, and accusing the engine there would be a false alarm. + const clock = new FakeClock() + const fetchMock = vi.fn(async () => judgmentResponse('on_task')) + __setSampleLoopRuntime( + buildSampleLoopRuntime({ clock, fetch: fetchMock as never }) + ) + const onStalled = vi.fn() + const handle = startSampleLoop({ + getTopic: () => 't', + modelId: 'test-model', + getFaceTrack: () => makeFakeTrack(), + isPaused: () => true, + onStalled, + }) + await flushMicrotasks(10) + for (let i = 0; i < 40; i += 1) await clock.advance(5000) + expect(fetchMock).not.toHaveBeenCalled() + expect(onStalled).not.toHaveBeenCalled() + await handle.stop() + }) +}) + describe('startSampleLoop — capture errors', () => { beforeEach(() => { resetAllStores() @@ -1422,6 +1593,38 @@ describe('startSampleLoop — A6 cadence backoff', () => { }) }) +// I79 — the live per-tick bound was a flat 90 s while benchmark.ts allows a +// request 5 minutes, and a completed benchmark is the ONLY thing that sets +// activeModelId. So a model measuring a p95 above ~90 s "passed" setup and then +// aborted every live tick forever, logging nothing a release build can show. +describe('effectiveRequestTimeoutMs — I79', () => { + test('falls back to the flat timeout when there is no benchmark', () => { + expect(effectiveRequestTimeoutMs(0)).toBe(REQUEST_TIMEOUT_MS) + expect(effectiveRequestTimeoutMs(-1)).toBe(REQUEST_TIMEOUT_MS) + expect(effectiveRequestTimeoutMs(Number.NaN)).toBe(REQUEST_TIMEOUT_MS) + }) + + test('never drops below the flat timeout for a fast model', () => { + // A 3 s p95 would derive 9 s, which would abort ticks a cold cache makes + // legitimately slow. The flat value stays the floor. + expect(effectiveRequestTimeoutMs(3)).toBe(REQUEST_TIMEOUT_MS) + }) + + test('scales with the measured p95 for a slow model', () => { + // 60 s p95 → 180 s, comfortably above the old flat 90 s ceiling that would + // have aborted every tick of a model that benchmarked fine. + expect(effectiveRequestTimeoutMs(60)).toBe( + 60 * REQUEST_TIMEOUT_P95_FACTOR * 1000 + ) + }) + + test('caps at the benchmark request bound', () => { + // Without the cap a 280 s p95 would let one wedged inference hold the loop + // for 14 minutes with nothing recorded. + expect(effectiveRequestTimeoutMs(280)).toBe(MAX_REQUEST_TIMEOUT_MS) + }) +}) + describe('nextBackoffState — A6 pure transition', () => { const P95 = 4 // slow threshold = 4 * SLOW_TICK_FACTOR (2.5) = 10s const SLOW = P95 * SLOW_TICK_FACTOR + 1 diff --git a/tests/unit/file-export.test.ts b/tests/unit/file-export.test.ts index e7f0ca16..77e09643 100644 --- a/tests/unit/file-export.test.ts +++ b/tests/unit/file-export.test.ts @@ -89,6 +89,7 @@ describe('buildStatsCsvModel', () => { generated_at: null, confident_samples: null, skipped_samples: null, + ai_enabled: null, ...over, } } diff --git a/tests/unit/report-data.test.ts b/tests/unit/report-data.test.ts index 458f0f01..ef7aa33e 100644 --- a/tests/unit/report-data.test.ts +++ b/tests/unit/report-data.test.ts @@ -6,6 +6,7 @@ import { describe, expect, test } from 'vitest' import { + aiCoverage, deriveBreaksSummary, deriveTopDistractions, deriveTopicTimeline, @@ -345,3 +346,59 @@ describe('sampleQualitySummary', () => { ).toBeNull() }) }) + +// I79 — the report could not tell "AI never ran" from "AI ran and saw nothing". +// Issue #92: a Windows session where AI was on and silently dead rendered +// "No distractions detected. Nice work." beside a card admitting no score was +// recorded. `aiCoverage` is the single derivation both the JSX and the text +// export branch on, so these cases pin the copy for both surfaces at once. +describe('aiCoverage', () => { + const base = { + score: null, + confident_samples: null, + skipped_samples: null, + ai_enabled: null, + } + + test("'ran' when confident samples were recorded", () => { + expect(aiCoverage({ ...base, confident_samples: 12, ai_enabled: 1 })).toBe( + 'ran' + ) + }) + + test("'ran' when every check was skipped but checks did run", () => { + // A2/A3 — a session of pure parse failures has confident_samples 0 and + // skipped_samples > 0. The AI was watching; it just couldn't read its own + // answers. An empty distraction list there is a real (if thin) finding, and + // the existing #47 D5 data-quality line is what caveats it. + expect( + aiCoverage({ + ...base, + confident_samples: 0, + skipped_samples: 7, + ai_enabled: 1, + }) + ).toBe('ran') + }) + + test("'ran' for a pre-003 row that recorded a score without counters", () => { + // The 003 migration added the counters, so an older row can hold a real + // score with NULL counts. That session was measured; don't demote it. + expect(aiCoverage({ ...base, score: 88 })).toBe('ran') + }) + + test("'noChecks' when AI was on and not one check completed", () => { + expect(aiCoverage({ ...base, ai_enabled: 1 })).toBe('noChecks') + }) + + test("'off' when AI was recorded as disabled", () => { + expect(aiCoverage({ ...base, ai_enabled: 0 })).toBe('off') + }) + + test("'unknown' for a row written before the 004 migration", () => { + // NULL ai_enabled is not 0: claiming "AI was off" for a session nobody + // recorded the setting for would invent a fact. The cause-neutral R1 copy + // is the honest render there. + expect(aiCoverage(base)).toBe('unknown') + }) +}) diff --git a/tests/unit/report-serialize.test.ts b/tests/unit/report-serialize.test.ts index da0cfd48..b0edb1db 100644 --- a/tests/unit/report-serialize.test.ts +++ b/tests/unit/report-serialize.test.ts @@ -47,6 +47,7 @@ function baseSession(over: Partial = {}): SessionRecord { generated_at: START_TS + 25 * 60_000, confident_samples: null, skipped_samples: null, + ai_enabled: null, ...over, } } @@ -124,3 +125,53 @@ describe('serializeReportToText score line', () => { expect(text).not.toContain('Score: 100/100') }) }) + +// I79 — the exported/copied text is an independent second implementation of +// the report's copy, so a fix applied only to the JSX would leave the pasted +// version still claiming a clean session. These pin both halves of the honesty +// change in the surface a user actually shares. +describe('serializeReportToText — AI coverage honesty (I79)', () => { + const unscored = { score: null, focused_pct: null } + + test('AI on but no checks: names the malfunction, not "Nice work"', () => { + const text = serializeReportToText( + buildData(baseSession({ ...unscored, ai_enabled: 1 }), []) + ) + expect(text).toContain('Score: not recorded (AI ran no checks)') + expect(text).toContain('No AI checks ran, so nothing was measured here.') + expect(text).not.toContain('Nice work') + }) + + test('AI off: says so, and still never claims a clean session', () => { + const text = serializeReportToText( + buildData(baseSession({ ...unscored, ai_enabled: 0 }), []) + ) + expect(text).toContain('Score: not recorded (AI off)') + expect(text).not.toContain('Nice work') + }) + + test('pre-004 row: stays cause-neutral', () => { + const text = serializeReportToText( + buildData(baseSession({ ...unscored, ai_enabled: null }), []) + ) + expect(text).toContain('Score: not recorded') + expect(text).not.toContain('(AI off)') + expect(text).not.toContain('(AI ran no checks)') + expect(text).not.toContain('Nice work') + }) + + test('AI ran and found nothing: the earned praise survives', () => { + // The whole point of the change is that this case still reads confidently. + const text = serializeReportToText( + buildData( + baseSession({ + confident_samples: 30, + skipped_samples: 0, + ai_enabled: 1, + }), + [] + ) + ) + expect(text).toContain('No distractions detected. Nice work.') + }) +}) diff --git a/tests/unit/stats-data.test.ts b/tests/unit/stats-data.test.ts index 27fd6b0d..a320ad40 100644 --- a/tests/unit/stats-data.test.ts +++ b/tests/unit/stats-data.test.ts @@ -47,6 +47,7 @@ function session(over: Partial = {}): SessionRecord { generated_at: null, confident_samples: null, skipped_samples: null, + ai_enabled: null, ...over, } } diff --git a/tests/unit/stats-insights.test.ts b/tests/unit/stats-insights.test.ts index 8aaa69d3..855f96de 100644 --- a/tests/unit/stats-insights.test.ts +++ b/tests/unit/stats-insights.test.ts @@ -33,6 +33,7 @@ function session(over: Partial = {}): SessionRecord { generated_at: null, confident_samples: null, skipped_samples: null, + ai_enabled: null, ...over, } } From f31520b2984a7e369be6778877834eff0819b97c Mon Sep 17 00:00:00 2001 From: scotej <134114466+scotej@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:46:45 +1000 Subject: [PATCH 2/6] fix(ai): release the pre-acquired screen stream when a session never starts (I79) The V2-P9 gesture pre-acquire stashes a live screen MediaStream for boot() to consume, and the only release path was SessionView's unmount. Two paths reach a stash with no SessionView behind it, and this PR added one of them: - handleRejoin: joinSession() throws (bad stored credentials), the catch toasts, and status never flips to 'active'. - handleTopicSubmit + host: runHostInvite returns silently on its identity/display_name guard before hostSession() begins the session. Either way the OS screen-recording indicator stays lit with nothing behind it until the app quits. Discard on the rejoin failure, and don't pre-acquire at all when the host precondition already fails. Co-Authored-By: Claude Opus 5 --- src/routes/Home.tsx | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/routes/Home.tsx b/src/routes/Home.tsx index 466e3d87..2edf9132 100644 --- a/src/routes/Home.tsx +++ b/src/routes/Home.tsx @@ -25,7 +25,11 @@ import { toast } from 'sonner' import { UpdateReadyBanner } from '@/components/UpdateReadyBanner' import { Button } from '@/components/ui/button' import { tokens } from '@/design/tokens' -import { preacquireScreenStream, useModelStore } from '@/features/ai' +import { + discardPendingScreenStream, + preacquireScreenStream, + useModelStore, +} from '@/features/ai' import { AddFriendDialog, ContactImportDialog, @@ -291,6 +295,11 @@ export function Home() { try { joinSession(s.sessionTopic, s.sessionPassword) } catch (err) { + // I79 — the rejoin failed, so no SessionView will mount to consume (or + // unmount to discard) the stream pre-acquired a few lines up. Release it + // here or the OS screen-recording indicator stays lit with no session + // behind it until the app quits. + discardPendingScreenStream() const message = err instanceof Error ? err.message : strings.friends.joinErrorFallback toast.error(message) @@ -339,8 +348,16 @@ export function Home() { // boot()'s gestureless getDisplayMedia is refused outright and the loop // dies before its first tick. An unconsumed stream is the cheap side of // this trade — SessionView discards it on unmount. + // + // The host branch additionally repeats `runHostInvite`'s own + // identity/display_name guard: that guard returns silently BEFORE the + // session begins, so SessionView never mounts and nothing would ever + // release the stream — the OS recording indicator would stay lit with no + // session behind it. const models = useModelStore.getState() - if (models.activeModelId || models.status === 'loading') { + const canStart = + req.kind === 'guest' || Boolean(identity && identity.display_name) + if (canStart && (models.activeModelId || models.status === 'loading')) { void preacquireScreenStream() } // Seed the one-shot topic BEFORE the session flips to active so @@ -350,7 +367,7 @@ export function Home() { if (req.kind === 'host') void runHostInvite(req.friend) else runGuestJoin(req.invite) }, - [pendingStart, runHostInvite, runGuestJoin] + [pendingStart, runHostInvite, runGuestJoin, identity] ) if (status === 'loading' || onboarding.status === 'loading') { From 2727e8b400318efe9964f5314304ccf6cbf87427 Mon Sep 17 00:00:00 2001 From: scotej <134114466+scotej@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:49:10 +1000 Subject: [PATCH 3/6] docs(db): record that sessions.ai_enabled is captured at teardown (I79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comment-only on migration 004, whose hash is re-pinned in migrations.rs and re-manifested. Editing a .sql already in MANIFEST.sha256 is exactly what the forward-only guard blocks, and the escape hatch it documents applies here: 004 is new in this PR and has never shipped, so its manifest line was removed by hand before regenerating. The semantic worth stating: the column is the FINAL state of the AI toggle, not its state at session start. That is deliberate. The question it answers is "should the report explain an absence?", and aiCoverage() consults the sample counters first — so a session that recorded checks still reads as measured even if the user switched AI off before leaving, and only a session with nothing to show falls through to this column for its wording. Co-Authored-By: Claude Opus 5 --- src-tauri/src/db/migrations.rs | 2 +- src-tauri/src/db/migrations/004_ai_enabled.sql | 8 ++++++++ src-tauri/src/db/migrations/MANIFEST.sha256 | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index f317b5a0..9d2e73ae 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -349,7 +349,7 @@ mod tests { ), ( 4, - "b083d1dfcf99b192a69ef21b6f1d1300dad999058feb5c9a6bf43853662d01cf", + "fce15a8badc863a1498260daaae96174d14ece8d0d87b73b7d03c75d35562a36", ), ]; assert_eq!( diff --git a/src-tauri/src/db/migrations/004_ai_enabled.sql b/src-tauri/src/db/migrations/004_ai_enabled.sql index f6fe55ac..64ae77d7 100644 --- a/src-tauri/src/db/migrations/004_ai_enabled.sql +++ b/src-tauri/src/db/migrations/004_ai_enabled.sql @@ -13,4 +13,12 @@ -- 1 = AI features were on, 0 = off, NULL = unknown (any row written before -- this migration). The report treats NULL as "unknown" and keeps its existing -- cause-neutral copy, so old rows never gain a claim nobody recorded. +-- +-- Read at TEARDOWN, not at session start, so a mid-session toggle is recorded +-- as its final state. That is deliberate rather than merely convenient: the +-- question this column answers is "should the report explain an absence?", and +-- a session that recorded checks is identified by the sample counters, which +-- the report consults FIRST (see aiCoverage in reportData.ts). So a user who +-- toggles AI off after a scored session still reads as measured, and only a +-- session with nothing to show falls through to this column for its wording. ALTER TABLE sessions ADD COLUMN ai_enabled INTEGER; diff --git a/src-tauri/src/db/migrations/MANIFEST.sha256 b/src-tauri/src/db/migrations/MANIFEST.sha256 index 945cf6dd..2f77b7e6 100644 --- a/src-tauri/src/db/migrations/MANIFEST.sha256 +++ b/src-tauri/src/db/migrations/MANIFEST.sha256 @@ -7,4 +7,4 @@ d19c380c48d5986806f36eedd72332f2d96e57390ce92ed839fbffe51bc8300e 001_initial.sql f01897d50e1d448a0995ace633c04c82b454c32c0e792a94964a81ae46031685 002_v2.sql a1ef24581336a04ecb9f9636afe3d0c574d9e47072f88ffccd1ff3c9aefffa42 003_sample_counts.sql -b083d1dfcf99b192a69ef21b6f1d1300dad999058feb5c9a6bf43853662d01cf 004_ai_enabled.sql +fce15a8badc863a1498260daaae96174d14ece8d0d87b73b7d03c75d35562a36 004_ai_enabled.sql From bbe34c462971326dc150a1337c5c4cfc49b0f964 Mon Sep 17 00:00:00 2001 From: scotej <134114466+scotej@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:25:07 +1000 Subject: [PATCH 4/6] fix(ai): close the six gaps an adversarial sweep found in the I79 fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 65-agent verification sweep over the first draft confirmed the root cause (two independent verifiers, cross-platform, still-broken at HEAD) and found six more defects on the same path: - hydrate()'s 'error' status was terminal. ModelPickerContainer retried only on 'loading', so a single failed models.json read (AV lock, partial write) left activeModelId null for the whole process — reopening #92 through a narrower door that fix A itself widened. Gate is now status !== 'ready', and the session notice distinguishes "no model picked" from "model list unreadable". - The footer chip read "AI off" while AI was ON with no model. That was the only on-screen signal during #92 and it pointed at the wrong setting. New 'unconfigured' status behind a pure deriveAiChipStatus(), with 'loading' deliberately reading 'off' so no launch flashes a warning during hydration. - aiCoverage returned 'ran' for confident_samples 0 with skipped_samples k, on the argument that the #47 D5 data-quality line caveats it. It does not below SKIPPED_SAMPLES_MIN (3): for k of 1-2 the page rendered "Focused-time —", no caveat, and "No distractions detected. Nice work." — this issue's own defect surviving its own fix. Fifth state 'noConfident'; the two tests that asserted the old behaviour are edited, not appended to. - append_windows_dll_hint probed only vcruntime140.dll, so it stayed silent on a machine with the C runtime but not the C++ one. Requires both now. - next_attempts resets the restart streak whenever a child clears MIN_HEALTHY_UPTIME, so a sidecar dying every ~2.5 minutes crash-looped forever and never set errored: the stall notice fired once and the session then ran an hour on a dying engine. TOTAL_RESTART_BUDGET (12 per generation) closes it, sized so an 8-hour session dying hourly never trips while a 121s cycle trips at ~24 minutes. - Settings → Sessions marks an unmeasured row "not measured" when ai_enabled is 1, never inferring it for 0 or NULL. One finding rejected: "onSidecarErrored re-arms every tick, so a flapping sidecar re-toasts forever". errored is cleared only by sidecar_start/stop and the watcher returns after setting it, so errored→running requires deliberate user action — re-notifying then is correct, as the existing test documents. Co-Authored-By: Claude Opus 5 --- ISSUES.md | 162 +++++++++--------- src-tauri/src/commands/sidecar.rs | 89 +++++++++- src/components/AiStatusChip.tsx | 11 +- src/features/ai/ModelPickerContainer.tsx | 11 +- src/features/session/Report.tsx | 9 +- src/features/session/SessionView.tsx | 38 ++-- src/features/session/aiChip.ts | 52 ++++++ src/features/session/reportData.ts | 50 +++--- src/features/session/reportSerialize.ts | 22 ++- .../settings/categories/SessionsCategory.tsx | 10 +- src/stories/AiStatusChip.stories.tsx | 4 + src/stories/Report.stories.tsx | 26 +++ src/strings.ts | 22 +++ tests/unit/ai-chip-status.test.ts | 82 +++++++++ tests/unit/ai-models.test.ts | 49 ++++++ tests/unit/report-data.test.ts | 40 ++++- tests/unit/report-serialize.test.ts | 26 +++ 17 files changed, 564 insertions(+), 139 deletions(-) create mode 100644 src/features/session/aiChip.ts create mode 100644 tests/unit/ai-chip-status.test.ts diff --git a/ISSUES.md b/ISSUES.md index 8da366e0..638e93a4 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -10,84 +10,84 @@ Round 1 (`audit/sev1-sev2-fixes`, PR #29): every Sev1/Sev2 fixed. Round 2 (`audi **Improvement wave 3 (post-v1.6.0, `feat/improvements-wave3`).** A 12-subsystem multi-agent survey with per-finding adversarial verification, then a multi-lens review of the branch (the three low-severity findings it confirmed were fixed on-branch). Rows I51+ record the confirmed fixes; each shipped as its own commit with tests where the harness allows. Test-only and doc-only outcomes (rendezvous-derivation vectors, signed-hello gate coverage, inbox replay coverage, and the DESIGN-SYSTEM §4 / ARCHITECTURE §11–§12 / README true-ups) are not ledgered separately. -| ID | Sev | Location | Evidence | Status | -| --- | ---- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| I1 | Sev1 | `src/features/session/pomodoro.ts` | `stop()` sent no wire signal; receivers' 10 s silence timer resurrected the timer under a new broadcaster ~10 s after Stop. | **fixed** (R1) — explicit `stopped:true` message; receivers reset to idle. ARCHITECTURE §7 updated. | -| I2 | Sev2 | `src/features/friends/presence.ts` | Online state compared sender wall clock to receiver's; backward sender clock step wedged presence permanently. | **fixed** (R1) — stamp receiver-local time on receive. | -| I3 | Sev2 | `src/features/session/lifecycle.ts` + `sessionStore.ts` | Everyone-else-leaves auto-end lost `sessions.peer_pubkeys` + `markStudied` because `peerLeft` pruned `peers` first. | **fixed** (R1) — cumulative `seenPeerEdPubkeys` set. | -| I4 | Sev2 | `src/features/ai/benchmark.ts` | p95 included the cold-start warmup sample, inflating the sample floor 5–10× with no user recourse. | **fixed** (R1) — run + discard one warmup sample. | -| I5 | Sev2 | `src-tauri/src/commands/models.rs` | Resume fast-path hashed a multi-GB GGUF synchronously on the async runtime, stalling concurrent IPC. | **fixed** (R1) — moved to `spawn_blocking`. | -| I6 | Sev3 | `src/features/ai/sampleLoop.ts` | Battery-pause branch omitted the §8 "thermal-aware notice" and rescheduled at the sample interval, not 60 s. | **fixed** (R2) — `onBatteryPause`/`onBatteryResume` callbacks (fire once each) wired to SessionView toasts; paused branch now reschedules at `BATTERY_POLL_INTERVAL_MS`. Regression test added. | -| I7 | Sev4 | `src/features/session/invite.ts` | Auditor flagged idle-invite `hostSession()` as bypassing the topic gate. | **not a bug** — `Home.tsx` enforces `TopicGateModal` (sets `pendingInitialTopic`) before `inviteToCurrentSession`; no other caller. No change. | -| I8 | Sev3 | `src/features/session/SessionView.tsx` | Audit receive did not check `session_topic` (the ai-alert path does). | **fixed** (R2) — added `verified.session_topic !== sessionTopic` drop, mirroring `aiAlerts.ts`. | -| I9 | Sev3 | `src/features/session/pomodoro.ts` | Any peer sending a valid signed `pomodoro` msg is accepted as broadcaster, even mid-broadcast by another. | **deferred — conflicts with canonical doc.** ARCHITECTURE §14 explicitly: "Friend disables their own AI / fakes score — **Not defended. Social trust. Accepted.**" The "most recent sender becomes broadcaster" behavior is a deliberate, code-documented reconnection-robustness choice; hardening it would silently deviate from the accepted friends-only threat model and risk regressing the documented original-broadcaster-returns path. Surfaced per house rule; user can override to request the hardening explicitly. | -| I10 | Sev3 | `ARCHITECTURE.md §7` | `score_final` wire type has no producer/consumer. | **fixed (doc)** (R2) — §7 annotated: `score_final` is reserved/not-implemented in V2; the report is local-SQLite by V2-P8 design; type kept so a future phase avoids a breaking wire change. Not removed (removal would be a forward-compat break). | -| I11 | Sev3 | `src/features/ai/sampleLoop.ts` | Declared topic interpolated into the focus prompt without injection delimiters. | **fixed** (R2) — topic wrapped in `` + labelled as data; system-prompt rule added; `FOCUS_SYSTEM_PROMPT_VERSION` → 2; `tests/ai-eval/run.ts` kept byte-identical; ARCHITECTURE §8 prompt updated. | -| I12 | Sev3 | `src/features/ai/aiAgent.ts` | Total JSON-parse failure echoed ≤200 chars of raw model output into the dialog. | **fixed** (R2) — fixed safe string to the user; raw logged to console only. Test updated. | -| I13 | Sev3 | `src-tauri/capabilities/default.json` | `ai-dialog` window granted `notification`/`store`; §12 says permissions are main-window-scoped. | **fixed** (R2) — `default.json` restricted to `["main"]`; new `ai-dialog.json` capability scoped to the dialog window with `core:default` only (it uses only core event/window IPC). | -| I14 | Sev3 | `src-tauri/src/db/migrations.rs` + `001_initial.sql` | Bare `CREATE TABLE` + no single-instance ⇒ two simultaneous first-launches could panic the second. | **fixed** (R2) — `IMMEDIATE` transaction with the version read moved inside the tx (locks before reading); `IF NOT EXISTS` on 001's DDL; `INSERT OR IGNORE` on `schema_version`. Sequential-upgrade tests preserved. | -| I15 | Sev3 | `src/stores/identityStore.ts` / `identity.rs` | Identity commit (keychain then file) had no rollback; a failed file write + re-onboard overwrites the keychain entry. | **mitigated** (R2) — the file write is now atomic (see I16); residual is now only "rename succeeded but the keychain `set` itself fails", an OS-keychain fault recoverable via BIP39 (PLAN §7). Full two-store transactionality is out of scope for a Sev3. | -| I16 | Sev3 | `src-tauri/src/commands/identity.rs` | `fs::write` non-atomic; a crash mid-write truncates `identity.json`. | **fixed** (R2) — write to `*.json.tmp` then `fs::rename` over the target (atomic on same FS); temp cleaned on rename failure. | -| I17 | Sev3 | `src-tauri/src/db/sessions.rs` | `started_at`/`ended_at`/`total_minutes` overwritten while the comment claimed additive upserts. | **fixed (comment)** (R2) — comment rewritten to state these three are deliberately authoritative-overwrite (a re-summarize must be able to correct them; COALESCE would swallow it) while the report columns are additive. No behavior change, by design. | -| I18 | Sev4 | `pair.ts` / `lib/trystero/index.ts` / `sidecar.rs` | `verifyHello` didn't reject self-pubkey; stale `selfId` comment; `sidecar_start` trusts JS `model_path`. | **partially fixed** (R2). `verifyHello` now rejects a hello whose `ed_pubkey` equals the local identity (passed `ctx.edPubHex` from `runPair`). `trystero/index.ts` comment corrected to describe the actual module-global-`selfId` mechanism. The `sidecar_start` model-path sandbox is **deferred — conflicts with canonical doc**: PLAN §5 explicitly promises "Advanced users can point at any local GGUF", so constraining `model_path` to `data_dir/models` would break a documented feature. Surfaced per house rule. | -| I19 | Sev4 | `package.json` devDependencies | `npm audit` flags ~20 dev-chain advisories across critical/high/moderate (criticals: `concurrently@9` → `shell-quote`; highs/moderates span the `@storybook/*` and `esbuild`/`tsx` chains). Re-flagged on every scan. | **triaged — no runtime exposure** (2026-06-13). Every one is a **devDependency**; none reaches the installed desktop app — `npm audit --omit=dev` is clean (0) and no advisory package appears in `dependencies`. Bump `concurrently` and the `@storybook/*` chain when convenient; do **not** rush a major Storybook upgrade for a dev-only advisory. Recorded so the scan result isn't re-investigated each time. (Exact counts shift with the lockfile; the load-bearing fact is the clean prod audit.) | -| I20 | Sev1 | `src-tauri/src/db/mod.rs` | `is_definitely_corrupt` only treated a non-"ok" `integrity_check` verdict as corruption; a truncated file (SQLITE_CORRUPT) or damaged header (SQLITE_NOTADB) makes the pragma ERROR, so recovery never fired and the app bricked on every launch after a power-loss/force-kill. | **fixed** — classify by SQLite error code; also clean up `-journal`/`-wal`/`-shm` on rename. Corruption-signature tests added. | -| I21 | Sev2 | `src-tauri/capabilities/ai-dialog.json` | The scoped ai-dialog capability lacked `core:window:allow-close`, so the floating AI dialog's Esc/blur/X all silently failed (regression from the I13 scope-down). | **fixed** — grant `core:window:allow-close`. | -| I22 | Sev2 | `src/features/session/reportData.ts` + `stats/statsInsights.ts` | Report topic-timeline / top-distractions and cross-session insights walked every audit event; peers' broadcast `topic_set`/`ai_alert` (persisted locally) were misattributed to the local user. | **fixed** — thread the local ed_pubkey and filter to it (matches the self-only score gauge / trend). | -| I23 | Sev3 | `src/features/ai/sampleLoop.ts` | `onScreenTrackEnded` latched `captureDenied` and tore down ALL AI capture when ANY screen track ended; unplugging a secondary display in "All displays" mode killed focus detection with a misleading permission overlay. | **fixed** — discriminate: drop the dead display, latch only when the last live one ends. | -| I24 | Sev2 | `src-tauri/src/commands/models.rs` | No read/idle timeout on downloads; a mid-stream stall hung `bytes_stream().next()` forever, freezing the UI and permanently locking the `model_id`. | **fixed** — 60s `read_timeout`. | -| I25 | Sev2 | `src-tauri/src/commands/system.rs` | `system_relaunch_app`'s `app.restart()` skips `RunEvent::Exit` (the only sidecar kill), orphaning a running llama-server on Window-Style relaunch. | **fixed** — `kill_blocking` before restart. | -| I26 | Sev2 | `src-tauri/src/lib.rs` | A boot-time global-shortcut OS conflict propagated out of `setup()` into `build().expect()`, panicking before first paint. | **fixed** — register best-effort; a failed binding is inert until rebound in Settings. | -| I27 | Sev3 | `src/features/friends/invite.ts` + `inviteRetry.ts` | An invite retry queued after the 15s send timeout escaped `cancelAll` if the session ended during the window, later pulling the friend into a dead room. | **fixed** — injected `isSessionLive` guard: a retry never fires for a session that isn't the host's current live one. | -| I28 | Sev3 | `src/lib/fileExport.ts` | CSV export didn't neutralize spreadsheet formula injection; a peer-chosen display name beginning with `= + - @` executed on open (=HYPERLINK exfil / DDE). | **fixed** — quote-prefix string cells starting with a trigger; numeric cells untouched. | -| I29 | Sev2 | `src/features/friends/AddFriendDialog.tsx` | A programmatic close (contact-card deep link) during an in-flight legacy pairing never aborted it — Radix doesn't fire `onOpenChange` on a parent-driven close — leaking the trystero room + relay sockets. | **fixed** — tear down on any `open` transition. | -| I30 | Sev3 | `src/features/friends/inbox.ts` | No replay/dedup on the inbox receive path; a stranger on the pubkey-derived inbox topic could re-broadcast a captured envelope to re-fire the invite toast/notification. | **fixed** — dedup on `(from_ed_pubkey, box nonce)`, TTL-bounded. §14 row added. | -| I31 | Sev3 | `src/features/friends/AddFriendDialog.tsx` + `lib/relayDiagnostics.ts` | Pairing's "network trouble" hint read only the Nostr socket map, so it wrongly blamed the user's network in the exact MQTT-fallback case the v1.2.2 race was built to survive. | **fixed** — transport-aware `pairingRelaysUnreachable()` judging both socket maps; Nostr-only signal kept for the invite path. | -| I32 | Sev3 | `src/routes/Home.tsx` | `PairDeepLinkBoot` rendered only in the non-session tail, so a `studyvis://` link clicked mid-session reached zero listeners and was dropped. | **fixed** — render the full tail in the active-session branch. | -| I33 | Sev3 | `src/strings.ts` | In-app AI copy promised screen access is requested "when you start your first session", but enabling AI requests it immediately. | **fixed (copy)** — reword to match the shipped enable-time prompt. | -| I34 | Sev3 | `src-tauri/src/lib.rs` | With minimize-to-tray off, closing the main window while the AI dialog was open stranded the app: process alive, main window gone, tray "Open" a no-op. | **fixed** — destroy the AI dialog on that close path so the runtime exits. | -| I35 | Sev3 | `src-tauri/src/commands/sidecar.rs` | The crash-restart watcher could spawn a fresh llama-server after `kill_blocking` already ran at quit (during the backoff window), orphaning it. | **fixed** — `shutting_down` flag re-checked after backoff, before respawn. | -| I36 | Sev3 | `src/design/theme.tsx` | `ThemeProvider` wrote the pre-hydration fallback `dark` class, stripping the boot-cache `light` class and flashing dark for light/auto users. | **fixed** — defer to the boot script until an authoritative mode exists. | -| I37 | Sev4 | `src/features/friends/pair.ts` | The legacy pairing hello's (unsigned) `display_name` was stored/rendered raw, unlike the ContactCard path's cap + bidi/zero-width sanitize. | **fixed** — shared `normalizeUntrustedName` applied on both paths. | -| I38 | Sev3 | `src/features/ai/sidecar.ts` | `useSidecarStore.start()` unconditionally set `running` after its await, clobbering an interleaved `stop()` and leaving the store `running` on a killed process. | **fixed** — bail if a stop intervened. | -| I39 | Sev3 | `src/App.tsx` + `src/components/ErrorBoundary.tsx` | No React error boundary anywhere; any render throw blanked the whole window and killed the always-on inbox/presence + live session. | **fixed** — top-level `ErrorBoundary` around the routed content with a calm "Try again". | -| I40 | Sev4 | `src-tauri/src/commands/system.rs` | Changing one PTT shortcut when both shared a combo (hand-edited settings.json) unregistered the other. | **fixed** — only unregister the old combo when the other action isn't still using it. | -| I41 | Sev4 | `src/lib/encoding.ts` | `hexToBytes` used `parseInt` per byte, silently mis-decoding malformed hex ('1g'→0x01, '-a'→wraps) instead of rejecting. | **fixed** — validate the whole string against `/^[0-9a-fA-F]*$/` first. Adversarial-input tests added. | -| I42 | Sev3 | `src/features/session/SessionView.tsx` | The local session camera/mic stream had no `ended` listener, so a mid-session device loss (unplug / OS-revoke / another app grabbing the camera) left peers on a frozen tile and silently killed the AI face path. | **fixed** — attach an `ended` listener that surfaces the existing "Try again" recovery banner. | -| I43 | Sev3 | `.github/workflows/ci.yml` | CI compiled only aarch64-apple-darwin, so `#[cfg(target_os="windows")]` code first built inside `release.yml` AFTER the tag was pushed. | **fixed** — macOS + Windows Rust matrix on every push/PR. | -| I44 | Sev3 | `.github/workflows/release-prep.yml` | The one-click gate skipped `check-a11y` and all Rust compilation, so a release could be cut over an axe-core / clippy regression. | **fixed** — add the a11y gate; require the exact main SHA's CI run to be green before bump/tag/push. | -| I45 | Sev3 | `README.md` / `PLAN.md` / `ARCHITECTURE.md` / `CHANGELOG.md` | User-facing doc drift: first-run described the retired 12-word flow as primary; "one WebSocket" (really ~8 relays); "three tiers" (four models); "v1.2.0 is current" (v1.3.1); §6 "MQTT not yet wired" (raced since v1.2.2); changelog x86_64 DMG claim (aarch64-only). | **fixed** — brought each in line with the shipped code. | -| I46 | Sev3 | `src/features/friends/invite.ts` | `sendInviteEnvelope` treats any peer joining the recipient's inbox topic as delivery, so an eavesdropper on that shared pubkey-derived topic can appear as "delivered" or drop the invite. | **accepted — friends-only threat model.** Envelope is still NaCl-box-sealed to the recipient; worst case is a suppressed offline-retry (re-click Invite). Documented in §14. The flagged signed invite-ACK shipped in #47 C2 (new `invite-ack` action, v1.2.x-wire-compatible: no ACK within the window → honest "unconfirmed" copy) — for UX legibility, not as a defense; the eavesdropper acceptance above stands. | -| I47 | Sev3 | `src/features/friends/presence.ts` | Presence heartbeats/goodbyes are unauthenticated on a pubkey-derived topic, so a stranger with a friend's public pubkey can forge that friend's online/offline state. | **accepted — friends-only threat model.** Presence is soft UX state, not a data/session compromise. Signing would break cross-version presence (older peers send unsigned), so enforcement is deferred, not shipped. Documented in §14. | -| I48 | Sev3 | `src/features/friends/pair.ts` (upstream `@trystero-p2p/mqtt`) | Each pairing's MQTT room open→leave orphans ~4 broker connections: trystero-core sets `didInit=false` on last-room-leave but never `.end()`s the MQTT clients. | **deferred — upstream trystero bug.** Bounded (a handful of pairings per session, cleared on process exit) under the friends-only 4-peer model. Fix is upstream (or an app-side always-on MQTT room, which trades the leak for a persistent idle broker connection — not worth it). | -| I49 | Sev3 | `src/features/friends/InboxBoot.tsx` + `presence.ts` | The presence effect keys on the whole friend set, so adding/removing any friend tears down + rebuilds the own presence room, broadcasting a goodbye that flickers your presence offline→online on every other friend's screen (and can fire a spurious "came online" notification). | **fixed** (#47 C6, the recorded dedicated pass) — `startPresence` gained `updateFriends`: friend list edits diff rooms in place (join added / leave removed), the own room and heartbeat cadence never churn, and `leave()`'s tested goodbye semantics are untouched. InboxBoot keys the subscription on identity only and drives list edits through the diff; removed friends' notify baselines are pruned so a re-add starts fresh. Unit tests cover added/removed/no-op churn including a watcher asserting no goodbye flicker. | -| I50 | Sev4 | `src-tauri/tauri.conf.json` | Both webview windows ship with CSP disabled (defense-in-depth only — no reachable XSS sink today: React auto-escapes, no `innerHTML`/`eval`). | **deferred — needs a desktop CSP smoke-test.** A wrong CSP hard-breaks Tauri IPC/asset loading, which no static gate catches; landing a `script-src 'self'` policy safely requires running the built desktop app (not possible headless). Recommended policy: `default-src 'self'; script-src 'self'; object-src 'none'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' ws: wss: http://127.0.0.1:*`. | -| I51 | Sev2 | `src/routes/Home.tsx` | The `tail` fragment (InboxBoot + deep-link + import dialog + topic gate) rendered at a different unkeyed child index per view branch, so React reconciled by index and re-mounted the always-on presence/inbox room on every view switch — re-triggering the I49 goodbye flicker, blanking the friends list for up to a heartbeat, and dropping an invite that arrived in the teardown window. | **fixed** — `` pins the tail fiber across branches of differing child arity. The load-bearing key is documented at the site; `pairDeepLink.ts`'s stale "view switches re-mount the boot" comment corrected (the `launchConsumed` guard kept). Not statically checkable and not node-testable without RTL, so protected by the site comment. | -| I52 | Sev2 | `src/features/session/lifecycle.ts` + `stores/sessionStore.ts` | `total_minutes` was pure wall-clock `endedAt − startedAt`, counting OS-sleep/suspend as study time; a session slept on persisted the whole span (a free streak day and inflated totals). | **fixed** — elapsed is `min(wallMs, monoMs)` off a `performance.now()` origin captured at start, mirrored in the live footer. Not retroactive (old rows stand); degrades to prior behavior on a platform whose monotonic clock happens to include suspend, never undercounts. Unit-tested via an injectable `monotonicNow` seam (awake / slept-through / backward wall clock / no-mono fallback / slept-through rejoin). | -| I53 | Sev3 | `src/features/session/lifecycle.ts` + `SessionView.tsx` | A peer's deliberate `left` (signed, on the wire since V1-P9) still armed the 20 s reconnect grace and offered a Rejoin into a dead room. | **fixed** — mark departed peers, and skip the grace/Rejoin only when the room empties with no unexplained absence remaining, via a new `SessionEndReason` (`'peer'`). Unexplained-absent peers are tracked in a Set (not a single flag, per the review) so an intervening join by another peer can't strand a still-absent blipper; the mark clears per-peer on rejoin so a later blip still gets grace. ARCHITECTURE §13 updated. Grace unit tests extended. | -| I54 | Sev3 | `src/features/friends/InboxBoot.tsx` + `friendOnlineNotify.ts` | The friend-online baseline suppressed every friend's _first_ online resolution after mount (not just boot's initial sweep), so a genuine later arrival never notified — the one event the feature exists for. | **fixed** — per-friend watch-start map with a settle bound. The bound is a dedicated `NOTIFY_SETTLE_MS` (3 min, sized above realistic presence-handshake latency), not the 60 s heartbeat window: reusing the latter let a slow-connecting already-online friend re-read as an arrival (review finding). Only the settle window is suppressed. Unit-tested. | -| I55 | Sev3 | `src/features/session/hello.ts` | The signed session-hello `display_name` was stored/rendered without the cap + bidi/zero-width sanitize every other untrusted-name path applies; on `main` it was unbounded. | **fixed** — `normalizeUntrustedName(name, HELLO_NAME_CAP)`. Cap is 192 UTF-8 bytes — the worst case for the 64-UTF-16-unit `maxLength` our own inputs enforce — so a legitimate multibyte name (CJK/emoji) survives intact rather than being byte-truncated (review finding), while a hand-modified sender is still bounded. Unit-tested incl. multibyte + bidi. | -| I56 | Sev3 | `src/features/ai/sampleLoop.ts` | `onCaptureError` fired per tick (contract says once/lifetime) and the face-track guard never checked `readyState`, so a dead webcam threw `track_ended` every tick and toast-stormed the session over the MediaErrorBanner already saying the same thing. | **fixed** — the ended-track guard skips the tick without counting a sample; a `captureErrorReported` latch mirrors `sidecarErrorReported`, reporting once and clearing on the next successful verdict. Unit-tested. | -| I57 | Sev3 | `src/design/tokens.ts` + `src/design/index.css` | The focus ring (`accent.ring`, 40 % alpha) measured ~2.6:1 dark / ~1.8:1 light against the surfaces it is drawn on — below WCAG 1.4.11 — because the UA outline is globally reset; the gate missed it by measuring the opaque accent. `shadow.glow` had also drifted 3px/4px. | **fixed** — raised alpha (60 % dark / 80 % light), mirrored in both hand-kept files; `check-contrast` now measures the ring in the bg-stack at its real per-theme alpha; `shadow.glow` reconciled to the tokens.ts value (3px). The ring's inner edge on `bg-accent-default` buttons intentionally stays below 3:1 — the outer edge against the canvas carries identification. | -| I58 | Sev3 | `src/components/ui/dropdown-menu.tsx` | Menu items declared `focus:bg-bg-raised` on a `bg-bg-raised` surface — a 1.00:1 no-op — so keyboard/mouse navigation showed no highlight (worst in the in-session audio pickers, where two identically-named devices are indistinguishable). | **fixed** — an inset accent ring highlight (keeps `focus:` so Radix pointer-move still lights it). The byte-identical Button/Badge `secondary` hover was fixed the same way (`hover:bg-bg-surface`). | -| I59 | Sev3 | `src/components/AuditLogPanel.tsx` + `SessionNotesPanel.tsx` | The session-log and notes scroll containers had no focusable descendant and no `tabIndex`, so a keyboard-only user couldn't scroll them (WCAG 2.1.1). macOS/WKWebView only; Windows WebView2 auto-focuses scrollers. | **fixed** — `tabIndex={0}` + a focus-visible inset ring on both. Overflowing Storybook stories added so the axe `scrollable-region-focusable` gate has something to assert on. | -| I60 | Sev3 | `src/strings.ts` (`searchKeywords`) + `Settings.tsx` | v1.6.0 settings search routed "tray"/"minimize"/"capture displays"/"auto-update" to Advanced (which owns none of them) and left Advanced's own settings ("launch at login", "clear history", "onboarding") unfindable. | **fixed** — keywords moved to the panes that own each setting; Advanced keywords added; a `Record` guard in `Settings.tsx` pins the bucket↔pane mapping without a strings→features import cycle. | -| I61 | Sev3 | `src/stores/settingsStore.ts` + `ShortcutsCategory.tsx` | `resetShortcutsToDefaults` rethrew on the first setter's combo collision and never ran the second; the rejection was swallowed to `console.error`, so the button was a silent no-op. | **fixed** — reorder + per-call try/catch so both setters run; a residual collision surfaces a `toast.error` (copy in strings.ts). The Rust `is_registered` skip the original proposal suggested was dropped — it would re-open #47 B5. Stateful fake added to the keybindings test. | -| I62 | Sev3 | `src/features/updater/updaterStore.ts` + `AboutCategory.tsx` | Settings → About offered a live Restart-now / Check-now during a session (unguarded, unlike the update banner), and its help text asserted "you're on X, the latest" from the initial `idle` state and after a silent background-check failure. | **fixed** — session-active guards in `installAndRestart`/`checkNow` (the `userInitiated` exemption, made false by the in-session settings overlay, removed); About disables the buttons in-session and derives its help from an explicit `upToDate` branch rather than a fallthrough. Store tests flipped to assert deferral. | -| I63 | Sev3 | `src/features/identity/recoverLogic.ts` | A failed 24-word restore pointed at all 24 words equally, with no way to narrow a single typo on the highest-stakes screen in the app. | **fixed** — name the words that aren't in the wordlist (`unknownWords` on `MnemonicClass`, populated only on the 24-word path); copy in strings.ts. Kept in `recoverLogic.ts`, not the cross-version crypto module. Unit-tested. | -| I64 | Sev3 | `src/features/stats/FocusInsights.tsx` | The focus-over-time trend tooltip had no date, so a dip couldn't be anchored to a day. | **fixed** — carry each point's `startedAt`; the tooltip renders the `dayKey` day, byte-identical to the bar chart's day format. | -| I65 | Sev4 | `src/features/stats/statsData.ts` | The stats CSV omitted the two headline tiles (total sessions, streak, average) — the numbers the pane is built around. | **fixed (summary)** — prepend summary rows, preserving the null-average ("AI off" vs "scored 0") distinction. Per-session detail left out of scope. Test extended. | -| I66 | Sev3 | `src-tauri/src/commands/sidecar.rs` | `sidecar_start` spawned llama-server then opened the log file; an `open_log_file` failure after a successful spawn dropped the `CommandChild` without `kill()`, orphaning a multi-GB process past app exit (same class as I25/I35). | **fixed** — open the log before spawning, so no fallible `?` sits between the spawn and `guard.child`. Reviewed by reading (CI is the first Rust compiler on this dev box). | -| I67 | Sev3 | `src-tauri/src/commands/sidecar.rs` | The respawn budget was a 30 s sliding window, so any crash spaced >30 s reset the counter and the watcher respawned llama-server forever without ever setting `errored` — no recovery affordance surfaced and the D7 log cap was defeated. | **fixed** — the budget now counts consecutive respawns that each died before `MIN_HEALTHY_UPTIME` (120 s); a durable child resets the streak (`next_attempts` pure fn, unit-tested). Once the budget is exceeded `errored` is set as before. | -| I68 | Sev4 | `src-tauri/src/db/audit_events.rs` | The cross-session insights read shipped the entire `audit_events` table over IPC though only `ai_warning`/`ai_alert` rows are consumed. | **fixed** — `WHERE kind IN ('ai_warning','ai_alert')` narrows the query (~4× less JSON at 10k rows); `list_all` → `list_ai_distractions_all`, but the Tauri command name is unchanged so the IPC/TS contract is untouched. The SQL twin of TS `isDistraction` is commented at the query. | -| I69 | Sev3 | `src-tauri/src/lib.rs` | The corrupt-DB recovery dialog asserted re-pairing was required and never mentioned the friends-backup import — wrong at the exact moment a friend loses their list. | **fixed (copy)** — the dialog now names Settings → Identity → Import friends as the restore path if a backup exists, otherwise re-pair. | -| I70 | Sev4 | `.github/workflows/release.yml` | A half-built draft (one platform's artifact missing from `latest.json`) could be published, stranding every friend on the missing platform with no update path and a false "you're on the latest". | **fixed** — a job asserts both platforms are present in the draft's `latest.json` and, on failure, stamps the draft title "INCOMPLETE, DO NOT PUBLISH" (needs `contents: write` to read a draft). Not runnable on this box; validated by YAML parse + reading. | -| I71 | Sev2 | `src/features/updater/updaterStore.ts` + `src-tauri/src/commands/system.rs` | Issue #77: an app opened straight from the mounted `.dmg` runs under macOS App Translocation (read-only bundle), where `update.install()`'s rename-into-place can never succeed — every launch re-downloaded the installer, offered "Restart now", and failed with the generic install toast. The one documented install step (drag to Applications) is exactly the one this path skipped, and the updater had no idea. | **fixed** — new `system_install_context` command (translocation via exe-path component, read-only volume via `statfs`; fail-open) consulted after a check finds an update: an unswappable bundle sets a new process-permanent `blocked` status _before_ any bytes move, and the banner + Settings → About replace the doomed Restart with move-to-Applications guidance. Verified live: dev binary on a read-only DMG against the real v1.7.0 release showed the blocked row. Windows/NSIS unaffected (always updatable). | -| I72 | Sev1 | `src-tauri/src/commands/models.rs` | Every model download failed at the picker's preflight with "…The model manifest may be stale." for every catalog entry. `model_head_check` populated `content_length` from `reqwest::Response::content_length()`, which is the body's size hint — an HTTP/1.1 HEAD response body is always empty (hyper decodes it as zero-length regardless of headers), so every probe reported 0 bytes and the size gate rejected all six entries. The manifest itself is current: the raw `Content-Length` (and `x-linked-etag` = pinned sha256) at every pinned revision still matches. | **fixed** — read the `Content-Length` response header instead; in-module regression test against a local HEAD server; live-verified that all 10 catalog files (6 model + 4 mmproj — the three Gemma quants share one projector) report header sizes byte-identical to the manifest. Git history dates the break to the picker's birth: the size gate, the `content_length()` call, and the no-http2 reqwest dep all landed in one commit (af2987d, V2-P2) and never changed, and the zero-length HEAD decode is server-independent — so no catalog download has ever passed this preflight, and the downstream GET/verify/resume path has never run end-to-end in a shipped build (first real install is its true test). First user report 2026-07-26. | -| I73 | Sev1 | `src-tauri/src/commands/sidecar.rs` + `src-tauri/src/commands/engine.rs` | In-app llama-server spawn has never worked in any build. `shell().sidecar("binaries/llama-server")` resolves `/binaries/llama-server` (tauri-plugin-shell 2.3.5 joins the full configured string against the exe dir), but tauri-build (dev) and the bundler (release) both strip the directory prefix and the triple, placing the file at `/llama-server` — verified in `target/debug/` and in the installed `StudyVis.app/Contents/MacOS/`. Every `sidecar_start` failed with `spawn llama-server: No such file or directory`, surfaced as "AI failed to start:" / "AI model crashed". The plugin has been pinned at 2.3.5 since V1-P1, so this is a day-one bug, not a regression; it sat behind I72 (downloads never completed), which is why the first user report of both landed the same day (2026-07-26 — the on-disk `llama-server.log` from that attempt is a 0-byte file: the child never ran). | **fixed** — sidecar binaries now resolve to absolute paths and spawn via `shell().command()`: bundled probe at `/llama-server(.exe)` (size-gated), then a managed install under `data_dir/engine/-/`. When neither resolves, `sidecar_start` auto-installs the pinned llama.cpp b9095 release asset (SHA-256-verified; pins lockstep-tested against `scripts/fetch-llama-server.sh`; tar.gz/zip unpacked flattened + filtered), gated by the new `engine_auto_install` setting (default ON) with `engine_info`/`engine_install` commands and a Settings → AI "AI engine" row (status/progress/Reinstall). `build.rs` writes a debug-profile-only placeholder so fresh checkouts compile without the fetch script; release-profile builds still hard-fail. Windows spawn failures name the VC++ redistributable when `vcruntime140.dll` is absent. Verified live on macOS: the installed bundle's binary spawns via the exact fixed resolution (`--version`, Metal init, exit 0), the placeholder build compiles and launches, and the pinned archives download, hash-match, extract, and run on this machine. The in-app GUI walk (Settings row + session start) is user-walked — the dev binary's keychain prompt blocks machine-driving it. | -| I74 | Sev2 | `src/features/friends/presence.ts` + `presenceRelay.ts` + `src/lib/nostr/` | A mutually added friend showed permanently offline on BOTH ends whenever a STUN-only WebRTC datachannel could not form between the two networks (symmetric NAT / CGNAT / strict firewall — no TURN ships, ARCHITECTURE §4). Heartbeats only rode datachannels; trystero fires no callback on a failed ICE attempt (it silently re-offers forever), and offline ContactCard pairing (§5.1) removed the last step that ever proved the P2P path worked — so the failure was invisible end to end, with every relay reachable and both apps running. Presence, invites, and sessions all share the broken leg; presence was just the visible symptom. | **fixed** — relay-carried presence: sealed ephemeral Nostr events (kind 20001, new `studyvis:presence-relay:v1` tag/key derivations pinned in topics.test.ts) published every 30 s to the pinned relays over an owned reconnecting socket pool; no `since` filter and `limit: 0` (the #47 C1 clock-skew lesson). The datachannel leg stays and now stamps `lastP2pAt`, so `presenceState()` distinguishes direct-online from relay-only "limited" (120 s settle, I54 lesson) — surfaced in the friends list as an amber "Available · limited connection" row plus a one-line hint deep-linking Settings → Network (TURN). Goodbyes keep `lastSeenAt` for "seen … ago". Sessions/invites behind the same NAT still need TURN — the UI now says so instead of lying "Offline". Old builds interop unchanged (they never see this leg). ARCHITECTURE §4/§7/§11/§14 + PLAN §2 updated; `offchain.pub` dropped from the relay pin (now rejects anonymous publishes). | -| I75 | Sev1 | `src-tauri/src/commands/sidecar.rs` | After 1.8.0 shipped I73's spawn-path fix, on-device AI still failed to start on a real Windows install: `llama-server.exe` spawned, printed its banner (`Running without SSL`, `loading model`), then exited with `no backends are loaded` / `failed to load model` / `giving up after 4 restart attempts` (friend's `llama-server.log`, 2026-07-26 — the same day 1.8.0 shipped, the very next link in the same chain). Root cause: the pinned llama.cpp b9095 release assets are `GGML_BACKEND_DL` builds — 15 `ggml-cpu-*.dll` variants on Windows (haswell/zen4/sse42/…), `libggml-cpu.dylib`/`libggml-metal.dylib`/`libggml-blas.dylib` on macOS — that ggml `dlopen()`s at startup rather than linking. `ggml_backend_load_best` (`ggml/src/ggml-backend-reg.cpp`) globs exactly two places for those: the executable's own directory and the process's current working directory — never `PATH`/`DYLD_FALLBACK_LIBRARY_PATH`/`LD_LIBRARY_PATH`. I73's env-var prepend only satisfies the binary's _linked_ imports (`llama.dll`/`ggml-base.dll`/…), which is why the process starts at all; it never reaches the dlopen glob, so `ggml_backend_reg_count()` stays 0, `common_init_from_params` fails, and the crash-restart watcher gives up after `RESTART_BUDGET` (4) identical failures — on every bundled Windows and macOS install, not an edge case. Verified against the pinned llama.cpp b9095 source (`ggml-backend-reg.cpp:479-489`) and the actual release archives (`llama-b9095-bin-win-cpu-x64.zip`, `llama-b9095-bin-macos-arm64.tar.gz`). | **fixed** — `spawn_llama` now also sets the child's working directory to the same runtime dir already resolved for the `PATH`/`DYLD_FALLBACK_LIBRARY_PATH`/`LD_LIBRARY_PATH` prepend (`Command::current_dir`, tauri-plugin-shell 2.3.5), since `fs::current_path()` is in ggml's search list. One code path covers both engine sources (bundled, and the managed install where `runtime_dir` already equals the exe's own directory) and all three platforms. Not runnable on this box — no cargo/node toolchain and `src-tauri/binaries/` has no fetched engine on this Linux dev host; gated by CI and the `Release prep` workflow's gate job instead. | -| I76 | Sev1 | `src/features/ai/sampleLoop.ts` + `captureScreen.ts` + `src/routes/Home.tsx` + `AiCategory.tsx` + `SessionView.tsx` | User report: "AI capture error: getDisplayMedia must be called from a user gesture handler" firing on ordinary session starts with AI already enabled, and — because the fallout from this same failure kept killing the just-started sidecar — a separate, misleading "AI isn't running yet. Turn it on in Settings → AI" from the Ctrl+] chat dialog even though AI genuinely was on. Root cause: `sampleLoop.ts`'s `boot()` acquires the session's long-lived screen `MediaStream` via `navigator.mediaDevices.getDisplayMedia()`, but `boot()` runs from a React `useEffect` fired by state changes (session active + AI on + model chosen + camera up), never from inside a click handler. WebView2 (Windows) and WKWebView (macOS) require `getDisplayMedia()` to run inside live transient user activation on _every_ call, not just the first — the same reason the OS picker itself fires on every acquire (documented in `src/features/ai/README.md`'s "Acquire strategy", which is why V2-P9 already moved to one long-lived stream instead of a per-tick acquire) — so with no gesture in `boot()`'s call stack the call was rejected outright. Because the rejection's `DOMException` name fell outside `mapDisplayMediaError`'s handled set, it surfaced as the generic `screen_capture_unavailable` code and a raw toast instead of the intended `screen_capture_denied` recovery overlay, and `boot()`'s existing failure path tore down the sidecar it had just started. A second, compounding gap: `onCaptureError` never updated `AiStatusChip`'s runtime status, so the chip kept reading "active" after AI had silently died underneath it — matching the reporter's "I can't tell if it's on or if it's errored." | **fixed** — a gesture-context handoff: callers that DO have a real user gesture (`TopicGateModal`'s submit when starting a session with AI already enabled; `AiCategory`'s "enable AI" toggle when a session is already active; `SessionView`'s permission-overlay retry) call the new `preacquireScreenStream()` synchronously (no `await` before it), which starts `getDisplayMedia()` inside that click and stashes the in-flight promise; `sampleLoop.ts`'s default `acquireScreenStream` runtime hook consumes that stash instead of calling `getDisplayMedia()` itself outside gesture context. An unconsumed stash (a rapid re-toggle, or a session that never reaches `boot()`) is released via `discardPendingScreenStream()`, including on `SessionView` unmount, so it never leaks a live stream or leaves the OS recording indicator lit. Separately, `onCaptureError` now carries a `fatal` flag — true for a `boot()`-time acquire failure (the loop really did tear itself and the sidecar down) vs. false for a `tick()`-time transient one (the loop keeps running) — so `SessionView` only flips the status chip to "error" on the former. Unit-tested (pending-stream stash/discard, default-runtime consumption of the stash, the `fatal` flag on both call sites); `npm run build`/`lint`/`test` all green (878 tests). | -| I77 | Sev1 | `src/features/session/lifecycle.ts` + `SessionView.tsx` + `tests/integration/session.test.ts` | User report: "on my device I can't see the other person's camera but they can see mine" — a guest joining a friend's session never received the host's camera **or** mic, in either direction of the pair, while the host saw the guest fine. Root cause: `SessionView`'s media-acquire effect published the local `MediaStream` with a single untargeted `room.addStream(stream)`, and trystero 0.24 delivers a stream only to the peers that are active **at that instant** — `addStream` → `applyMediaOp` → `iterate` enumerates `keys(activePeerMap)` right then (`@trystero-p2p/core` `room.mjs:83`, `:494`) and queues nothing; peer activation (`room.mjs:306-314`) sets `activePeerMap` and fires `onPeerJoin` but replays no previously added local stream. The host is structurally guaranteed to lose that race: `hostSession()` derives a session topic from 32 fresh random bytes and `begin()`s the room **before** the invite is even sent, so the host's camera opens while it is provably alone and its one broadcast reaches nobody, forever. The guest normally wins it, because the session peer activates over trystero's already-open shared connection to that same friend in roughly one RTT — faster than a cold camera opens — so the guest's `addStream` lands and the host sees the guest. Two stale comments asserted the opposite of the library's actual behavior and are what preserved the bug: `SessionView.tsx` claimed `addStream` "forwards new tracks to all current peers **and to peers who join later**", and the stream-binding effect claimed "trystero replays existing peers when we register the stream callback" (`onPeerStream` is a bare assignment at `room.mjs:511`; only `onPeerJoin` sweeps, at `:506-509`, a replay our own `wrapRoom` consumes at construction). CI could not catch it: the integration bus mock hard-coded both false beliefs — its `addStream` ignored `targetPeers` and fanned out to every room, and its join + `onPeerStream` paths both replayed existing streams. Day-one defect; `trystero` has been pinned `^0.24.0` since the media path was introduced, so host→guest video has never worked in any shipped build. | **fixed** — publishing moved into `publishLocalStream(room, stream)` in `lifecycle.ts`, which broadcasts to the currently-active peers and, in the immediately adjacent statement, subscribes `onPeerJoin` to re-send the same stream targeted at each later joiner (the pattern trystero's own README prescribes). The two calls live in one function so the "no `await` in the seam" invariant is structural: the broadcast covers who is active now, the subscriber covers who arrives later, and JS's single thread means no peer is missed or served twice — a double-add would desync trystero's FIFO pairing of stream metadata to incoming tracks. `SessionView`'s effect cleanup unsubscribes **before** `stopTracks`, so a "Try again" re-acquire can't hand a later joiner a dead stream. Both false comments replaced with the verified semantics + `room.mjs` line refs. The integration bus mock now models `activePeerMap` honestly (targeted sends honored, no join replay, no `onPeerStream` replay), and `tests/unit/session-publish-stream.test.ts` pins the contract — 2 of its 4 cases fail against the pre-fix code. **Both friends must update:** a patched host reaches an unpatched guest, but a patched guest still receives nothing from an unpatched host. | -| I78 | Sev2 | `src-tauri/Cargo.toml` (`tauri 2.11.0`) | GHSA-7gmj-67g7-phm9 — "Tauri has an Origin Confusion Issue that Allows Remote Pages to Invoke Local-Only IPC Commands" (CVSS 8.8), affecting `tauri >= 2.0.0, <= 2.11.0`; fixed upstream in 2.11.1. StudyVis exposes a wide IPC surface (SQLite, keychain-backed identity, sidecar spawn, filesystem paths), so origin confusion is the class that matters most here rather than a theoretical one. Not found by `cargo deny`: the advisory is GitHub-Advisory-Database-only and RustSec does not carry it — it surfaced when OSV-Scanner was run over `Cargo.lock` while building the #102 supply-chain gates. | **fixed** — `cargo update -p tauri --precise 2.11.1` (lockfile-only; `Cargo.toml` already requires `"2"`, so no manifest change). Pulled tauri-build/codegen/macros/runtime/runtime-wry/utils forward with it. Verified: OSV over `Cargo.lock` no longer reports the advisory, and `cargo deny check advisories licenses bans sources` stays green. Shipped as its own PR rather than bundled into the #102 CI branch: a Tauri bump is a Rust change that this box cannot compile, so it wants its own PR and its own full CI run. The new `.github/dependabot.yml` opens the 2.11.0 → 2.11.1 bump automatically (cargo ecosystem; `tauri*` is excluded from the routine grouping precisely so it lands as its own reviewable PR), and `maintenance.yml`'s weekly OSV scan keeps reporting it until the bump lands. Nothing in the pinned-ignore list of `src-tauri/deny.toml` suppresses it. | -| I79 | Sev1 | `src/features/ai/modelStore.ts` + `src/routes/Home.tsx` + `src/features/session/SessionView.tsx` + `sampleLoop.ts` + `Report.tsx` | Issue #92: a real 10-minute two-person session on Windows rendered a report with `Focused-time —`, "No focus score was recorded for this session.", zero `ai_*` timeline rows — and, directly beside all that, "No distractions detected. Nice work." **Root cause: `useModelStore` is never hydrated outside Settings → AI.** `hydrate()` had exactly one caller, `ModelPickerContainer`'s mount effect (`ModelPickerContainer.tsx:85`), and that component mounts only inside the Settings → AI pane. `useSettingsStore` is hydrated at boot by `ThemeProvider` (`src/design/theme.tsx:52`), so `aiFeaturesEnabled` was correctly `true` while `activeModelId` sat at its `null` initial value — and `activeModelId` gates everything: `SessionView.tsx`'s sample-loop effect returns early on `if (!activeModelId)`, so `startSampleLoop` is never called and its `onStartFail('no_active_model')` toast — the one surface that names this — can never fire; `Home.tsx`'s `handleTopicSubmit` skips the V2-P9 gesture-context `preacquireScreenStream()` on the same condition, which on WebView2 is separately fatal. So any launch where the user didn't happen to open Settings → AI ran a whole session with AI silently dead: no loop, no toast, no audit row, no log line, and an unscored `sessions` row. Cross-platform and present at HEAD — it also explains #94 ("Ai does not work on macos when its enabled"). The report then made the silence permanent: `score`/`focused_pct`/`confident_samples`/`skipped_samples` all read NULL for an AI-off session, an AI-on-but-dead session, AND a pre-003 row, so no surface could tell a deliberate choice from a malfunction, and the distractions empty state asserted a clean measurement that never happened. Five further silent-death paths found alongside it: a sidecar that spawns but never reports healthy, an HTTP error from the sidecar, a per-tick abort, and any other tick throw were each `console.warn`-only (no devtools in release builds); the live 90 s per-tick timeout was 3.3× tighter than benchmark.ts's 300 s bound, so a model could benchmark successfully — the only thing that sets `activeModelId` — and then abort every live inference forever; an unanswered screen-share picker wedged `boot()` with no timeout, and `stop()` awaits `bootPromise`, so the sidecar was never killed; the Rejoin path and the camera/mic "Try again" path both re-`boot()` with no gesture pre-acquire; `mapDisplayMediaError` had no `InvalidStateError`/`InvalidAccessError` case, so a missing-transient-activation refusal was filed as `unavailable` (a dead-end toast) instead of reaching the recovery overlay whose retry button IS a gesture; `resolve_runtime_dir`'s `_ => Ok(None)` still degraded to a spawn with no CWD and no PATH prepend — the exact lethal-on-Windows state I75 fixed; and a child that dies in the Windows loader spawns Ok, so it crash-loops to the restart budget without ever reaching the VC++-redist hint. | **fixed** — (1) hydrate `useModelStore` in `Home.tsx`'s boot effect, so the persisted model is the truth from launch rather than from a Settings visit; (2) `handleTopicSubmit` + `handleRejoin` + `handleMediaRetry` all pre-acquire the screen stream inside their real user gesture, and a store still mid-hydration counts as "maybe active" (an unconsumed stream is discarded on unmount; a missed pre-acquire is fatal on WebView2); (3) a once-per-session toast when AI is on, the model store is `ready`, and no model is active — the gap where `onStartFail` could never fire; (4) `onStalled` fires once per loop lifetime after `STALL_TICKS` (3) consecutive unproductive ticks, with a distinct reason per cause (`engine_unavailable` / `engine_error` / `inference_timeout` / `unknown`) and actionable copy; paused states (break, camera off, pomodoro rest, battery) are deliberately not stalls; (5) the per-tick timeout is derived from the model's benchmarked p95 (`effectiveRequestTimeoutMs`: 3× p95, floored at 90 s, capped at benchmark.ts's 300 s); (6) `SCREEN_ACQUIRE_TIMEOUT_MS` (120 s) bounds the acquire so an unanswered picker becomes a visible retryable error instead of a permanent wedge, and a late-arriving stream is stopped rather than leaked; (7) `InvalidStateError` / `InvalidAccessError` → `screen_capture_denied`, routing to the overlay whose retry is itself the missing gesture; (8) migration **004** adds `sessions.ai_enabled` (1/0, NULL = pre-004), written from live settings at teardown, and the new `aiCoverage()` derivation gives the report four honest states — `ran` keeps the earned "Nice work", `noChecks` names the malfunction and points at Settings → AI, `off` says AI was off, `unknown` stays cause-neutral for pre-004 rows — shared by the rendered report and the text export so a pasted copy can never disagree; (9) Rust: `resolve_runtime_dir` falls back to the binary's own directory (one of the two places ggml globs anyway) instead of `None`, and the crash-loop give-up path now carries `append_windows_dll_hint`. Tests: `aiCoverage` (6 cases incl. the pre-003 scored row and the NULL-is-not-0 rule), serializer honesty (4), `snapshotFocusForReport.aiEnabled` (3), stall notice (4 incl. streak-reset and camera-off-is-not-a-stall), `effectiveRequestTimeoutMs` boundaries (4), and a Rust 003→004 upgrade test asserting old rows read NULL. Stories: `AiOnButNoChecks`, `AiOffForSession`. | +| ID | Sev | Location | Evidence | Status | +| --- | ---- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| I1 | Sev1 | `src/features/session/pomodoro.ts` | `stop()` sent no wire signal; receivers' 10 s silence timer resurrected the timer under a new broadcaster ~10 s after Stop. | **fixed** (R1) — explicit `stopped:true` message; receivers reset to idle. ARCHITECTURE §7 updated. | +| I2 | Sev2 | `src/features/friends/presence.ts` | Online state compared sender wall clock to receiver's; backward sender clock step wedged presence permanently. | **fixed** (R1) — stamp receiver-local time on receive. | +| I3 | Sev2 | `src/features/session/lifecycle.ts` + `sessionStore.ts` | Everyone-else-leaves auto-end lost `sessions.peer_pubkeys` + `markStudied` because `peerLeft` pruned `peers` first. | **fixed** (R1) — cumulative `seenPeerEdPubkeys` set. | +| I4 | Sev2 | `src/features/ai/benchmark.ts` | p95 included the cold-start warmup sample, inflating the sample floor 5–10× with no user recourse. | **fixed** (R1) — run + discard one warmup sample. | +| I5 | Sev2 | `src-tauri/src/commands/models.rs` | Resume fast-path hashed a multi-GB GGUF synchronously on the async runtime, stalling concurrent IPC. | **fixed** (R1) — moved to `spawn_blocking`. | +| I6 | Sev3 | `src/features/ai/sampleLoop.ts` | Battery-pause branch omitted the §8 "thermal-aware notice" and rescheduled at the sample interval, not 60 s. | **fixed** (R2) — `onBatteryPause`/`onBatteryResume` callbacks (fire once each) wired to SessionView toasts; paused branch now reschedules at `BATTERY_POLL_INTERVAL_MS`. Regression test added. | +| I7 | Sev4 | `src/features/session/invite.ts` | Auditor flagged idle-invite `hostSession()` as bypassing the topic gate. | **not a bug** — `Home.tsx` enforces `TopicGateModal` (sets `pendingInitialTopic`) before `inviteToCurrentSession`; no other caller. No change. | +| I8 | Sev3 | `src/features/session/SessionView.tsx` | Audit receive did not check `session_topic` (the ai-alert path does). | **fixed** (R2) — added `verified.session_topic !== sessionTopic` drop, mirroring `aiAlerts.ts`. | +| I9 | Sev3 | `src/features/session/pomodoro.ts` | Any peer sending a valid signed `pomodoro` msg is accepted as broadcaster, even mid-broadcast by another. | **deferred — conflicts with canonical doc.** ARCHITECTURE §14 explicitly: "Friend disables their own AI / fakes score — **Not defended. Social trust. Accepted.**" The "most recent sender becomes broadcaster" behavior is a deliberate, code-documented reconnection-robustness choice; hardening it would silently deviate from the accepted friends-only threat model and risk regressing the documented original-broadcaster-returns path. Surfaced per house rule; user can override to request the hardening explicitly. | +| I10 | Sev3 | `ARCHITECTURE.md §7` | `score_final` wire type has no producer/consumer. | **fixed (doc)** (R2) — §7 annotated: `score_final` is reserved/not-implemented in V2; the report is local-SQLite by V2-P8 design; type kept so a future phase avoids a breaking wire change. Not removed (removal would be a forward-compat break). | +| I11 | Sev3 | `src/features/ai/sampleLoop.ts` | Declared topic interpolated into the focus prompt without injection delimiters. | **fixed** (R2) — topic wrapped in `` + labelled as data; system-prompt rule added; `FOCUS_SYSTEM_PROMPT_VERSION` → 2; `tests/ai-eval/run.ts` kept byte-identical; ARCHITECTURE §8 prompt updated. | +| I12 | Sev3 | `src/features/ai/aiAgent.ts` | Total JSON-parse failure echoed ≤200 chars of raw model output into the dialog. | **fixed** (R2) — fixed safe string to the user; raw logged to console only. Test updated. | +| I13 | Sev3 | `src-tauri/capabilities/default.json` | `ai-dialog` window granted `notification`/`store`; §12 says permissions are main-window-scoped. | **fixed** (R2) — `default.json` restricted to `["main"]`; new `ai-dialog.json` capability scoped to the dialog window with `core:default` only (it uses only core event/window IPC). | +| I14 | Sev3 | `src-tauri/src/db/migrations.rs` + `001_initial.sql` | Bare `CREATE TABLE` + no single-instance ⇒ two simultaneous first-launches could panic the second. | **fixed** (R2) — `IMMEDIATE` transaction with the version read moved inside the tx (locks before reading); `IF NOT EXISTS` on 001's DDL; `INSERT OR IGNORE` on `schema_version`. Sequential-upgrade tests preserved. | +| I15 | Sev3 | `src/stores/identityStore.ts` / `identity.rs` | Identity commit (keychain then file) had no rollback; a failed file write + re-onboard overwrites the keychain entry. | **mitigated** (R2) — the file write is now atomic (see I16); residual is now only "rename succeeded but the keychain `set` itself fails", an OS-keychain fault recoverable via BIP39 (PLAN §7). Full two-store transactionality is out of scope for a Sev3. | +| I16 | Sev3 | `src-tauri/src/commands/identity.rs` | `fs::write` non-atomic; a crash mid-write truncates `identity.json`. | **fixed** (R2) — write to `*.json.tmp` then `fs::rename` over the target (atomic on same FS); temp cleaned on rename failure. | +| I17 | Sev3 | `src-tauri/src/db/sessions.rs` | `started_at`/`ended_at`/`total_minutes` overwritten while the comment claimed additive upserts. | **fixed (comment)** (R2) — comment rewritten to state these three are deliberately authoritative-overwrite (a re-summarize must be able to correct them; COALESCE would swallow it) while the report columns are additive. No behavior change, by design. | +| I18 | Sev4 | `pair.ts` / `lib/trystero/index.ts` / `sidecar.rs` | `verifyHello` didn't reject self-pubkey; stale `selfId` comment; `sidecar_start` trusts JS `model_path`. | **partially fixed** (R2). `verifyHello` now rejects a hello whose `ed_pubkey` equals the local identity (passed `ctx.edPubHex` from `runPair`). `trystero/index.ts` comment corrected to describe the actual module-global-`selfId` mechanism. The `sidecar_start` model-path sandbox is **deferred — conflicts with canonical doc**: PLAN §5 explicitly promises "Advanced users can point at any local GGUF", so constraining `model_path` to `data_dir/models` would break a documented feature. Surfaced per house rule. | +| I19 | Sev4 | `package.json` devDependencies | `npm audit` flags ~20 dev-chain advisories across critical/high/moderate (criticals: `concurrently@9` → `shell-quote`; highs/moderates span the `@storybook/*` and `esbuild`/`tsx` chains). Re-flagged on every scan. | **triaged — no runtime exposure** (2026-06-13). Every one is a **devDependency**; none reaches the installed desktop app — `npm audit --omit=dev` is clean (0) and no advisory package appears in `dependencies`. Bump `concurrently` and the `@storybook/*` chain when convenient; do **not** rush a major Storybook upgrade for a dev-only advisory. Recorded so the scan result isn't re-investigated each time. (Exact counts shift with the lockfile; the load-bearing fact is the clean prod audit.) | +| I20 | Sev1 | `src-tauri/src/db/mod.rs` | `is_definitely_corrupt` only treated a non-"ok" `integrity_check` verdict as corruption; a truncated file (SQLITE_CORRUPT) or damaged header (SQLITE_NOTADB) makes the pragma ERROR, so recovery never fired and the app bricked on every launch after a power-loss/force-kill. | **fixed** — classify by SQLite error code; also clean up `-journal`/`-wal`/`-shm` on rename. Corruption-signature tests added. | +| I21 | Sev2 | `src-tauri/capabilities/ai-dialog.json` | The scoped ai-dialog capability lacked `core:window:allow-close`, so the floating AI dialog's Esc/blur/X all silently failed (regression from the I13 scope-down). | **fixed** — grant `core:window:allow-close`. | +| I22 | Sev2 | `src/features/session/reportData.ts` + `stats/statsInsights.ts` | Report topic-timeline / top-distractions and cross-session insights walked every audit event; peers' broadcast `topic_set`/`ai_alert` (persisted locally) were misattributed to the local user. | **fixed** — thread the local ed_pubkey and filter to it (matches the self-only score gauge / trend). | +| I23 | Sev3 | `src/features/ai/sampleLoop.ts` | `onScreenTrackEnded` latched `captureDenied` and tore down ALL AI capture when ANY screen track ended; unplugging a secondary display in "All displays" mode killed focus detection with a misleading permission overlay. | **fixed** — discriminate: drop the dead display, latch only when the last live one ends. | +| I24 | Sev2 | `src-tauri/src/commands/models.rs` | No read/idle timeout on downloads; a mid-stream stall hung `bytes_stream().next()` forever, freezing the UI and permanently locking the `model_id`. | **fixed** — 60s `read_timeout`. | +| I25 | Sev2 | `src-tauri/src/commands/system.rs` | `system_relaunch_app`'s `app.restart()` skips `RunEvent::Exit` (the only sidecar kill), orphaning a running llama-server on Window-Style relaunch. | **fixed** — `kill_blocking` before restart. | +| I26 | Sev2 | `src-tauri/src/lib.rs` | A boot-time global-shortcut OS conflict propagated out of `setup()` into `build().expect()`, panicking before first paint. | **fixed** — register best-effort; a failed binding is inert until rebound in Settings. | +| I27 | Sev3 | `src/features/friends/invite.ts` + `inviteRetry.ts` | An invite retry queued after the 15s send timeout escaped `cancelAll` if the session ended during the window, later pulling the friend into a dead room. | **fixed** — injected `isSessionLive` guard: a retry never fires for a session that isn't the host's current live one. | +| I28 | Sev3 | `src/lib/fileExport.ts` | CSV export didn't neutralize spreadsheet formula injection; a peer-chosen display name beginning with `= + - @` executed on open (=HYPERLINK exfil / DDE). | **fixed** — quote-prefix string cells starting with a trigger; numeric cells untouched. | +| I29 | Sev2 | `src/features/friends/AddFriendDialog.tsx` | A programmatic close (contact-card deep link) during an in-flight legacy pairing never aborted it — Radix doesn't fire `onOpenChange` on a parent-driven close — leaking the trystero room + relay sockets. | **fixed** — tear down on any `open` transition. | +| I30 | Sev3 | `src/features/friends/inbox.ts` | No replay/dedup on the inbox receive path; a stranger on the pubkey-derived inbox topic could re-broadcast a captured envelope to re-fire the invite toast/notification. | **fixed** — dedup on `(from_ed_pubkey, box nonce)`, TTL-bounded. §14 row added. | +| I31 | Sev3 | `src/features/friends/AddFriendDialog.tsx` + `lib/relayDiagnostics.ts` | Pairing's "network trouble" hint read only the Nostr socket map, so it wrongly blamed the user's network in the exact MQTT-fallback case the v1.2.2 race was built to survive. | **fixed** — transport-aware `pairingRelaysUnreachable()` judging both socket maps; Nostr-only signal kept for the invite path. | +| I32 | Sev3 | `src/routes/Home.tsx` | `PairDeepLinkBoot` rendered only in the non-session tail, so a `studyvis://` link clicked mid-session reached zero listeners and was dropped. | **fixed** — render the full tail in the active-session branch. | +| I33 | Sev3 | `src/strings.ts` | In-app AI copy promised screen access is requested "when you start your first session", but enabling AI requests it immediately. | **fixed (copy)** — reword to match the shipped enable-time prompt. | +| I34 | Sev3 | `src-tauri/src/lib.rs` | With minimize-to-tray off, closing the main window while the AI dialog was open stranded the app: process alive, main window gone, tray "Open" a no-op. | **fixed** — destroy the AI dialog on that close path so the runtime exits. | +| I35 | Sev3 | `src-tauri/src/commands/sidecar.rs` | The crash-restart watcher could spawn a fresh llama-server after `kill_blocking` already ran at quit (during the backoff window), orphaning it. | **fixed** — `shutting_down` flag re-checked after backoff, before respawn. | +| I36 | Sev3 | `src/design/theme.tsx` | `ThemeProvider` wrote the pre-hydration fallback `dark` class, stripping the boot-cache `light` class and flashing dark for light/auto users. | **fixed** — defer to the boot script until an authoritative mode exists. | +| I37 | Sev4 | `src/features/friends/pair.ts` | The legacy pairing hello's (unsigned) `display_name` was stored/rendered raw, unlike the ContactCard path's cap + bidi/zero-width sanitize. | **fixed** — shared `normalizeUntrustedName` applied on both paths. | +| I38 | Sev3 | `src/features/ai/sidecar.ts` | `useSidecarStore.start()` unconditionally set `running` after its await, clobbering an interleaved `stop()` and leaving the store `running` on a killed process. | **fixed** — bail if a stop intervened. | +| I39 | Sev3 | `src/App.tsx` + `src/components/ErrorBoundary.tsx` | No React error boundary anywhere; any render throw blanked the whole window and killed the always-on inbox/presence + live session. | **fixed** — top-level `ErrorBoundary` around the routed content with a calm "Try again". | +| I40 | Sev4 | `src-tauri/src/commands/system.rs` | Changing one PTT shortcut when both shared a combo (hand-edited settings.json) unregistered the other. | **fixed** — only unregister the old combo when the other action isn't still using it. | +| I41 | Sev4 | `src/lib/encoding.ts` | `hexToBytes` used `parseInt` per byte, silently mis-decoding malformed hex ('1g'→0x01, '-a'→wraps) instead of rejecting. | **fixed** — validate the whole string against `/^[0-9a-fA-F]*$/` first. Adversarial-input tests added. | +| I42 | Sev3 | `src/features/session/SessionView.tsx` | The local session camera/mic stream had no `ended` listener, so a mid-session device loss (unplug / OS-revoke / another app grabbing the camera) left peers on a frozen tile and silently killed the AI face path. | **fixed** — attach an `ended` listener that surfaces the existing "Try again" recovery banner. | +| I43 | Sev3 | `.github/workflows/ci.yml` | CI compiled only aarch64-apple-darwin, so `#[cfg(target_os="windows")]` code first built inside `release.yml` AFTER the tag was pushed. | **fixed** — macOS + Windows Rust matrix on every push/PR. | +| I44 | Sev3 | `.github/workflows/release-prep.yml` | The one-click gate skipped `check-a11y` and all Rust compilation, so a release could be cut over an axe-core / clippy regression. | **fixed** — add the a11y gate; require the exact main SHA's CI run to be green before bump/tag/push. | +| I45 | Sev3 | `README.md` / `PLAN.md` / `ARCHITECTURE.md` / `CHANGELOG.md` | User-facing doc drift: first-run described the retired 12-word flow as primary; "one WebSocket" (really ~8 relays); "three tiers" (four models); "v1.2.0 is current" (v1.3.1); §6 "MQTT not yet wired" (raced since v1.2.2); changelog x86_64 DMG claim (aarch64-only). | **fixed** — brought each in line with the shipped code. | +| I46 | Sev3 | `src/features/friends/invite.ts` | `sendInviteEnvelope` treats any peer joining the recipient's inbox topic as delivery, so an eavesdropper on that shared pubkey-derived topic can appear as "delivered" or drop the invite. | **accepted — friends-only threat model.** Envelope is still NaCl-box-sealed to the recipient; worst case is a suppressed offline-retry (re-click Invite). Documented in §14. The flagged signed invite-ACK shipped in #47 C2 (new `invite-ack` action, v1.2.x-wire-compatible: no ACK within the window → honest "unconfirmed" copy) — for UX legibility, not as a defense; the eavesdropper acceptance above stands. | +| I47 | Sev3 | `src/features/friends/presence.ts` | Presence heartbeats/goodbyes are unauthenticated on a pubkey-derived topic, so a stranger with a friend's public pubkey can forge that friend's online/offline state. | **accepted — friends-only threat model.** Presence is soft UX state, not a data/session compromise. Signing would break cross-version presence (older peers send unsigned), so enforcement is deferred, not shipped. Documented in §14. | +| I48 | Sev3 | `src/features/friends/pair.ts` (upstream `@trystero-p2p/mqtt`) | Each pairing's MQTT room open→leave orphans ~4 broker connections: trystero-core sets `didInit=false` on last-room-leave but never `.end()`s the MQTT clients. | **deferred — upstream trystero bug.** Bounded (a handful of pairings per session, cleared on process exit) under the friends-only 4-peer model. Fix is upstream (or an app-side always-on MQTT room, which trades the leak for a persistent idle broker connection — not worth it). | +| I49 | Sev3 | `src/features/friends/InboxBoot.tsx` + `presence.ts` | The presence effect keys on the whole friend set, so adding/removing any friend tears down + rebuilds the own presence room, broadcasting a goodbye that flickers your presence offline→online on every other friend's screen (and can fire a spurious "came online" notification). | **fixed** (#47 C6, the recorded dedicated pass) — `startPresence` gained `updateFriends`: friend list edits diff rooms in place (join added / leave removed), the own room and heartbeat cadence never churn, and `leave()`'s tested goodbye semantics are untouched. InboxBoot keys the subscription on identity only and drives list edits through the diff; removed friends' notify baselines are pruned so a re-add starts fresh. Unit tests cover added/removed/no-op churn including a watcher asserting no goodbye flicker. | +| I50 | Sev4 | `src-tauri/tauri.conf.json` | Both webview windows ship with CSP disabled (defense-in-depth only — no reachable XSS sink today: React auto-escapes, no `innerHTML`/`eval`). | **deferred — needs a desktop CSP smoke-test.** A wrong CSP hard-breaks Tauri IPC/asset loading, which no static gate catches; landing a `script-src 'self'` policy safely requires running the built desktop app (not possible headless). Recommended policy: `default-src 'self'; script-src 'self'; object-src 'none'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' ws: wss: http://127.0.0.1:*`. | +| I51 | Sev2 | `src/routes/Home.tsx` | The `tail` fragment (InboxBoot + deep-link + import dialog + topic gate) rendered at a different unkeyed child index per view branch, so React reconciled by index and re-mounted the always-on presence/inbox room on every view switch — re-triggering the I49 goodbye flicker, blanking the friends list for up to a heartbeat, and dropping an invite that arrived in the teardown window. | **fixed** — `` pins the tail fiber across branches of differing child arity. The load-bearing key is documented at the site; `pairDeepLink.ts`'s stale "view switches re-mount the boot" comment corrected (the `launchConsumed` guard kept). Not statically checkable and not node-testable without RTL, so protected by the site comment. | +| I52 | Sev2 | `src/features/session/lifecycle.ts` + `stores/sessionStore.ts` | `total_minutes` was pure wall-clock `endedAt − startedAt`, counting OS-sleep/suspend as study time; a session slept on persisted the whole span (a free streak day and inflated totals). | **fixed** — elapsed is `min(wallMs, monoMs)` off a `performance.now()` origin captured at start, mirrored in the live footer. Not retroactive (old rows stand); degrades to prior behavior on a platform whose monotonic clock happens to include suspend, never undercounts. Unit-tested via an injectable `monotonicNow` seam (awake / slept-through / backward wall clock / no-mono fallback / slept-through rejoin). | +| I53 | Sev3 | `src/features/session/lifecycle.ts` + `SessionView.tsx` | A peer's deliberate `left` (signed, on the wire since V1-P9) still armed the 20 s reconnect grace and offered a Rejoin into a dead room. | **fixed** — mark departed peers, and skip the grace/Rejoin only when the room empties with no unexplained absence remaining, via a new `SessionEndReason` (`'peer'`). Unexplained-absent peers are tracked in a Set (not a single flag, per the review) so an intervening join by another peer can't strand a still-absent blipper; the mark clears per-peer on rejoin so a later blip still gets grace. ARCHITECTURE §13 updated. Grace unit tests extended. | +| I54 | Sev3 | `src/features/friends/InboxBoot.tsx` + `friendOnlineNotify.ts` | The friend-online baseline suppressed every friend's _first_ online resolution after mount (not just boot's initial sweep), so a genuine later arrival never notified — the one event the feature exists for. | **fixed** — per-friend watch-start map with a settle bound. The bound is a dedicated `NOTIFY_SETTLE_MS` (3 min, sized above realistic presence-handshake latency), not the 60 s heartbeat window: reusing the latter let a slow-connecting already-online friend re-read as an arrival (review finding). Only the settle window is suppressed. Unit-tested. | +| I55 | Sev3 | `src/features/session/hello.ts` | The signed session-hello `display_name` was stored/rendered without the cap + bidi/zero-width sanitize every other untrusted-name path applies; on `main` it was unbounded. | **fixed** — `normalizeUntrustedName(name, HELLO_NAME_CAP)`. Cap is 192 UTF-8 bytes — the worst case for the 64-UTF-16-unit `maxLength` our own inputs enforce — so a legitimate multibyte name (CJK/emoji) survives intact rather than being byte-truncated (review finding), while a hand-modified sender is still bounded. Unit-tested incl. multibyte + bidi. | +| I56 | Sev3 | `src/features/ai/sampleLoop.ts` | `onCaptureError` fired per tick (contract says once/lifetime) and the face-track guard never checked `readyState`, so a dead webcam threw `track_ended` every tick and toast-stormed the session over the MediaErrorBanner already saying the same thing. | **fixed** — the ended-track guard skips the tick without counting a sample; a `captureErrorReported` latch mirrors `sidecarErrorReported`, reporting once and clearing on the next successful verdict. Unit-tested. | +| I57 | Sev3 | `src/design/tokens.ts` + `src/design/index.css` | The focus ring (`accent.ring`, 40 % alpha) measured ~2.6:1 dark / ~1.8:1 light against the surfaces it is drawn on — below WCAG 1.4.11 — because the UA outline is globally reset; the gate missed it by measuring the opaque accent. `shadow.glow` had also drifted 3px/4px. | **fixed** — raised alpha (60 % dark / 80 % light), mirrored in both hand-kept files; `check-contrast` now measures the ring in the bg-stack at its real per-theme alpha; `shadow.glow` reconciled to the tokens.ts value (3px). The ring's inner edge on `bg-accent-default` buttons intentionally stays below 3:1 — the outer edge against the canvas carries identification. | +| I58 | Sev3 | `src/components/ui/dropdown-menu.tsx` | Menu items declared `focus:bg-bg-raised` on a `bg-bg-raised` surface — a 1.00:1 no-op — so keyboard/mouse navigation showed no highlight (worst in the in-session audio pickers, where two identically-named devices are indistinguishable). | **fixed** — an inset accent ring highlight (keeps `focus:` so Radix pointer-move still lights it). The byte-identical Button/Badge `secondary` hover was fixed the same way (`hover:bg-bg-surface`). | +| I59 | Sev3 | `src/components/AuditLogPanel.tsx` + `SessionNotesPanel.tsx` | The session-log and notes scroll containers had no focusable descendant and no `tabIndex`, so a keyboard-only user couldn't scroll them (WCAG 2.1.1). macOS/WKWebView only; Windows WebView2 auto-focuses scrollers. | **fixed** — `tabIndex={0}` + a focus-visible inset ring on both. Overflowing Storybook stories added so the axe `scrollable-region-focusable` gate has something to assert on. | +| I60 | Sev3 | `src/strings.ts` (`searchKeywords`) + `Settings.tsx` | v1.6.0 settings search routed "tray"/"minimize"/"capture displays"/"auto-update" to Advanced (which owns none of them) and left Advanced's own settings ("launch at login", "clear history", "onboarding") unfindable. | **fixed** — keywords moved to the panes that own each setting; Advanced keywords added; a `Record` guard in `Settings.tsx` pins the bucket↔pane mapping without a strings→features import cycle. | +| I61 | Sev3 | `src/stores/settingsStore.ts` + `ShortcutsCategory.tsx` | `resetShortcutsToDefaults` rethrew on the first setter's combo collision and never ran the second; the rejection was swallowed to `console.error`, so the button was a silent no-op. | **fixed** — reorder + per-call try/catch so both setters run; a residual collision surfaces a `toast.error` (copy in strings.ts). The Rust `is_registered` skip the original proposal suggested was dropped — it would re-open #47 B5. Stateful fake added to the keybindings test. | +| I62 | Sev3 | `src/features/updater/updaterStore.ts` + `AboutCategory.tsx` | Settings → About offered a live Restart-now / Check-now during a session (unguarded, unlike the update banner), and its help text asserted "you're on X, the latest" from the initial `idle` state and after a silent background-check failure. | **fixed** — session-active guards in `installAndRestart`/`checkNow` (the `userInitiated` exemption, made false by the in-session settings overlay, removed); About disables the buttons in-session and derives its help from an explicit `upToDate` branch rather than a fallthrough. Store tests flipped to assert deferral. | +| I63 | Sev3 | `src/features/identity/recoverLogic.ts` | A failed 24-word restore pointed at all 24 words equally, with no way to narrow a single typo on the highest-stakes screen in the app. | **fixed** — name the words that aren't in the wordlist (`unknownWords` on `MnemonicClass`, populated only on the 24-word path); copy in strings.ts. Kept in `recoverLogic.ts`, not the cross-version crypto module. Unit-tested. | +| I64 | Sev3 | `src/features/stats/FocusInsights.tsx` | The focus-over-time trend tooltip had no date, so a dip couldn't be anchored to a day. | **fixed** — carry each point's `startedAt`; the tooltip renders the `dayKey` day, byte-identical to the bar chart's day format. | +| I65 | Sev4 | `src/features/stats/statsData.ts` | The stats CSV omitted the two headline tiles (total sessions, streak, average) — the numbers the pane is built around. | **fixed (summary)** — prepend summary rows, preserving the null-average ("AI off" vs "scored 0") distinction. Per-session detail left out of scope. Test extended. | +| I66 | Sev3 | `src-tauri/src/commands/sidecar.rs` | `sidecar_start` spawned llama-server then opened the log file; an `open_log_file` failure after a successful spawn dropped the `CommandChild` without `kill()`, orphaning a multi-GB process past app exit (same class as I25/I35). | **fixed** — open the log before spawning, so no fallible `?` sits between the spawn and `guard.child`. Reviewed by reading (CI is the first Rust compiler on this dev box). | +| I67 | Sev3 | `src-tauri/src/commands/sidecar.rs` | The respawn budget was a 30 s sliding window, so any crash spaced >30 s reset the counter and the watcher respawned llama-server forever without ever setting `errored` — no recovery affordance surfaced and the D7 log cap was defeated. | **fixed** — the budget now counts consecutive respawns that each died before `MIN_HEALTHY_UPTIME` (120 s); a durable child resets the streak (`next_attempts` pure fn, unit-tested). Once the budget is exceeded `errored` is set as before. | +| I68 | Sev4 | `src-tauri/src/db/audit_events.rs` | The cross-session insights read shipped the entire `audit_events` table over IPC though only `ai_warning`/`ai_alert` rows are consumed. | **fixed** — `WHERE kind IN ('ai_warning','ai_alert')` narrows the query (~4× less JSON at 10k rows); `list_all` → `list_ai_distractions_all`, but the Tauri command name is unchanged so the IPC/TS contract is untouched. The SQL twin of TS `isDistraction` is commented at the query. | +| I69 | Sev3 | `src-tauri/src/lib.rs` | The corrupt-DB recovery dialog asserted re-pairing was required and never mentioned the friends-backup import — wrong at the exact moment a friend loses their list. | **fixed (copy)** — the dialog now names Settings → Identity → Import friends as the restore path if a backup exists, otherwise re-pair. | +| I70 | Sev4 | `.github/workflows/release.yml` | A half-built draft (one platform's artifact missing from `latest.json`) could be published, stranding every friend on the missing platform with no update path and a false "you're on the latest". | **fixed** — a job asserts both platforms are present in the draft's `latest.json` and, on failure, stamps the draft title "INCOMPLETE, DO NOT PUBLISH" (needs `contents: write` to read a draft). Not runnable on this box; validated by YAML parse + reading. | +| I71 | Sev2 | `src/features/updater/updaterStore.ts` + `src-tauri/src/commands/system.rs` | Issue #77: an app opened straight from the mounted `.dmg` runs under macOS App Translocation (read-only bundle), where `update.install()`'s rename-into-place can never succeed — every launch re-downloaded the installer, offered "Restart now", and failed with the generic install toast. The one documented install step (drag to Applications) is exactly the one this path skipped, and the updater had no idea. | **fixed** — new `system_install_context` command (translocation via exe-path component, read-only volume via `statfs`; fail-open) consulted after a check finds an update: an unswappable bundle sets a new process-permanent `blocked` status _before_ any bytes move, and the banner + Settings → About replace the doomed Restart with move-to-Applications guidance. Verified live: dev binary on a read-only DMG against the real v1.7.0 release showed the blocked row. Windows/NSIS unaffected (always updatable). | +| I72 | Sev1 | `src-tauri/src/commands/models.rs` | Every model download failed at the picker's preflight with "…The model manifest may be stale." for every catalog entry. `model_head_check` populated `content_length` from `reqwest::Response::content_length()`, which is the body's size hint — an HTTP/1.1 HEAD response body is always empty (hyper decodes it as zero-length regardless of headers), so every probe reported 0 bytes and the size gate rejected all six entries. The manifest itself is current: the raw `Content-Length` (and `x-linked-etag` = pinned sha256) at every pinned revision still matches. | **fixed** — read the `Content-Length` response header instead; in-module regression test against a local HEAD server; live-verified that all 10 catalog files (6 model + 4 mmproj — the three Gemma quants share one projector) report header sizes byte-identical to the manifest. Git history dates the break to the picker's birth: the size gate, the `content_length()` call, and the no-http2 reqwest dep all landed in one commit (af2987d, V2-P2) and never changed, and the zero-length HEAD decode is server-independent — so no catalog download has ever passed this preflight, and the downstream GET/verify/resume path has never run end-to-end in a shipped build (first real install is its true test). First user report 2026-07-26. | +| I73 | Sev1 | `src-tauri/src/commands/sidecar.rs` + `src-tauri/src/commands/engine.rs` | In-app llama-server spawn has never worked in any build. `shell().sidecar("binaries/llama-server")` resolves `/binaries/llama-server` (tauri-plugin-shell 2.3.5 joins the full configured string against the exe dir), but tauri-build (dev) and the bundler (release) both strip the directory prefix and the triple, placing the file at `/llama-server` — verified in `target/debug/` and in the installed `StudyVis.app/Contents/MacOS/`. Every `sidecar_start` failed with `spawn llama-server: No such file or directory`, surfaced as "AI failed to start:" / "AI model crashed". The plugin has been pinned at 2.3.5 since V1-P1, so this is a day-one bug, not a regression; it sat behind I72 (downloads never completed), which is why the first user report of both landed the same day (2026-07-26 — the on-disk `llama-server.log` from that attempt is a 0-byte file: the child never ran). | **fixed** — sidecar binaries now resolve to absolute paths and spawn via `shell().command()`: bundled probe at `/llama-server(.exe)` (size-gated), then a managed install under `data_dir/engine/-/`. When neither resolves, `sidecar_start` auto-installs the pinned llama.cpp b9095 release asset (SHA-256-verified; pins lockstep-tested against `scripts/fetch-llama-server.sh`; tar.gz/zip unpacked flattened + filtered), gated by the new `engine_auto_install` setting (default ON) with `engine_info`/`engine_install` commands and a Settings → AI "AI engine" row (status/progress/Reinstall). `build.rs` writes a debug-profile-only placeholder so fresh checkouts compile without the fetch script; release-profile builds still hard-fail. Windows spawn failures name the VC++ redistributable when `vcruntime140.dll` is absent. Verified live on macOS: the installed bundle's binary spawns via the exact fixed resolution (`--version`, Metal init, exit 0), the placeholder build compiles and launches, and the pinned archives download, hash-match, extract, and run on this machine. The in-app GUI walk (Settings row + session start) is user-walked — the dev binary's keychain prompt blocks machine-driving it. | +| I74 | Sev2 | `src/features/friends/presence.ts` + `presenceRelay.ts` + `src/lib/nostr/` | A mutually added friend showed permanently offline on BOTH ends whenever a STUN-only WebRTC datachannel could not form between the two networks (symmetric NAT / CGNAT / strict firewall — no TURN ships, ARCHITECTURE §4). Heartbeats only rode datachannels; trystero fires no callback on a failed ICE attempt (it silently re-offers forever), and offline ContactCard pairing (§5.1) removed the last step that ever proved the P2P path worked — so the failure was invisible end to end, with every relay reachable and both apps running. Presence, invites, and sessions all share the broken leg; presence was just the visible symptom. | **fixed** — relay-carried presence: sealed ephemeral Nostr events (kind 20001, new `studyvis:presence-relay:v1` tag/key derivations pinned in topics.test.ts) published every 30 s to the pinned relays over an owned reconnecting socket pool; no `since` filter and `limit: 0` (the #47 C1 clock-skew lesson). The datachannel leg stays and now stamps `lastP2pAt`, so `presenceState()` distinguishes direct-online from relay-only "limited" (120 s settle, I54 lesson) — surfaced in the friends list as an amber "Available · limited connection" row plus a one-line hint deep-linking Settings → Network (TURN). Goodbyes keep `lastSeenAt` for "seen … ago". Sessions/invites behind the same NAT still need TURN — the UI now says so instead of lying "Offline". Old builds interop unchanged (they never see this leg). ARCHITECTURE §4/§7/§11/§14 + PLAN §2 updated; `offchain.pub` dropped from the relay pin (now rejects anonymous publishes). | +| I75 | Sev1 | `src-tauri/src/commands/sidecar.rs` | After 1.8.0 shipped I73's spawn-path fix, on-device AI still failed to start on a real Windows install: `llama-server.exe` spawned, printed its banner (`Running without SSL`, `loading model`), then exited with `no backends are loaded` / `failed to load model` / `giving up after 4 restart attempts` (friend's `llama-server.log`, 2026-07-26 — the same day 1.8.0 shipped, the very next link in the same chain). Root cause: the pinned llama.cpp b9095 release assets are `GGML_BACKEND_DL` builds — 15 `ggml-cpu-*.dll` variants on Windows (haswell/zen4/sse42/…), `libggml-cpu.dylib`/`libggml-metal.dylib`/`libggml-blas.dylib` on macOS — that ggml `dlopen()`s at startup rather than linking. `ggml_backend_load_best` (`ggml/src/ggml-backend-reg.cpp`) globs exactly two places for those: the executable's own directory and the process's current working directory — never `PATH`/`DYLD_FALLBACK_LIBRARY_PATH`/`LD_LIBRARY_PATH`. I73's env-var prepend only satisfies the binary's _linked_ imports (`llama.dll`/`ggml-base.dll`/…), which is why the process starts at all; it never reaches the dlopen glob, so `ggml_backend_reg_count()` stays 0, `common_init_from_params` fails, and the crash-restart watcher gives up after `RESTART_BUDGET` (4) identical failures — on every bundled Windows and macOS install, not an edge case. Verified against the pinned llama.cpp b9095 source (`ggml-backend-reg.cpp:479-489`) and the actual release archives (`llama-b9095-bin-win-cpu-x64.zip`, `llama-b9095-bin-macos-arm64.tar.gz`). | **fixed** — `spawn_llama` now also sets the child's working directory to the same runtime dir already resolved for the `PATH`/`DYLD_FALLBACK_LIBRARY_PATH`/`LD_LIBRARY_PATH` prepend (`Command::current_dir`, tauri-plugin-shell 2.3.5), since `fs::current_path()` is in ggml's search list. One code path covers both engine sources (bundled, and the managed install where `runtime_dir` already equals the exe's own directory) and all three platforms. Not runnable on this box — no cargo/node toolchain and `src-tauri/binaries/` has no fetched engine on this Linux dev host; gated by CI and the `Release prep` workflow's gate job instead. | +| I76 | Sev1 | `src/features/ai/sampleLoop.ts` + `captureScreen.ts` + `src/routes/Home.tsx` + `AiCategory.tsx` + `SessionView.tsx` | User report: "AI capture error: getDisplayMedia must be called from a user gesture handler" firing on ordinary session starts with AI already enabled, and — because the fallout from this same failure kept killing the just-started sidecar — a separate, misleading "AI isn't running yet. Turn it on in Settings → AI" from the Ctrl+] chat dialog even though AI genuinely was on. Root cause: `sampleLoop.ts`'s `boot()` acquires the session's long-lived screen `MediaStream` via `navigator.mediaDevices.getDisplayMedia()`, but `boot()` runs from a React `useEffect` fired by state changes (session active + AI on + model chosen + camera up), never from inside a click handler. WebView2 (Windows) and WKWebView (macOS) require `getDisplayMedia()` to run inside live transient user activation on _every_ call, not just the first — the same reason the OS picker itself fires on every acquire (documented in `src/features/ai/README.md`'s "Acquire strategy", which is why V2-P9 already moved to one long-lived stream instead of a per-tick acquire) — so with no gesture in `boot()`'s call stack the call was rejected outright. Because the rejection's `DOMException` name fell outside `mapDisplayMediaError`'s handled set, it surfaced as the generic `screen_capture_unavailable` code and a raw toast instead of the intended `screen_capture_denied` recovery overlay, and `boot()`'s existing failure path tore down the sidecar it had just started. A second, compounding gap: `onCaptureError` never updated `AiStatusChip`'s runtime status, so the chip kept reading "active" after AI had silently died underneath it — matching the reporter's "I can't tell if it's on or if it's errored." | **fixed** — a gesture-context handoff: callers that DO have a real user gesture (`TopicGateModal`'s submit when starting a session with AI already enabled; `AiCategory`'s "enable AI" toggle when a session is already active; `SessionView`'s permission-overlay retry) call the new `preacquireScreenStream()` synchronously (no `await` before it), which starts `getDisplayMedia()` inside that click and stashes the in-flight promise; `sampleLoop.ts`'s default `acquireScreenStream` runtime hook consumes that stash instead of calling `getDisplayMedia()` itself outside gesture context. An unconsumed stash (a rapid re-toggle, or a session that never reaches `boot()`) is released via `discardPendingScreenStream()`, including on `SessionView` unmount, so it never leaks a live stream or leaves the OS recording indicator lit. Separately, `onCaptureError` now carries a `fatal` flag — true for a `boot()`-time acquire failure (the loop really did tear itself and the sidecar down) vs. false for a `tick()`-time transient one (the loop keeps running) — so `SessionView` only flips the status chip to "error" on the former. Unit-tested (pending-stream stash/discard, default-runtime consumption of the stash, the `fatal` flag on both call sites); `npm run build`/`lint`/`test` all green (878 tests). | +| I77 | Sev1 | `src/features/session/lifecycle.ts` + `SessionView.tsx` + `tests/integration/session.test.ts` | User report: "on my device I can't see the other person's camera but they can see mine" — a guest joining a friend's session never received the host's camera **or** mic, in either direction of the pair, while the host saw the guest fine. Root cause: `SessionView`'s media-acquire effect published the local `MediaStream` with a single untargeted `room.addStream(stream)`, and trystero 0.24 delivers a stream only to the peers that are active **at that instant** — `addStream` → `applyMediaOp` → `iterate` enumerates `keys(activePeerMap)` right then (`@trystero-p2p/core` `room.mjs:83`, `:494`) and queues nothing; peer activation (`room.mjs:306-314`) sets `activePeerMap` and fires `onPeerJoin` but replays no previously added local stream. The host is structurally guaranteed to lose that race: `hostSession()` derives a session topic from 32 fresh random bytes and `begin()`s the room **before** the invite is even sent, so the host's camera opens while it is provably alone and its one broadcast reaches nobody, forever. The guest normally wins it, because the session peer activates over trystero's already-open shared connection to that same friend in roughly one RTT — faster than a cold camera opens — so the guest's `addStream` lands and the host sees the guest. Two stale comments asserted the opposite of the library's actual behavior and are what preserved the bug: `SessionView.tsx` claimed `addStream` "forwards new tracks to all current peers **and to peers who join later**", and the stream-binding effect claimed "trystero replays existing peers when we register the stream callback" (`onPeerStream` is a bare assignment at `room.mjs:511`; only `onPeerJoin` sweeps, at `:506-509`, a replay our own `wrapRoom` consumes at construction). CI could not catch it: the integration bus mock hard-coded both false beliefs — its `addStream` ignored `targetPeers` and fanned out to every room, and its join + `onPeerStream` paths both replayed existing streams. Day-one defect; `trystero` has been pinned `^0.24.0` since the media path was introduced, so host→guest video has never worked in any shipped build. | **fixed** — publishing moved into `publishLocalStream(room, stream)` in `lifecycle.ts`, which broadcasts to the currently-active peers and, in the immediately adjacent statement, subscribes `onPeerJoin` to re-send the same stream targeted at each later joiner (the pattern trystero's own README prescribes). The two calls live in one function so the "no `await` in the seam" invariant is structural: the broadcast covers who is active now, the subscriber covers who arrives later, and JS's single thread means no peer is missed or served twice — a double-add would desync trystero's FIFO pairing of stream metadata to incoming tracks. `SessionView`'s effect cleanup unsubscribes **before** `stopTracks`, so a "Try again" re-acquire can't hand a later joiner a dead stream. Both false comments replaced with the verified semantics + `room.mjs` line refs. The integration bus mock now models `activePeerMap` honestly (targeted sends honored, no join replay, no `onPeerStream` replay), and `tests/unit/session-publish-stream.test.ts` pins the contract — 2 of its 4 cases fail against the pre-fix code. **Both friends must update:** a patched host reaches an unpatched guest, but a patched guest still receives nothing from an unpatched host. | +| I78 | Sev2 | `src-tauri/Cargo.toml` (`tauri 2.11.0`) | GHSA-7gmj-67g7-phm9 — "Tauri has an Origin Confusion Issue that Allows Remote Pages to Invoke Local-Only IPC Commands" (CVSS 8.8), affecting `tauri >= 2.0.0, <= 2.11.0`; fixed upstream in 2.11.1. StudyVis exposes a wide IPC surface (SQLite, keychain-backed identity, sidecar spawn, filesystem paths), so origin confusion is the class that matters most here rather than a theoretical one. Not found by `cargo deny`: the advisory is GitHub-Advisory-Database-only and RustSec does not carry it — it surfaced when OSV-Scanner was run over `Cargo.lock` while building the #102 supply-chain gates. | **fixed** — `cargo update -p tauri --precise 2.11.1` (lockfile-only; `Cargo.toml` already requires `"2"`, so no manifest change). Pulled tauri-build/codegen/macros/runtime/runtime-wry/utils forward with it. Verified: OSV over `Cargo.lock` no longer reports the advisory, and `cargo deny check advisories licenses bans sources` stays green. Shipped as its own PR rather than bundled into the #102 CI branch: a Tauri bump is a Rust change that this box cannot compile, so it wants its own PR and its own full CI run. The new `.github/dependabot.yml` opens the 2.11.0 → 2.11.1 bump automatically (cargo ecosystem; `tauri*` is excluded from the routine grouping precisely so it lands as its own reviewable PR), and `maintenance.yml`'s weekly OSV scan keeps reporting it until the bump lands. Nothing in the pinned-ignore list of `src-tauri/deny.toml` suppresses it. | +| I79 | Sev1 | `src/features/ai/modelStore.ts` + `src/routes/Home.tsx` + `src/features/session/SessionView.tsx` + `sampleLoop.ts` + `Report.tsx` | Issue #92: a real 10-minute two-person session on Windows rendered a report with `Focused-time —`, "No focus score was recorded for this session.", zero `ai_*` timeline rows — and, directly beside all that, "No distractions detected. Nice work." **Root cause: `useModelStore` is never hydrated outside Settings → AI.** `hydrate()` had exactly one caller, `ModelPickerContainer`'s mount effect (`ModelPickerContainer.tsx:85`), and that component mounts only inside the Settings → AI pane. `useSettingsStore` is hydrated at boot by `ThemeProvider` (`src/design/theme.tsx:52`), so `aiFeaturesEnabled` was correctly `true` while `activeModelId` sat at its `null` initial value — and `activeModelId` gates everything: `SessionView.tsx`'s sample-loop effect returns early on `if (!activeModelId)`, so `startSampleLoop` is never called and its `onStartFail('no_active_model')` toast — the one surface that names this — can never fire; `Home.tsx`'s `handleTopicSubmit` skips the V2-P9 gesture-context `preacquireScreenStream()` on the same condition, which on WebView2 is separately fatal. So any launch where the user didn't happen to open Settings → AI ran a whole session with AI silently dead: no loop, no toast, no audit row, no log line, and an unscored `sessions` row. Cross-platform and present at HEAD — it also explains #94 ("Ai does not work on macos when its enabled"). The report then made the silence permanent: `score`/`focused_pct`/`confident_samples`/`skipped_samples` all read NULL for an AI-off session, an AI-on-but-dead session, AND a pre-003 row, so no surface could tell a deliberate choice from a malfunction, and the distractions empty state asserted a clean measurement that never happened. Five further silent-death paths found alongside it: a sidecar that spawns but never reports healthy, an HTTP error from the sidecar, a per-tick abort, and any other tick throw were each `console.warn`-only (no devtools in release builds); the live 90 s per-tick timeout was 3.3× tighter than benchmark.ts's 300 s bound, so a model could benchmark successfully — the only thing that sets `activeModelId` — and then abort every live inference forever; an unanswered screen-share picker wedged `boot()` with no timeout, and `stop()` awaits `bootPromise`, so the sidecar was never killed; the Rejoin path and the camera/mic "Try again" path both re-`boot()` with no gesture pre-acquire; `mapDisplayMediaError` had no `InvalidStateError`/`InvalidAccessError` case, so a missing-transient-activation refusal was filed as `unavailable` (a dead-end toast) instead of reaching the recovery overlay whose retry button IS a gesture; `resolve_runtime_dir`'s `_ => Ok(None)` still degraded to a spawn with no CWD and no PATH prepend — the exact lethal-on-Windows state I75 fixed; and a child that dies in the Windows loader spawns Ok, so it crash-loops to the restart budget without ever reaching the VC++-redist hint. | **fixed** — (1) hydrate `useModelStore` in `Home.tsx`'s boot effect, so the persisted model is the truth from launch rather than from a Settings visit; (2) `handleTopicSubmit` + `handleRejoin` + `handleMediaRetry` all pre-acquire the screen stream inside their real user gesture, and a store still mid-hydration counts as "maybe active" (an unconsumed stream is discarded on unmount; a missed pre-acquire is fatal on WebView2); (3) a once-per-session toast when AI is on, the model store is `ready`, and no model is active — the gap where `onStartFail` could never fire; (4) `onStalled` fires once per loop lifetime after `STALL_TICKS` (3) consecutive unproductive ticks, with a distinct reason per cause (`engine_unavailable` / `engine_error` / `inference_timeout` / `unknown`) and actionable copy; paused states (break, camera off, pomodoro rest, battery) are deliberately not stalls; (5) the per-tick timeout is derived from the model's benchmarked p95 (`effectiveRequestTimeoutMs`: 3× p95, floored at 90 s, capped at benchmark.ts's 300 s); (6) `SCREEN_ACQUIRE_TIMEOUT_MS` (120 s) bounds the acquire so an unanswered picker becomes a visible retryable error instead of a permanent wedge, and a late-arriving stream is stopped rather than leaked; (7) `InvalidStateError` / `InvalidAccessError` → `screen_capture_denied`, routing to the overlay whose retry is itself the missing gesture; (8) migration **004** adds `sessions.ai_enabled` (1/0, NULL = pre-004), written from live settings at teardown, and the new `aiCoverage()` derivation gives the report four honest states — `ran` keeps the earned "Nice work", `noChecks` names the malfunction and points at Settings → AI, `off` says AI was off, `unknown` stays cause-neutral for pre-004 rows — shared by the rendered report and the text export so a pasted copy can never disagree; (9) Rust: `resolve_runtime_dir` falls back to the binary's own directory (one of the two places ggml globs anyway) instead of `None`, and the crash-loop give-up path now carries `append_windows_dll_hint`. Tests: `aiCoverage` (6 cases incl. the pre-003 scored row and the NULL-is-not-0 rule), serializer honesty (4), `snapshotFocusForReport.aiEnabled` (3), stall notice (4 incl. streak-reset and camera-off-is-not-a-stall), `effectiveRequestTimeoutMs` boundaries (4), and a Rust 003→004 upgrade test asserting old rows read NULL. Stories: `AiOnButNoChecks`, `AiOffForSession`. **Round 2** (a 65-agent adversarial sweep over the first draft found six more): (10) `hydrate()`'s `status: 'error'` was terminal — `ModelPickerContainer` only retried on `'loading'`, so one failed models.json read (AV lock, partial write) killed AI for the whole process and reopened this very issue through a narrower door; the gate is now `status !== 'ready'` and the session notice distinguishes it (`modelListUnreadable`). (11) the footer chip read **"AI off" while AI was ON** with no model — the single on-screen signal during #92, pointing at exactly the wrong setting; new `'unconfigured'` status via a pure, unit-tested `deriveAiChipStatus()`, with `'loading'` deliberately reading `'off'` so no launch flashes it. (12) `aiCoverage`'s first cut returned `'ran'` for `confident_samples: 0, skipped_samples: k`, defended on the grounds that the #47 D5 line caveats it — it does not below `SKIPPED_SAMPLES_MIN` (3), so k of 1–2 rendered a fabricated all-clear with no caveat at all; fifth state `'noConfident'` added and the two tests asserting the old behavior **edited**, not appended. (13) `append_windows_dll_hint` probed only `vcruntime140.dll`, staying silent on a box with the C runtime but not the C++ one; now requires both. (14) `next_attempts` resets the streak on any child clearing `MIN_HEALTHY_UPTIME` (120 s), so a sidecar dying every ~2.5 min crash-looped **forever** without ever setting `errored` — the stall notice fired once and the session then ran for an hour on a dying engine; `TOTAL_RESTART_BUDGET` (12 per generation) closes it, sized so an 8-hour session dying hourly never trips while a 121 s cycle trips at ~24 min. (15) Settings → Sessions now marks an unmeasured row `not measured` when `ai_enabled === 1`. One round-2 finding was **rejected**: "`onSidecarErrored` re-arms every tick, so a flapping sidecar re-toasts forever" — `errored` is cleared only by `sidecar_start`/`sidecar_stop` (`sidecar.rs:233`/`:270`) and the watcher `return`s after setting it, so errored→running requires deliberate user action and re-notifying then is correct, as the existing test documents. | diff --git a/src-tauri/src/commands/sidecar.rs b/src-tauri/src/commands/sidecar.rs index 93f6667d..8aab613a 100644 --- a/src-tauri/src/commands/sidecar.rs +++ b/src-tauri/src/commands/sidecar.rs @@ -78,6 +78,20 @@ const LOG_MAX_BYTES: u64 = 5 * 1024 * 1024; const RESTART_BUDGET: u32 = 3; const MIN_HEALTHY_UPTIME: Duration = Duration::from_secs(120); const RESTART_BACKOFF: Duration = Duration::from_millis(500); +// I79 — a lifetime ceiling on respawns within one generation, because the +// consecutive-streak rule above has a hole: a child that dies every ~2.5 +// minutes clears MIN_HEALTHY_UPTIME every time, so `restart_attempts` resets to +// 1 forever and `errored` is never set. The JS stall notice fires once and the +// session then runs for an hour on an engine that is dying and respawning the +// whole time, recording nothing. This counts EVERY respawn in the generation, +// not just the sub-uptime ones, or a 121-second cycle would escape it again. +// +// 12 is chosen so the two cases stay far apart: a genuinely long session whose +// sidecar dies once an hour after clean uptime spends 8 respawns across 8 hours +// and never trips, while a 121-second crash cycle trips at roughly 24 minutes. +// An explicit sidecar_stop + sidecar_start bumps the generation, so a +// deliberate retry always starts from zero. +const TOTAL_RESTART_BUDGET: u32 = 12; #[derive(Default)] struct SidecarInner { @@ -520,15 +534,21 @@ fn spawn_with_fallback( // bare CreateProcess error. #[cfg(target_os = "windows")] fn append_windows_dll_hint(err: String) -> String { + // I79 — probe BOTH halves of the redistributable. llama-server.exe links + // the C++ standard library (msvcp140.dll) as well as the C runtime + // (vcruntime140.dll), and a machine can carry one without the other: some + // installers ship vcruntime140 alone, and a repair/uninstall can leave a + // partial set. Checking only vcruntime140 meant the actionable hint stayed + // silent on exactly the boxes that needed it most. let sysroot = std::env::var("SystemRoot").unwrap_or_else(|_| r"C:\Windows".to_string()); - let vcruntime = Path::new(&sysroot) - .join("System32") - .join("vcruntime140.dll"); - if vcruntime.exists() { + let system32 = Path::new(&sysroot).join("System32"); + let both_present = + system32.join("vcruntime140.dll").exists() && system32.join("msvcp140.dll").exists(); + if both_present { err } else { format!( - "{err}; the Microsoft Visual C++ runtime is missing — install it from https://aka.ms/vs/17/release/vc_redist.x64.exe and try again" + "{err}; the Microsoft Visual C++ runtime is missing or incomplete — install it from https://aka.ms/vs/17/release/vc_redist.x64.exe and try again" ) } } @@ -635,6 +655,14 @@ fn next_attempts(prev: u32, uptime: Duration) -> u32 { } } +// I79 — has this generation respawned so many times that the engine should be +// called dead regardless of how long each child survived? Separate from +// `next_attempts` on purpose: that one answers "is this a crash loop right +// now?", this one answers "has this been going on all session?". +fn exceeded_total_restarts(total: u32) -> bool { + total > TOTAL_RESTART_BUDGET +} + async fn watch( app: AppHandle, state: Arc>, @@ -647,6 +675,8 @@ async fn watch( ) { let mut log = log_file; let mut restart_attempts: u32 = 0; + // I79 — every respawn in this generation, never reset by a clean run. + let mut total_restarts: u32 = 0; let mut child_started_at = Instant::now(); loop { @@ -693,6 +723,23 @@ async fn watch( // Count consecutive short-lived deaths; a durable child resets to 1. restart_attempts = next_attempts(restart_attempts, child_started_at.elapsed()); + total_restarts += 1; + // I79 — a slow crash loop clears MIN_HEALTHY_UPTIME on every cycle, so + // the streak check below can never fire for it. Stop pretending this + // engine is going to recover. + if exceeded_total_restarts(total_restarts) { + guard.errored = true; + guard.last_error = Some(append_windows_dll_hint(format!( + "the AI engine restarted {total_restarts} times this session and kept dying" + ))); + guard.port = None; + let _ = writeln!( + log, + "[event] giving up after {total_restarts} lifetime restarts (slow crash loop)" + ); + let _ = log.flush(); + return; + } if restart_attempts > RESTART_BUDGET { guard.errored = true; // I79 — carry the Windows VC++ hint here too. A child that dies @@ -817,4 +864,36 @@ mod tests { fn first_crash_starts_at_one() { assert_eq!(next_attempts(0, Duration::from_secs(1)), 1); } + + // I79 — the hole the lifetime cap closes: a child dying just past + // MIN_HEALTHY_UPTIME resets the consecutive streak on every cycle, so + // `restart_attempts` never exceeds RESTART_BUDGET and `errored` is never + // set. Simulate that cycle and show the streak rule alone never gives up. + #[test] + fn slow_crash_loop_never_trips_the_consecutive_streak() { + let mut attempts = 0; + let just_past_healthy = MIN_HEALTHY_UPTIME + Duration::from_secs(1); + for _ in 0..50 { + attempts = next_attempts(attempts, just_past_healthy); + assert!( + attempts <= RESTART_BUDGET, + "a 121s crash cycle must never reach the streak budget — \ + that is exactly why the lifetime cap exists" + ); + } + } + + #[test] + fn lifetime_cap_stops_a_slow_crash_loop() { + // The same cycle, counted the other way: every respawn accumulates. + assert!(!exceeded_total_restarts(TOTAL_RESTART_BUDGET)); + assert!(exceeded_total_restarts(TOTAL_RESTART_BUDGET + 1)); + } + + #[test] + fn lifetime_cap_leaves_a_long_healthy_session_alone() { + // An 8-hour session whose sidecar dies once an hour after clean uptime + // spends 8 respawns. It must never be called a crash loop. + assert!(!exceeded_total_restarts(8)); + } } diff --git a/src/components/AiStatusChip.tsx b/src/components/AiStatusChip.tsx index 3304f939..292ed537 100644 --- a/src/components/AiStatusChip.tsx +++ b/src/components/AiStatusChip.tsx @@ -4,7 +4,11 @@ import type { LucideIcon } from 'lucide-react' import { cn } from '@/lib/utils' import { strings } from '@/strings' -export type AiStatus = 'off' | 'active' | 'paused' | 'error' +// I79 — 'unconfigured' is distinct from 'off': AI is switched ON and cannot +// run, because no model is active (never picked, or the model list failed to +// read). Reading that as "AI off" during issue #92 pointed the user at the one +// wrong conclusion — that they had left the feature off. +export type AiStatus = 'off' | 'unconfigured' | 'active' | 'paused' | 'error' // Icon tint carries the status color; the label always renders in a // neutral text token so small footer text keeps AA contrast on @@ -12,6 +16,7 @@ export type AiStatus = 'off' | 'active' | 'paused' | 'error' // chip satisfies no-color-alone (WCAG 1.4.1) on its own. const STATUS_ICON: Record = { off: EyeOff, + unconfigured: EyeOff, active: ScanFace, paused: PauseCircle, error: EyeOff, @@ -19,6 +24,9 @@ const STATUS_ICON: Record = { const STATUS_ICON_TINT: Record = { off: 'text-text-secondary', + // Warning, not alerted: nothing has failed, something is unset. Same token + // the paused state already uses, so no new contrast pairing. + unconfigured: 'text-status-warning', active: 'text-status-focused', paused: 'text-status-warning', error: 'text-status-alerted', @@ -26,6 +34,7 @@ const STATUS_ICON_TINT: Record = { const STATUS_LABEL: Record = { off: strings.session.aiStatus.off, + unconfigured: strings.session.aiStatus.unconfigured, active: strings.session.aiStatus.active, paused: strings.session.aiStatus.paused, error: strings.session.aiStatus.error, diff --git a/src/features/ai/ModelPickerContainer.tsx b/src/features/ai/ModelPickerContainer.tsx index 3bd18581..d19744f9 100644 --- a/src/features/ai/ModelPickerContainer.tsx +++ b/src/features/ai/ModelPickerContainer.tsx @@ -81,8 +81,17 @@ export function ModelPickerContainer() { }, []) // Hydrate persistent records once on mount. + // + // I79 — retry on `'error'` too, not only `'loading'`. A LazyStore read of + // models.json can fail transiently (an AV scanner holding the file, a + // partial write from a previous run), and `status: 'error'` used to be + // terminal for the whole process: `activeModelId` stayed null, so no AI ran + // for any session until the app was restarted, and this — the one surface + // that could re-read it — refused to try. `hydrate()` early-returns only on + // `'ready'`, and this effect's deps re-run it on a status CHANGE rather than + // in a loop, so a persistent failure costs one attempt per visit here. useEffect(() => { - if (status === 'loading') void hydrate() + if (status !== 'ready') void hydrate() }, [status, hydrate]) // Probe initial install state for every model so the cards reflect what's diff --git a/src/features/session/Report.tsx b/src/features/session/Report.tsx index d82a5e7f..38c9d3e1 100644 --- a/src/features/session/Report.tsx +++ b/src/features/session/Report.tsx @@ -67,6 +67,7 @@ import { } from './reportData' import { describeRow, + distractionsEmptyMessage, formatTopicHeading, labelFor, noScoreBody, @@ -522,13 +523,7 @@ export function ReportView({
    {topDistractions.length === 0 ? ( - + ) : (
      {topDistractions.map((entry, i) => ( diff --git a/src/features/session/SessionView.tsx b/src/features/session/SessionView.tsx index d04ead84..7e372744 100644 --- a/src/features/session/SessionView.tsx +++ b/src/features/session/SessionView.tsx @@ -74,6 +74,7 @@ import { usePttStore } from '@/stores/pttStore' import { strings } from '@/strings' import { startAiAlertDispatcher, type AiAlertDispatcher } from './aiAlerts' +import { deriveAiChipStatus } from './aiChip' import { cancelActiveBreakTimer, @@ -889,17 +890,27 @@ export function SessionView({ // `startSampleLoop` never runs and its `onStartFail('no_active_model')` // toast — the one surface that names this problem — can never fire. Issue // #92 is what that silence looks like from the outside: a full session, an - // unscored report, and nothing anywhere saying AI sat out. Gated on - // modelStatus === 'ready' so a mid-hydration null never accuses a correctly - // configured install, and keyed on startedAt so it fires once per session - // rather than on every model/camera flap. + // unscored report, and nothing anywhere saying AI sat out. + // + // Two distinct causes, two messages: `'ready'` with no model means the user + // never picked one, while `'error'` means models.json itself couldn't be read + // — nothing is wrong with their choice and the advice differs. `'loading'` + // stays silent so a mid-hydration null never accuses a correctly configured + // install. Keyed on startedAt so a loading → error → ready sequence still + // toasts at most once per session rather than on every model/camera flap. const noModelNoticeShownFor = useRef(null) useEffect(() => { if (status !== 'active' || !startedAt) return - if (!aiFeaturesEnabled || modelStatus !== 'ready' || activeModelId) return + if (!aiFeaturesEnabled || activeModelId) return + if (modelStatus !== 'ready' && modelStatus !== 'error') return if (noModelNoticeShownFor.current === startedAt) return noModelNoticeShownFor.current = startedAt - toast.error(strings.session.errors.pickModel, aiSettingsToastAction()) + toast.error( + modelStatus === 'error' + ? strings.session.errors.modelListUnreadable + : strings.session.errors.pickModel, + aiSettingsToastAction() + ) }, [status, startedAt, aiFeaturesEnabled, modelStatus, activeModelId]) // V2-P5 AI sample loop: starts when AI features are on, an active model @@ -1461,10 +1472,17 @@ export function SessionView({ // from a prior loop never lies once AI is disabled, the model is cleared, // or the camera track drops. Otherwise the runtime state (set by the // sample-loop callbacks) is the truth for the running loop. - const aiChipStatus: AiStatus = - !aiFeaturesEnabled || !activeModelId || !localStream - ? 'off' - : aiRuntimeStatus + // + // I79 — the decision moved to `deriveAiChipStatus` (pure, unit-tested) to add + // the 'unconfigured' case: AI on with no model used to read "AI off", which is + // the one reading that sends a user to the wrong setting. + const aiChipStatus: AiStatus = deriveAiChipStatus({ + aiFeaturesEnabled, + activeModelId, + modelStatus, + hasLocalStream: Boolean(localStream), + runtimeStatus: aiRuntimeStatus, + }) if (!room) return null diff --git a/src/features/session/aiChip.ts b/src/features/session/aiChip.ts new file mode 100644 index 00000000..b188b5a5 --- /dev/null +++ b/src/features/session/aiChip.ts @@ -0,0 +1,52 @@ +// I79 — the footer AI chip's status, as a pure function. +// +// It lives here rather than inside SessionView for two reasons: react-refresh +// forbids non-component exports from a component file (the same constraint that +// put `noScoreBody` in reportSerialize.ts), and vitest runs node-env with no +// jsdom, so a pure module is the only part of this the test suite can reach. +// +// The bug it fixes: the chip read "AI off" whenever `activeModelId` was null, +// even with AI switched ON in Settings. During issue #92 that was the single +// on-screen signal that anything was wrong, and it pointed the user at exactly +// the wrong conclusion — that they had left AI off — while the real state was +// "AI is on and cannot run". "AI off" is now reserved for AI actually being off. + +import type { AiStatus } from '@/components/AiStatusChip' + +export type AiChipInputs = { + aiFeaturesEnabled: boolean + activeModelId: string | null + // The model store's hydration status. 'loading' is the reason this function + // exists as a four-input decision rather than a two-input one. + modelStatus: 'loading' | 'ready' | 'error' + hasLocalStream: boolean + // What the running loop's callbacks last reported. Only consulted once every + // "AI cannot possibly be running" case is excluded, so a stale 'error' from a + // previous loop can never outlive the condition that produced it. + runtimeStatus: AiStatus +} + +// Guard order is load-bearing; each comment says what breaks if it moves. +export function deriveAiChipStatus({ + aiFeaturesEnabled, + activeModelId, + modelStatus, + hasLocalStream, + runtimeStatus, +}: AiChipInputs): AiStatus { + // First, so a stale runtime 'error' or 'paused' from a loop that has since + // been torn down can never claim something is wrong after the user switched + // AI off themselves. + if (!aiFeaturesEnabled) return 'off' + // AI is on but has nothing to run. Only claimable once the store has actually + // reported: while it is still 'loading' a null model is unknown, not absent, + // and treating it as absent would flash "AI needs a model" on every launch. + if (!activeModelId) { + return modelStatus === 'loading' ? 'off' : 'unconfigured' + } + // Camera still spinning up (or off). Reads as 'off' rather than + // 'unconfigured' — nothing is missing from the user's setup, and the camera + // tile plus MediaErrorBanner already own that story. + if (!hasLocalStream) return 'off' + return runtimeStatus +} diff --git a/src/features/session/reportData.ts b/src/features/session/reportData.ts index ebc46b5a..882e7959 100644 --- a/src/features/session/reportData.ts +++ b/src/features/session/reportData.ts @@ -35,26 +35,33 @@ export function sampleQualitySummary(session: { return { skipped, totalChecks } } -// I79 — how much the AI actually saw of this session. Four states, because -// three of them used to render identically (issue #92: a Windows session where -// AI was on and silently dead was byte-identical to a clean AI-off one, down to -// "No distractions detected. Nice work." asserting a measurement that never +// I79 — how much the AI actually saw of this session. Five states, because four +// of them used to render identically (issue #92: a Windows session where AI was +// on and silently dead was byte-identical to a clean AI-off one, down to "No +// distractions detected. Nice work." asserting a measurement that never // happened). // -// 'ran' at least one check completed — an empty distraction list is a -// real finding and the confident "Nice work" copy is earned. -// 'noChecks' AI was on and not one check completed. Nothing was measured; -// the report says so and points at Settings → AI. -// 'off' AI was deliberately off. Nothing was measured, and that is -// exactly what the user asked for. -// 'unknown' a row written before the 004 migration recorded `ai_enabled`. -// Nothing was measured as far as we know, and we don't claim why. +// 'ran' at least one CONFIDENT check completed — an empty distraction +// list is a real finding and the "Nice work" copy is earned. +// 'noConfident' checks ran but none could be read (every one uncertain per +// A2/A3). Nothing was measured, so praise is unearned here too. +// 'noChecks' AI was on and not one check completed. Nothing was measured; +// the report says so and points at Settings → AI. +// 'off' AI was deliberately off. Nothing was measured, and that is +// exactly what the user asked for. +// 'unknown' a row written before the 004 migration recorded `ai_enabled`. +// Nothing was measured as far as we know; we don't claim why. // -// `confident_samples` / `skipped_samples` are the check-ran evidence: both are -// non-null together whenever the loop completed a tick (snapshotFocusForReport), -// and null together otherwise. `score` is checked too so a pre-003 row that -// recorded a score still reads as 'ran' rather than losing its history. -export type AiCoverage = 'ran' | 'noChecks' | 'off' | 'unknown' +// `confident_samples` must be > 0 for 'ran', not merely non-null. A session of +// pure parse failures has `confident_samples: 0` with `skipped_samples: k`, and +// the #47 D5 data-quality line does NOT cover the small-k case: it needs +// SKIPPED_SAMPLES_MIN (3) skips before it renders anything. So for k of 1 or 2 +// the page would show "Focused-time —", no caveat, and "No distractions +// detected. Nice work." — issue #92's own defect surviving its own fix. +// +// `score` is checked first so a pre-003 row that recorded a score without the +// counters still reads 'ran' rather than losing its history. +export type AiCoverage = 'ran' | 'noConfident' | 'noChecks' | 'off' | 'unknown' export function aiCoverage(session: { score: number | null @@ -62,11 +69,10 @@ export function aiCoverage(session: { skipped_samples: number | null ai_enabled: number | null }): AiCoverage { - const ranAnyCheck = - session.confident_samples != null || - session.skipped_samples != null || - session.score != null - if (ranAnyCheck) return 'ran' + if (session.score != null || (session.confident_samples ?? 0) > 0) { + return 'ran' + } + if ((session.skipped_samples ?? 0) > 0) return 'noConfident' if (session.ai_enabled === 1) return 'noChecks' if (session.ai_enabled === 0) return 'off' return 'unknown' diff --git a/src/features/session/reportSerialize.ts b/src/features/session/reportSerialize.ts index 00fd6155..52cbf28f 100644 --- a/src/features/session/reportSerialize.ts +++ b/src/features/session/reportSerialize.ts @@ -79,15 +79,31 @@ export function describeRow( export function noScoreBody(coverage: AiCoverage): string { if (coverage === 'off') return strings.report.noScore.bodyOff if (coverage === 'noChecks') return strings.report.noScore.bodyNoChecks + if (coverage === 'noConfident') { + return strings.report.noScore.bodyNoConfident + } return strings.report.noScore.body } export function noScoreCopyLine(coverage: AiCoverage): string { if (coverage === 'off') return strings.report.noScore.copyLineOff if (coverage === 'noChecks') return strings.report.noScore.copyLineNoChecks + if (coverage === 'noConfident') { + return strings.report.noScore.copyLineNoConfident + } return strings.report.noScore.copyLine } +// I79 — the distractions empty state, which must agree with the score card +// beside it. Only a session with at least one readable check earns "Nice work". +export function distractionsEmptyMessage(coverage: AiCoverage): string { + if (coverage === 'ran') return strings.report.sections.distractions.empty + if (coverage === 'noConfident') { + return strings.report.sections.distractions.emptyNoReadableChecks + } + return strings.report.sections.distractions.emptyNoChecks +} + export function formatTopicHeading(topic: string | null): string { if (!topic || !topic.trim()) return strings.report.studiedFallback return strings.report.studiedWithTopic(topic) @@ -158,11 +174,7 @@ export function serializeReportToText(data: ResolvedReportData): string { // user just saw. The on-screen Distractions section precedes Breaks. lines.push('', `## ${strings.report.sections.distractions.heading}`) if (distractions.length === 0) { - lines.push( - coverage === 'ran' - ? strings.report.sections.distractions.empty - : strings.report.sections.distractions.emptyNoChecks - ) + lines.push(distractionsEmptyMessage(coverage)) } else { for (const d of distractions) { const ded = d.totalDeduction > 0 ? ` · −${d.totalDeduction}` : '' diff --git a/src/features/settings/categories/SessionsCategory.tsx b/src/features/settings/categories/SessionsCategory.tsx index c63838b5..c21dc77a 100644 --- a/src/features/settings/categories/SessionsCategory.tsx +++ b/src/features/settings/categories/SessionsCategory.tsx @@ -197,8 +197,16 @@ function formatSessionMeta(session: SessionRecord): string { : peers === 1 ? meta.oneFriend : meta.manyFriends(peers) + // I79 — a scored session shows its score; a session that recorded AI as ON + // and still has no score says so. A row with ai_enabled 0 or NULL adds + // nothing, because "AI was off" and "we never recorded it" are not claims + // this list should invent. const scoreLabel = - session.score != null ? ` · ${meta.score(session.score)}` : '' + session.score != null + ? ` · ${meta.score(session.score)}` + : session.ai_enabled === 1 + ? ` · ${meta.notMeasured}` + : '' return `${meta.minutes(minutes)} · ${peerLabel}${scoreLabel}` } diff --git a/src/stories/AiStatusChip.stories.tsx b/src/stories/AiStatusChip.stories.tsx index cd9ebda2..15416f51 100644 --- a/src/stories/AiStatusChip.stories.tsx +++ b/src/stories/AiStatusChip.stories.tsx @@ -13,6 +13,9 @@ export default meta type Story = StoryObj export const Off: Story = { args: { status: 'off' } } +// I79 — AI is on but has no model to run. Distinct from Off, which used to +// absorb this case and send users to the wrong setting. +export const Unconfigured: Story = { args: { status: 'unconfigured' } } export const Active: Story = { args: { status: 'active' } } export const Paused: Story = { args: { status: 'paused' } } export const Error: Story = { args: { status: 'error' } } @@ -21,6 +24,7 @@ export const AllStates: Story = { render: () => (
      + diff --git a/src/stories/Report.stories.tsx b/src/stories/Report.stories.tsx index f3bdbd56..2cfd9d6c 100644 --- a/src/stories/Report.stories.tsx +++ b/src/stories/Report.stories.tsx @@ -277,3 +277,29 @@ export const AiOffForSession: Story = { onClose, }, } + +// I79 — checks ran and none could be read. Two skipped checks is BELOW +// SKIPPED_SAMPLES_MIN (3), so the #47 D5 data-quality line renders nothing and +// this copy is the only thing on the page telling the truth. It must not say +// "No AI checks ran" either: checks did run, none were readable. +export const AiRanNoReadableChecks: Story = { + args: { + data: buildData( + baseSession({ + score: null, + focused_pct: null, + confident_samples: 0, + skipped_samples: 2, + ai_enabled: 1, + declared_topic: 'latin', + }), + [ + event(ME, 'joined', 0), + event(ME, 'topic_set', 0, { topic: 'latin' }), + event(ME, 'left', 638_000), + ] + ), + animateScore: false, + onClose, + }, +} diff --git a/src/strings.ts b/src/strings.ts index 2b0c0695..4f3843bd 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -601,6 +601,9 @@ export const strings = { }, aiStatus: { off: 'AI off', + // I79 — AI is on but has no model to run. Deliberately not "AI off": + // that wording sent the #92 reporter looking at the wrong setting. + unconfigured: 'AI needs a model', active: 'AI watching', paused: 'AI paused', error: 'AI error', @@ -640,6 +643,12 @@ export const strings = { // the AI category (the copy above names it; the button honors it). openSettingsAction: 'Open settings', pickModel: 'Pick a model in Settings → AI.', + // I79 — the model store's own read failed (a locked or corrupt + // models.json), which is indistinguishable from "no model picked" to + // every other surface but needs different advice: nothing is wrong with + // the user's choice, the list just couldn't be loaded. + modelListUnreadable: + "Your AI model list couldn't be read, so AI sat out this session. Open Settings → AI to try again.", // I79 — the loop is running but has produced no judgment for several // consecutive checks. Each reason names what to do about it; all four // used to be a console.warn nobody in a release build can read, so the @@ -769,6 +778,11 @@ export const strings = { // session it never watched it was a fabricated all-clear, and it read // as one right beside a score card admitting no score was recorded. emptyNoChecks: 'No AI checks ran, so nothing was measured here.', + // Distinct from emptyNoChecks: checks DID run, so "No AI checks ran" + // would be false. None of them could be read, so nothing was measured + // and the praise is still unearned. + emptyNoReadableChecks: + 'AI checks ran but none could be read, so nothing was measured here.', }, breaks: { heading: 'Breaks', @@ -795,8 +809,11 @@ export const strings = { bodyOff: 'AI focus detection was off for this session.', bodyNoChecks: 'AI was on but never ran a check, so nothing was measured. Check Settings → AI.', + bodyNoConfident: + 'AI ran, but no check could be read clearly, so nothing was measured.', copyLineOff: 'Score: not recorded (AI off)', copyLineNoChecks: 'Score: not recorded (AI ran no checks)', + copyLineNoConfident: 'Score: not recorded (no readable AI checks)', }, copyCta: 'Copy report', copyAriaLabel: 'Copy session report to clipboard', @@ -1045,6 +1062,11 @@ export const strings = { manyFriends: (n: number) => `${n} friends`, minutes: (n: number) => `${n} min`, score: (n: number) => `${n} / 100`, + // I79 — Settings → Sessions is the second place a user looks for a + // missing score, and a row with no score used to be indistinguishable + // from one where AI was off. Only rendered when the row actually + // recorded that AI was on (ai_enabled === 1), never inferred. + notMeasured: 'not measured', }, // R4 — per-session delete behind an AlertDialog confirm, mirroring the // Friends remove pattern. Deleting removes the session row and its diff --git a/tests/unit/ai-chip-status.test.ts b/tests/unit/ai-chip-status.test.ts new file mode 100644 index 00000000..b90d20bd --- /dev/null +++ b/tests/unit/ai-chip-status.test.ts @@ -0,0 +1,82 @@ +// I79 — the footer AI chip read "AI off" whenever no model was active, even +// with AI switched ON. During issue #92 that was the only on-screen signal that +// anything was wrong, and it pointed at the wrong setting entirely. +// +// The guard ORDER is the whole design here, so these tests pin the boundaries +// between the states rather than each state in isolation. + +import { describe, expect, test } from 'vitest' + +import { deriveAiChipStatus } from '@/features/session/aiChip' + +const running = { + aiFeaturesEnabled: true, + activeModelId: 'model-a', + modelStatus: 'ready' as const, + hasLocalStream: true, + runtimeStatus: 'active' as const, +} + +describe('deriveAiChipStatus', () => { + test('AI off wins over a stale runtime error from a torn-down loop', () => { + expect( + deriveAiChipStatus({ + ...running, + aiFeaturesEnabled: false, + runtimeStatus: 'error', + }) + ).toBe('off') + }) + + test("a null model while the store is still loading reads 'off', not a warning", () => { + // Otherwise every launch flashes "AI needs a model" during the hydration + // window that fix A introduced. + expect( + deriveAiChipStatus({ + ...running, + activeModelId: null, + modelStatus: 'loading', + }) + ).toBe('off') + }) + + test("no model once the store is ready reads 'unconfigured'", () => { + expect( + deriveAiChipStatus({ + ...running, + activeModelId: null, + modelStatus: 'ready', + }) + ).toBe('unconfigured') + }) + + test("no model because the store failed to read also reads 'unconfigured'", () => { + // Different cause, same truth for the chip: AI is on and cannot run. The + // toast carries the cause-specific advice. + expect( + deriveAiChipStatus({ + ...running, + activeModelId: null, + modelStatus: 'error', + }) + ).toBe('unconfigured') + }) + + test("a camera still spinning up reads 'off', not 'unconfigured'", () => { + // Nothing is missing from the user's AI setup; the camera tile and + // MediaErrorBanner own that story. + expect(deriveAiChipStatus({ ...running, hasLocalStream: false })).toBe( + 'off' + ) + }) + + test('a fully configured loop passes its runtime status through', () => { + expect(deriveAiChipStatus(running)).toBe('active') + expect(deriveAiChipStatus({ ...running, runtimeStatus: 'paused' })).toBe( + 'paused' + ) + expect(deriveAiChipStatus({ ...running, runtimeStatus: 'error' })).toBe( + 'error' + ) + }) +}) diff --git a/tests/unit/ai-models.test.ts b/tests/unit/ai-models.test.ts index c543a109..921aa220 100644 --- a/tests/unit/ai-models.test.ts +++ b/tests/unit/ai-models.test.ts @@ -326,6 +326,55 @@ describe('useModelStore (LazyStore-backed)', () => { expect(state.activeModelId).toBeNull() }) + // I79 — a failed read used to be terminal for the whole process: + // `status: 'error'` left activeModelId null, so no session could run AI, and + // the one surface able to re-read it (ModelPickerContainer) only retried on + // 'loading'. Its gate is now `status !== 'ready'`, which these two pin. + test('hydrate records an error without inventing a model', async () => { + const throwing: ModelStoreDeps = { + storeFactory: () => ({ + get: async (): Promise => { + throw new Error('models.json is locked') + }, + set: async () => {}, + delete: async () => true, + save: async () => {}, + }), + } + __setModelStoreDeps(throwing) + await useModelStore.getState().hydrate() + const after = useModelStore.getState() + expect(after.status).toBe('error') + expect(after.error).toContain('locked') + expect(after.activeModelId).toBeNull() + }) + + test('a second hydrate after an error can still reach ready', async () => { + // The recovery this enables: the user opens Settings → AI, that mount + // retries, and AI works for the rest of the process without a restart. + let calls = 0 + const flaky: ModelStoreDeps = { + storeFactory: () => ({ + get: async (key: string): Promise => { + calls += 1 + if (calls === 1) throw new Error('transient AV lock') + return (key === 'active_model_id' ? 'qwen2_5-vl-3b' : {}) as T + }, + set: async () => {}, + delete: async () => true, + save: async () => {}, + }), + } + __setModelStoreDeps(flaky) + await useModelStore.getState().hydrate() + expect(useModelStore.getState().status).toBe('error') + + await useModelStore.getState().hydrate() + const after = useModelStore.getState() + expect(after.status).toBe('ready') + expect(after.activeModelId).toBe('qwen2_5-vl-3b') + }) + test('hydrate without a Tauri store factory falls back to defaults', async () => { __setModelStoreDeps({ storeFactory: null }) await useModelStore.getState().hydrate() diff --git a/tests/unit/report-data.test.ts b/tests/unit/report-data.test.ts index ef7aa33e..b03cecbc 100644 --- a/tests/unit/report-data.test.ts +++ b/tests/unit/report-data.test.ts @@ -366,11 +366,26 @@ describe('aiCoverage', () => { ) }) - test("'ran' when every check was skipped but checks did run", () => { - // A2/A3 — a session of pure parse failures has confident_samples 0 and - // skipped_samples > 0. The AI was watching; it just couldn't read its own - // answers. An empty distraction list there is a real (if thin) finding, and - // the existing #47 D5 data-quality line is what caveats it. + test("a zero confident count is not 'ran' just for being non-null", () => { + // snapshotFocusForReport writes 0 (not NULL) whenever any tick resolved, so + // non-null-ness alone cannot carry the "was measured" meaning. + expect( + aiCoverage({ + ...base, + confident_samples: 0, + skipped_samples: 0, + ai_enabled: 1, + }) + ).toBe('noChecks') + }) + + // REVERSED during review. The first cut of this returned 'ran' for a session + // of pure parse failures, arguing the #47 D5 data-quality line caveats it. + // It does not below SKIPPED_SAMPLES_MIN (3) skips — see the paired test + // below — so for 1 or 2 unreadable checks the page rendered "Focused-time —", + // no caveat at all, and "No distractions detected. Nice work.": issue #92's + // own defect surviving its own fix. + test("'noConfident' when checks ran but none could be read", () => { expect( aiCoverage({ ...base, @@ -378,7 +393,20 @@ describe('aiCoverage', () => { skipped_samples: 7, ai_enabled: 1, }) - ).toBe('ran') + ).toBe('noConfident') + }) + + test("'noConfident' in the window the data-quality line does not cover", () => { + const session = { + ...base, + confident_samples: 0, + skipped_samples: 2, + ai_enabled: 1, + } + expect(aiCoverage(session)).toBe('noConfident') + // The evidence for the reversal: at 2 skips nothing else on the page says + // a word about it, so the distractions copy is the only honest surface. + expect(sampleQualitySummary(session)).toBeNull() }) test("'ran' for a pre-003 row that recorded a score without counters", () => { diff --git a/tests/unit/report-serialize.test.ts b/tests/unit/report-serialize.test.ts index b0edb1db..bae26680 100644 --- a/tests/unit/report-serialize.test.ts +++ b/tests/unit/report-serialize.test.ts @@ -162,6 +162,8 @@ describe('serializeReportToText — AI coverage honesty (I79)', () => { test('AI ran and found nothing: the earned praise survives', () => { // The whole point of the change is that this case still reads confidently. + // Note `confident_samples: 30` — the praise is earned by READABLE checks, + // which is what the noConfident case below establishes. const text = serializeReportToText( buildData( baseSession({ @@ -174,4 +176,28 @@ describe('serializeReportToText — AI coverage honesty (I79)', () => { ) expect(text).toContain('No distractions detected. Nice work.') }) + + test('checks ran but none readable: distinct copy, still no praise', () => { + // Two unreadable checks is below SKIPPED_SAMPLES_MIN, so the #47 D5 + // data-quality line renders nothing and this copy is the only thing that + // tells the truth. It must not claim "No AI checks ran" either — checks did + // run; none could be read. + const text = serializeReportToText( + buildData( + baseSession({ + ...unscored, + confident_samples: 0, + skipped_samples: 2, + ai_enabled: 1, + }), + [] + ) + ) + expect(text).toContain('Score: not recorded (no readable AI checks)') + expect(text).toContain( + 'AI checks ran but none could be read, so nothing was measured here.' + ) + expect(text).not.toContain('Nice work') + expect(text).not.toContain('No AI checks ran') + }) }) From 15f22c1a28d6598e6fbac418db77a42aa357b448 Mon Sep 17 00:00:00 2001 From: scotej <134114466+scotej@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:50:55 +1000 Subject: [PATCH 5/6] style: re-format with the prettier the lockfile actually pins (3.9.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge from main brought prettier 3.9.5 into package-lock.json, but this working tree still had 3.8.3 installed from the pre-merge lockfile. Every `npm run format` I ran after the merge therefore rewrote files — including nine that belong to main and that this PR never meant to touch — in 3.8.3 style, and CI's Frontend job failed its Format check against 3.9.5. `npm ci` + `npm run format` restores all nine to byte-identical with main and formats the two files this PR does own. The diff against main is now exactly the 36 intended files. Worth remembering: `format:check` compares against the LOCKFILE's prettier, so a stale node_modules turns a formatting pass into a formatting regression. Co-Authored-By: Claude Opus 5 --- src/features/ai/aiAgent.ts | 5 +---- src/features/ai/download.ts | 6 +----- src/features/ai/engine.ts | 6 +----- src/features/ai/sampleLoop.ts | 4 +--- src/features/friends/pairLink.ts | 4 +--- src/features/system/useAutostart.ts | 6 +----- src/lib/audit-icons.ts | 6 +----- src/lib/fileExport.ts | 3 +-- src/lib/mediaError.ts | 6 +----- tests/unit/ai-sample-loop.test.ts | 3 +-- tests/unit/turn-probe.test.ts | 3 +-- 11 files changed, 11 insertions(+), 41 deletions(-) diff --git a/src/features/ai/aiAgent.ts b/src/features/ai/aiAgent.ts index 12e046a8..16232c41 100644 --- a/src/features/ai/aiAgent.ts +++ b/src/features/ai/aiAgent.ts @@ -42,10 +42,7 @@ type ChatCompletionResponse = { } export type AgentIntent = - | 'topic_change' - | 'break_request' - | 'question' - | 'unknown' + 'topic_change' | 'break_request' | 'question' | 'unknown' export type TopicChangePayload = { new_topic: string } export type BreakRequestPayload = { diff --git a/src/features/ai/download.ts b/src/features/ai/download.ts index 0698d550..dc404cbd 100644 --- a/src/features/ai/download.ts +++ b/src/features/ai/download.ts @@ -10,11 +10,7 @@ import { modelDownloadUrls, type ModelSpec } from './models' export type ModelFileKind = 'model' | 'mmproj' export type ProgressPhase = - | 'downloading' - | 'verifying' - | 'done' - | 'failed' - | 'cancelled' + 'downloading' | 'verifying' | 'done' | 'failed' | 'cancelled' export type ProgressEvent = { model_id: string diff --git a/src/features/ai/engine.ts b/src/features/ai/engine.ts index 93b1b955..ba26cc01 100644 --- a/src/features/ai/engine.ts +++ b/src/features/ai/engine.ts @@ -17,11 +17,7 @@ export type EngineInfo = { } export type EnginePhase = - | 'downloading' - | 'verifying' - | 'extracting' - | 'done' - | 'failed' + 'downloading' | 'verifying' | 'extracting' | 'done' | 'failed' export type EngineProgressEvent = { phase: EnginePhase diff --git a/src/features/ai/sampleLoop.ts b/src/features/ai/sampleLoop.ts index 6b0f6f0d..e5276c9d 100644 --- a/src/features/ai/sampleLoop.ts +++ b/src/features/ai/sampleLoop.ts @@ -353,9 +353,7 @@ export function getSampleLoopRuntime(): SampleLoopRuntime { } export type SampleLoopStartReason = - | 'no_active_model' - | 'model_files_missing' - | 'sidecar_start_failed' + 'no_active_model' | 'model_files_missing' | 'sidecar_start_failed' // I83 — why a running loop has produced no judgments. Distinct from // `SampleLoopStartReason`: the loop DID start, and is still ticking. diff --git a/src/features/friends/pairLink.ts b/src/features/friends/pairLink.ts index 94b06314..0a422ab0 100644 --- a/src/features/friends/pairLink.ts +++ b/src/features/friends/pairLink.ts @@ -70,9 +70,7 @@ export function decodeContactLink(text: string): Uint8Array | null { // pairing code, or neither. First non-null by exact prefix wins. Kept pure (no // Tauri import) so it is unit-testable and shared by the deep-link subscriber. export type DeepLinkRoute = - | { kind: 'add'; card: Uint8Array } - | { kind: 'pair'; words: string[] } - | null + { kind: 'add'; card: Uint8Array } | { kind: 'pair'; words: string[] } | null export function routeDeepLinkUrl(url: string): DeepLinkRoute { const card = decodeContactLink(url) diff --git a/src/features/system/useAutostart.ts b/src/features/system/useAutostart.ts index 3140b709..707caee2 100644 --- a/src/features/system/useAutostart.ts +++ b/src/features/system/useAutostart.ts @@ -10,11 +10,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { getAutostartEnabled, setAutostartEnabled } from './autostart' export type AutostartStatus = - | 'loading' - | 'ready' - | 'saving' - | 'error' - | 'unavailable' + 'loading' | 'ready' | 'saving' | 'error' | 'unavailable' export type UseAutostartResult = { enabled: boolean diff --git a/src/lib/audit-icons.ts b/src/lib/audit-icons.ts index 39347b27..60c9c525 100644 --- a/src/lib/audit-icons.ts +++ b/src/lib/audit-icons.ts @@ -43,11 +43,7 @@ export const AUDIT_ICONS: Record = { // and Report timeline both read this to apply per-kind styling without // duplicating the lookup. export type AuditIconTone = - | 'default' - | 'warning' - | 'alerted' - | 'focused' - | 'accent' + 'default' | 'warning' | 'alerted' | 'focused' | 'accent' export const AUDIT_ICON_TONE: Record = { joined: 'default', diff --git a/src/lib/fileExport.ts b/src/lib/fileExport.ts index b403239d..74d965ac 100644 --- a/src/lib/fileExport.ts +++ b/src/lib/fileExport.ts @@ -12,8 +12,7 @@ import { invoke } from '@tauri-apps/api/core' import { save } from '@tauri-apps/plugin-dialog' export type SaveTextFileResult = - | { kind: 'saved'; path: string } - | { kind: 'cancelled' } + { kind: 'saved'; path: string } | { kind: 'cancelled' } export type DialogFilter = { name: string; extensions: string[] } diff --git a/src/lib/mediaError.ts b/src/lib/mediaError.ts index 3d77b5df..95dca42e 100644 --- a/src/lib/mediaError.ts +++ b/src/lib/mediaError.ts @@ -5,11 +5,7 @@ // consumes this owns the strings + rendering. export type MediaErrorKind = - | 'denied' - | 'notFound' - | 'inUse' - | 'overconstrained' - | 'generic' + 'denied' | 'notFound' | 'inUse' | 'overconstrained' | 'generic' export function mediaErrorKind(name: string | undefined): MediaErrorKind { switch (name) { diff --git a/tests/unit/ai-sample-loop.test.ts b/tests/unit/ai-sample-loop.test.ts index c99fdd5a..041e0b68 100644 --- a/tests/unit/ai-sample-loop.test.ts +++ b/tests/unit/ai-sample-loop.test.ts @@ -192,8 +192,7 @@ function makeFakeScreenStream(): MediaStream { // `screenExtractImpl` lets a test inject a transient/throwing frame grab. let screenEncodeCalls = 0 let screenExtractImpl: - | ((track: MediaStreamTrack) => Promise) - | null = null + ((track: MediaStreamTrack) => Promise) | null = null const fakeCaptureRuntime: CaptureRuntime = { extractFrame: async (track) => { diff --git a/tests/unit/turn-probe.test.ts b/tests/unit/turn-probe.test.ts index 48ce932c..9a5960fc 100644 --- a/tests/unit/turn-probe.test.ts +++ b/tests/unit/turn-probe.test.ts @@ -17,8 +17,7 @@ function fakePc(opts?: { failOffer?: boolean }): { let closed = false const pc = { onicecandidate: null as - | ((evt: { candidate: Partial | null }) => void) - | null, + ((evt: { candidate: Partial | null }) => void) | null, createDataChannel: () => ({}), createOffer: () => opts?.failOffer From 1cec908102793a10f9768f1caec97083794f78b8 Mon Sep 17 00:00:00 2001 From: scotej <134114466+scotej@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:56:49 +1000 Subject: [PATCH 6/6] docs(issues): correct the I83 aiCoverage state and test counts Round 2 added a fifth coverage state (noConfident) and grew the aiCoverage/serializer suites, but the ledger entry still described the first draft's four states and 6/4 case counts. Keeps 'pre-003 scored row' as-is: that case is about migration 003's sample counters, not 004's ai_enabled. Co-Authored-By: Claude Opus 5 --- ISSUES.md | 168 +++++++++++++++++++++++++++--------------------------- 1 file changed, 84 insertions(+), 84 deletions(-) diff --git a/ISSUES.md b/ISSUES.md index 8f10b30a..3b21a079 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -10,87 +10,87 @@ Round 1 (`audit/sev1-sev2-fixes`, PR #29): every Sev1/Sev2 fixed. Round 2 (`audi **Improvement wave 3 (post-v1.6.0, `feat/improvements-wave3`).** A 12-subsystem multi-agent survey with per-finding adversarial verification, then a multi-lens review of the branch (the three low-severity findings it confirmed were fixed on-branch). Rows I51+ record the confirmed fixes; each shipped as its own commit with tests where the harness allows. Test-only and doc-only outcomes (rendezvous-derivation vectors, signed-hello gate coverage, inbox replay coverage, and the DESIGN-SYSTEM §4 / ARCHITECTURE §11–§12 / README true-ups) are not ledgered separately. -| ID | Sev | Location | Evidence | Status | -| --- | ---- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| I1 | Sev1 | `src/features/session/pomodoro.ts` | `stop()` sent no wire signal; receivers' 10 s silence timer resurrected the timer under a new broadcaster ~10 s after Stop. | **fixed** (R1) — explicit `stopped:true` message; receivers reset to idle. ARCHITECTURE §7 updated. | -| I2 | Sev2 | `src/features/friends/presence.ts` | Online state compared sender wall clock to receiver's; backward sender clock step wedged presence permanently. | **fixed** (R1) — stamp receiver-local time on receive. | -| I3 | Sev2 | `src/features/session/lifecycle.ts` + `sessionStore.ts` | Everyone-else-leaves auto-end lost `sessions.peer_pubkeys` + `markStudied` because `peerLeft` pruned `peers` first. | **fixed** (R1) — cumulative `seenPeerEdPubkeys` set. | -| I4 | Sev2 | `src/features/ai/benchmark.ts` | p95 included the cold-start warmup sample, inflating the sample floor 5–10× with no user recourse. | **fixed** (R1) — run + discard one warmup sample. | -| I5 | Sev2 | `src-tauri/src/commands/models.rs` | Resume fast-path hashed a multi-GB GGUF synchronously on the async runtime, stalling concurrent IPC. | **fixed** (R1) — moved to `spawn_blocking`. | -| I6 | Sev3 | `src/features/ai/sampleLoop.ts` | Battery-pause branch omitted the §8 "thermal-aware notice" and rescheduled at the sample interval, not 60 s. | **fixed** (R2) — `onBatteryPause`/`onBatteryResume` callbacks (fire once each) wired to SessionView toasts; paused branch now reschedules at `BATTERY_POLL_INTERVAL_MS`. Regression test added. | -| I7 | Sev4 | `src/features/session/invite.ts` | Auditor flagged idle-invite `hostSession()` as bypassing the topic gate. | **not a bug** — `Home.tsx` enforces `TopicGateModal` (sets `pendingInitialTopic`) before `inviteToCurrentSession`; no other caller. No change. | -| I8 | Sev3 | `src/features/session/SessionView.tsx` | Audit receive did not check `session_topic` (the ai-alert path does). | **fixed** (R2) — added `verified.session_topic !== sessionTopic` drop, mirroring `aiAlerts.ts`. | -| I9 | Sev3 | `src/features/session/pomodoro.ts` | Any peer sending a valid signed `pomodoro` msg is accepted as broadcaster, even mid-broadcast by another. | **deferred — conflicts with canonical doc.** ARCHITECTURE §14 explicitly: "Friend disables their own AI / fakes score — **Not defended. Social trust. Accepted.**" The "most recent sender becomes broadcaster" behavior is a deliberate, code-documented reconnection-robustness choice; hardening it would silently deviate from the accepted friends-only threat model and risk regressing the documented original-broadcaster-returns path. Surfaced per house rule; user can override to request the hardening explicitly. | -| I10 | Sev3 | `ARCHITECTURE.md §7` | `score_final` wire type has no producer/consumer. | **fixed (doc)** (R2) — §7 annotated: `score_final` is reserved/not-implemented in V2; the report is local-SQLite by V2-P8 design; type kept so a future phase avoids a breaking wire change. Not removed (removal would be a forward-compat break). | -| I11 | Sev3 | `src/features/ai/sampleLoop.ts` | Declared topic interpolated into the focus prompt without injection delimiters. | **fixed** (R2) — topic wrapped in `` + labelled as data; system-prompt rule added; `FOCUS_SYSTEM_PROMPT_VERSION` → 2; `tests/ai-eval/run.ts` kept byte-identical; ARCHITECTURE §8 prompt updated. | -| I12 | Sev3 | `src/features/ai/aiAgent.ts` | Total JSON-parse failure echoed ≤200 chars of raw model output into the dialog. | **fixed** (R2) — fixed safe string to the user; raw logged to console only. Test updated. | -| I13 | Sev3 | `src-tauri/capabilities/default.json` | `ai-dialog` window granted `notification`/`store`; §12 says permissions are main-window-scoped. | **fixed** (R2) — `default.json` restricted to `["main"]`; new `ai-dialog.json` capability scoped to the dialog window with `core:default` only (it uses only core event/window IPC). | -| I14 | Sev3 | `src-tauri/src/db/migrations.rs` + `001_initial.sql` | Bare `CREATE TABLE` + no single-instance ⇒ two simultaneous first-launches could panic the second. | **fixed** (R2) — `IMMEDIATE` transaction with the version read moved inside the tx (locks before reading); `IF NOT EXISTS` on 001's DDL; `INSERT OR IGNORE` on `schema_version`. Sequential-upgrade tests preserved. | -| I15 | Sev3 | `src/stores/identityStore.ts` / `identity.rs` | Identity commit (keychain then file) had no rollback; a failed file write + re-onboard overwrites the keychain entry. | **mitigated** (R2) — the file write is now atomic (see I16); residual is now only "rename succeeded but the keychain `set` itself fails", an OS-keychain fault recoverable via BIP39 (PLAN §7). Full two-store transactionality is out of scope for a Sev3. | -| I16 | Sev3 | `src-tauri/src/commands/identity.rs` | `fs::write` non-atomic; a crash mid-write truncates `identity.json`. | **fixed** (R2) — write to `*.json.tmp` then `fs::rename` over the target (atomic on same FS); temp cleaned on rename failure. | -| I17 | Sev3 | `src-tauri/src/db/sessions.rs` | `started_at`/`ended_at`/`total_minutes` overwritten while the comment claimed additive upserts. | **fixed (comment)** (R2) — comment rewritten to state these three are deliberately authoritative-overwrite (a re-summarize must be able to correct them; COALESCE would swallow it) while the report columns are additive. No behavior change, by design. | -| I18 | Sev4 | `pair.ts` / `lib/trystero/index.ts` / `sidecar.rs` | `verifyHello` didn't reject self-pubkey; stale `selfId` comment; `sidecar_start` trusts JS `model_path`. | **partially fixed** (R2). `verifyHello` now rejects a hello whose `ed_pubkey` equals the local identity (passed `ctx.edPubHex` from `runPair`). `trystero/index.ts` comment corrected to describe the actual module-global-`selfId` mechanism. The `sidecar_start` model-path sandbox is **deferred — conflicts with canonical doc**: PLAN §5 explicitly promises "Advanced users can point at any local GGUF", so constraining `model_path` to `data_dir/models` would break a documented feature. Surfaced per house rule. | -| I19 | Sev4 | `package.json` devDependencies | `npm audit` flags ~20 dev-chain advisories across critical/high/moderate (criticals: `concurrently@9` → `shell-quote`; highs/moderates span the `@storybook/*` and `esbuild`/`tsx` chains). Re-flagged on every scan. | **triaged — no runtime exposure** (2026-06-13). Every one is a **devDependency**; none reaches the installed desktop app — `npm audit --omit=dev` is clean (0) and no advisory package appears in `dependencies`. Bump `concurrently` and the `@storybook/*` chain when convenient; do **not** rush a major Storybook upgrade for a dev-only advisory. Recorded so the scan result isn't re-investigated each time. (Exact counts shift with the lockfile; the load-bearing fact is the clean prod audit.) | -| I20 | Sev1 | `src-tauri/src/db/mod.rs` | `is_definitely_corrupt` only treated a non-"ok" `integrity_check` verdict as corruption; a truncated file (SQLITE_CORRUPT) or damaged header (SQLITE_NOTADB) makes the pragma ERROR, so recovery never fired and the app bricked on every launch after a power-loss/force-kill. | **fixed** — classify by SQLite error code; also clean up `-journal`/`-wal`/`-shm` on rename. Corruption-signature tests added. | -| I21 | Sev2 | `src-tauri/capabilities/ai-dialog.json` | The scoped ai-dialog capability lacked `core:window:allow-close`, so the floating AI dialog's Esc/blur/X all silently failed (regression from the I13 scope-down). | **fixed** — grant `core:window:allow-close`. | -| I22 | Sev2 | `src/features/session/reportData.ts` + `stats/statsInsights.ts` | Report topic-timeline / top-distractions and cross-session insights walked every audit event; peers' broadcast `topic_set`/`ai_alert` (persisted locally) were misattributed to the local user. | **fixed** — thread the local ed_pubkey and filter to it (matches the self-only score gauge / trend). | -| I23 | Sev3 | `src/features/ai/sampleLoop.ts` | `onScreenTrackEnded` latched `captureDenied` and tore down ALL AI capture when ANY screen track ended; unplugging a secondary display in "All displays" mode killed focus detection with a misleading permission overlay. | **fixed** — discriminate: drop the dead display, latch only when the last live one ends. | -| I24 | Sev2 | `src-tauri/src/commands/models.rs` | No read/idle timeout on downloads; a mid-stream stall hung `bytes_stream().next()` forever, freezing the UI and permanently locking the `model_id`. | **fixed** — 60s `read_timeout`. | -| I25 | Sev2 | `src-tauri/src/commands/system.rs` | `system_relaunch_app`'s `app.restart()` skips `RunEvent::Exit` (the only sidecar kill), orphaning a running llama-server on Window-Style relaunch. | **fixed** — `kill_blocking` before restart. | -| I26 | Sev2 | `src-tauri/src/lib.rs` | A boot-time global-shortcut OS conflict propagated out of `setup()` into `build().expect()`, panicking before first paint. | **fixed** — register best-effort; a failed binding is inert until rebound in Settings. | -| I27 | Sev3 | `src/features/friends/invite.ts` + `inviteRetry.ts` | An invite retry queued after the 15s send timeout escaped `cancelAll` if the session ended during the window, later pulling the friend into a dead room. | **fixed** — injected `isSessionLive` guard: a retry never fires for a session that isn't the host's current live one. | -| I28 | Sev3 | `src/lib/fileExport.ts` | CSV export didn't neutralize spreadsheet formula injection; a peer-chosen display name beginning with `= + - @` executed on open (=HYPERLINK exfil / DDE). | **fixed** — quote-prefix string cells starting with a trigger; numeric cells untouched. | -| I29 | Sev2 | `src/features/friends/AddFriendDialog.tsx` | A programmatic close (contact-card deep link) during an in-flight legacy pairing never aborted it — Radix doesn't fire `onOpenChange` on a parent-driven close — leaking the trystero room + relay sockets. | **fixed** — tear down on any `open` transition. | -| I30 | Sev3 | `src/features/friends/inbox.ts` | No replay/dedup on the inbox receive path; a stranger on the pubkey-derived inbox topic could re-broadcast a captured envelope to re-fire the invite toast/notification. | **fixed** — dedup on `(from_ed_pubkey, box nonce)`, TTL-bounded. §14 row added. | -| I31 | Sev3 | `src/features/friends/AddFriendDialog.tsx` + `lib/relayDiagnostics.ts` | Pairing's "network trouble" hint read only the Nostr socket map, so it wrongly blamed the user's network in the exact MQTT-fallback case the v1.2.2 race was built to survive. | **fixed** — transport-aware `pairingRelaysUnreachable()` judging both socket maps; Nostr-only signal kept for the invite path. | -| I32 | Sev3 | `src/routes/Home.tsx` | `PairDeepLinkBoot` rendered only in the non-session tail, so a `studyvis://` link clicked mid-session reached zero listeners and was dropped. | **fixed** — render the full tail in the active-session branch. | -| I33 | Sev3 | `src/strings.ts` | In-app AI copy promised screen access is requested "when you start your first session", but enabling AI requests it immediately. | **fixed (copy)** — reword to match the shipped enable-time prompt. | -| I34 | Sev3 | `src-tauri/src/lib.rs` | With minimize-to-tray off, closing the main window while the AI dialog was open stranded the app: process alive, main window gone, tray "Open" a no-op. | **fixed** — destroy the AI dialog on that close path so the runtime exits. | -| I35 | Sev3 | `src-tauri/src/commands/sidecar.rs` | The crash-restart watcher could spawn a fresh llama-server after `kill_blocking` already ran at quit (during the backoff window), orphaning it. | **fixed** — `shutting_down` flag re-checked after backoff, before respawn. | -| I36 | Sev3 | `src/design/theme.tsx` | `ThemeProvider` wrote the pre-hydration fallback `dark` class, stripping the boot-cache `light` class and flashing dark for light/auto users. | **fixed** — defer to the boot script until an authoritative mode exists. | -| I37 | Sev4 | `src/features/friends/pair.ts` | The legacy pairing hello's (unsigned) `display_name` was stored/rendered raw, unlike the ContactCard path's cap + bidi/zero-width sanitize. | **fixed** — shared `normalizeUntrustedName` applied on both paths. | -| I38 | Sev3 | `src/features/ai/sidecar.ts` | `useSidecarStore.start()` unconditionally set `running` after its await, clobbering an interleaved `stop()` and leaving the store `running` on a killed process. | **fixed** — bail if a stop intervened. | -| I39 | Sev3 | `src/App.tsx` + `src/components/ErrorBoundary.tsx` | No React error boundary anywhere; any render throw blanked the whole window and killed the always-on inbox/presence + live session. | **fixed** — top-level `ErrorBoundary` around the routed content with a calm "Try again". | -| I40 | Sev4 | `src-tauri/src/commands/system.rs` | Changing one PTT shortcut when both shared a combo (hand-edited settings.json) unregistered the other. | **fixed** — only unregister the old combo when the other action isn't still using it. | -| I41 | Sev4 | `src/lib/encoding.ts` | `hexToBytes` used `parseInt` per byte, silently mis-decoding malformed hex ('1g'→0x01, '-a'→wraps) instead of rejecting. | **fixed** — validate the whole string against `/^[0-9a-fA-F]*$/` first. Adversarial-input tests added. | -| I42 | Sev3 | `src/features/session/SessionView.tsx` | The local session camera/mic stream had no `ended` listener, so a mid-session device loss (unplug / OS-revoke / another app grabbing the camera) left peers on a frozen tile and silently killed the AI face path. | **fixed** — attach an `ended` listener that surfaces the existing "Try again" recovery banner. | -| I43 | Sev3 | `.github/workflows/ci.yml` | CI compiled only aarch64-apple-darwin, so `#[cfg(target_os="windows")]` code first built inside `release.yml` AFTER the tag was pushed. | **fixed** — macOS + Windows Rust matrix on every push/PR. | -| I44 | Sev3 | `.github/workflows/release-prep.yml` | The one-click gate skipped `check-a11y` and all Rust compilation, so a release could be cut over an axe-core / clippy regression. | **fixed** — add the a11y gate; require the exact main SHA's CI run to be green before bump/tag/push. | -| I45 | Sev3 | `README.md` / `PLAN.md` / `ARCHITECTURE.md` / `CHANGELOG.md` | User-facing doc drift: first-run described the retired 12-word flow as primary; "one WebSocket" (really ~8 relays); "three tiers" (four models); "v1.2.0 is current" (v1.3.1); §6 "MQTT not yet wired" (raced since v1.2.2); changelog x86_64 DMG claim (aarch64-only). | **fixed** — brought each in line with the shipped code. | -| I46 | Sev3 | `src/features/friends/invite.ts` | `sendInviteEnvelope` treats any peer joining the recipient's inbox topic as delivery, so an eavesdropper on that shared pubkey-derived topic can appear as "delivered" or drop the invite. | **accepted — friends-only threat model.** Envelope is still NaCl-box-sealed to the recipient; worst case is a suppressed offline-retry (re-click Invite). Documented in §14. The flagged signed invite-ACK shipped in #47 C2 (new `invite-ack` action, v1.2.x-wire-compatible: no ACK within the window → honest "unconfirmed" copy) — for UX legibility, not as a defense; the eavesdropper acceptance above stands. | -| I47 | Sev3 | `src/features/friends/presence.ts` | Presence heartbeats/goodbyes are unauthenticated on a pubkey-derived topic, so a stranger with a friend's public pubkey can forge that friend's online/offline state. | **accepted — friends-only threat model.** Presence is soft UX state, not a data/session compromise. Signing would break cross-version presence (older peers send unsigned), so enforcement is deferred, not shipped. Documented in §14. | -| I48 | Sev3 | `src/features/friends/pair.ts` (upstream `@trystero-p2p/mqtt`) | Each pairing's MQTT room open→leave orphans ~4 broker connections: trystero-core sets `didInit=false` on last-room-leave but never `.end()`s the MQTT clients. | **deferred — upstream trystero bug.** Bounded (a handful of pairings per session, cleared on process exit) under the friends-only 4-peer model. Fix is upstream (or an app-side always-on MQTT room, which trades the leak for a persistent idle broker connection — not worth it). | -| I49 | Sev3 | `src/features/friends/InboxBoot.tsx` + `presence.ts` | The presence effect keys on the whole friend set, so adding/removing any friend tears down + rebuilds the own presence room, broadcasting a goodbye that flickers your presence offline→online on every other friend's screen (and can fire a spurious "came online" notification). | **fixed** (#47 C6, the recorded dedicated pass) — `startPresence` gained `updateFriends`: friend list edits diff rooms in place (join added / leave removed), the own room and heartbeat cadence never churn, and `leave()`'s tested goodbye semantics are untouched. InboxBoot keys the subscription on identity only and drives list edits through the diff; removed friends' notify baselines are pruned so a re-add starts fresh. Unit tests cover added/removed/no-op churn including a watcher asserting no goodbye flicker. | -| I50 | Sev4 | `src-tauri/tauri.conf.json` | Both webview windows ship with CSP disabled (defense-in-depth only — no reachable XSS sink today: React auto-escapes, no `innerHTML`/`eval`). | **deferred — needs a desktop CSP smoke-test.** A wrong CSP hard-breaks Tauri IPC/asset loading, which no static gate catches; landing a `script-src 'self'` policy safely requires running the built desktop app (not possible headless). Recommended policy: `default-src 'self'; script-src 'self'; object-src 'none'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' ws: wss: http://127.0.0.1:*`. | -| I51 | Sev2 | `src/routes/Home.tsx` | The `tail` fragment (InboxBoot + deep-link + import dialog + topic gate) rendered at a different unkeyed child index per view branch, so React reconciled by index and re-mounted the always-on presence/inbox room on every view switch — re-triggering the I49 goodbye flicker, blanking the friends list for up to a heartbeat, and dropping an invite that arrived in the teardown window. | **fixed** — `` pins the tail fiber across branches of differing child arity. The load-bearing key is documented at the site; `pairDeepLink.ts`'s stale "view switches re-mount the boot" comment corrected (the `launchConsumed` guard kept). Not statically checkable and not node-testable without RTL, so protected by the site comment. | -| I52 | Sev2 | `src/features/session/lifecycle.ts` + `stores/sessionStore.ts` | `total_minutes` was pure wall-clock `endedAt − startedAt`, counting OS-sleep/suspend as study time; a session slept on persisted the whole span (a free streak day and inflated totals). | **fixed** — elapsed is `min(wallMs, monoMs)` off a `performance.now()` origin captured at start, mirrored in the live footer. Not retroactive (old rows stand); degrades to prior behavior on a platform whose monotonic clock happens to include suspend, never undercounts. Unit-tested via an injectable `monotonicNow` seam (awake / slept-through / backward wall clock / no-mono fallback / slept-through rejoin). | -| I53 | Sev3 | `src/features/session/lifecycle.ts` + `SessionView.tsx` | A peer's deliberate `left` (signed, on the wire since V1-P9) still armed the 20 s reconnect grace and offered a Rejoin into a dead room. | **fixed** — mark departed peers, and skip the grace/Rejoin only when the room empties with no unexplained absence remaining, via a new `SessionEndReason` (`'peer'`). Unexplained-absent peers are tracked in a Set (not a single flag, per the review) so an intervening join by another peer can't strand a still-absent blipper; the mark clears per-peer on rejoin so a later blip still gets grace. ARCHITECTURE §13 updated. Grace unit tests extended. | -| I54 | Sev3 | `src/features/friends/InboxBoot.tsx` + `friendOnlineNotify.ts` | The friend-online baseline suppressed every friend's _first_ online resolution after mount (not just boot's initial sweep), so a genuine later arrival never notified — the one event the feature exists for. | **fixed** — per-friend watch-start map with a settle bound. The bound is a dedicated `NOTIFY_SETTLE_MS` (3 min, sized above realistic presence-handshake latency), not the 60 s heartbeat window: reusing the latter let a slow-connecting already-online friend re-read as an arrival (review finding). Only the settle window is suppressed. Unit-tested. | -| I55 | Sev3 | `src/features/session/hello.ts` | The signed session-hello `display_name` was stored/rendered without the cap + bidi/zero-width sanitize every other untrusted-name path applies; on `main` it was unbounded. | **fixed** — `normalizeUntrustedName(name, HELLO_NAME_CAP)`. Cap is 192 UTF-8 bytes — the worst case for the 64-UTF-16-unit `maxLength` our own inputs enforce — so a legitimate multibyte name (CJK/emoji) survives intact rather than being byte-truncated (review finding), while a hand-modified sender is still bounded. Unit-tested incl. multibyte + bidi. | -| I56 | Sev3 | `src/features/ai/sampleLoop.ts` | `onCaptureError` fired per tick (contract says once/lifetime) and the face-track guard never checked `readyState`, so a dead webcam threw `track_ended` every tick and toast-stormed the session over the MediaErrorBanner already saying the same thing. | **fixed** — the ended-track guard skips the tick without counting a sample; a `captureErrorReported` latch mirrors `sidecarErrorReported`, reporting once and clearing on the next successful verdict. Unit-tested. | -| I57 | Sev3 | `src/design/tokens.ts` + `src/design/index.css` | The focus ring (`accent.ring`, 40 % alpha) measured ~2.6:1 dark / ~1.8:1 light against the surfaces it is drawn on — below WCAG 1.4.11 — because the UA outline is globally reset; the gate missed it by measuring the opaque accent. `shadow.glow` had also drifted 3px/4px. | **fixed** — raised alpha (60 % dark / 80 % light), mirrored in both hand-kept files; `check-contrast` now measures the ring in the bg-stack at its real per-theme alpha; `shadow.glow` reconciled to the tokens.ts value (3px). The ring's inner edge on `bg-accent-default` buttons intentionally stays below 3:1 — the outer edge against the canvas carries identification. | -| I58 | Sev3 | `src/components/ui/dropdown-menu.tsx` | Menu items declared `focus:bg-bg-raised` on a `bg-bg-raised` surface — a 1.00:1 no-op — so keyboard/mouse navigation showed no highlight (worst in the in-session audio pickers, where two identically-named devices are indistinguishable). | **fixed** — an inset accent ring highlight (keeps `focus:` so Radix pointer-move still lights it). The byte-identical Button/Badge `secondary` hover was fixed the same way (`hover:bg-bg-surface`). | -| I59 | Sev3 | `src/components/AuditLogPanel.tsx` + `SessionNotesPanel.tsx` | The session-log and notes scroll containers had no focusable descendant and no `tabIndex`, so a keyboard-only user couldn't scroll them (WCAG 2.1.1). macOS/WKWebView only; Windows WebView2 auto-focuses scrollers. | **fixed** — `tabIndex={0}` + a focus-visible inset ring on both. Overflowing Storybook stories added so the axe `scrollable-region-focusable` gate has something to assert on. | -| I60 | Sev3 | `src/strings.ts` (`searchKeywords`) + `Settings.tsx` | v1.6.0 settings search routed "tray"/"minimize"/"capture displays"/"auto-update" to Advanced (which owns none of them) and left Advanced's own settings ("launch at login", "clear history", "onboarding") unfindable. | **fixed** — keywords moved to the panes that own each setting; Advanced keywords added; a `Record` guard in `Settings.tsx` pins the bucket↔pane mapping without a strings→features import cycle. | -| I61 | Sev3 | `src/stores/settingsStore.ts` + `ShortcutsCategory.tsx` | `resetShortcutsToDefaults` rethrew on the first setter's combo collision and never ran the second; the rejection was swallowed to `console.error`, so the button was a silent no-op. | **fixed** — reorder + per-call try/catch so both setters run; a residual collision surfaces a `toast.error` (copy in strings.ts). The Rust `is_registered` skip the original proposal suggested was dropped — it would re-open #47 B5. Stateful fake added to the keybindings test. | -| I62 | Sev3 | `src/features/updater/updaterStore.ts` + `AboutCategory.tsx` | Settings → About offered a live Restart-now / Check-now during a session (unguarded, unlike the update banner), and its help text asserted "you're on X, the latest" from the initial `idle` state and after a silent background-check failure. | **fixed** — session-active guards in `installAndRestart`/`checkNow` (the `userInitiated` exemption, made false by the in-session settings overlay, removed); About disables the buttons in-session and derives its help from an explicit `upToDate` branch rather than a fallthrough. Store tests flipped to assert deferral. | -| I63 | Sev3 | `src/features/identity/recoverLogic.ts` | A failed 24-word restore pointed at all 24 words equally, with no way to narrow a single typo on the highest-stakes screen in the app. | **fixed** — name the words that aren't in the wordlist (`unknownWords` on `MnemonicClass`, populated only on the 24-word path); copy in strings.ts. Kept in `recoverLogic.ts`, not the cross-version crypto module. Unit-tested. | -| I64 | Sev3 | `src/features/stats/FocusInsights.tsx` | The focus-over-time trend tooltip had no date, so a dip couldn't be anchored to a day. | **fixed** — carry each point's `startedAt`; the tooltip renders the `dayKey` day, byte-identical to the bar chart's day format. | -| I65 | Sev4 | `src/features/stats/statsData.ts` | The stats CSV omitted the two headline tiles (total sessions, streak, average) — the numbers the pane is built around. | **fixed (summary)** — prepend summary rows, preserving the null-average ("AI off" vs "scored 0") distinction. Per-session detail left out of scope. Test extended. | -| I66 | Sev3 | `src-tauri/src/commands/sidecar.rs` | `sidecar_start` spawned llama-server then opened the log file; an `open_log_file` failure after a successful spawn dropped the `CommandChild` without `kill()`, orphaning a multi-GB process past app exit (same class as I25/I35). | **fixed** — open the log before spawning, so no fallible `?` sits between the spawn and `guard.child`. Reviewed by reading (CI is the first Rust compiler on this dev box). | -| I67 | Sev3 | `src-tauri/src/commands/sidecar.rs` | The respawn budget was a 30 s sliding window, so any crash spaced >30 s reset the counter and the watcher respawned llama-server forever without ever setting `errored` — no recovery affordance surfaced and the D7 log cap was defeated. | **fixed** — the budget now counts consecutive respawns that each died before `MIN_HEALTHY_UPTIME` (120 s); a durable child resets the streak (`next_attempts` pure fn, unit-tested). Once the budget is exceeded `errored` is set as before. | -| I68 | Sev4 | `src-tauri/src/db/audit_events.rs` | The cross-session insights read shipped the entire `audit_events` table over IPC though only `ai_warning`/`ai_alert` rows are consumed. | **fixed** — `WHERE kind IN ('ai_warning','ai_alert')` narrows the query (~4× less JSON at 10k rows); `list_all` → `list_ai_distractions_all`, but the Tauri command name is unchanged so the IPC/TS contract is untouched. The SQL twin of TS `isDistraction` is commented at the query. | -| I69 | Sev3 | `src-tauri/src/lib.rs` | The corrupt-DB recovery dialog asserted re-pairing was required and never mentioned the friends-backup import — wrong at the exact moment a friend loses their list. | **fixed (copy)** — the dialog now names Settings → Identity → Import friends as the restore path if a backup exists, otherwise re-pair. | -| I70 | Sev4 | `.github/workflows/release.yml` | A half-built draft (one platform's artifact missing from `latest.json`) could be published, stranding every friend on the missing platform with no update path and a false "you're on the latest". | **fixed** — a job asserts both platforms are present in the draft's `latest.json` and, on failure, stamps the draft title "INCOMPLETE, DO NOT PUBLISH" (needs `contents: write` to read a draft). Not runnable on this box; validated by YAML parse + reading. | -| I71 | Sev2 | `src/features/updater/updaterStore.ts` + `src-tauri/src/commands/system.rs` | Issue #77: an app opened straight from the mounted `.dmg` runs under macOS App Translocation (read-only bundle), where `update.install()`'s rename-into-place can never succeed — every launch re-downloaded the installer, offered "Restart now", and failed with the generic install toast. The one documented install step (drag to Applications) is exactly the one this path skipped, and the updater had no idea. | **fixed** — new `system_install_context` command (translocation via exe-path component, read-only volume via `statfs`; fail-open) consulted after a check finds an update: an unswappable bundle sets a new process-permanent `blocked` status _before_ any bytes move, and the banner + Settings → About replace the doomed Restart with move-to-Applications guidance. Verified live: dev binary on a read-only DMG against the real v1.7.0 release showed the blocked row. Windows/NSIS unaffected (always updatable). | -| I72 | Sev1 | `src-tauri/src/commands/models.rs` | Every model download failed at the picker's preflight with "…The model manifest may be stale." for every catalog entry. `model_head_check` populated `content_length` from `reqwest::Response::content_length()`, which is the body's size hint — an HTTP/1.1 HEAD response body is always empty (hyper decodes it as zero-length regardless of headers), so every probe reported 0 bytes and the size gate rejected all six entries. The manifest itself is current: the raw `Content-Length` (and `x-linked-etag` = pinned sha256) at every pinned revision still matches. | **fixed** — read the `Content-Length` response header instead; in-module regression test against a local HEAD server; live-verified that all 10 catalog files (6 model + 4 mmproj — the three Gemma quants share one projector) report header sizes byte-identical to the manifest. Git history dates the break to the picker's birth: the size gate, the `content_length()` call, and the no-http2 reqwest dep all landed in one commit (af2987d, V2-P2) and never changed, and the zero-length HEAD decode is server-independent — so no catalog download has ever passed this preflight, and the downstream GET/verify/resume path has never run end-to-end in a shipped build (first real install is its true test). First user report 2026-07-26. | -| I73 | Sev1 | `src-tauri/src/commands/sidecar.rs` + `src-tauri/src/commands/engine.rs` | In-app llama-server spawn has never worked in any build. `shell().sidecar("binaries/llama-server")` resolves `/binaries/llama-server` (tauri-plugin-shell 2.3.5 joins the full configured string against the exe dir), but tauri-build (dev) and the bundler (release) both strip the directory prefix and the triple, placing the file at `/llama-server` — verified in `target/debug/` and in the installed `StudyVis.app/Contents/MacOS/`. Every `sidecar_start` failed with `spawn llama-server: No such file or directory`, surfaced as "AI failed to start:" / "AI model crashed". The plugin has been pinned at 2.3.5 since V1-P1, so this is a day-one bug, not a regression; it sat behind I72 (downloads never completed), which is why the first user report of both landed the same day (2026-07-26 — the on-disk `llama-server.log` from that attempt is a 0-byte file: the child never ran). | **fixed** — sidecar binaries now resolve to absolute paths and spawn via `shell().command()`: bundled probe at `/llama-server(.exe)` (size-gated), then a managed install under `data_dir/engine/-/`. When neither resolves, `sidecar_start` auto-installs the pinned llama.cpp b9095 release asset (SHA-256-verified; pins lockstep-tested against `scripts/fetch-llama-server.sh`; tar.gz/zip unpacked flattened + filtered), gated by the new `engine_auto_install` setting (default ON) with `engine_info`/`engine_install` commands and a Settings → AI "AI engine" row (status/progress/Reinstall). `build.rs` writes a debug-profile-only placeholder so fresh checkouts compile without the fetch script; release-profile builds still hard-fail. Windows spawn failures name the VC++ redistributable when `vcruntime140.dll` is absent. Verified live on macOS: the installed bundle's binary spawns via the exact fixed resolution (`--version`, Metal init, exit 0), the placeholder build compiles and launches, and the pinned archives download, hash-match, extract, and run on this machine. The in-app GUI walk (Settings row + session start) is user-walked — the dev binary's keychain prompt blocks machine-driving it. | -| I74 | Sev2 | `src/features/friends/presence.ts` + `presenceRelay.ts` + `src/lib/nostr/` | A mutually added friend showed permanently offline on BOTH ends whenever a STUN-only WebRTC datachannel could not form between the two networks (symmetric NAT / CGNAT / strict firewall — no TURN ships, ARCHITECTURE §4). Heartbeats only rode datachannels; trystero fires no callback on a failed ICE attempt (it silently re-offers forever), and offline ContactCard pairing (§5.1) removed the last step that ever proved the P2P path worked — so the failure was invisible end to end, with every relay reachable and both apps running. Presence, invites, and sessions all share the broken leg; presence was just the visible symptom. | **fixed** — relay-carried presence: sealed ephemeral Nostr events (kind 20001, new `studyvis:presence-relay:v1` tag/key derivations pinned in topics.test.ts) published every 30 s to the pinned relays over an owned reconnecting socket pool; no `since` filter and `limit: 0` (the #47 C1 clock-skew lesson). The datachannel leg stays and now stamps `lastP2pAt`, so `presenceState()` distinguishes direct-online from relay-only "limited" (120 s settle, I54 lesson) — surfaced in the friends list as an amber "Available · limited connection" row plus a one-line hint deep-linking Settings → Network (TURN). Goodbyes keep `lastSeenAt` for "seen … ago". Sessions/invites behind the same NAT still need TURN — the UI now says so instead of lying "Offline". Old builds interop unchanged (they never see this leg). ARCHITECTURE §4/§7/§11/§14 + PLAN §2 updated; `offchain.pub` dropped from the relay pin (now rejects anonymous publishes). | -| I75 | Sev1 | `src-tauri/src/commands/sidecar.rs` | After 1.8.0 shipped I73's spawn-path fix, on-device AI still failed to start on a real Windows install: `llama-server.exe` spawned, printed its banner (`Running without SSL`, `loading model`), then exited with `no backends are loaded` / `failed to load model` / `giving up after 4 restart attempts` (friend's `llama-server.log`, 2026-07-26 — the same day 1.8.0 shipped, the very next link in the same chain). Root cause: the pinned llama.cpp b9095 release assets are `GGML_BACKEND_DL` builds — 15 `ggml-cpu-*.dll` variants on Windows (haswell/zen4/sse42/…), `libggml-cpu.dylib`/`libggml-metal.dylib`/`libggml-blas.dylib` on macOS — that ggml `dlopen()`s at startup rather than linking. `ggml_backend_load_best` (`ggml/src/ggml-backend-reg.cpp`) globs exactly two places for those: the executable's own directory and the process's current working directory — never `PATH`/`DYLD_FALLBACK_LIBRARY_PATH`/`LD_LIBRARY_PATH`. I73's env-var prepend only satisfies the binary's _linked_ imports (`llama.dll`/`ggml-base.dll`/…), which is why the process starts at all; it never reaches the dlopen glob, so `ggml_backend_reg_count()` stays 0, `common_init_from_params` fails, and the crash-restart watcher gives up after `RESTART_BUDGET` (4) identical failures — on every bundled Windows and macOS install, not an edge case. Verified against the pinned llama.cpp b9095 source (`ggml-backend-reg.cpp:479-489`) and the actual release archives (`llama-b9095-bin-win-cpu-x64.zip`, `llama-b9095-bin-macos-arm64.tar.gz`). | **fixed** — `spawn_llama` now also sets the child's working directory to the same runtime dir already resolved for the `PATH`/`DYLD_FALLBACK_LIBRARY_PATH`/`LD_LIBRARY_PATH` prepend (`Command::current_dir`, tauri-plugin-shell 2.3.5), since `fs::current_path()` is in ggml's search list. One code path covers both engine sources (bundled, and the managed install where `runtime_dir` already equals the exe's own directory) and all three platforms. Not runnable on this box — no cargo/node toolchain and `src-tauri/binaries/` has no fetched engine on this Linux dev host; gated by CI and the `Release prep` workflow's gate job instead. | -| I76 | Sev1 | `src/features/ai/sampleLoop.ts` + `captureScreen.ts` + `src/routes/Home.tsx` + `AiCategory.tsx` + `SessionView.tsx` | User report: "AI capture error: getDisplayMedia must be called from a user gesture handler" firing on ordinary session starts with AI already enabled, and — because the fallout from this same failure kept killing the just-started sidecar — a separate, misleading "AI isn't running yet. Turn it on in Settings → AI" from the Ctrl+] chat dialog even though AI genuinely was on. Root cause: `sampleLoop.ts`'s `boot()` acquires the session's long-lived screen `MediaStream` via `navigator.mediaDevices.getDisplayMedia()`, but `boot()` runs from a React `useEffect` fired by state changes (session active + AI on + model chosen + camera up), never from inside a click handler. WebView2 (Windows) and WKWebView (macOS) require `getDisplayMedia()` to run inside live transient user activation on _every_ call, not just the first — the same reason the OS picker itself fires on every acquire (documented in `src/features/ai/README.md`'s "Acquire strategy", which is why V2-P9 already moved to one long-lived stream instead of a per-tick acquire) — so with no gesture in `boot()`'s call stack the call was rejected outright. Because the rejection's `DOMException` name fell outside `mapDisplayMediaError`'s handled set, it surfaced as the generic `screen_capture_unavailable` code and a raw toast instead of the intended `screen_capture_denied` recovery overlay, and `boot()`'s existing failure path tore down the sidecar it had just started. A second, compounding gap: `onCaptureError` never updated `AiStatusChip`'s runtime status, so the chip kept reading "active" after AI had silently died underneath it — matching the reporter's "I can't tell if it's on or if it's errored." | **fixed** — a gesture-context handoff: callers that DO have a real user gesture (`TopicGateModal`'s submit when starting a session with AI already enabled; `AiCategory`'s "enable AI" toggle when a session is already active; `SessionView`'s permission-overlay retry) call the new `preacquireScreenStream()` synchronously (no `await` before it), which starts `getDisplayMedia()` inside that click and stashes the in-flight promise; `sampleLoop.ts`'s default `acquireScreenStream` runtime hook consumes that stash instead of calling `getDisplayMedia()` itself outside gesture context. An unconsumed stash (a rapid re-toggle, or a session that never reaches `boot()`) is released via `discardPendingScreenStream()`, including on `SessionView` unmount, so it never leaks a live stream or leaves the OS recording indicator lit. Separately, `onCaptureError` now carries a `fatal` flag — true for a `boot()`-time acquire failure (the loop really did tear itself and the sidecar down) vs. false for a `tick()`-time transient one (the loop keeps running) — so `SessionView` only flips the status chip to "error" on the former. Unit-tested (pending-stream stash/discard, default-runtime consumption of the stash, the `fatal` flag on both call sites); `npm run build`/`lint`/`test` all green (878 tests). | -| I77 | Sev1 | `src/features/session/lifecycle.ts` + `SessionView.tsx` + `tests/integration/session.test.ts` | User report: "on my device I can't see the other person's camera but they can see mine" — a guest joining a friend's session never received the host's camera **or** mic, in either direction of the pair, while the host saw the guest fine. Root cause: `SessionView`'s media-acquire effect published the local `MediaStream` with a single untargeted `room.addStream(stream)`, and trystero 0.24 delivers a stream only to the peers that are active **at that instant** — `addStream` → `applyMediaOp` → `iterate` enumerates `keys(activePeerMap)` right then (`@trystero-p2p/core` `room.mjs:83`, `:494`) and queues nothing; peer activation (`room.mjs:306-314`) sets `activePeerMap` and fires `onPeerJoin` but replays no previously added local stream. The host is structurally guaranteed to lose that race: `hostSession()` derives a session topic from 32 fresh random bytes and `begin()`s the room **before** the invite is even sent, so the host's camera opens while it is provably alone and its one broadcast reaches nobody, forever. The guest normally wins it, because the session peer activates over trystero's already-open shared connection to that same friend in roughly one RTT — faster than a cold camera opens — so the guest's `addStream` lands and the host sees the guest. Two stale comments asserted the opposite of the library's actual behavior and are what preserved the bug: `SessionView.tsx` claimed `addStream` "forwards new tracks to all current peers **and to peers who join later**", and the stream-binding effect claimed "trystero replays existing peers when we register the stream callback" (`onPeerStream` is a bare assignment at `room.mjs:511`; only `onPeerJoin` sweeps, at `:506-509`, a replay our own `wrapRoom` consumes at construction). CI could not catch it: the integration bus mock hard-coded both false beliefs — its `addStream` ignored `targetPeers` and fanned out to every room, and its join + `onPeerStream` paths both replayed existing streams. Day-one defect; `trystero` has been pinned `^0.24.0` since the media path was introduced, so host→guest video has never worked in any shipped build. | **fixed** — publishing moved into `publishLocalStream(room, stream)` in `lifecycle.ts`, which broadcasts to the currently-active peers and, in the immediately adjacent statement, subscribes `onPeerJoin` to re-send the same stream targeted at each later joiner (the pattern trystero's own README prescribes). The two calls live in one function so the "no `await` in the seam" invariant is structural: the broadcast covers who is active now, the subscriber covers who arrives later, and JS's single thread means no peer is missed or served twice — a double-add would desync trystero's FIFO pairing of stream metadata to incoming tracks. `SessionView`'s effect cleanup unsubscribes **before** `stopTracks`, so a "Try again" re-acquire can't hand a later joiner a dead stream. Both false comments replaced with the verified semantics + `room.mjs` line refs. The integration bus mock now models `activePeerMap` honestly (targeted sends honored, no join replay, no `onPeerStream` replay), and `tests/unit/session-publish-stream.test.ts` pins the contract — 2 of its 4 cases fail against the pre-fix code. **Both friends must update:** a patched host reaches an unpatched guest, but a patched guest still receives nothing from an unpatched host. | -| I78 | Sev2 | `src-tauri/Cargo.toml` (`tauri 2.11.0`) | GHSA-7gmj-67g7-phm9 — "Tauri has an Origin Confusion Issue that Allows Remote Pages to Invoke Local-Only IPC Commands" (CVSS 8.8), affecting `tauri >= 2.0.0, <= 2.11.0`; fixed upstream in 2.11.1. StudyVis exposes a wide IPC surface (SQLite, keychain-backed identity, sidecar spawn, filesystem paths), so origin confusion is the class that matters most here rather than a theoretical one. Not found by `cargo deny`: the advisory is GitHub-Advisory-Database-only and RustSec does not carry it — it surfaced when OSV-Scanner was run over `Cargo.lock` while building the #102 supply-chain gates. | **fixed** — `cargo update -p tauri --precise 2.11.1` (lockfile-only; `Cargo.toml` already requires `"2"`, so no manifest change). Pulled tauri-build/codegen/macros/runtime/runtime-wry/utils forward with it. Verified: OSV over `Cargo.lock` no longer reports the advisory, and `cargo deny check advisories licenses bans sources` stays green. Shipped as its own PR rather than bundled into the #102 CI branch: a Tauri bump is a Rust change that this box cannot compile, so it wants its own PR and its own full CI run. The new `.github/dependabot.yml` opens the 2.11.0 → 2.11.1 bump automatically (cargo ecosystem; `tauri*` is excluded from the routine grouping precisely so it lands as its own reviewable PR), and `maintenance.yml`'s weekly OSV scan keeps reporting it until the bump lands. Nothing in the pinned-ignore list of `src-tauri/deny.toml` suppresses it. | -| I79 | Sev1 | `src-tauri/src/macos_display_capture.rs` (new) + `src-tauri/src/lib.rs` + `src/features/ai/sampleLoop.ts` | User report (issue #94): "AI does not work on macOS when it's enabled, model does not load into machine." AI is dead on macOS end-to-end, and the diagnostic log makes it look like the engine is at fault: `llama-server.log` shows a clean model load and four successful `/v1/chat/completions` (that run is the V2-P2 **benchmark**, which never touches screen capture) followed by bare `[event] terminated code=None` lines with no stderr at all — a child killed within milliseconds of spawn, before it could print its banner. Root cause is upstream and not the sidecar: since macOS 13, WebKit resolves `getDisplayMedia()` either by its own default action (when the app implements **no** capture delegate) or by the private `_webView:requestDisplayCapturePermissionForOrigin:initiatedByFrame:withSystemAudio:decisionHandler:` delegate — and it **denies the request outright** when the app implements the public `webView:requestMediaCapturePermissionForOrigin:…:type:decisionHandler:` (which wry does, to grant camera/mic) but not the private one (which wry does not: tauri-apps/wry#1195 open, #1196 unmerged, an earlier attempt #1111 reverted by #1186). So every `getDisplayMedia()` in a Tauri app is rejected with `NotAllowedError` on macOS regardless of user gesture or the app's Screen Recording grant. `mapDisplayMediaError` reads that as `screen_capture_denied` and `SessionView` mounts `ScreenCapturePermissionOverlay`, sending the user to System Settings for a grant that cannot help. **I76 is superseded, not wrong** — the gesture handoff it added is a real fix and remains correct on Windows; it shipped in v1.8.1, which is the build that failed here, which is what rules it out as the cause. Compounding it, `sampleLoop.ts`'s `boot()` spawned llama-server BEFORE acquiring the screen stream, so each attempt loaded a multi-GB model and killed it on the failed acquire — the literal "model does not load into machine" the reporter saw. | **fixed** — `macos_display_capture::install()` adds the missing private method to wry's already-registered UI-delegate class at setup (`class_addMethod`, macOS only), answering `WKDisplayCapturePermissionDecisionScreenPrompt` so WebKit shows the OS picker — the behaviour the capture path was always written against. wry keeps owning every selector it already implements; the class just gains one more, process-wide, so the V2-P7 AI dialog window is covered too. Fail-safe: a renamed selector, an unreachable delegate or a failed `class_addMethod` logs and leaves the pre-fix behaviour rather than breaking anything. The method's type encoding is built from objc2's own `Encode` impls (BOOL is `B` on arm64, `c` on x86_64) with a test pinning the shipped arm64 signature, and a second test pins the selector spelling — the earlier upstream attempt shipped `ForSecurityOrigin` and silently did nothing. Separately `boot()` now acquires every screen stream BEFORE starting the sidecar, so a denied or cancelled capture costs nothing and a failed spawn releases the streams; two unit tests cover both directions. Not machine-walked: this repo has no macOS GUI-automation host. | -| I80 | Sev4 | `src/lib/nostr/pool.ts` + `tests/unit/nostr-pool.test.ts` | CodeQL `js/log-injection` (alerts 51/52, medium) on the two `console.warn` calls that surface relay complaints — the OK-false reason (`frame[3]`) and the NOTICE body (`frame[1]`). Both strings are authored by the relay, and I74 added them deliberately so the relay-presence leg could not fail silently. The console is not a throwaway here: README points a friend at Settings → Advanced → Open data folder when something goes wrong, so a newline in relay text forges log lines that read as ours, and a bidi override (`‮`) reorders what a human sees without changing the bytes. No security decision is made on log content, which is what keeps this Sev4 rather than higher; a hostile pinned relay is also already outside the friends-only threat model. Found by the CI gate rather than by a report. | **fixed** — a module-local `forLog()` replaces the `Cc`/`Cf` Unicode classes with spaces and clamps to 200 characters with an ellipsis, applied to both sinks. Deliberately not silent swallowing: the text still reaches the log, which is the whole point of I74's diagnostic. Two tests pin it — one asserts the message survives while no control character does, one asserts the clamp — and both were confirmed to FAIL with the fix reverted. `slot.url` in the third `console.warn` is ours (the pinned relay list), not relay-authored, so it is untouched and CodeQL does not flag it. | -| I81 | Sev3 | `src-tauri/src/commands/ai_dialog.rs` | User report (issue #97): on macOS the `Ctrl+]` AI panel renders a phantom rounded-rect outline floating around it. Measured off the reported screenshot (2x capture; panel 428x102 pt): the outline is a 1 pt black hairline over a 1 pt grey one — AppKit's two-tone window rim — tracing a rect that hugs the panel's top edge and top corners, then swings ~10 pt outside its left and right edges and ~22 pt below its bottom, with a ~23 pt corner radius against the panel's 12 pt. Those offsets are the panel's own `shadow-lg` (`0 12px 32px`): the union of the opaque panel and the outer contour where that shadow's alpha still survives 8-bit quantization (~10 pt of the 16 pt blur reach, offset 12 pt down) — which is exactly the alpha silhouette AppKit shapes a borderless transparent window from. The dialog is `transparent: true` + `decorations: false` and tao defaults `has_shadow: true`, so the window server drew its rim around the shadow halo instead of around the panel. Cosmetic only; nothing is mispositioned or unclickable. Windows is unaffected because it does not derive window shape from content alpha. | **fixed** — the macOS branch of `toggle_ai_dialog` now calls `builder.shadow(false)`, which tao maps straight onto `NSWindow::setHasShadow` at window creation (`tao-0.35.2` `platform_impl/macos/window.rs:328`), turning off the shadow-and-rim pass that draws the phantom outline. Scoped to macOS deliberately: on Windows that same flag is what gives an undecorated window its 1 px border and Windows 11 rounded corners, and Windows shows no artifact. The panel keeps its `shadow-lg`, so the depth cue is unchanged on both platforms. ARCHITECTURE §12's flag list updated to match. Not machine-walked — this repo has no macOS GUI-automation host and the Linux dev box cannot render the app; the macOS leg of CI's `Rust` job compiles the cfg-gated line, and `deploy.yml`'s macOS installer gives the reporter a build to confirm against. | -| I83 | Sev1 | `src/features/ai/modelStore.ts` + `src/routes/Home.tsx` + `src/features/session/SessionView.tsx` + `sampleLoop.ts` + `Report.tsx` | Issue #92: a real 10-minute two-person session on Windows rendered a report with `Focused-time —`, "No focus score was recorded for this session.", zero `ai_*` timeline rows — and, directly beside all that, "No distractions detected. Nice work." **Root cause: `useModelStore` is never hydrated outside Settings → AI.** `hydrate()` had exactly one caller, `ModelPickerContainer`'s mount effect (`ModelPickerContainer.tsx:85`), and that component mounts only inside the Settings → AI pane. `useSettingsStore` is hydrated at boot by `ThemeProvider` (`src/design/theme.tsx:52`), so `aiFeaturesEnabled` was correctly `true` while `activeModelId` sat at its `null` initial value — and `activeModelId` gates everything: `SessionView.tsx`'s sample-loop effect returns early on `if (!activeModelId)`, so `startSampleLoop` is never called and its `onStartFail('no_active_model')` toast — the one surface that names this — can never fire; `Home.tsx`'s `handleTopicSubmit` skips the V2-P9 gesture-context `preacquireScreenStream()` on the same condition, which on WebView2 is separately fatal. So any launch where the user didn't happen to open Settings → AI ran a whole session with AI silently dead: no loop, no toast, no audit row, no log line, and an unscored `sessions` row. Cross-platform and present at HEAD — it also explains #94 ("Ai does not work on macos when its enabled"). The report then made the silence permanent: `score`/`focused_pct`/`confident_samples`/`skipped_samples` all read NULL for an AI-off session, an AI-on-but-dead session, AND a pre-003 row, so no surface could tell a deliberate choice from a malfunction, and the distractions empty state asserted a clean measurement that never happened. Five further silent-death paths found alongside it: a sidecar that spawns but never reports healthy, an HTTP error from the sidecar, a per-tick abort, and any other tick throw were each `console.warn`-only (no devtools in release builds); the live 90 s per-tick timeout was 3.3× tighter than benchmark.ts's 300 s bound, so a model could benchmark successfully — the only thing that sets `activeModelId` — and then abort every live inference forever; an unanswered screen-share picker wedged `boot()` with no timeout, and `stop()` awaits `bootPromise`, so the sidecar was never killed; the Rejoin path and the camera/mic "Try again" path both re-`boot()` with no gesture pre-acquire; `mapDisplayMediaError` had no `InvalidStateError`/`InvalidAccessError` case, so a missing-transient-activation refusal was filed as `unavailable` (a dead-end toast) instead of reaching the recovery overlay whose retry button IS a gesture; `resolve_runtime_dir`'s `_ => Ok(None)` still degraded to a spawn with no CWD and no PATH prepend — the exact lethal-on-Windows state I75 fixed; and a child that dies in the Windows loader spawns Ok, so it crash-loops to the restart budget without ever reaching the VC++-redist hint. | **fixed** — (1) hydrate `useModelStore` in `Home.tsx`'s boot effect, so the persisted model is the truth from launch rather than from a Settings visit; (2) `handleTopicSubmit` + `handleRejoin` + `handleMediaRetry` all pre-acquire the screen stream inside their real user gesture, and a store still mid-hydration counts as "maybe active" (an unconsumed stream is discarded on unmount; a missed pre-acquire is fatal on WebView2); (3) a once-per-session toast when AI is on, the model store is `ready`, and no model is active — the gap where `onStartFail` could never fire; (4) `onStalled` fires once per loop lifetime after `STALL_TICKS` (3) consecutive unproductive ticks, with a distinct reason per cause (`engine_unavailable` / `engine_error` / `inference_timeout` / `unknown`) and actionable copy; paused states (break, camera off, pomodoro rest, battery) are deliberately not stalls; (5) the per-tick timeout is derived from the model's benchmarked p95 (`effectiveRequestTimeoutMs`: 3× p95, floored at 90 s, capped at benchmark.ts's 300 s); (6) `SCREEN_ACQUIRE_TIMEOUT_MS` (120 s) bounds the acquire so an unanswered picker becomes a visible retryable error instead of a permanent wedge, and a late-arriving stream is stopped rather than leaked; (7) `InvalidStateError` / `InvalidAccessError` → `screen_capture_denied`, routing to the overlay whose retry is itself the missing gesture; (8) migration **004** adds `sessions.ai_enabled` (1/0, NULL = pre-004), written from live settings at teardown, and the new `aiCoverage()` derivation gives the report four honest states — `ran` keeps the earned "Nice work", `noChecks` names the malfunction and points at Settings → AI, `off` says AI was off, `unknown` stays cause-neutral for pre-004 rows — shared by the rendered report and the text export so a pasted copy can never disagree; (9) Rust: `resolve_runtime_dir` falls back to the binary's own directory (one of the two places ggml globs anyway) instead of `None`, and the crash-loop give-up path now carries `append_windows_dll_hint`. Tests: `aiCoverage` (6 cases incl. the pre-003 scored row and the NULL-is-not-0 rule), serializer honesty (4), `snapshotFocusForReport.aiEnabled` (3), stall notice (4 incl. streak-reset and camera-off-is-not-a-stall), `effectiveRequestTimeoutMs` boundaries (4), and a Rust 003→004 upgrade test asserting old rows read NULL. Stories: `AiOnButNoChecks`, `AiOffForSession`. **Round 2** (a 65-agent adversarial sweep over the first draft found six more): (10) `hydrate()`'s `status: 'error'` was terminal — `ModelPickerContainer` only retried on `'loading'`, so one failed models.json read (AV lock, partial write) killed AI for the whole process and reopened this very issue through a narrower door; the gate is now `status !== 'ready'` and the session notice distinguishes it (`modelListUnreadable`). (11) the footer chip read **"AI off" while AI was ON** with no model — the single on-screen signal during #92, pointing at exactly the wrong setting; new `'unconfigured'` status via a pure, unit-tested `deriveAiChipStatus()`, with `'loading'` deliberately reading `'off'` so no launch flashes it. (12) `aiCoverage`'s first cut returned `'ran'` for `confident_samples: 0, skipped_samples: k`, defended on the grounds that the #47 D5 line caveats it — it does not below `SKIPPED_SAMPLES_MIN` (3), so k of 1–2 rendered a fabricated all-clear with no caveat at all; fifth state `'noConfident'` added and the two tests asserting the old behavior **edited**, not appended. (13) `append_windows_dll_hint` probed only `vcruntime140.dll`, staying silent on a box with the C runtime but not the C++ one; now requires both. (14) `next_attempts` resets the streak on any child clearing `MIN_HEALTHY_UPTIME` (120 s), so a sidecar dying every ~2.5 min crash-looped **forever** without ever setting `errored` — the stall notice fired once and the session then ran for an hour on a dying engine; `TOTAL_RESTART_BUDGET` (12 per generation) closes it, sized so an 8-hour session dying hourly never trips while a 121 s cycle trips at ~24 min. (15) Settings → Sessions now marks an unmeasured row `not measured` when `ai_enabled === 1`. One round-2 finding was **rejected**: "`onSidecarErrored` re-arms every tick, so a flapping sidecar re-toasts forever" — `errored` is cleared only by `sidecar_start`/`sidecar_stop` (`sidecar.rs:233`/`:270`) and the watcher `return`s after setting it, so errored→running requires deliberate user action and re-notifying then is correct, as the existing test documents. | +| ID | Sev | Location | Evidence | Status | +| --- | ---- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| I1 | Sev1 | `src/features/session/pomodoro.ts` | `stop()` sent no wire signal; receivers' 10 s silence timer resurrected the timer under a new broadcaster ~10 s after Stop. | **fixed** (R1) — explicit `stopped:true` message; receivers reset to idle. ARCHITECTURE §7 updated. | +| I2 | Sev2 | `src/features/friends/presence.ts` | Online state compared sender wall clock to receiver's; backward sender clock step wedged presence permanently. | **fixed** (R1) — stamp receiver-local time on receive. | +| I3 | Sev2 | `src/features/session/lifecycle.ts` + `sessionStore.ts` | Everyone-else-leaves auto-end lost `sessions.peer_pubkeys` + `markStudied` because `peerLeft` pruned `peers` first. | **fixed** (R1) — cumulative `seenPeerEdPubkeys` set. | +| I4 | Sev2 | `src/features/ai/benchmark.ts` | p95 included the cold-start warmup sample, inflating the sample floor 5–10× with no user recourse. | **fixed** (R1) — run + discard one warmup sample. | +| I5 | Sev2 | `src-tauri/src/commands/models.rs` | Resume fast-path hashed a multi-GB GGUF synchronously on the async runtime, stalling concurrent IPC. | **fixed** (R1) — moved to `spawn_blocking`. | +| I6 | Sev3 | `src/features/ai/sampleLoop.ts` | Battery-pause branch omitted the §8 "thermal-aware notice" and rescheduled at the sample interval, not 60 s. | **fixed** (R2) — `onBatteryPause`/`onBatteryResume` callbacks (fire once each) wired to SessionView toasts; paused branch now reschedules at `BATTERY_POLL_INTERVAL_MS`. Regression test added. | +| I7 | Sev4 | `src/features/session/invite.ts` | Auditor flagged idle-invite `hostSession()` as bypassing the topic gate. | **not a bug** — `Home.tsx` enforces `TopicGateModal` (sets `pendingInitialTopic`) before `inviteToCurrentSession`; no other caller. No change. | +| I8 | Sev3 | `src/features/session/SessionView.tsx` | Audit receive did not check `session_topic` (the ai-alert path does). | **fixed** (R2) — added `verified.session_topic !== sessionTopic` drop, mirroring `aiAlerts.ts`. | +| I9 | Sev3 | `src/features/session/pomodoro.ts` | Any peer sending a valid signed `pomodoro` msg is accepted as broadcaster, even mid-broadcast by another. | **deferred — conflicts with canonical doc.** ARCHITECTURE §14 explicitly: "Friend disables their own AI / fakes score — **Not defended. Social trust. Accepted.**" The "most recent sender becomes broadcaster" behavior is a deliberate, code-documented reconnection-robustness choice; hardening it would silently deviate from the accepted friends-only threat model and risk regressing the documented original-broadcaster-returns path. Surfaced per house rule; user can override to request the hardening explicitly. | +| I10 | Sev3 | `ARCHITECTURE.md §7` | `score_final` wire type has no producer/consumer. | **fixed (doc)** (R2) — §7 annotated: `score_final` is reserved/not-implemented in V2; the report is local-SQLite by V2-P8 design; type kept so a future phase avoids a breaking wire change. Not removed (removal would be a forward-compat break). | +| I11 | Sev3 | `src/features/ai/sampleLoop.ts` | Declared topic interpolated into the focus prompt without injection delimiters. | **fixed** (R2) — topic wrapped in `` + labelled as data; system-prompt rule added; `FOCUS_SYSTEM_PROMPT_VERSION` → 2; `tests/ai-eval/run.ts` kept byte-identical; ARCHITECTURE §8 prompt updated. | +| I12 | Sev3 | `src/features/ai/aiAgent.ts` | Total JSON-parse failure echoed ≤200 chars of raw model output into the dialog. | **fixed** (R2) — fixed safe string to the user; raw logged to console only. Test updated. | +| I13 | Sev3 | `src-tauri/capabilities/default.json` | `ai-dialog` window granted `notification`/`store`; §12 says permissions are main-window-scoped. | **fixed** (R2) — `default.json` restricted to `["main"]`; new `ai-dialog.json` capability scoped to the dialog window with `core:default` only (it uses only core event/window IPC). | +| I14 | Sev3 | `src-tauri/src/db/migrations.rs` + `001_initial.sql` | Bare `CREATE TABLE` + no single-instance ⇒ two simultaneous first-launches could panic the second. | **fixed** (R2) — `IMMEDIATE` transaction with the version read moved inside the tx (locks before reading); `IF NOT EXISTS` on 001's DDL; `INSERT OR IGNORE` on `schema_version`. Sequential-upgrade tests preserved. | +| I15 | Sev3 | `src/stores/identityStore.ts` / `identity.rs` | Identity commit (keychain then file) had no rollback; a failed file write + re-onboard overwrites the keychain entry. | **mitigated** (R2) — the file write is now atomic (see I16); residual is now only "rename succeeded but the keychain `set` itself fails", an OS-keychain fault recoverable via BIP39 (PLAN §7). Full two-store transactionality is out of scope for a Sev3. | +| I16 | Sev3 | `src-tauri/src/commands/identity.rs` | `fs::write` non-atomic; a crash mid-write truncates `identity.json`. | **fixed** (R2) — write to `*.json.tmp` then `fs::rename` over the target (atomic on same FS); temp cleaned on rename failure. | +| I17 | Sev3 | `src-tauri/src/db/sessions.rs` | `started_at`/`ended_at`/`total_minutes` overwritten while the comment claimed additive upserts. | **fixed (comment)** (R2) — comment rewritten to state these three are deliberately authoritative-overwrite (a re-summarize must be able to correct them; COALESCE would swallow it) while the report columns are additive. No behavior change, by design. | +| I18 | Sev4 | `pair.ts` / `lib/trystero/index.ts` / `sidecar.rs` | `verifyHello` didn't reject self-pubkey; stale `selfId` comment; `sidecar_start` trusts JS `model_path`. | **partially fixed** (R2). `verifyHello` now rejects a hello whose `ed_pubkey` equals the local identity (passed `ctx.edPubHex` from `runPair`). `trystero/index.ts` comment corrected to describe the actual module-global-`selfId` mechanism. The `sidecar_start` model-path sandbox is **deferred — conflicts with canonical doc**: PLAN §5 explicitly promises "Advanced users can point at any local GGUF", so constraining `model_path` to `data_dir/models` would break a documented feature. Surfaced per house rule. | +| I19 | Sev4 | `package.json` devDependencies | `npm audit` flags ~20 dev-chain advisories across critical/high/moderate (criticals: `concurrently@9` → `shell-quote`; highs/moderates span the `@storybook/*` and `esbuild`/`tsx` chains). Re-flagged on every scan. | **triaged — no runtime exposure** (2026-06-13). Every one is a **devDependency**; none reaches the installed desktop app — `npm audit --omit=dev` is clean (0) and no advisory package appears in `dependencies`. Bump `concurrently` and the `@storybook/*` chain when convenient; do **not** rush a major Storybook upgrade for a dev-only advisory. Recorded so the scan result isn't re-investigated each time. (Exact counts shift with the lockfile; the load-bearing fact is the clean prod audit.) | +| I20 | Sev1 | `src-tauri/src/db/mod.rs` | `is_definitely_corrupt` only treated a non-"ok" `integrity_check` verdict as corruption; a truncated file (SQLITE_CORRUPT) or damaged header (SQLITE_NOTADB) makes the pragma ERROR, so recovery never fired and the app bricked on every launch after a power-loss/force-kill. | **fixed** — classify by SQLite error code; also clean up `-journal`/`-wal`/`-shm` on rename. Corruption-signature tests added. | +| I21 | Sev2 | `src-tauri/capabilities/ai-dialog.json` | The scoped ai-dialog capability lacked `core:window:allow-close`, so the floating AI dialog's Esc/blur/X all silently failed (regression from the I13 scope-down). | **fixed** — grant `core:window:allow-close`. | +| I22 | Sev2 | `src/features/session/reportData.ts` + `stats/statsInsights.ts` | Report topic-timeline / top-distractions and cross-session insights walked every audit event; peers' broadcast `topic_set`/`ai_alert` (persisted locally) were misattributed to the local user. | **fixed** — thread the local ed_pubkey and filter to it (matches the self-only score gauge / trend). | +| I23 | Sev3 | `src/features/ai/sampleLoop.ts` | `onScreenTrackEnded` latched `captureDenied` and tore down ALL AI capture when ANY screen track ended; unplugging a secondary display in "All displays" mode killed focus detection with a misleading permission overlay. | **fixed** — discriminate: drop the dead display, latch only when the last live one ends. | +| I24 | Sev2 | `src-tauri/src/commands/models.rs` | No read/idle timeout on downloads; a mid-stream stall hung `bytes_stream().next()` forever, freezing the UI and permanently locking the `model_id`. | **fixed** — 60s `read_timeout`. | +| I25 | Sev2 | `src-tauri/src/commands/system.rs` | `system_relaunch_app`'s `app.restart()` skips `RunEvent::Exit` (the only sidecar kill), orphaning a running llama-server on Window-Style relaunch. | **fixed** — `kill_blocking` before restart. | +| I26 | Sev2 | `src-tauri/src/lib.rs` | A boot-time global-shortcut OS conflict propagated out of `setup()` into `build().expect()`, panicking before first paint. | **fixed** — register best-effort; a failed binding is inert until rebound in Settings. | +| I27 | Sev3 | `src/features/friends/invite.ts` + `inviteRetry.ts` | An invite retry queued after the 15s send timeout escaped `cancelAll` if the session ended during the window, later pulling the friend into a dead room. | **fixed** — injected `isSessionLive` guard: a retry never fires for a session that isn't the host's current live one. | +| I28 | Sev3 | `src/lib/fileExport.ts` | CSV export didn't neutralize spreadsheet formula injection; a peer-chosen display name beginning with `= + - @` executed on open (=HYPERLINK exfil / DDE). | **fixed** — quote-prefix string cells starting with a trigger; numeric cells untouched. | +| I29 | Sev2 | `src/features/friends/AddFriendDialog.tsx` | A programmatic close (contact-card deep link) during an in-flight legacy pairing never aborted it — Radix doesn't fire `onOpenChange` on a parent-driven close — leaking the trystero room + relay sockets. | **fixed** — tear down on any `open` transition. | +| I30 | Sev3 | `src/features/friends/inbox.ts` | No replay/dedup on the inbox receive path; a stranger on the pubkey-derived inbox topic could re-broadcast a captured envelope to re-fire the invite toast/notification. | **fixed** — dedup on `(from_ed_pubkey, box nonce)`, TTL-bounded. §14 row added. | +| I31 | Sev3 | `src/features/friends/AddFriendDialog.tsx` + `lib/relayDiagnostics.ts` | Pairing's "network trouble" hint read only the Nostr socket map, so it wrongly blamed the user's network in the exact MQTT-fallback case the v1.2.2 race was built to survive. | **fixed** — transport-aware `pairingRelaysUnreachable()` judging both socket maps; Nostr-only signal kept for the invite path. | +| I32 | Sev3 | `src/routes/Home.tsx` | `PairDeepLinkBoot` rendered only in the non-session tail, so a `studyvis://` link clicked mid-session reached zero listeners and was dropped. | **fixed** — render the full tail in the active-session branch. | +| I33 | Sev3 | `src/strings.ts` | In-app AI copy promised screen access is requested "when you start your first session", but enabling AI requests it immediately. | **fixed (copy)** — reword to match the shipped enable-time prompt. | +| I34 | Sev3 | `src-tauri/src/lib.rs` | With minimize-to-tray off, closing the main window while the AI dialog was open stranded the app: process alive, main window gone, tray "Open" a no-op. | **fixed** — destroy the AI dialog on that close path so the runtime exits. | +| I35 | Sev3 | `src-tauri/src/commands/sidecar.rs` | The crash-restart watcher could spawn a fresh llama-server after `kill_blocking` already ran at quit (during the backoff window), orphaning it. | **fixed** — `shutting_down` flag re-checked after backoff, before respawn. | +| I36 | Sev3 | `src/design/theme.tsx` | `ThemeProvider` wrote the pre-hydration fallback `dark` class, stripping the boot-cache `light` class and flashing dark for light/auto users. | **fixed** — defer to the boot script until an authoritative mode exists. | +| I37 | Sev4 | `src/features/friends/pair.ts` | The legacy pairing hello's (unsigned) `display_name` was stored/rendered raw, unlike the ContactCard path's cap + bidi/zero-width sanitize. | **fixed** — shared `normalizeUntrustedName` applied on both paths. | +| I38 | Sev3 | `src/features/ai/sidecar.ts` | `useSidecarStore.start()` unconditionally set `running` after its await, clobbering an interleaved `stop()` and leaving the store `running` on a killed process. | **fixed** — bail if a stop intervened. | +| I39 | Sev3 | `src/App.tsx` + `src/components/ErrorBoundary.tsx` | No React error boundary anywhere; any render throw blanked the whole window and killed the always-on inbox/presence + live session. | **fixed** — top-level `ErrorBoundary` around the routed content with a calm "Try again". | +| I40 | Sev4 | `src-tauri/src/commands/system.rs` | Changing one PTT shortcut when both shared a combo (hand-edited settings.json) unregistered the other. | **fixed** — only unregister the old combo when the other action isn't still using it. | +| I41 | Sev4 | `src/lib/encoding.ts` | `hexToBytes` used `parseInt` per byte, silently mis-decoding malformed hex ('1g'→0x01, '-a'→wraps) instead of rejecting. | **fixed** — validate the whole string against `/^[0-9a-fA-F]*$/` first. Adversarial-input tests added. | +| I42 | Sev3 | `src/features/session/SessionView.tsx` | The local session camera/mic stream had no `ended` listener, so a mid-session device loss (unplug / OS-revoke / another app grabbing the camera) left peers on a frozen tile and silently killed the AI face path. | **fixed** — attach an `ended` listener that surfaces the existing "Try again" recovery banner. | +| I43 | Sev3 | `.github/workflows/ci.yml` | CI compiled only aarch64-apple-darwin, so `#[cfg(target_os="windows")]` code first built inside `release.yml` AFTER the tag was pushed. | **fixed** — macOS + Windows Rust matrix on every push/PR. | +| I44 | Sev3 | `.github/workflows/release-prep.yml` | The one-click gate skipped `check-a11y` and all Rust compilation, so a release could be cut over an axe-core / clippy regression. | **fixed** — add the a11y gate; require the exact main SHA's CI run to be green before bump/tag/push. | +| I45 | Sev3 | `README.md` / `PLAN.md` / `ARCHITECTURE.md` / `CHANGELOG.md` | User-facing doc drift: first-run described the retired 12-word flow as primary; "one WebSocket" (really ~8 relays); "three tiers" (four models); "v1.2.0 is current" (v1.3.1); §6 "MQTT not yet wired" (raced since v1.2.2); changelog x86_64 DMG claim (aarch64-only). | **fixed** — brought each in line with the shipped code. | +| I46 | Sev3 | `src/features/friends/invite.ts` | `sendInviteEnvelope` treats any peer joining the recipient's inbox topic as delivery, so an eavesdropper on that shared pubkey-derived topic can appear as "delivered" or drop the invite. | **accepted — friends-only threat model.** Envelope is still NaCl-box-sealed to the recipient; worst case is a suppressed offline-retry (re-click Invite). Documented in §14. The flagged signed invite-ACK shipped in #47 C2 (new `invite-ack` action, v1.2.x-wire-compatible: no ACK within the window → honest "unconfirmed" copy) — for UX legibility, not as a defense; the eavesdropper acceptance above stands. | +| I47 | Sev3 | `src/features/friends/presence.ts` | Presence heartbeats/goodbyes are unauthenticated on a pubkey-derived topic, so a stranger with a friend's public pubkey can forge that friend's online/offline state. | **accepted — friends-only threat model.** Presence is soft UX state, not a data/session compromise. Signing would break cross-version presence (older peers send unsigned), so enforcement is deferred, not shipped. Documented in §14. | +| I48 | Sev3 | `src/features/friends/pair.ts` (upstream `@trystero-p2p/mqtt`) | Each pairing's MQTT room open→leave orphans ~4 broker connections: trystero-core sets `didInit=false` on last-room-leave but never `.end()`s the MQTT clients. | **deferred — upstream trystero bug.** Bounded (a handful of pairings per session, cleared on process exit) under the friends-only 4-peer model. Fix is upstream (or an app-side always-on MQTT room, which trades the leak for a persistent idle broker connection — not worth it). | +| I49 | Sev3 | `src/features/friends/InboxBoot.tsx` + `presence.ts` | The presence effect keys on the whole friend set, so adding/removing any friend tears down + rebuilds the own presence room, broadcasting a goodbye that flickers your presence offline→online on every other friend's screen (and can fire a spurious "came online" notification). | **fixed** (#47 C6, the recorded dedicated pass) — `startPresence` gained `updateFriends`: friend list edits diff rooms in place (join added / leave removed), the own room and heartbeat cadence never churn, and `leave()`'s tested goodbye semantics are untouched. InboxBoot keys the subscription on identity only and drives list edits through the diff; removed friends' notify baselines are pruned so a re-add starts fresh. Unit tests cover added/removed/no-op churn including a watcher asserting no goodbye flicker. | +| I50 | Sev4 | `src-tauri/tauri.conf.json` | Both webview windows ship with CSP disabled (defense-in-depth only — no reachable XSS sink today: React auto-escapes, no `innerHTML`/`eval`). | **deferred — needs a desktop CSP smoke-test.** A wrong CSP hard-breaks Tauri IPC/asset loading, which no static gate catches; landing a `script-src 'self'` policy safely requires running the built desktop app (not possible headless). Recommended policy: `default-src 'self'; script-src 'self'; object-src 'none'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self' ws: wss: http://127.0.0.1:*`. | +| I51 | Sev2 | `src/routes/Home.tsx` | The `tail` fragment (InboxBoot + deep-link + import dialog + topic gate) rendered at a different unkeyed child index per view branch, so React reconciled by index and re-mounted the always-on presence/inbox room on every view switch — re-triggering the I49 goodbye flicker, blanking the friends list for up to a heartbeat, and dropping an invite that arrived in the teardown window. | **fixed** — `` pins the tail fiber across branches of differing child arity. The load-bearing key is documented at the site; `pairDeepLink.ts`'s stale "view switches re-mount the boot" comment corrected (the `launchConsumed` guard kept). Not statically checkable and not node-testable without RTL, so protected by the site comment. | +| I52 | Sev2 | `src/features/session/lifecycle.ts` + `stores/sessionStore.ts` | `total_minutes` was pure wall-clock `endedAt − startedAt`, counting OS-sleep/suspend as study time; a session slept on persisted the whole span (a free streak day and inflated totals). | **fixed** — elapsed is `min(wallMs, monoMs)` off a `performance.now()` origin captured at start, mirrored in the live footer. Not retroactive (old rows stand); degrades to prior behavior on a platform whose monotonic clock happens to include suspend, never undercounts. Unit-tested via an injectable `monotonicNow` seam (awake / slept-through / backward wall clock / no-mono fallback / slept-through rejoin). | +| I53 | Sev3 | `src/features/session/lifecycle.ts` + `SessionView.tsx` | A peer's deliberate `left` (signed, on the wire since V1-P9) still armed the 20 s reconnect grace and offered a Rejoin into a dead room. | **fixed** — mark departed peers, and skip the grace/Rejoin only when the room empties with no unexplained absence remaining, via a new `SessionEndReason` (`'peer'`). Unexplained-absent peers are tracked in a Set (not a single flag, per the review) so an intervening join by another peer can't strand a still-absent blipper; the mark clears per-peer on rejoin so a later blip still gets grace. ARCHITECTURE §13 updated. Grace unit tests extended. | +| I54 | Sev3 | `src/features/friends/InboxBoot.tsx` + `friendOnlineNotify.ts` | The friend-online baseline suppressed every friend's _first_ online resolution after mount (not just boot's initial sweep), so a genuine later arrival never notified — the one event the feature exists for. | **fixed** — per-friend watch-start map with a settle bound. The bound is a dedicated `NOTIFY_SETTLE_MS` (3 min, sized above realistic presence-handshake latency), not the 60 s heartbeat window: reusing the latter let a slow-connecting already-online friend re-read as an arrival (review finding). Only the settle window is suppressed. Unit-tested. | +| I55 | Sev3 | `src/features/session/hello.ts` | The signed session-hello `display_name` was stored/rendered without the cap + bidi/zero-width sanitize every other untrusted-name path applies; on `main` it was unbounded. | **fixed** — `normalizeUntrustedName(name, HELLO_NAME_CAP)`. Cap is 192 UTF-8 bytes — the worst case for the 64-UTF-16-unit `maxLength` our own inputs enforce — so a legitimate multibyte name (CJK/emoji) survives intact rather than being byte-truncated (review finding), while a hand-modified sender is still bounded. Unit-tested incl. multibyte + bidi. | +| I56 | Sev3 | `src/features/ai/sampleLoop.ts` | `onCaptureError` fired per tick (contract says once/lifetime) and the face-track guard never checked `readyState`, so a dead webcam threw `track_ended` every tick and toast-stormed the session over the MediaErrorBanner already saying the same thing. | **fixed** — the ended-track guard skips the tick without counting a sample; a `captureErrorReported` latch mirrors `sidecarErrorReported`, reporting once and clearing on the next successful verdict. Unit-tested. | +| I57 | Sev3 | `src/design/tokens.ts` + `src/design/index.css` | The focus ring (`accent.ring`, 40 % alpha) measured ~2.6:1 dark / ~1.8:1 light against the surfaces it is drawn on — below WCAG 1.4.11 — because the UA outline is globally reset; the gate missed it by measuring the opaque accent. `shadow.glow` had also drifted 3px/4px. | **fixed** — raised alpha (60 % dark / 80 % light), mirrored in both hand-kept files; `check-contrast` now measures the ring in the bg-stack at its real per-theme alpha; `shadow.glow` reconciled to the tokens.ts value (3px). The ring's inner edge on `bg-accent-default` buttons intentionally stays below 3:1 — the outer edge against the canvas carries identification. | +| I58 | Sev3 | `src/components/ui/dropdown-menu.tsx` | Menu items declared `focus:bg-bg-raised` on a `bg-bg-raised` surface — a 1.00:1 no-op — so keyboard/mouse navigation showed no highlight (worst in the in-session audio pickers, where two identically-named devices are indistinguishable). | **fixed** — an inset accent ring highlight (keeps `focus:` so Radix pointer-move still lights it). The byte-identical Button/Badge `secondary` hover was fixed the same way (`hover:bg-bg-surface`). | +| I59 | Sev3 | `src/components/AuditLogPanel.tsx` + `SessionNotesPanel.tsx` | The session-log and notes scroll containers had no focusable descendant and no `tabIndex`, so a keyboard-only user couldn't scroll them (WCAG 2.1.1). macOS/WKWebView only; Windows WebView2 auto-focuses scrollers. | **fixed** — `tabIndex={0}` + a focus-visible inset ring on both. Overflowing Storybook stories added so the axe `scrollable-region-focusable` gate has something to assert on. | +| I60 | Sev3 | `src/strings.ts` (`searchKeywords`) + `Settings.tsx` | v1.6.0 settings search routed "tray"/"minimize"/"capture displays"/"auto-update" to Advanced (which owns none of them) and left Advanced's own settings ("launch at login", "clear history", "onboarding") unfindable. | **fixed** — keywords moved to the panes that own each setting; Advanced keywords added; a `Record` guard in `Settings.tsx` pins the bucket↔pane mapping without a strings→features import cycle. | +| I61 | Sev3 | `src/stores/settingsStore.ts` + `ShortcutsCategory.tsx` | `resetShortcutsToDefaults` rethrew on the first setter's combo collision and never ran the second; the rejection was swallowed to `console.error`, so the button was a silent no-op. | **fixed** — reorder + per-call try/catch so both setters run; a residual collision surfaces a `toast.error` (copy in strings.ts). The Rust `is_registered` skip the original proposal suggested was dropped — it would re-open #47 B5. Stateful fake added to the keybindings test. | +| I62 | Sev3 | `src/features/updater/updaterStore.ts` + `AboutCategory.tsx` | Settings → About offered a live Restart-now / Check-now during a session (unguarded, unlike the update banner), and its help text asserted "you're on X, the latest" from the initial `idle` state and after a silent background-check failure. | **fixed** — session-active guards in `installAndRestart`/`checkNow` (the `userInitiated` exemption, made false by the in-session settings overlay, removed); About disables the buttons in-session and derives its help from an explicit `upToDate` branch rather than a fallthrough. Store tests flipped to assert deferral. | +| I63 | Sev3 | `src/features/identity/recoverLogic.ts` | A failed 24-word restore pointed at all 24 words equally, with no way to narrow a single typo on the highest-stakes screen in the app. | **fixed** — name the words that aren't in the wordlist (`unknownWords` on `MnemonicClass`, populated only on the 24-word path); copy in strings.ts. Kept in `recoverLogic.ts`, not the cross-version crypto module. Unit-tested. | +| I64 | Sev3 | `src/features/stats/FocusInsights.tsx` | The focus-over-time trend tooltip had no date, so a dip couldn't be anchored to a day. | **fixed** — carry each point's `startedAt`; the tooltip renders the `dayKey` day, byte-identical to the bar chart's day format. | +| I65 | Sev4 | `src/features/stats/statsData.ts` | The stats CSV omitted the two headline tiles (total sessions, streak, average) — the numbers the pane is built around. | **fixed (summary)** — prepend summary rows, preserving the null-average ("AI off" vs "scored 0") distinction. Per-session detail left out of scope. Test extended. | +| I66 | Sev3 | `src-tauri/src/commands/sidecar.rs` | `sidecar_start` spawned llama-server then opened the log file; an `open_log_file` failure after a successful spawn dropped the `CommandChild` without `kill()`, orphaning a multi-GB process past app exit (same class as I25/I35). | **fixed** — open the log before spawning, so no fallible `?` sits between the spawn and `guard.child`. Reviewed by reading (CI is the first Rust compiler on this dev box). | +| I67 | Sev3 | `src-tauri/src/commands/sidecar.rs` | The respawn budget was a 30 s sliding window, so any crash spaced >30 s reset the counter and the watcher respawned llama-server forever without ever setting `errored` — no recovery affordance surfaced and the D7 log cap was defeated. | **fixed** — the budget now counts consecutive respawns that each died before `MIN_HEALTHY_UPTIME` (120 s); a durable child resets the streak (`next_attempts` pure fn, unit-tested). Once the budget is exceeded `errored` is set as before. | +| I68 | Sev4 | `src-tauri/src/db/audit_events.rs` | The cross-session insights read shipped the entire `audit_events` table over IPC though only `ai_warning`/`ai_alert` rows are consumed. | **fixed** — `WHERE kind IN ('ai_warning','ai_alert')` narrows the query (~4× less JSON at 10k rows); `list_all` → `list_ai_distractions_all`, but the Tauri command name is unchanged so the IPC/TS contract is untouched. The SQL twin of TS `isDistraction` is commented at the query. | +| I69 | Sev3 | `src-tauri/src/lib.rs` | The corrupt-DB recovery dialog asserted re-pairing was required and never mentioned the friends-backup import — wrong at the exact moment a friend loses their list. | **fixed (copy)** — the dialog now names Settings → Identity → Import friends as the restore path if a backup exists, otherwise re-pair. | +| I70 | Sev4 | `.github/workflows/release.yml` | A half-built draft (one platform's artifact missing from `latest.json`) could be published, stranding every friend on the missing platform with no update path and a false "you're on the latest". | **fixed** — a job asserts both platforms are present in the draft's `latest.json` and, on failure, stamps the draft title "INCOMPLETE, DO NOT PUBLISH" (needs `contents: write` to read a draft). Not runnable on this box; validated by YAML parse + reading. | +| I71 | Sev2 | `src/features/updater/updaterStore.ts` + `src-tauri/src/commands/system.rs` | Issue #77: an app opened straight from the mounted `.dmg` runs under macOS App Translocation (read-only bundle), where `update.install()`'s rename-into-place can never succeed — every launch re-downloaded the installer, offered "Restart now", and failed with the generic install toast. The one documented install step (drag to Applications) is exactly the one this path skipped, and the updater had no idea. | **fixed** — new `system_install_context` command (translocation via exe-path component, read-only volume via `statfs`; fail-open) consulted after a check finds an update: an unswappable bundle sets a new process-permanent `blocked` status _before_ any bytes move, and the banner + Settings → About replace the doomed Restart with move-to-Applications guidance. Verified live: dev binary on a read-only DMG against the real v1.7.0 release showed the blocked row. Windows/NSIS unaffected (always updatable). | +| I72 | Sev1 | `src-tauri/src/commands/models.rs` | Every model download failed at the picker's preflight with "…The model manifest may be stale." for every catalog entry. `model_head_check` populated `content_length` from `reqwest::Response::content_length()`, which is the body's size hint — an HTTP/1.1 HEAD response body is always empty (hyper decodes it as zero-length regardless of headers), so every probe reported 0 bytes and the size gate rejected all six entries. The manifest itself is current: the raw `Content-Length` (and `x-linked-etag` = pinned sha256) at every pinned revision still matches. | **fixed** — read the `Content-Length` response header instead; in-module regression test against a local HEAD server; live-verified that all 10 catalog files (6 model + 4 mmproj — the three Gemma quants share one projector) report header sizes byte-identical to the manifest. Git history dates the break to the picker's birth: the size gate, the `content_length()` call, and the no-http2 reqwest dep all landed in one commit (af2987d, V2-P2) and never changed, and the zero-length HEAD decode is server-independent — so no catalog download has ever passed this preflight, and the downstream GET/verify/resume path has never run end-to-end in a shipped build (first real install is its true test). First user report 2026-07-26. | +| I73 | Sev1 | `src-tauri/src/commands/sidecar.rs` + `src-tauri/src/commands/engine.rs` | In-app llama-server spawn has never worked in any build. `shell().sidecar("binaries/llama-server")` resolves `/binaries/llama-server` (tauri-plugin-shell 2.3.5 joins the full configured string against the exe dir), but tauri-build (dev) and the bundler (release) both strip the directory prefix and the triple, placing the file at `/llama-server` — verified in `target/debug/` and in the installed `StudyVis.app/Contents/MacOS/`. Every `sidecar_start` failed with `spawn llama-server: No such file or directory`, surfaced as "AI failed to start:" / "AI model crashed". The plugin has been pinned at 2.3.5 since V1-P1, so this is a day-one bug, not a regression; it sat behind I72 (downloads never completed), which is why the first user report of both landed the same day (2026-07-26 — the on-disk `llama-server.log` from that attempt is a 0-byte file: the child never ran). | **fixed** — sidecar binaries now resolve to absolute paths and spawn via `shell().command()`: bundled probe at `/llama-server(.exe)` (size-gated), then a managed install under `data_dir/engine/-/`. When neither resolves, `sidecar_start` auto-installs the pinned llama.cpp b9095 release asset (SHA-256-verified; pins lockstep-tested against `scripts/fetch-llama-server.sh`; tar.gz/zip unpacked flattened + filtered), gated by the new `engine_auto_install` setting (default ON) with `engine_info`/`engine_install` commands and a Settings → AI "AI engine" row (status/progress/Reinstall). `build.rs` writes a debug-profile-only placeholder so fresh checkouts compile without the fetch script; release-profile builds still hard-fail. Windows spawn failures name the VC++ redistributable when `vcruntime140.dll` is absent. Verified live on macOS: the installed bundle's binary spawns via the exact fixed resolution (`--version`, Metal init, exit 0), the placeholder build compiles and launches, and the pinned archives download, hash-match, extract, and run on this machine. The in-app GUI walk (Settings row + session start) is user-walked — the dev binary's keychain prompt blocks machine-driving it. | +| I74 | Sev2 | `src/features/friends/presence.ts` + `presenceRelay.ts` + `src/lib/nostr/` | A mutually added friend showed permanently offline on BOTH ends whenever a STUN-only WebRTC datachannel could not form between the two networks (symmetric NAT / CGNAT / strict firewall — no TURN ships, ARCHITECTURE §4). Heartbeats only rode datachannels; trystero fires no callback on a failed ICE attempt (it silently re-offers forever), and offline ContactCard pairing (§5.1) removed the last step that ever proved the P2P path worked — so the failure was invisible end to end, with every relay reachable and both apps running. Presence, invites, and sessions all share the broken leg; presence was just the visible symptom. | **fixed** — relay-carried presence: sealed ephemeral Nostr events (kind 20001, new `studyvis:presence-relay:v1` tag/key derivations pinned in topics.test.ts) published every 30 s to the pinned relays over an owned reconnecting socket pool; no `since` filter and `limit: 0` (the #47 C1 clock-skew lesson). The datachannel leg stays and now stamps `lastP2pAt`, so `presenceState()` distinguishes direct-online from relay-only "limited" (120 s settle, I54 lesson) — surfaced in the friends list as an amber "Available · limited connection" row plus a one-line hint deep-linking Settings → Network (TURN). Goodbyes keep `lastSeenAt` for "seen … ago". Sessions/invites behind the same NAT still need TURN — the UI now says so instead of lying "Offline". Old builds interop unchanged (they never see this leg). ARCHITECTURE §4/§7/§11/§14 + PLAN §2 updated; `offchain.pub` dropped from the relay pin (now rejects anonymous publishes). | +| I75 | Sev1 | `src-tauri/src/commands/sidecar.rs` | After 1.8.0 shipped I73's spawn-path fix, on-device AI still failed to start on a real Windows install: `llama-server.exe` spawned, printed its banner (`Running without SSL`, `loading model`), then exited with `no backends are loaded` / `failed to load model` / `giving up after 4 restart attempts` (friend's `llama-server.log`, 2026-07-26 — the same day 1.8.0 shipped, the very next link in the same chain). Root cause: the pinned llama.cpp b9095 release assets are `GGML_BACKEND_DL` builds — 15 `ggml-cpu-*.dll` variants on Windows (haswell/zen4/sse42/…), `libggml-cpu.dylib`/`libggml-metal.dylib`/`libggml-blas.dylib` on macOS — that ggml `dlopen()`s at startup rather than linking. `ggml_backend_load_best` (`ggml/src/ggml-backend-reg.cpp`) globs exactly two places for those: the executable's own directory and the process's current working directory — never `PATH`/`DYLD_FALLBACK_LIBRARY_PATH`/`LD_LIBRARY_PATH`. I73's env-var prepend only satisfies the binary's _linked_ imports (`llama.dll`/`ggml-base.dll`/…), which is why the process starts at all; it never reaches the dlopen glob, so `ggml_backend_reg_count()` stays 0, `common_init_from_params` fails, and the crash-restart watcher gives up after `RESTART_BUDGET` (4) identical failures — on every bundled Windows and macOS install, not an edge case. Verified against the pinned llama.cpp b9095 source (`ggml-backend-reg.cpp:479-489`) and the actual release archives (`llama-b9095-bin-win-cpu-x64.zip`, `llama-b9095-bin-macos-arm64.tar.gz`). | **fixed** — `spawn_llama` now also sets the child's working directory to the same runtime dir already resolved for the `PATH`/`DYLD_FALLBACK_LIBRARY_PATH`/`LD_LIBRARY_PATH` prepend (`Command::current_dir`, tauri-plugin-shell 2.3.5), since `fs::current_path()` is in ggml's search list. One code path covers both engine sources (bundled, and the managed install where `runtime_dir` already equals the exe's own directory) and all three platforms. Not runnable on this box — no cargo/node toolchain and `src-tauri/binaries/` has no fetched engine on this Linux dev host; gated by CI and the `Release prep` workflow's gate job instead. | +| I76 | Sev1 | `src/features/ai/sampleLoop.ts` + `captureScreen.ts` + `src/routes/Home.tsx` + `AiCategory.tsx` + `SessionView.tsx` | User report: "AI capture error: getDisplayMedia must be called from a user gesture handler" firing on ordinary session starts with AI already enabled, and — because the fallout from this same failure kept killing the just-started sidecar — a separate, misleading "AI isn't running yet. Turn it on in Settings → AI" from the Ctrl+] chat dialog even though AI genuinely was on. Root cause: `sampleLoop.ts`'s `boot()` acquires the session's long-lived screen `MediaStream` via `navigator.mediaDevices.getDisplayMedia()`, but `boot()` runs from a React `useEffect` fired by state changes (session active + AI on + model chosen + camera up), never from inside a click handler. WebView2 (Windows) and WKWebView (macOS) require `getDisplayMedia()` to run inside live transient user activation on _every_ call, not just the first — the same reason the OS picker itself fires on every acquire (documented in `src/features/ai/README.md`'s "Acquire strategy", which is why V2-P9 already moved to one long-lived stream instead of a per-tick acquire) — so with no gesture in `boot()`'s call stack the call was rejected outright. Because the rejection's `DOMException` name fell outside `mapDisplayMediaError`'s handled set, it surfaced as the generic `screen_capture_unavailable` code and a raw toast instead of the intended `screen_capture_denied` recovery overlay, and `boot()`'s existing failure path tore down the sidecar it had just started. A second, compounding gap: `onCaptureError` never updated `AiStatusChip`'s runtime status, so the chip kept reading "active" after AI had silently died underneath it — matching the reporter's "I can't tell if it's on or if it's errored." | **fixed** — a gesture-context handoff: callers that DO have a real user gesture (`TopicGateModal`'s submit when starting a session with AI already enabled; `AiCategory`'s "enable AI" toggle when a session is already active; `SessionView`'s permission-overlay retry) call the new `preacquireScreenStream()` synchronously (no `await` before it), which starts `getDisplayMedia()` inside that click and stashes the in-flight promise; `sampleLoop.ts`'s default `acquireScreenStream` runtime hook consumes that stash instead of calling `getDisplayMedia()` itself outside gesture context. An unconsumed stash (a rapid re-toggle, or a session that never reaches `boot()`) is released via `discardPendingScreenStream()`, including on `SessionView` unmount, so it never leaks a live stream or leaves the OS recording indicator lit. Separately, `onCaptureError` now carries a `fatal` flag — true for a `boot()`-time acquire failure (the loop really did tear itself and the sidecar down) vs. false for a `tick()`-time transient one (the loop keeps running) — so `SessionView` only flips the status chip to "error" on the former. Unit-tested (pending-stream stash/discard, default-runtime consumption of the stash, the `fatal` flag on both call sites); `npm run build`/`lint`/`test` all green (878 tests). | +| I77 | Sev1 | `src/features/session/lifecycle.ts` + `SessionView.tsx` + `tests/integration/session.test.ts` | User report: "on my device I can't see the other person's camera but they can see mine" — a guest joining a friend's session never received the host's camera **or** mic, in either direction of the pair, while the host saw the guest fine. Root cause: `SessionView`'s media-acquire effect published the local `MediaStream` with a single untargeted `room.addStream(stream)`, and trystero 0.24 delivers a stream only to the peers that are active **at that instant** — `addStream` → `applyMediaOp` → `iterate` enumerates `keys(activePeerMap)` right then (`@trystero-p2p/core` `room.mjs:83`, `:494`) and queues nothing; peer activation (`room.mjs:306-314`) sets `activePeerMap` and fires `onPeerJoin` but replays no previously added local stream. The host is structurally guaranteed to lose that race: `hostSession()` derives a session topic from 32 fresh random bytes and `begin()`s the room **before** the invite is even sent, so the host's camera opens while it is provably alone and its one broadcast reaches nobody, forever. The guest normally wins it, because the session peer activates over trystero's already-open shared connection to that same friend in roughly one RTT — faster than a cold camera opens — so the guest's `addStream` lands and the host sees the guest. Two stale comments asserted the opposite of the library's actual behavior and are what preserved the bug: `SessionView.tsx` claimed `addStream` "forwards new tracks to all current peers **and to peers who join later**", and the stream-binding effect claimed "trystero replays existing peers when we register the stream callback" (`onPeerStream` is a bare assignment at `room.mjs:511`; only `onPeerJoin` sweeps, at `:506-509`, a replay our own `wrapRoom` consumes at construction). CI could not catch it: the integration bus mock hard-coded both false beliefs — its `addStream` ignored `targetPeers` and fanned out to every room, and its join + `onPeerStream` paths both replayed existing streams. Day-one defect; `trystero` has been pinned `^0.24.0` since the media path was introduced, so host→guest video has never worked in any shipped build. | **fixed** — publishing moved into `publishLocalStream(room, stream)` in `lifecycle.ts`, which broadcasts to the currently-active peers and, in the immediately adjacent statement, subscribes `onPeerJoin` to re-send the same stream targeted at each later joiner (the pattern trystero's own README prescribes). The two calls live in one function so the "no `await` in the seam" invariant is structural: the broadcast covers who is active now, the subscriber covers who arrives later, and JS's single thread means no peer is missed or served twice — a double-add would desync trystero's FIFO pairing of stream metadata to incoming tracks. `SessionView`'s effect cleanup unsubscribes **before** `stopTracks`, so a "Try again" re-acquire can't hand a later joiner a dead stream. Both false comments replaced with the verified semantics + `room.mjs` line refs. The integration bus mock now models `activePeerMap` honestly (targeted sends honored, no join replay, no `onPeerStream` replay), and `tests/unit/session-publish-stream.test.ts` pins the contract — 2 of its 4 cases fail against the pre-fix code. **Both friends must update:** a patched host reaches an unpatched guest, but a patched guest still receives nothing from an unpatched host. | +| I78 | Sev2 | `src-tauri/Cargo.toml` (`tauri 2.11.0`) | GHSA-7gmj-67g7-phm9 — "Tauri has an Origin Confusion Issue that Allows Remote Pages to Invoke Local-Only IPC Commands" (CVSS 8.8), affecting `tauri >= 2.0.0, <= 2.11.0`; fixed upstream in 2.11.1. StudyVis exposes a wide IPC surface (SQLite, keychain-backed identity, sidecar spawn, filesystem paths), so origin confusion is the class that matters most here rather than a theoretical one. Not found by `cargo deny`: the advisory is GitHub-Advisory-Database-only and RustSec does not carry it — it surfaced when OSV-Scanner was run over `Cargo.lock` while building the #102 supply-chain gates. | **fixed** — `cargo update -p tauri --precise 2.11.1` (lockfile-only; `Cargo.toml` already requires `"2"`, so no manifest change). Pulled tauri-build/codegen/macros/runtime/runtime-wry/utils forward with it. Verified: OSV over `Cargo.lock` no longer reports the advisory, and `cargo deny check advisories licenses bans sources` stays green. Shipped as its own PR rather than bundled into the #102 CI branch: a Tauri bump is a Rust change that this box cannot compile, so it wants its own PR and its own full CI run. The new `.github/dependabot.yml` opens the 2.11.0 → 2.11.1 bump automatically (cargo ecosystem; `tauri*` is excluded from the routine grouping precisely so it lands as its own reviewable PR), and `maintenance.yml`'s weekly OSV scan keeps reporting it until the bump lands. Nothing in the pinned-ignore list of `src-tauri/deny.toml` suppresses it. | +| I79 | Sev1 | `src-tauri/src/macos_display_capture.rs` (new) + `src-tauri/src/lib.rs` + `src/features/ai/sampleLoop.ts` | User report (issue #94): "AI does not work on macOS when it's enabled, model does not load into machine." AI is dead on macOS end-to-end, and the diagnostic log makes it look like the engine is at fault: `llama-server.log` shows a clean model load and four successful `/v1/chat/completions` (that run is the V2-P2 **benchmark**, which never touches screen capture) followed by bare `[event] terminated code=None` lines with no stderr at all — a child killed within milliseconds of spawn, before it could print its banner. Root cause is upstream and not the sidecar: since macOS 13, WebKit resolves `getDisplayMedia()` either by its own default action (when the app implements **no** capture delegate) or by the private `_webView:requestDisplayCapturePermissionForOrigin:initiatedByFrame:withSystemAudio:decisionHandler:` delegate — and it **denies the request outright** when the app implements the public `webView:requestMediaCapturePermissionForOrigin:…:type:decisionHandler:` (which wry does, to grant camera/mic) but not the private one (which wry does not: tauri-apps/wry#1195 open, #1196 unmerged, an earlier attempt #1111 reverted by #1186). So every `getDisplayMedia()` in a Tauri app is rejected with `NotAllowedError` on macOS regardless of user gesture or the app's Screen Recording grant. `mapDisplayMediaError` reads that as `screen_capture_denied` and `SessionView` mounts `ScreenCapturePermissionOverlay`, sending the user to System Settings for a grant that cannot help. **I76 is superseded, not wrong** — the gesture handoff it added is a real fix and remains correct on Windows; it shipped in v1.8.1, which is the build that failed here, which is what rules it out as the cause. Compounding it, `sampleLoop.ts`'s `boot()` spawned llama-server BEFORE acquiring the screen stream, so each attempt loaded a multi-GB model and killed it on the failed acquire — the literal "model does not load into machine" the reporter saw. | **fixed** — `macos_display_capture::install()` adds the missing private method to wry's already-registered UI-delegate class at setup (`class_addMethod`, macOS only), answering `WKDisplayCapturePermissionDecisionScreenPrompt` so WebKit shows the OS picker — the behaviour the capture path was always written against. wry keeps owning every selector it already implements; the class just gains one more, process-wide, so the V2-P7 AI dialog window is covered too. Fail-safe: a renamed selector, an unreachable delegate or a failed `class_addMethod` logs and leaves the pre-fix behaviour rather than breaking anything. The method's type encoding is built from objc2's own `Encode` impls (BOOL is `B` on arm64, `c` on x86_64) with a test pinning the shipped arm64 signature, and a second test pins the selector spelling — the earlier upstream attempt shipped `ForSecurityOrigin` and silently did nothing. Separately `boot()` now acquires every screen stream BEFORE starting the sidecar, so a denied or cancelled capture costs nothing and a failed spawn releases the streams; two unit tests cover both directions. Not machine-walked: this repo has no macOS GUI-automation host. | +| I80 | Sev4 | `src/lib/nostr/pool.ts` + `tests/unit/nostr-pool.test.ts` | CodeQL `js/log-injection` (alerts 51/52, medium) on the two `console.warn` calls that surface relay complaints — the OK-false reason (`frame[3]`) and the NOTICE body (`frame[1]`). Both strings are authored by the relay, and I74 added them deliberately so the relay-presence leg could not fail silently. The console is not a throwaway here: README points a friend at Settings → Advanced → Open data folder when something goes wrong, so a newline in relay text forges log lines that read as ours, and a bidi override (`‮`) reorders what a human sees without changing the bytes. No security decision is made on log content, which is what keeps this Sev4 rather than higher; a hostile pinned relay is also already outside the friends-only threat model. Found by the CI gate rather than by a report. | **fixed** — a module-local `forLog()` replaces the `Cc`/`Cf` Unicode classes with spaces and clamps to 200 characters with an ellipsis, applied to both sinks. Deliberately not silent swallowing: the text still reaches the log, which is the whole point of I74's diagnostic. Two tests pin it — one asserts the message survives while no control character does, one asserts the clamp — and both were confirmed to FAIL with the fix reverted. `slot.url` in the third `console.warn` is ours (the pinned relay list), not relay-authored, so it is untouched and CodeQL does not flag it. | +| I81 | Sev3 | `src-tauri/src/commands/ai_dialog.rs` | User report (issue #97): on macOS the `Ctrl+]` AI panel renders a phantom rounded-rect outline floating around it. Measured off the reported screenshot (2x capture; panel 428x102 pt): the outline is a 1 pt black hairline over a 1 pt grey one — AppKit's two-tone window rim — tracing a rect that hugs the panel's top edge and top corners, then swings ~10 pt outside its left and right edges and ~22 pt below its bottom, with a ~23 pt corner radius against the panel's 12 pt. Those offsets are the panel's own `shadow-lg` (`0 12px 32px`): the union of the opaque panel and the outer contour where that shadow's alpha still survives 8-bit quantization (~10 pt of the 16 pt blur reach, offset 12 pt down) — which is exactly the alpha silhouette AppKit shapes a borderless transparent window from. The dialog is `transparent: true` + `decorations: false` and tao defaults `has_shadow: true`, so the window server drew its rim around the shadow halo instead of around the panel. Cosmetic only; nothing is mispositioned or unclickable. Windows is unaffected because it does not derive window shape from content alpha. | **fixed** — the macOS branch of `toggle_ai_dialog` now calls `builder.shadow(false)`, which tao maps straight onto `NSWindow::setHasShadow` at window creation (`tao-0.35.2` `platform_impl/macos/window.rs:328`), turning off the shadow-and-rim pass that draws the phantom outline. Scoped to macOS deliberately: on Windows that same flag is what gives an undecorated window its 1 px border and Windows 11 rounded corners, and Windows shows no artifact. The panel keeps its `shadow-lg`, so the depth cue is unchanged on both platforms. ARCHITECTURE §12's flag list updated to match. Not machine-walked — this repo has no macOS GUI-automation host and the Linux dev box cannot render the app; the macOS leg of CI's `Rust` job compiles the cfg-gated line, and `deploy.yml`'s macOS installer gives the reporter a build to confirm against. | +| I83 | Sev1 | `src/features/ai/modelStore.ts` + `src/routes/Home.tsx` + `src/features/session/SessionView.tsx` + `sampleLoop.ts` + `Report.tsx` | Issue #92: a real 10-minute two-person session on Windows rendered a report with `Focused-time —`, "No focus score was recorded for this session.", zero `ai_*` timeline rows — and, directly beside all that, "No distractions detected. Nice work." **Root cause: `useModelStore` is never hydrated outside Settings → AI.** `hydrate()` had exactly one caller, `ModelPickerContainer`'s mount effect (`ModelPickerContainer.tsx:85`), and that component mounts only inside the Settings → AI pane. `useSettingsStore` is hydrated at boot by `ThemeProvider` (`src/design/theme.tsx:52`), so `aiFeaturesEnabled` was correctly `true` while `activeModelId` sat at its `null` initial value — and `activeModelId` gates everything: `SessionView.tsx`'s sample-loop effect returns early on `if (!activeModelId)`, so `startSampleLoop` is never called and its `onStartFail('no_active_model')` toast — the one surface that names this — can never fire; `Home.tsx`'s `handleTopicSubmit` skips the V2-P9 gesture-context `preacquireScreenStream()` on the same condition, which on WebView2 is separately fatal. So any launch where the user didn't happen to open Settings → AI ran a whole session with AI silently dead: no loop, no toast, no audit row, no log line, and an unscored `sessions` row. Cross-platform and present at HEAD — it also explains #94 ("Ai does not work on macos when its enabled"). The report then made the silence permanent: `score`/`focused_pct`/`confident_samples`/`skipped_samples` all read NULL for an AI-off session, an AI-on-but-dead session, AND a pre-003 row, so no surface could tell a deliberate choice from a malfunction, and the distractions empty state asserted a clean measurement that never happened. Five further silent-death paths found alongside it: a sidecar that spawns but never reports healthy, an HTTP error from the sidecar, a per-tick abort, and any other tick throw were each `console.warn`-only (no devtools in release builds); the live 90 s per-tick timeout was 3.3× tighter than benchmark.ts's 300 s bound, so a model could benchmark successfully — the only thing that sets `activeModelId` — and then abort every live inference forever; an unanswered screen-share picker wedged `boot()` with no timeout, and `stop()` awaits `bootPromise`, so the sidecar was never killed; the Rejoin path and the camera/mic "Try again" path both re-`boot()` with no gesture pre-acquire; `mapDisplayMediaError` had no `InvalidStateError`/`InvalidAccessError` case, so a missing-transient-activation refusal was filed as `unavailable` (a dead-end toast) instead of reaching the recovery overlay whose retry button IS a gesture; `resolve_runtime_dir`'s `_ => Ok(None)` still degraded to a spawn with no CWD and no PATH prepend — the exact lethal-on-Windows state I75 fixed; and a child that dies in the Windows loader spawns Ok, so it crash-loops to the restart budget without ever reaching the VC++-redist hint. | **fixed** — (1) hydrate `useModelStore` in `Home.tsx`'s boot effect, so the persisted model is the truth from launch rather than from a Settings visit; (2) `handleTopicSubmit` + `handleRejoin` + `handleMediaRetry` all pre-acquire the screen stream inside their real user gesture, and a store still mid-hydration counts as "maybe active" (an unconsumed stream is discarded on unmount; a missed pre-acquire is fatal on WebView2); (3) a once-per-session toast when AI is on, the model store is `ready`, and no model is active — the gap where `onStartFail` could never fire; (4) `onStalled` fires once per loop lifetime after `STALL_TICKS` (3) consecutive unproductive ticks, with a distinct reason per cause (`engine_unavailable` / `engine_error` / `inference_timeout` / `unknown`) and actionable copy; paused states (break, camera off, pomodoro rest, battery) are deliberately not stalls; (5) the per-tick timeout is derived from the model's benchmarked p95 (`effectiveRequestTimeoutMs`: 3× p95, floored at 90 s, capped at benchmark.ts's 300 s); (6) `SCREEN_ACQUIRE_TIMEOUT_MS` (120 s) bounds the acquire so an unanswered picker becomes a visible retryable error instead of a permanent wedge, and a late-arriving stream is stopped rather than leaked; (7) `InvalidStateError` / `InvalidAccessError` → `screen_capture_denied`, routing to the overlay whose retry is itself the missing gesture; (8) migration **004** adds `sessions.ai_enabled` (1/0, NULL = pre-004), written from live settings at teardown, and the new `aiCoverage()` derivation gives the report five honest states — `ran` keeps the earned "Nice work", `noConfident` covers checks that ran but couldn't be read, `noChecks` names the malfunction and points at Settings → AI, `off` says AI was off, `unknown` stays cause-neutral for pre-004 rows — shared by the rendered report and the text export so a pasted copy can never disagree; (9) Rust: `resolve_runtime_dir` falls back to the binary's own directory (one of the two places ggml globs anyway) instead of `None`, and the crash-loop give-up path now carries `append_windows_dll_hint`. Tests: `aiCoverage` (8 cases incl. the pre-003 scored row and the NULL-is-not-0 rule), serializer honesty (5), `snapshotFocusForReport.aiEnabled` (3), stall notice (4 incl. streak-reset and camera-off-is-not-a-stall), `effectiveRequestTimeoutMs` boundaries (4), and a Rust 003→004 upgrade test asserting old rows read NULL. Stories: `AiOnButNoChecks`, `AiOffForSession`. **Round 2** (a 65-agent adversarial sweep over the first draft found six more): (10) `hydrate()`'s `status: 'error'` was terminal — `ModelPickerContainer` only retried on `'loading'`, so one failed models.json read (AV lock, partial write) killed AI for the whole process and reopened this very issue through a narrower door; the gate is now `status !== 'ready'` and the session notice distinguishes it (`modelListUnreadable`). (11) the footer chip read **"AI off" while AI was ON** with no model — the single on-screen signal during #92, pointing at exactly the wrong setting; new `'unconfigured'` status via a pure, unit-tested `deriveAiChipStatus()`, with `'loading'` deliberately reading `'off'` so no launch flashes it. (12) `aiCoverage`'s first cut returned `'ran'` for `confident_samples: 0, skipped_samples: k`, defended on the grounds that the #47 D5 line caveats it — it does not below `SKIPPED_SAMPLES_MIN` (3), so k of 1–2 rendered a fabricated all-clear with no caveat at all; fifth state `'noConfident'` added and the two tests asserting the old behavior **edited**, not appended. (13) `append_windows_dll_hint` probed only `vcruntime140.dll`, staying silent on a box with the C runtime but not the C++ one; now requires both. (14) `next_attempts` resets the streak on any child clearing `MIN_HEALTHY_UPTIME` (120 s), so a sidecar dying every ~2.5 min crash-looped **forever** without ever setting `errored` — the stall notice fired once and the session then ran for an hour on a dying engine; `TOTAL_RESTART_BUDGET` (12 per generation) closes it, sized so an 8-hour session dying hourly never trips while a 121 s cycle trips at ~24 min. (15) Settings → Sessions now marks an unmeasured row `not measured` when `ai_enabled === 1`. One round-2 finding was **rejected**: "`onSidecarErrored` re-arms every tick, so a flapping sidecar re-toasts forever" — `errored` is cleared only by `sidecar_start`/`sidecar_stop` (`sidecar.rs:233`/`:270`) and the watcher `return`s after setting it, so errored→running requires deliberate user action and re-notifying then is correct, as the existing test documents. |