diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 00000000..491f026a
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,14 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(npm test)",
+ "Bash(npm run typecheck)",
+ "Bash(codesign -d*)",
+ "Bash(plutil -p *)",
+ "Bash(adb devices)",
+ "Bash(adb logcat *)",
+ "Bash(xcrun devicectl list *)",
+ "Bash(system_profiler *)"
+ ]
+ }
+}
diff --git a/.coderabbit.yaml b/.coderabbit.yaml
new file mode 100644
index 00000000..1fee0cab
--- /dev/null
+++ b/.coderabbit.yaml
@@ -0,0 +1,45 @@
+# CodeRabbit configuration.
+#
+# Why this exists: on the sync release PRs CodeRabbit reported a GREEN check having reviewed nothing at all -
+# "Review skipped: 140 files exceed the limit of 100" (desktop) and "316 files exceed the limit of 300"
+# (mobile). A passing check that means "not reviewed" is worse than a missing one, so the file count is kept
+# honest here: screenshots, generated artefacts, lockfiles and docs are excluded from review, and therefore
+# from the count that trips the limit.
+#
+# This does NOT rescue a release-sized PR - desktop's diff is still 140 code files against a limit of 100.
+# The fix for those is smaller PRs; this keeps ordinary ones reviewable and the noise out.
+language: en
+reviews:
+ request_changes_workflow: false
+ high_level_summary: true
+ poem: false
+ review_status: true
+ collapse_walkthrough: true
+ path_filters:
+ # Binary and generated evidence: a reviewer cannot read these, and 27 PNGs alone pushed desktop over.
+ - '!**/*.png'
+ - '!**/*.jpg'
+ - '!**/*.jpeg'
+ - '!**/*.gif'
+ - '!**/*.pdf'
+ - '!**/e2e/screenshots/**'
+ - '!**/__tests__/device/screenshots/**'
+ # Lockfiles and dependency graphs: reviewed by the install gate, not by reading.
+ - '!**/package-lock.json'
+ - '!**/yarn.lock'
+ - '!**/Podfile.lock'
+ - '!**/Gemfile.lock'
+ # Build output and vendored trees.
+ - '!**/dist/**'
+ - '!**/out/**'
+ - '!**/build/**'
+ - '!**/coverage/**'
+ - '!**/node_modules/**'
+ - '!**/Pods/**'
+ - '!**/*.xcodeproj/**'
+ - '!**/.claude/**'
+ auto_review:
+ enabled: true
+ drafts: false
+chat:
+ auto_reply: true
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2753fa75..20723399 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,5 +1,6 @@
-# PR + main verification: typecheck (core + pro), lint, and the full vitest suite
-# (which includes the pro/ tests when the pro repo is checked out alongside).
+# PR + main verification, as ONE job: typecheck (core + pro), the full vitest suite with the coverage floor
+# (which includes the pro/ tests when the pro repo is checked out alongside), dependency boundaries, lint, and
+# the Playwright e2e tour on the built app.
#
# Cross-repo note: pro/ is a separate private repo (paid features). Set the repo
# secret CI_CROSS_REPO_TOKEN (a PAT with read access to off-grid-ai/desktop-pro)
@@ -11,9 +12,17 @@ on:
push:
branches: [main]
jobs:
- verify:
+ # ONE job per repo, matching mobile and mobile-pro: typecheck, tests + coverage, boundaries, lint and the
+ # Playwright e2e all report as a single `ci` check.
+ #
+ # This used to be two parallel jobs (verify + e2e). Merging them costs ~7 minutes of wall clock - the e2e tour
+ # now runs after the unit gates instead of beside them - and buys one check to read instead of two, plus the
+ # deletion of a duplicated pro + shared checkout and a second `npm ci` that existed only to feed the second
+ # job. An earlier attempt at this was reverted because appending e2e "blew the 25-min job cap" (PR #68); that
+ # cap was this workflow's own timeout-minutes, so it is raised here deliberately rather than worked around.
+ ci:
runs-on: ubuntu-latest
- timeout-minutes: 25 # backstop: a hung step fails fast instead of running for hours
+ timeout-minutes: 50 # backstop: a hung step fails fast instead of running for hours (unit gates + e2e tour)
steps:
- uses: actions/checkout@v4
# Check out pro on the branch that MATCHES this PR/push (so a coordinated
@@ -55,6 +64,49 @@ jobs:
- uses: actions/setup-node@v4
with:
node-version: '24' # node:sqlite (used by integration tests) is available unflagged
+ # @offgrid/sync is a file: dependency on the SIBLING shared monorepo ("file:../shared/packages/sync").
+ # It arrived with the sync work and CI never provisioned it, so every job that installs would fail on the
+ # same missing module. actions/checkout refuses a path outside the workspace, so it lands inside and is
+ # moved up one level - exactly where the file: specifier points.
+ #
+ # Matching branch first, main as the fallback, the same shape as the pro checkout above: a PR that changes
+ # the app and the shared package together must be tested against the package it expects.
+ - name: Check out the shared monorepo
+ id: shared_branch
+ continue-on-error: true
+ uses: actions/checkout@v4
+ with:
+ repository: off-grid-ai/shared
+ token: ${{ secrets.CI_CROSS_REPO_TOKEN }}
+ path: _shared
+ ref: ${{ github.head_ref || github.ref_name }}
+ persist-credentials: false
+ - name: Fall back to shared main
+ if: ${{ steps.shared_branch.outcome != 'success' }}
+ continue-on-error: true
+ uses: actions/checkout@v4
+ with:
+ repository: off-grid-ai/shared
+ token: ${{ secrets.CI_CROSS_REPO_TOKEN }}
+ path: _shared
+ persist-credentials: false
+ - name: Put shared beside this checkout
+ run: |
+ if [ ! -d _shared ]; then
+ echo "::error::off-grid-ai/shared was not checked out - @offgrid/sync cannot resolve. Check CI_CROSS_REPO_TOKEN."
+ exit 1
+ fi
+ rm -rf ../shared
+ mv _shared ../shared
+ # Install at the WORKSPACE ROOT, not inside packages/sync. shared is an npm-workspaces
+ # monorepo: the lockfile and the build tool (tsup) live at the root, and packages/sync
+ # declares neither. Installing inside the member failed `npm ci` (no lockfile there), fell
+ # through to `npm install`, pulled the member's five runtime deps, and left the build to
+ # die on `sh: 1: tsup: not found` (exit 127), taking both jobs with it.
+ # A LOCKED install, and no fallback: an unlocked install would resolve a different graph and let the
+ # later gates run against dependencies nobody committed. A drifted lock should stop the build.
+ npm --prefix ../shared ci
+ npm --prefix ../shared/packages/sync run build
- run: npm ci
# Hard gates: types + the full test suite.
- name: Typecheck (core)
@@ -71,6 +123,32 @@ jobs:
- name: Test + coverage thresholds
timeout-minutes: 10
run: npm run test:coverage
+ # The DB journeys - 74 files, 255 cases - which CI has NEVER run.
+ #
+ # They are excluded from the default vitest project because they load the real native SQLite and need
+ # better-sqlite3-multiple-ciphers rebuilt for the TEST RUNNER's node ABI (the app builds it for
+ # ELECTRON's). scripts/test-db.sh does that swap and restores Electron's build afterwards, which is why
+ # this is its own step: the rebuild mutates node_modules.
+ #
+ # Not running them had a measurable cost. The default config EXCLUDES database.ts, rag/store.ts,
+ # prompt-store and runtime-residency with the note "covered by the tests in *.dbtest.ts via
+ # npm run test:db" - a claim nothing verified. approval-lifecycle.ts measured 0% for the same reason
+ # while having tests all along.
+ #
+ # OFFGRID_DB_VITEST_CONFIG selects the coverage variant, which is "the db journeys that pass today": it
+ # skips four files whose causes are each documented in vitest.db.coverage.config.ts (one of them is the
+ # control-center projection bug still awaiting a decision). vitest writes no report at all when any test
+ # fails, so the variant is what makes a report possible; the exclusions are visible in that file rather
+ # than hidden here, and each is meant to be deleted as its cause is resolved.
+ - name: DB journeys (real native SQLite)
+ timeout-minutes: 12
+ env:
+ # The CI variant: the coverage variant plus four files that fail on a LINUX runner rather than in the
+ # code - a bundled macOS ffmpeg, a live engine port, the update feed, and one still undiagnosed. Each
+ # reason is recorded in vitest.db.ci.config.ts with the evidence from the run that found it. 243 of the
+ # 248 cases still run here.
+ OFFGRID_DB_VITEST_CONFIG: vitest.db.ci.config.ts
+ run: npm run test:db
# Build/native/port integration tests (packaging, whisper build-staging, the
# model-port + System Health seams that own :8439). These need a packaged
# build / native toolchain / a live engine port the pure `verify` runner
@@ -95,70 +173,65 @@ jobs:
timeout-minutes: 8
continue-on-error: true
run: npm run lint
-
- # Full Playwright e2e tour on the BUILT app — its OWN job (parallel to verify) so its ~20-min
- # runtime doesn't eat verify's budget (appending it to verify blew the 25-min job cap — PR #68).
- # Specs needing real models/engine binaries (functional-real-engine, voice/tts) self-skip when
- # those aren't present (they aren't in CI), so this gates the model-free surfaces: onboarding,
- # nav, Settings, Models, Replay (incl. the capture toggle), Integrations (BYO Google). This is
- # the one check unit/integration can't give: does the built app actually render + drive.
- e2e:
- runs-on: ubuntu-latest
- timeout-minutes: 35
- steps:
- - uses: actions/checkout@v4
- - name: Check out pro (matching branch) into ./pro
- id: pro_branch
- uses: actions/checkout@v4
- continue-on-error: true
- with:
- repository: off-grid-ai/desktop-pro
- token: ${{ secrets.CI_CROSS_REPO_TOKEN }}
- path: pro
- ref: ${{ github.head_ref || github.ref_name }}
- persist-credentials: false
- - name: Fall back to pro main if no matching branch
- if: ${{ steps.pro_branch.outcome != 'success' || hashFiles('pro/tsconfig.json') == '' }}
- continue-on-error: true
- uses: actions/checkout@v4
- with:
- repository: off-grid-ai/desktop-pro
- token: ${{ secrets.CI_CROSS_REPO_TOKEN }}
- path: pro
- persist-credentials: false
- - name: Require pro checkout to have succeeded
- run: |
- if [ ! -f pro/tsconfig.json ]; then
- echo "::error::pro (desktop-pro) was not checked out — cannot run the open-core e2e. Check CI_CROSS_REPO_TOKEN / repo access."
- exit 1
- fi
- - uses: actions/setup-node@v4
- with:
- node-version: '24'
- - run: npm ci
- # Electron needs an X server on Linux; xvfb-run provides a virtual one. --no-sandbox via env
- # (Electron's sandbox can't run under the CI user without SUID setup).
+ # The full Playwright tour on the BUILT app - the one check unit/integration cannot give: does the app
+ # actually render and drive. Specs needing real models/engine binaries (functional-real-engine, voice/tts)
+ # self-skip when those are absent, as they are here, so this gates the model-free surfaces: onboarding,
+ # nav, Settings, Models, Replay (incl. the capture toggle), Integrations (BYO Google).
+ #
+ # Electron needs an X server on Linux; xvfb-run provides a virtual one. --no-sandbox via env (Electron's
+ # sandbox cannot run under the CI user without SUID setup).
#
- # ADVISORY (continue-on-error) for now — same policy as lint/test:heavy above. The remaining
- # reason is headless-Electron launch instability on the ubuntu runner (waitForEvent 'window'
- # timeouts, "page/context closed", clipboard/display-dependent specs), NOT product failures.
+ # ADVISORY (continue-on-error) for now - same policy as lint and test:heavy above. The remaining reason is
+ # headless-Electron launch instability on the ubuntu runner (waitForEvent 'window' timeouts, "page/context
+ # closed", clipboard/display-dependent specs), NOT product failures.
#
- # NOTE: this being advisory hid four real spec defects on main for days (a selector that
- # could never match, a section never opened, two stale accessible names) — the job was green
- # while 7 specs failed. Every one is fixed and the full suite is 73/73 on a real display, so
- # the ONLY thing still standing between this and BLOCKING is runner stability. Steps taken:
- # retries: 2 in CI (playwright.config.ts) for whole-instance launch flakes, and fixed-port
- # specs now self-skip instead of failing (e2e/helpers/ports.ts).
+ # NOTE: this being advisory hid four real spec defects on main for days (a selector that could never match,
+ # a section never opened, two stale accessible names) - the job was green while 7 specs failed. Every one is
+ # fixed and the full suite is 73/73 on a real display, so the ONLY thing still standing between this and
+ # BLOCKING is runner stability. Steps taken: retries: 2 in CI (playwright.config.ts) for whole-instance
+ # launch flakes, and fixed-port specs now self-skip instead of failing (e2e/helpers/ports.ts).
#
- # Graduate to BLOCKING (delete continue-on-error) once a few consecutive runs are green —
- # do NOT flip it while the runner still drops instances, or the gate gets reverted and we
- # lose the signal again. Tracked in docs/GAPS_BACKLOG.md.
+ # Graduate to BLOCKING (delete continue-on-error) once a few consecutive runs are green - do NOT flip it
+ # while the runner still drops instances, or the gate gets reverted and we lose the signal again. Tracked
+ # in docs/GAPS_BACKLOG.md.
+ #
+ # OFFGRID_E2E_COVERAGE makes this run COUNT. 25 specs drive the real app - devices-sync alone stands up a
+ # synthetic peer with a real SyncEngine - and none of it reached a coverage report before, because
+ # Playwright launches Electron as its own process. With the variable set, Node writes V8 coverage for the
+ # main process and the vite build emits the sourcemaps that map it back to src/**.ts; both are gated on this
+ # variable, so a normal run and every shipped artifact are byte-identical to before.
+ #
+ # Free to run here: this repo is public, so the runner minutes cost nothing.
- name: E2E (Playwright, xvfb)
timeout-minutes: 28
continue-on-error: true
env:
ELECTRON_DISABLE_SANDBOX: '1'
+ OFFGRID_E2E_COVERAGE: ${{ github.workspace }}/coverage-e2e-raw
run: xvfb-run -a npm run test:e2e
+ # c8 turns the raw V8 output into an Istanbul report. It lives in the shared monorepo's node_modules and
+ # needs no new dependency here.
+ #
+ # The report is a build ARTIFACT rather than a gate: its statement map comes from the bundle, so its
+ # denominators are whole-file and must not set a threshold - see shared/scripts/merge-line-coverage.mjs,
+ # which accepts it as --coarse and lets it contribute covered lines only.
+ - name: Convert e2e coverage
+ if: always()
+ continue-on-error: true
+ run: |
+ if [ -d coverage-e2e-raw ] && [ -n "$(ls -A coverage-e2e-raw 2>/dev/null)" ]; then
+ npx c8 report --temp-directory=coverage-e2e-raw --reporter=json --reporter=text-summary \
+ --report-dir=coverage-e2e --all=false
+ else
+ echo "no e2e coverage captured (the suite may not have launched)"
+ fi
+ - name: Upload e2e coverage
+ if: always()
+ uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4
+ with:
+ name: e2e-coverage
+ path: coverage-e2e/
+ if-no-files-found: ignore
- name: Upload e2e screenshots
if: always()
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4
diff --git a/.gitignore b/.gitignore
index 0482c83d..b01caaae 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,6 +29,8 @@ resources/bin/mflux/
# Parakeet STT runtime — CI-staged via scripts/fetch-parakeet.sh (like mflux/coreml-sd),
# not committed. Devs stage it locally with the same script.
resources/bin/parakeet/
+# Pro MultipeerConnectivity helper - built from the private Pro repo and staged for packaging.
+resources/bin/proximity-helper
# Gateway probe scratch output
.gateway-probe/
# TS build cache
@@ -66,3 +68,10 @@ swift-tests/**/.build/
# Off Grid AI Pro early-bird campaign (customer PII + send tooling - do not commit)
marketing/emails/pro-earlybird/
+
+# Coverage report from the DB journey suite (npm run test:db --coverage)
+coverage-db/
+
+# Raw V8 output and the converted report from the e2e coverage capture
+coverage-e2e-raw/
+coverage-e2e/
diff --git a/.sonarcloud.properties b/.sonarcloud.properties
new file mode 100644
index 00000000..fcba66e7
--- /dev/null
+++ b/.sonarcloud.properties
@@ -0,0 +1,20 @@
+# Scope for SonarCloud AUTOMATIC ANALYSIS.
+#
+# sonar-project.properties beside this file declares the same scope and is IGNORED in this mode - which is
+# provable rather than assumed: on PR #76 SonarCloud reported issues in scripts/, e2e/ and
+# .github/workflows/ci.yml, all three of which that file excludes. Automatic Analysis reads
+# .sonarcloud.properties; a CI-based scan reads sonar-project.properties, and this repo runs the former (no
+# scan step, no token - see the note in .github/workflows/ci.yml).
+#
+# The cost of the file being ignored was not noise, it was a gate that graded the wrong code: the quality
+# gate wants rating A on new code, and every BUG and VULNERABILITY it reported was in a developer script or
+# in CI YAML - zero in product source. One BLOCKER in scripts/physical-sync/desktopKnowledgeSyncAdapter.mjs
+# ("'expectedPresent' is not modified in this loop") was enough to put new-code reliability at E, so the
+# check has been failing for reasons no user could ever encounter, which is how a red check stops being read.
+#
+# Shipped source only: scripts, e2e and the test files are OUT of the analysis, not merely reclassified as
+# tests. They are developer tooling and harnesses, held to this repo's own lint, type and coverage gates - a
+# product-quality rating on them is what made this check unreadable. pro/ is a submodule Automatic Analysis
+# never clones (private repo); it is scanned in its own repo via eslint-plugin-sonarjs.
+sonar.sources=src
+sonar.exclusions=**/node_modules/**,out/**,dist/**,e2e/**,scripts/**,resources/**,component-library-animations/**,pro/**,**/*.d.ts,**/*.test.ts,**/*.test.tsx,**/*.dbtest.ts,**/__tests__/**
diff --git a/AGENTS.md b/AGENTS.md
index bdc1be29..182aa424 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -108,7 +108,7 @@ When iterating (a request, a fix, a tweak the user just confirmed), add a test t
## E2E capture — SYNTHETIC data only, seeded via the demo script
-E2E and screenshot/video capture (including the Provit capture harness) run the app on a **fresh temp `OFFGRID_USER_DATA` profile** and must use **synthetic demo data only — never a real profile, never upload real user data.**
+E2E and screenshot/video capture (including any device capture harness) run the app on a **fresh temp `OFFGRID_USER_DATA` profile** and must use **synthetic demo data only — never a real profile, never upload real user data.**
- **Seed with the demo script — BOTH seeders.** A blank profile is EMPTY, so any flow that _generates_ (chat, especially the "All memory" scope) will error with **"Sorry, something went wrong…"** — a **profile/RAG gap, not a bug**: no seeded memory store means the memory path throws before streaming. There are TWO independent seeders and a flow may need both: **`OFFGRID_SEED=force`** → core `seedDemo` (`src/main/index.ts` → `dev-seed.ts`) seeds chats / knowledge / RAG memory (this is what "All memory" chat queries); **`OFFGRID_SEED_PRO=force`** → pro `seedProDemo` (`pro/main/index.ts` → `pro/main/dev-seed.ts`) seeds observations / entities / clipboard / replay frames. `npm run demo` sets both — use it (or set both env vars) for any capture that exercises chat/generation.
- **Model ports are single-owner.** Only one app instance can bind the model engine ports (`:7878` gateway, `:8439` llama-server, `:7879`). A running `npm run dev` will block a second capture instance's engine → generation errors that look like a bug but aren't. Free the ports (stop the dev app) before an e2e capture, or the recording exercises the error path only.
diff --git a/CLAUDE.md b/CLAUDE.md
index a96a9d2e..167f8270 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -108,7 +108,7 @@ When iterating (a request, a fix, a tweak the user just confirmed), add a test t
## E2E capture — SYNTHETIC data only, seeded via the demo script
-E2E and screenshot/video capture (including the Provit capture harness) run the app on a **fresh temp `OFFGRID_USER_DATA` profile** and must use **synthetic demo data only — never a real profile, never upload real user data.**
+E2E and screenshot/video capture (including any device capture harness) run the app on a **fresh temp `OFFGRID_USER_DATA` profile** and must use **synthetic demo data only — never a real profile, never upload real user data.**
- **Seed with the demo script — BOTH seeders.** A blank profile is EMPTY, so any flow that _generates_ (chat, especially the "All memory" scope) will error with **"Sorry, something went wrong…"** — a **profile/RAG gap, not a bug**: no seeded memory store means the memory path throws before streaming. There are TWO independent seeders and a flow may need both: **`OFFGRID_SEED=force`** → core `seedDemo` (`src/main/index.ts` → `dev-seed.ts`) seeds chats / knowledge / RAG memory (this is what "All memory" chat queries); **`OFFGRID_SEED_PRO=force`** → pro `seedProDemo` (`pro/main/index.ts` → `pro/main/dev-seed.ts`) seeds observations / entities / clipboard / replay frames. `npm run demo` sets both — use it (or set both env vars) for any capture that exercises chat/generation.
- **Model ports are single-owner.** Only one app instance can bind the model engine ports (`:7878` gateway, `:8439` llama-server, `:7879`). A running `npm run dev` will block a second capture instance's engine → generation errors that look like a bug but aren't. Free the ports (stop the dev app) before an e2e capture, or the recording exercises the error path only.
@@ -148,6 +148,53 @@ The `pro/` directory is a **git submodule** pointing at the private `desktop-pro
**Settings sections follow the same rule.** A pro Settings section (proactive delivery, secretary/learned-prefs, identity, fleet console, etc.) is pro feature code — its component + logic live in `pro/renderer` and register into the core Settings screen via the section-registry seam (`pro/renderer/settings.ts` `registerProSettings` → core `registerSettingsSection`; core renders its own sections + all registered ones). Core must NOT hardcode pro section bodies in `Settings.tsx` gated by `isPro` — core only renders a dimmed `ProPlaceholder` for the locked preview when the section isn't registered (free build). Do not `if (isPro) : ` with the real section defined in core.
+
+> **Generated from `shared/CLAUDE.md` - do not edit this section here.**
+> Run `node scripts/mirror-doctrine.mjs` in `shared/` after changing the canonical copy.
+> `--check` fails the build when a mirror drifts, so these cannot silently disagree.
+
+## Debugging — start with the source of truth
+
+**Most bugs here are source-of-truth bugs, and the fix is almost always to collapse two sources into
+one.** So before reading a stack trace or reaching for a log, ask three questions in order:
+
+1. **What is the source of truth for this fact?** Not "where is the bug" - "who is entitled to answer
+ this question". A device's connection state, a model's identity, whether a transfer finished.
+2. **Is anything else answering the same question?** Two answers is the bug, even when both are
+ individually correct. Look for a value derived twice, a rule written in two layers, a state
+ hardcoded next to a state that is computed.
+3. **Can we refactor so there is ONE source, and would that fix it?** If yes, that is the fix. Patching
+ the wrong answer leaves the second source in place, and it will disagree again somewhere else.
+
+If the answer to 3 is no, say so explicitly and fix the symptom - but say WHY one source is not
+achievable, because that is usually a design constraint worth writing down.
+
+### Why this is the default heuristic (a session's worth of evidence)
+
+Every one of these presented as a different bug and was the same bug:
+
+| Symptom | The two sources | The one source |
+|---|---|---|
+| A connected device had no actions at all on macOS | two hand-written button lists, one per section | one component driven by `device.actions.*.visible` |
+| "4 of 5 licensed devices" over a list of one | count from the registry, list from `saved` (which excludes devices that are ON the network) | the whole mesh |
+| One model appeared 35 times | absolute path as identity, and iOS moves it every reinstall | `fileName`, unique within the dir |
+| Sender said "sent", receiver said "could not receive" | the send loop's "I pushed bytes" vs the receiver's verdict | one package-state rule (`modelPackagePhase`) |
+| Activity said COMPLETED for a half-sent model | per-FILE rows vs a package the user asked for | package state, files underneath |
+| A live mesh read as half-down | each flow reading device rows its own way | the surface layer owns reading |
+| "Needs repair" after a deliberate disconnect | a flag set by one path and clearable only by another | one lifecycle, cleared on the next success |
+
+The tell is almost always the same: **two things that must agree, kept in step by hand.** A comment
+saying "these must match" is a bug waiting for a witness; so is a hardcoded literal sitting next to a
+computed value (`status: 'completed'` beside a record that also has a status).
+
+### Durability and resilience are SSOT problems too
+
+A fact that is not persisted has no source of truth after a restart - it silently becomes whatever the
+UI last remembered. Failures were dropped on the floor (`if (status !== 'completed') return`), so a
+failed transfer stopped existing the moment the view reset, and the surface confidently showed success.
+When you fix durability, fix the READ at the same time: persisting a failure while the renderer still
+hardcodes `status: 'completed'` converts a lost record into a durable lie.
+
## Architecture & abstractions (SOLID)
Design to abstractions, not concrete types. When implementations are interchangeable (model backends, TTS/STT engines, image/diffusion runtimes, connectors), the rest of the app depends on one service/interface — never branch on a concrete type in UI/stores (`if (engine === 'kokoro')`, `instanceof X`). Push the decision behind the abstraction; adding an implementation should need zero changes to callers. Normalize capability gaps inside the service, not the UI.
diff --git a/ROADMAP_DESKTOP.md b/ROADMAP_DESKTOP.md
index b8069d17..d44e2727 100644
--- a/ROADMAP_DESKTOP.md
+++ b/ROADMAP_DESKTOP.md
@@ -120,7 +120,11 @@ The Off Grid chat (`MemoryChat.tsx`) is becoming a full local-first studio — l
- ✅ Engine exists: `@offgrid/sync` + `@offgrid/memory` (pairing, anti-entropy op-log)
- ⬜ Carry the new memory (observations/actions/entities/reflect) across the mesh
-- ⬜ Embed sync + memory + clipboard into Desktop; desktop↔desktop converge; universal clipboard
+- 🟡 **Desktop mesh** — pairing/discovery, encrypted chat + project + model-settings convergence,
+ and opt-in universal text clipboard are verified in the desktop UI. Model transfer streams,
+ resumes, checksum-verifies, and registers single-file GGUF models; the physical multi-GB,
+ interrupted-transfer, and receiver-load gate remains open. Clipboard images/files, immediate
+ live-session teardown on unpair, and ambient file sharing remain open.
- ⬜ **One brain across devices** — laptop (work) + phone (life) unify into a single working model, syncing over the home network, no cloud relay (`vision.md`)
## Phase 7 — Org / B2B distribution ⬜
@@ -132,7 +136,7 @@ The Off Grid chat (`MemoryChat.tsx`) is becoming a full local-first studio — l
- ⬜ Onboarding permission ladder (screen → Google OAuth → MCP) — _deferred, not now_
- ⬜ Settings consolidation; ✅ theme toggle wiring
- ⬜ Packaging: signed/notarized DMG + auto-update
-- ⬜ Licensing: AGPL + CLA + open-core; device cap (2 free / 3+ paid) — _deferred, not now_
+- ⬜ Licensing: AGPL + CLA + open-core; Sync is a Pro feature, personal mesh capped at 5 — _deferred, not now_
---
diff --git a/docs/GAPS_BACKLOG.md b/docs/GAPS_BACKLOG.md
index 3e46a494..a646b28b 100644
--- a/docs/GAPS_BACKLOG.md
+++ b/docs/GAPS_BACKLOG.md
@@ -168,3 +168,478 @@ Accessibility/automation timing). With `retries: 2` in CI the practical failure
this spec should not be trusted as a hard gate until the hotkey is driven deterministically —
options: assert the global-shortcut registration before pressing, or expose a test-only IPC that
triggers the same handler and keep the native-key path as a separate, quarantined check.
+
+### RESOLVED: a licensed installation this device never paired with became a repair row that could not repair
+
+Fixed in `@offgrid/sync`. Three parts: a row with no local pairing now reports `hasCredential: false`
+rather than leaving it absent, so the repair asks for the code instead of promising a reconnection with
+nothing to reconnect with; a device with an eviction in flight no longer also gets a saved row; and the
+saved pass no longer deletes devices from the discovered map, which is what hid `Pair again` after a
+failed eviction. Covered by two new tests in `shared/packages/sync/test/control-center.test.mjs`.
+
+Desktop needed no change of its own: its eviction store already tolerates an empty local side
+(`prepareEviction` uses `active?.membershipId ?? ''`) and `runEviction` already surfaces failures. The
+mobile host had neither and was fixed there.
+
+Original report follows.
+
+`projectSyncControlCenter` builds its `saved` list by walking the licence registry's installations and
+treating a local pairing as enrichment. That is correct for the roster - the licence IS the authority on
+which devices belong to the mesh - but it means an installation with NO matching local pairing still
+produces a row, and that row lands in `needs_repair` (`control-center.ts`: `!paired || repairIds.has(...)`).
+
+Two consequences, one of them user-visible and already seen on device:
+
+1. **A repair that cannot succeed.** The row offers `membershipRepair.kind === 'reconnect'` -
+ "Trying the saved pairing again may be enough" - when there is no saved pairing to try. This is the
+ ghost row seen after reinstalling a phone: the phone re-registers under a new sync device id, its old
+ installation stays on the licence, and the stale one renders as a device asking to be reconnected.
+ The wording is `reconnect` rather than `pair` only because `hasCredential` is absent and absent is
+ deliberately read as present (see the comment at the `credentialLost` line) so that a host which does
+ not report the field is not accused of having lost every pairing.
+
+2. **It can steal the discovered record from another row.** The `saved` pass calls
+ `discoveredById.delete(deviceId)`, so a stale installation consumes the discovery entry before the
+ membership-revocation pass looks for it. `revocationPeerDiscovered` is then false and
+ `actions.pairAgain` is hidden - meaning a failed eviction cannot be recovered from even while the
+ other device is sitting on the network. Demonstrated: with the licence listing the device and a
+ `stage: 'failed'` revocation present, `pairAgain` projects as `{visible: false, enabled: false}`.
+
+Note this does NOT arise from a normal eviction. `PersonalMeshDeviceEvictionCoordinator.evict()`
+deregisters the installation before it ever contacts the peer, so the seat is released immediately and
+the evicted device correctly appears once, in `available`. The trigger is a genuinely stale installation.
+
+Candidate fixes, both deliberately not taken yet:
+- Do not emit a `saved` row for an installation with no local pairing (narrow; may hide a real device
+ whose pairing this side genuinely lost).
+- Have the desktop and mobile hosts report `hasCredential` so the repair correctly says "Pair" and asks
+ for the code (touches both hosts, and is the more honest fix).
+
+The test asserts only the revocation row's own retry semantics and states in a comment that the
+`saved` count is deliberately unasserted, so this defect is recorded rather than blessed.
+
+### Worth a look: the eviction confirmation promises the peer's licence is cleared
+
+`projectMembershipEvictionConfirmation` adds "Off Grid AI will also remove its saved licence" whenever
+the device is connected. That sentence is only earned if the eviction actually reaches the peer, and the
+`stage: 'failed'` path exists precisely because it may not. Not a defect in itself - the copy is gated on
+an authenticated session, which is the strongest reachability fact available - but the promise is made
+before delivery is confirmed, and a failed eviction leaves the user believing something that did not
+happen. Flagged for a copy decision, not changed.
+
+### Needs a decision: the Entity Graph screen is gone from the pro renderer, its IPC is not
+
+`entity-graph-renderer.integration.dbtest.ts` asks for `proView('graph', ...)` and gets nothing back:
+the route does not exist. The router now knows day, replay, reflect, devices, actions, meetings,
+entities, memories, search, notifications, clipboard, voice and vault - no graph. Nothing under
+`pro/renderer/` imports `react-force-graph-3d` or calls `getEntityGraph` any more.
+
+Core still carries the whole surface, though: `getEntityGraph` and `rebuildEntityGraph` are in
+`src/main/ipc.ts`, `src/main/database.ts` and the preload contract. A feature that was retired on
+purpose would normally have taken its IPC with it, which is why this is written down rather than
+resolved by deleting the test.
+
+Two readings, and they want opposite actions:
+- The graph was deliberately retired and folded into Entities. Then the test should go, and so should
+ the three IPC handlers and the preload entries, or they are dead surface area a renderer can still call.
+- The screen was lost in a refactor. Then the test is correctly failing and the screen needs restoring.
+
+The test is left red on purpose. Deleting it would remove the only thing still asserting that the graph
+services work end to end, and would make the second reading invisible.
+
+### RESOLVED (2 of 3): the pro-tier Devices e2e specs
+
+`e2e/devices-sync.spec.ts` is new on this branch and its `Devices surface — pro tier` describe was red in
+BOTH environments, hidden because the desktop CI `E2E (Playwright, xvfb)` step is `continue-on-error`.
+
+**Fixed - `renders the real Devices screen with live sync status`.** It asserted the text `LAN + nearby ready`
+and a heading `Personal mesh`. Neither string exists anywhere in `src/`, `pro/` or `shared/packages/`: the
+first was never shipped, and the second is now `Licensed devices`. The screen reports itself per ROUTE - one
+chip reading `LAN: ready`, or `LAN: //` when it is not (`syncRouteDisplay` +
+`DevicesScreen.tsx`) - so the spec now asserts that, plus the nearby counter. The screen underneath was fine
+the whole time; the spec was failing on its own stale copy.
+
+**Fixed - `sync settings ... expose a toggle per replicated category`.** Passes with the above; it was
+inheriting a broken screen state from the spec before it, not failing on its own account.
+
+**Still red - `pairs a real peer and converges projects and chats`.** Two problems, one down:
+
+- The harness constructed `ClipboardSyncCoordinator` without the `deliveryPersistence` its options require, so
+ the spec died inside its own setup (`Cannot read properties of undefined (reading 'load')` from
+ `loadPendingDeliveries`) before reaching the app. The synthetic peer now has an in-memory delivery store
+ beside its history store. FIXED.
+- What remains is not a harness defect: pairing now requires an **8-character code** shown on the other
+ device ("Enter the 8-character pairing code shown on the other device"), and the synthetic peer neither
+ mints one nor presents one the app will accept. `PairingCodeService` lives in
+ `shared/packages/sync/src/pairing-code.ts`; wiring it into the synthetic peer is the same job as the
+ standing "make pairing work in the test harness" item, so it is tracked there rather than bodged here.
+
+Verified headless: 5 of 6 in that file pass; the pairing one is the single remaining failure.
+
+### The desktop `ci` check hides three advisory steps
+
+`Lint`, `Heavy integration (build/native/port)` and `E2E (Playwright, xvfb)` are all
+`continue-on-error: true` in `.github/workflows/ci.yml`, so a green `ci` says nothing about them. On the
+last successful run: heavy integration reported **12 failed / 15 passed**, all in macOS packaging and
+real-engine files that cannot pass on a Linux runner (`packaged-helpers`, `release-packaging`,
+`whisper-cli-build`, `model-server-chat`, `HealthPanel`), and the e2e step failed the macOS-only pro
+surfaces (clipboard restore, Vault clipboard copy, dictation) plus `resilience-single-instance`.
+
+The Linux-impossible ones are a platform mismatch rather than rot - but they are being run and reported
+as failures on every push, which trains everyone to ignore the step. They should either be excluded by
+platform (like `vitest.db.ci.config.ts` does, with the reason recorded per file) or moved to a macOS
+runner, so that what remains inside an advisory step is only ever a real signal.
+
+### P1 - the desktop always reports its platform as `macos`, so a Windows node lies about itself
+
+`pro/main/sync/sync-store.ts:318` builds the LOCAL device identity with `platform: 'macos'` hardcoded,
+unconditionally, on every OS. Nothing misdetects Windows - the local device never reports its OS at all.
+The same literal is hardcoded in three more places: `pro/main/sync/model-transfer-service.ts:105` and
+`:414`, and `pro/main/sync/keygen-personal-mesh-registry.ts:163-164`.
+
+**Observed on the lab mesh (2026-08-06).** The Windows 11 ARM guest on .64, renamed
+`OGAD x.x.x.64 (Win)`, appears in the macOS node's own LICENSED DEVICES list as `macOS`, and the Android
+lists TWO macOS devices when the LAN has exactly one Mac. `DevicePlatform` in
+`shared/packages/sync/src/types/index.ts:2` already allows `"windows"`, so this is a missing
+`process.platform` map (`darwin`->macos, `win32`->windows, `linux`->linux), not a missing type.
+
+**Why P1 and not a labelling nit - `platform` gates two real decisions:**
+
+- `shared/packages/sync/src/multi-transport.ts:29` treats `platform === "ios" || "macos"` as
+ Apple-proximity-capable, so the mesh will attempt an APPLE-ONLY transport route to a Windows box.
+- `shared/packages/sync/src/transfer/model.ts:96-104` (`platformTransferBlocker`) refuses a model whose
+ `origin` platform differs from `receiverPlatform`, which exists precisely to stop an unrunnable
+ transfer. A Windows receiver claiming `macos` DEFEATS that guard: a macOS-only GGUF is allowed to
+ transfer to a machine that cannot load it. `model-transfer-service.ts:414` pins
+ `receiverPlatform: 'macos'` too, so both sides of that comparison are wrong together.
+
+Not fixed here: this is product code under `pro/`, and this sweep is not authorised to change `src/`.
+A fix needs a single platform helper used by all four sites, plus a test that a non-darwin
+`process.platform` yields a non-`macos` identity - otherwise the next hardcode reintroduces it.
+
+### P2 - a long-running desktop instance can end up with NO sockets at all, mesh included
+
+Observed on .64 (packaged v0.0.42) on 2026-08-06. The app had been up since 09:36 and was licensed
+(`[Pro] license loaded - entitled=true`), and `pro:sync:status` was answering IPC on a 2s poll - yet the
+process held **zero TCP and zero UDP sockets**. Confirmed three independent ways, all agreeing:
+`sudo lsof -nP -iTCP -sTCP:LISTEN`, `netstat -an -p tcp`, and `sudo lsof -nP -p ` for each of the
+four app pids. Machine-wide there was only sshd:22 and a launchd 127.0.0.1:8021.
+
+Not just the mesh: `llama-server` (127.0.0.1:8439) and the gateway (7878/7879) were absent too, and the
+app's own sidebar read `Model stopped`. A restart restored everything at once - mesh listener on an
+ephemeral wildcard port, 8439, 7878, 7879 - and the sidebar went to `Model running`.
+
+**Why this is worth a gate, not just a restart.** `pro:sync:status` reported `serviceState: 'running'`
+throughout. The LAN route is `required: true` in the MultiTransportBridge, so a listen failure at startup
+would have rethrown out of `service.start(0)` and aborted `setupSyncIPC` before that handler was ever
+registered - meaning the socket was NOT lost at startup, it went away later while the service went on
+claiming to be up. From the phones' side this is indistinguishable from the Mac being switched off: both
+phones simply showed it Offline, for days (`last seen 03/08/2026`).
+
+Cause not established - this box is also running a VMware Fusion Windows guest, so resource pressure or a
+sleep/wake cycle are both plausible and neither is proven. What IS actionable regardless: the status a
+peer reports should be derived from the listener actually being bound, so `serviceState: 'running'` cannot
+outlive the socket. A liveness check that re-binds or reports unhealthy would have surfaced this in
+seconds instead of days.
+
+### P1 - the device cap REFUSES at 5 instead of reclaiming, and the seat it counts is the pairing target's own
+
+Observed 2026-08-06, driving the real lab mesh. After activating the Mac's Pro licence
+(`08634d13-641c-455d-957b-ad1834c5fb50`, policy `ec95153c`, `maxMachines: null`) on the Android, the
+Android's Devices screen reports:
+
+ 5 of 5 devices saved
+ All slots are in use. Forget a saved device before pairing another.
+ 0 connected
+
+and pairing with the macOS node on .64 is refused outright. Three separate defects are tangled here.
+
+**1. It refuses where it is documented to reclaim.** The stated behaviour is that a 6th device is
+admitted by reclaiming the least attributable seat, never by refusing. This is a flat refusal at 5, with
+the remedy pushed onto the user ("Forget a saved device"). Nothing was reclaimed.
+
+**2. The counter and the list disagree, so the remedy is impossible.** The screen says `5 of 5 devices
+saved` but renders only TWO saved rows (`fa4d14a6…`, `c375a25b…`). The other three seats are invisible,
+so a user told to "forget a saved device before pairing another" cannot forget them - there is no row to
+act on. The cap is counting LICENCE MACHINES (Keygen reports exactly 5 on that licence) while the list
+renders only locally-saved sync pairings. Two different populations behind one number.
+
+**3. Worst: the target's own seat blocks pairing with the target.** The device being paired with -
+the .64 Mac, fingerprint `d0e933934ac1be2b3ecf50ce0d7fbc85` - is ITSELF one of the 5 machines on that
+licence. So the Android is refused a pairing with a device that already holds a seat on the Android's own
+licence. A seat held by the pairing target cannot sensibly count against admitting that same target;
+the cap check needs to exclude the counterparty (and ideally any machine already in the mesh) before
+declaring the mesh full.
+
+**Related, same session:** the licence swap silently dropped the working iPhone<->Android pairing. The
+iPhone (`9d25c24e…`) is not among the machines on this licence - it is still on the previous one
+(`c88a9e27…`) - and its row on the Android reverted from `Connected - LAN` to an unpaired
+`sync-pair-9d25c24e…`. A licence change invalidating existing trust may be intended, but it happens with
+no warning and no explanation on either screen.
+
+Evidence: Keygen machine roster for the licence (5: one android `6e1c3b71…`, four macos incl.
+`fa4d14a6…` which is really the Windows guest per the platform P1 above), against the Android's two
+rendered rows.
+
+---
+
+## Sync never re-connects a saved device after the session drops (only after a NEW discovery)
+
+**Status:** open. Found 2026-08-07 while building the four-device e2e flow suite.
+
+**Symptom, seen on two screens at once.** The iPhone `17 pro max` and the Mac `OGAD x.x.x.25 (MacOS)`
+are paired, both hold the credential, and each can see the other. The link drops (a phone restart is one
+way in). Neither side ever comes back on its own. The Mac sits on `Last connected just now` /
+`The device could not be reached.` with a `Reconnect` button, and the iPhone sits on `macos - Nearby`
+with its own `Reconnect`. Tapping Reconnect works instantly - so the credential, the address and the
+transport are all fine. The only thing missing is anything that decides to retry.
+
+**Cause.** Auto-reconnect is edge-triggered on discovery and nothing else. `Orchestrator.handleFound`
+(`shared/packages/sync/src/orchestrator.ts:223`) is the only automatic caller of `engine.reconnect()`,
+and it is wired to `discovery.onDeviceFound` (`orchestrator.ts:81`). Two consequences:
+
+1. A peer that is ALREADY in the discovery set produces no new `found` event, so `handleFound` never
+ runs again for it. The device is visible and saved and still never retried.
+2. A dropped session is a dead end. `onDisconnected` reaches production at
+ `desktop/pro/main/sync-ipc.ts:342`, where it only calls `chatStream?.onDisconnected(deviceId)`.
+ The orchestrator is never told, so nothing schedules a reconnect.
+
+The state machine heals on the RISING edge of discovery and never on the FALLING edge of a session.
+`orchestrator.ts:174` shows the intent was already understood - "until now the user was the retry
+mechanism" - but that was fixed for a STALE ADDRESS (`connectSaved`), not for a lost session.
+
+**Why it looks like one bad pair.** It is not. The other four links in the mesh simply have not dropped.
+Any link that drops stays dropped in exactly the same way.
+
+**Fix shape.** Make healing level-triggered: on `onDisconnected`, hand the device back to the
+orchestrator so a saved peer with a held credential is retried on a backoff for as long as discovery
+still sees it. `connectSaved` already does the hard part (re-resolve a stale address, then reconnect);
+what is missing is a caller on session loss. Guard with the existing `connecting` set so a flapping link
+cannot stack retries.
+
+**Consequence for the e2e suite.** Flow 2 ("reconnect a dropped saved device with the held credential,
+no code") passes only because the flow TAPS Reconnect. The unattended behaviour a user actually relies
+on - it comes back by itself - is untested and currently absent. Worth its own flow once fixed.
+
+---
+
+## Disconnecting a device leaves BOTH sides saying "Needs repair", and it never clears
+
+**Status:** open. Found 2026-08-07 driving the four-device e2e suite.
+
+**Symptom.** Press the `x` (disconnect) on a connected peer - a deliberate, non-destructive action that
+is supposed to close the session and keep the credential. Both devices then show the other as
+`Needs repair`, with the description "The other device did not recognise this one." Nothing failed to
+recognise anything: the user pressed disconnect. Both sides still hold their credentials
+(`sync-paired-`, offering repair rather than pair), so the accusation is not even true.
+
+Observed on OnePlus Nord 5 <-> 17 pro max. After the disconnect, BOTH rows read `Needs repair`.
+
+**It does not heal.** The peer was made discoverable again and a full rescan run; 90 seconds later
+both rows still read `Needs repair`. Only a manual repair tap clears it.
+
+**Cause.** `needs_repair` has exactly one source - `syncRuntimeCallbacks.ts:185`:
+
+ onPairingFailed: (remote, error) => {
+ if (remote && error === 'unknown_device') {
+ pairingSecretStore.markNeedsRepair(remote)
+
+So a single `unknown_device` answer is taken as fact. `pairingSecretStore.markNeedsRepair` documents
+the very false positive this hits - "a peer that is restarting, or whose pairing store has not
+finished loading, answers exactly the same way" - and keeps the secret for that reason, but the STATE
+is still set from one unanswered handshake, and nothing later re-tests it.
+
+`control-center.ts:298` then makes it stick to the top of the priority list: `needs_repair` beats
+`available`, so the row keeps the warning even once the peer is discovered again and reachable.
+
+**Why it matters.** This is the ordinary path - disconnect and reconnect later is what the control is
+FOR. A user who uses it once is left with two devices showing a red warning triangle and an
+instruction to repair a pairing that was never broken.
+
+**Fixed, in part (2026-08-07).** A disconnect the user asked for no longer enters this path.
+`onDisconnected` already consulted `manuallyDisconnected` to present the row as
+available-and-disconnected; a handshake refusal arriving afterwards overwrote that. `onPairingFailed`
+now consults the same set and leaves the pairing alone. That is the whole of the reported symptom.
+
+**Still open: one `unknown_device` is still taken as a verdict when the disconnect was NOT deliberate.**
+Requiring two consecutive refusals was tried and reverted, because nothing retries after a refusal: a
+pairing failure is not a disconnect, so no heal is scheduled, the second answer never arrives, and a
+peer that has genuinely forgotten this device would sit silent instead of asking for a repair. Trading
+a false accusation for silence is the worse bug, and `syncPersistence.integration.test.ts`
+("repairs one-sided trust") catches exactly that.
+
+So corroboration needs a RETRY before it can be safe: on the first refusal, re-attempt the reconnect
+with the held credential and decide on the second answer. The orchestrator already knows how to retry
+on a backoff (`connectSaved`, added the same day for dropped sessions); what is missing is entering it
+from a refused handshake rather than only from a lost session.
+
+---
+
+## macOS: a CONNECTED device offers no actions at all
+
+**Status:** open. Found 2026-08-07 while driving the model-transfer flow by hand.
+
+**Symptom.** On the Mac's Devices screen, every connected peer card carries ZERO controls. Enumerated
+live from the DOM:
+
+ CARD: 17 pro max -> buttons: NONE
+ CARD: OnePlus Nord 5 -> buttons: NONE
+ CARD: OGAD x.x.x.26 (Win) -> buttons: NONE
+ CARD: Off Grid AI Desktop -> buttons: Pair (unpaired - this one has a control)
+ CARD: OGAD x.x.x.25 (MacOS) -> buttons: NONE
+
+So on macOS a user cannot send a model, disconnect, forget or rename a device they are connected to.
+The only cards with controls are the ones NOT connected: unpaired shows `Pair`, saved-but-away shows
+`Reconnect` / `Evict`. Being connected removes every action.
+
+Both phones offer all four on every row - `sync-rename-`, `sync-disconnect-`,
+`sync-send-model-`, `sync-forget-` - so this is a desktop gap, not a product decision.
+
+**Consequence.** Flow 12 (send a model, with progress on both sides) cannot be driven from the Mac at
+all. Model transfer desktop -> phone is unreachable through the UI.
+
+---
+
+## macOS: the "N/5 licensed devices" chip is a button that goes nowhere
+
+**Status:** open. Found 2026-08-07, same session.
+
+`4/5 licensed devices` on the Devices header is a real `